Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
View File
+151
View File
@@ -0,0 +1,151 @@
"""Shared spurious stdin-EOF recovery for the TUI gateway entry point and slash worker.
When a child process inherits fd 0 (stdin) and sets ``O_NONBLOCK``, the flag
lands on the **shared open file description** — not just the child's descriptor.
The gateway's next ``read()`` returns ``EAGAIN``, which CPython's buffered
``TextIOWrapper`` converts to ``b''`` (apparent EOF), killing the gateway.
This module provides:
- :func:`diagnose_stdin_state` — forensic diagnostic (``O_NONBLOCK`` / ``SO_RCVTIMEO``)
- :func:`handle_spurious_eof` — check whether an empty ``readline()`` is a genuine
peer-close or a spurious EOF, and recover if spurious.
The recovery is **POSIX-only** (``fcntl``). On Windows, ``O_NONBLOCK`` on a
shared file description is not a concern, so the guard simply reports a
genuine EOF and lets the caller exit.
"""
from __future__ import annotations
import os
import time
try:
import fcntl as _fcntl
_HAS_FCNTL = True
except ImportError:
_fcntl = None # type: ignore[assignment]
_HAS_FCNTL = False
try:
import socket as _socket
_HAS_SOCKET = True
except ImportError:
_socket = None # type: ignore[assignment]
_HAS_SOCKET = False
import struct
# Rate-limit: at most this many spurious-EOF recoveries per 60-second window.
# A child aggressively flipping ``O_NONBLOCK`` on the shared fd would otherwise
# create a tight busy-loop burning CPU. Exceeding the cap exits the process —
# the parent (TUI / gateway) respawns it with fresh state, which is safer than
# fighting forever.
MAX_RECOVERIES_PER_MINUTE = 10
def diagnose_stdin_state() -> str:
"""Return a diagnostic string about stdin's current state.
Used for crash-log forensics when stdin iteration falls through.
Distinguishes genuine peer-close (flag clear) from spurious EOF
caused by a child setting ``O_NONBLOCK`` on the shared file description.
"""
parts: list[str] = []
if _HAS_FCNTL and _fcntl is not None:
try:
flags = _fcntl.fcntl(0, _fcntl.F_GETFL)
parts.append(f"O_NONBLOCK={'1' if flags & os.O_NONBLOCK else '0'}")
except Exception as e:
parts.append(f"F_GETFL error: {e}")
else:
parts.append("O_NONBLOCK=n/a (no fcntl)")
# ``SO_RCVTIMEO`` is a socket option (not a file-status flag), equally
# shared on the open file description. A child setting it via
# ``setsockopt`` launders into the same spurious-EOF path with
# ``O_NONBLOCK`` clear, so we report it alongside the flag.
if _HAS_SOCKET and _socket is not None:
try:
s = _socket.fromfd(0, _socket.AF_UNIX, _socket.SOCK_STREAM)
try:
tv = s.getsockopt(_socket.SOL_SOCKET, _socket.SO_RCVTIMEO)
parts.append(f"SO_RCVTIMEO={tv!r}")
finally:
# ``fromfd`` duped the fd; ``close`` releases the dup without
# touching the original fd 0.
s.close()
except Exception:
pass
return ", ".join(parts) if parts else "unknown"
def handle_spurious_eof(
recovery_times: list[float],
log_fn: object,
) -> bool:
"""Check whether an empty ``readline()`` is spurious; recover if so.
Returns ``True`` if the caller should ``continue`` the read loop
(spurious EOF was recovered), ``False`` if it should ``break`` (genuine
peer-close or rate limit exceeded).
``log_fn`` is called with a diagnostic string — ``_log_exit`` in
``entry.py``, ``print(file=sys.stderr)`` in ``slash_worker.py``.
"""
# Without ``fcntl`` (Windows) we can't check the flag, and the
# ``O_NONBLOCK`` shared-description issue is POSIX-specific anyway —
# treat it as a genuine EOF.
if not (_HAS_FCNTL and _fcntl is not None):
log_fn("stdin EOF (peer closed)") # type: ignore[operator]
return False
try:
flags = _fcntl.fcntl(0, _fcntl.F_GETFL)
is_nonblock = bool(flags & os.O_NONBLOCK)
except Exception:
is_nonblock = False
if not is_nonblock:
# Genuine peer-close — no subprocess flag tampering detected.
log_fn("stdin EOF (peer closed)") # type: ignore[operator]
return False
# Spurious EOF: a child set ``O_NONBLOCK`` (and/or ``SO_RCVTIMEO``) on
# the shared file description, laundered into ``b''`` / ``EAGAIN`` by
# CPython's buffered layer. Restore blocking mode and resume.
now = time.time()
recovery_times.append(now)
recovery_times[:] = [t for t in recovery_times if t > now - 60]
if len(recovery_times) > MAX_RECOVERIES_PER_MINUTE:
log_fn( # type: ignore[operator]
f"stdin spurious-EOF recovery rate exceeded "
f"({len(recovery_times)}/min, cap {MAX_RECOVERIES_PER_MINUTE})"
)
return False
diag = diagnose_stdin_state()
log_fn(f"stdin spurious EOF (subprocess O_NONBLOCK flip), recovering: {diag}") # type: ignore[operator]
# Clear ``O_NONBLOCK`` on the shared file description.
os.set_blocking(0, True)
# Also clear ``SO_RCVTIMEO`` if it was set by a child on the shared
# description. A non-zero timeout would cause the next ``readline()``
# to time out and return ``''`` again, looping until the rate limiter
# kicks in. Clearing it restores fully blocking semantics.
if _HAS_SOCKET and _socket is not None:
try:
s = _socket.fromfd(0, _socket.AF_UNIX, _socket.SOCK_STREAM)
try:
# Zero timeval: tv_sec=0, tv_usec=0 (struct timeval on most platforms)
s.setsockopt(_socket.SOL_SOCKET, _socket.SO_RCVTIMEO, struct.pack("ll", 0, 0))
finally:
s.close()
except Exception:
pass
# ``_io.TextIOWrapper.readline`` returns an empty string on ``EAGAIN``
# but does NOT stick EOF; after restoring blocking, the next call will
# block until data arrives or the peer truly closes.
return True
+950
View File
@@ -0,0 +1,950 @@
"""Persistent dashboard compute-host process.
Phase 0 used this module as a deterministic line-JSON spike. Phase 1 keeps the
same transport and turns it into the long-lived child that owns live AIAgent
objects when ``dashboard.turn_isolation`` is enabled.
"""
from __future__ import annotations
import argparse
import concurrent.futures
import contextlib
import json
import os
import signal
import subprocess
import sys
import threading
import time
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, Collection
from agent.interrupt_compat import request_hard_interrupt
def now_ns() -> int:
return time.perf_counter_ns()
@dataclass
class SpikeAgent:
"""A deterministic AIAgent-shaped object for pipe/interrupt measurements."""
session_id: str
history: list[dict[str, str]] = field(default_factory=list)
_interrupt: threading.Event = field(default_factory=threading.Event)
def clear_interrupt(self) -> None:
self._interrupt.clear()
def interrupt(self, *, hard_cancel: bool = False) -> None:
self._interrupt.set()
def run_conversation(
self,
prompt: str,
*,
conversation_history: list[dict[str, str]] | None = None,
stream_callback: Callable[[str], None] | None = None,
delta_count: int = 24,
delay_s: float = 0.001,
) -> dict[str, Any]:
base_history = list(conversation_history if conversation_history is not None else self.history)
chunks: list[str] = []
interrupted = False
for index in range(max(0, int(delta_count))):
if self._interrupt.is_set():
interrupted = True
break
chunk = f"{self.session_id}:{prompt}:{index:04d} "
chunks.append(chunk)
if stream_callback is not None:
stream_callback(chunk)
if delay_s > 0:
time.sleep(delay_s)
if self._interrupt.is_set():
interrupted = True
final = "".join(chunks)
if interrupted:
final += "[interrupted]"
messages = [
*base_history,
{"role": "user", "content": prompt},
{"role": "assistant", "content": final},
]
self.history = messages
return {"final_response": final, "messages": messages, "interrupted": interrupted}
@dataclass
class HostSession:
sid: str
agent: SpikeAgent
history_version: int = 0
running: bool = False
lock: threading.Lock = field(default_factory=threading.Lock)
class _HostTransport:
def __init__(self, emit: Callable[[dict[str, Any]], None]) -> None:
self._emit = emit
def write(self, obj: dict) -> bool:
sid = ""
try:
if obj.get("method") == "event":
sid = str(((obj.get("params") or {}).get("session_id")) or "")
except Exception:
sid = ""
self._emit({"type": "rpc", "sid": sid, "message": obj})
return True
def close(self) -> None:
return None
def _repo_root() -> Path:
return Path(__file__).resolve().parents[1]
def _build_sha() -> str:
try:
return subprocess.check_output(
["git", "rev-parse", "HEAD"],
cwd=str(_repo_root()),
text=True,
encoding="utf-8",
errors="replace",
stderr=subprocess.DEVNULL,
timeout=2,
).strip()
except Exception:
return "unknown"
# Slice of ``ComputeHost.shutdown``'s budget held back for the post-drain
# finalize. ``HostSupervisor._terminate_pid`` SIGKILLs the host
# ``_SHUTDOWN_TIMEOUT_SECS`` (10s — the same value as ``shutdown``'s default
# ``wait``) after SIGTERM, so a drain allowed to consume the whole budget would
# leave the flush racing that kill and persist nothing at all.
_FLUSH_RESERVE_SECS = 1.0
class ComputeHost:
def __init__(
self,
*,
stdout: Any = None,
max_workers: int | None = None,
heartbeat_secs: int | float | None = None,
) -> None:
self._stdout = stdout or sys.stdout
self._write_lock = threading.Lock()
self._sessions: dict[str, HostSession] = {}
self._executor = concurrent.futures.ThreadPoolExecutor(
max_workers=max_workers or _default_workers(),
thread_name_prefix="compute-host-turn",
)
self._closed = threading.Event()
self._parent_pid = os.getppid()
self._boot_id = uuid.uuid4().hex
self._progress_counter = 0
self._progress_lock = threading.Lock()
# Future -> the ``sid`` whose turn it is running. ``shutdown`` needs to
# know *whose* turn is still live, not merely that something is, so that
# it can leave those sessions unfinalized; a bare set cannot answer that.
self._turn_futures: dict[concurrent.futures.Future, str] = {}
self._turn_futures_lock = threading.Lock()
self._transport = _HostTransport(self.emit)
self._heartbeat_secs = (
float(heartbeat_secs)
if heartbeat_secs is not None
else float(os.environ.get("HERMES_COMPUTE_HOST_HEARTBEAT_SECS") or "15")
)
if self._heartbeat_secs > 0:
threading.Thread(target=self._heartbeat_loop, name="compute-host-heartbeat", daemon=True).start()
threading.Thread(target=self._parent_guard_loop, name="compute-host-ppid-guard", daemon=True).start()
def emit(self, frame: dict[str, Any]) -> None:
frame.setdefault("host_ns", now_ns())
data = json.dumps(frame, separators=(",", ":"), ensure_ascii=False)
with self._write_lock:
print(data, file=self._stdout, flush=True)
def close(self) -> None:
self._closed.set()
self._executor.shutdown(wait=False, cancel_futures=True)
def shutdown(self, *, reason: str = "shutdown", wait: float = 10.0) -> None:
"""Drain in-flight turns, then finalize every session.
Order matters. ``_finalize_session`` is a one-shot latch: it sets
``session["_finalized"]`` and every later call returns immediately, so
the flush gets exactly one chance to snapshot the session. Running it
before the drain meant that chance was spent while turns were still
producing output — the tail was unpersistable, ``on_session_end`` fired
with ``interrupted=True`` against a session that was still running, and
the active-session lease was released out from under a live turn. The
drain loop exists precisely so that work survives; finalizing first
defeated it.
``_FLUSH_RESERVE_SECS`` of the budget — but never more than half of it,
so a short explicit ``wait`` still gets a real drain — is withheld from
the drain, so the flush still runs when in-flight turns outlast the
window. ``wait`` itself is unchanged, so this adds no shutdown latency
and no new exposure to the supervisor's kill escalation.
Sessions whose turn is *still running* when the drain deadline expires
are excluded from that flush. Finalizing one would spend its single
latch mid-turn — ``shutdown(wait=False, cancel_futures=True)`` below
does not join the turn — leaving the session permanently
un-finalizable and its active-session lease released out from under
live work: exactly the race the drain exists to close, just moved later.
Leaving them unfinalized keeps them recoverable instead. Sessions with
no live turn finalize here as they always have.
NOTE: ``server._shutdown_sessions`` is registered via ``atexit``
(``server.py``) and runs on ``SystemExit`` after ``shutdown()``
returns. It calls ``_finalize_session`` on any session still in
``server._sessions`` — including ones skipped here whose turn is
still running, since ``_executor.shutdown(wait=False)`` only cancels
pending futures, not running ones. The orphan path (``os._exit(0)``)
bypasses atexit, so the skip is fully effective there. For the
SIGTERM and stdin_closed paths the atexit handler may re-finalize
skipped sessions; this is a pre-existing issue (the old finalize-
first order had the same atexit interaction) and does not make the
drain-before-finalize reordering worse. A follow-up could gate
``_shutdown_sessions`` on ``not session.get("_finalized") and not
session.get("running")`` to close the gap.
"""
self._closed.set()
budget = max(0.0, wait)
deadline = time.monotonic() + budget - min(_FLUSH_RESERVE_SECS, budget / 2.0)
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
break
with self._turn_futures_lock:
pending = [f for f in self._turn_futures if not f.done()]
if not pending:
break
# Bounded by ``remaining``: a flat 0.05s sleep would overshoot the
# deadline and eat into the reserve it is there to protect, which
# for a small ``wait`` can be the whole of it.
time.sleep(min(0.05, remaining))
with self._turn_futures_lock:
live_sids = {sid for future, sid in self._turn_futures.items() if sid and not future.done()}
self.flush_all_sessions(reason=reason, skip_sids=live_sids)
self._executor.shutdown(wait=False, cancel_futures=True)
def flush_all_sessions(
self,
*,
reason: str = "shutdown",
skip_sids: Collection[str] | None = None,
) -> None:
"""Finalize every server session except the ones named in ``skip_sids``.
``skip_sids`` carries the sessions whose turn is still live, which must
not spend their one-shot ``_finalize_session`` while running.
"""
try:
from tui_gateway import server
except Exception:
return
skip = set(skip_sids or ())
for sid, session in list(getattr(server, "_sessions", {}).items()):
if sid in skip:
continue
try:
server._finalize_session(session, end_reason=f"compute_host_{reason}")
except Exception:
pass
def handle_frame(self, frame: dict[str, Any]) -> None:
kind = str(frame.get("type") or "")
if kind == "session.seed":
self._handle_seed(frame)
elif kind == "turn.start":
self._handle_turn_start(frame)
elif kind == "interrupt":
self._handle_interrupt(frame)
elif kind == "respond":
self._handle_respond(frame)
elif kind == "reload_mcp":
self._handle_reload_mcp(frame)
elif kind == "control":
self._handle_control(frame)
elif kind == "shutdown":
self.emit({"type": "shutdown.ack", "request_id": frame.get("request_id")})
# Explicit supervisor/test shutdown is a clean child-process close;
# SIGTERM and orphan paths are the durability flush paths.
self._closed.set()
self._executor.shutdown(wait=False, cancel_futures=True)
else:
self.emit(
{
"type": "error",
"request_id": frame.get("request_id"),
"message": f"unknown frame type: {kind}",
}
)
# ── Phase-0 deterministic spike frames ─────────────────────────────
def _handle_seed(self, frame: dict[str, Any]) -> None:
sid = str(frame.get("sid") or "")
if not sid:
self.emit({"type": "error", "request_id": frame.get("request_id"), "message": "sid required"})
return
history = frame.get("history")
if not isinstance(history, list):
history = []
self._sessions[sid] = HostSession(sid=sid, agent=SpikeAgent(sid, list(history)))
self.emit({"type": "session.seeded", "sid": sid, "request_id": frame.get("request_id")})
def _track_turn_future(self, future: concurrent.futures.Future, sid: str) -> None:
"""Register an in-flight turn against the session running it.
The callback has to remove the entry under the lock — a bare
``dict.pop`` bound method is not the drop-in ``set.discard`` was — or
the mapping grows for the life of the host.
"""
with self._turn_futures_lock:
self._turn_futures[future] = sid
future.add_done_callback(self._untrack_turn_future)
def _untrack_turn_future(self, future: concurrent.futures.Future) -> None:
with self._turn_futures_lock:
self._turn_futures.pop(future, None)
def _handle_turn_start(self, frame: dict[str, Any]) -> None:
sid = str(frame.get("sid") or "")
if sid in self._sessions:
self._handle_spike_turn_start(frame)
return
future = self._executor.submit(self._run_real_turn, dict(frame))
self._track_turn_future(future, sid)
def _handle_spike_turn_start(self, frame: dict[str, Any]) -> None:
sid = str(frame.get("sid") or "")
session = self._sessions.get(sid)
if session is None:
self.emit({"type": "turn.error", "sid": sid, "request_id": frame.get("request_id"), "message": "unknown session"})
return
with session.lock:
if session.running:
self.emit({"type": "turn.error", "sid": sid, "request_id": frame.get("request_id"), "message": "session busy"})
return
session.running = True
future = self._executor.submit(self._run_spike_turn, session, dict(frame))
self._track_turn_future(future, sid)
def _handle_interrupt(self, frame: dict[str, Any]) -> None:
sid = str(frame.get("sid") or "")
spike = self._sessions.get(sid)
if spike is not None:
request_hard_interrupt(spike.agent)
self.emit(
{
"type": "interrupt.ack",
"sid": sid,
"request_id": frame.get("request_id"),
"applied": True,
"applied_ns": now_ns(),
}
)
return
try:
from tui_gateway import server
session = server._sessions.get(sid)
if session is None:
self.emit({"type": "interrupt.ack", "sid": sid, "request_id": frame.get("request_id"), "applied": False})
return
# In the child, `_session_uses_compute_host()` is false, so the
# shared helper interrupts the local agent and releases this
# process's pending clarify Event. The parent has only a metadata
# mirror and cannot release the prompt that is blocking the turn.
server._interrupt_session_turn(sid, session)
self.emit({"type": "interrupt.ack", "sid": sid, "request_id": frame.get("request_id"), "applied": True, "applied_ns": now_ns()})
except Exception as exc:
self.emit({"type": "interrupt.ack", "sid": sid, "request_id": frame.get("request_id"), "applied": False, "message": str(exc)})
def _handle_respond(self, frame: dict[str, Any]) -> None:
"""Resolve an interactive request in the host-owned pending registry."""
sid = str(frame.get("sid") or "")
request_id = frame.get("request_id")
try:
from tui_gateway import server
if sid not in server._sessions:
self.emit(
{
"type": "respond.error",
"sid": sid,
"request_id": request_id,
"message": "session not found",
}
)
return
params = frame.get("params")
if not isinstance(params, dict):
self.emit(
{
"type": "respond.error",
"sid": sid,
"request_id": request_id,
"message": "response params must be an object",
}
)
return
response = server._methods["clarify.respond"](request_id, params)
self.emit(
{
"type": "respond.ack",
"sid": sid,
"request_id": request_id,
"response": response,
}
)
except Exception as exc:
self.emit(
{
"type": "respond.error",
"sid": sid,
"request_id": request_id,
"message": str(exc),
}
)
def _run_spike_turn(self, session: HostSession, frame: dict[str, Any]) -> None:
request_id = frame.get("request_id") or uuid.uuid4().hex
prompt = str(frame.get("prompt") or frame.get("text") or "")
try:
delta_count = int(frame.get("delta_count", 24))
except (TypeError, ValueError):
delta_count = 24
try:
delay_s = float(frame.get("delay_s", 0.001))
except (TypeError, ValueError):
delay_s = 0.001
with session.lock:
history = list(session.agent.history)
session.agent.clear_interrupt()
self.emit({"type": "turn.started", "sid": session.sid, "request_id": request_id, "started_ns": now_ns()})
def stream(delta: str) -> None:
self._bump_progress()
self.emit(
{
"type": "delta",
"sid": session.sid,
"request_id": request_id,
"text": delta,
"emitted_ns": now_ns(),
}
)
try:
result = session.agent.run_conversation(
prompt,
conversation_history=history,
stream_callback=stream,
delta_count=delta_count,
delay_s=delay_s,
)
with session.lock:
session.history_version += 1
session.running = False
history_version = session.history_version
self._bump_progress()
self.emit(
{
"type": "turn.end",
"sid": session.sid,
"request_id": request_id,
"history_version": history_version,
"message_count": len(result.get("messages") or []),
"interrupted": bool(result.get("interrupted")),
"ended_ns": now_ns(),
}
)
except Exception as exc: # pragma: no cover - defensive host boundary
with session.lock:
session.running = False
self.emit({"type": "turn.error", "sid": session.sid, "request_id": request_id, "message": str(exc)})
# ── Real dashboard turn path ───────────────────────────────────────
def _run_real_turn(self, frame: dict[str, Any]) -> None:
sid = str(frame.get("sid") or "")
request_id = str(frame.get("request_id") or uuid.uuid4().hex)
if not sid:
self.emit({"type": "turn.error", "sid": sid, "request_id": request_id, "message": "sid required"})
return
try:
from tui_gateway import server
session = self._ensure_server_session(server, frame)
with session["history_lock"]:
queued_prompt_generation = frame.get("queued_prompt_generation")
if (
queued_prompt_generation is not None
and int(session.get("_queued_prompt_generation", 0))
!= int(queued_prompt_generation)
):
self.emit(
{
"type": "turn.end",
"sid": sid,
"request_id": request_id,
"interrupted": True,
"ended_ns": now_ns(),
}
)
return
if session.get("running"):
self.emit({"type": "turn.error", "sid": sid, "request_id": request_id, "message": "session busy"})
return
session["running"] = True
session["_turn_cancel_requested"] = False
session["last_active"] = time.time()
server._start_inflight_turn(session, frame.get("text") if "text" in frame else frame.get("prompt"))
self.emit({"type": "turn.started", "sid": sid, "request_id": request_id, "started_ns": now_ns()})
try:
server._ensure_session_db_row(session)
except Exception:
pass
try:
import hermes_undo
hermes_undo.on_user_message_appended(session["session_key"])
except Exception:
pass
try:
server._persist_branch_seed(session)
except Exception:
pass
text = frame.get("text") if "text" in frame else frame.get("prompt", "")
server._run_prompt_submit(
request_id,
sid,
session,
text,
display_kind=frame.get("display_kind") or None,
)
run_thread = session.get("_run_thread")
if run_thread is not None and hasattr(run_thread, "join"):
run_thread.join()
with session["history_lock"]:
history_version = int(session.get("history_version", 0))
message_count = len(session.get("history") or [])
interrupted = bool(session.get("_turn_cancel_requested"))
session_key = str(session.get("session_key") or "")
session_info = server._session_info(session.get("agent"), session)
self._bump_progress()
self.emit(
{
"type": "turn.end",
"sid": sid,
"request_id": request_id,
"history_version": history_version,
"session_key": session_key,
"message_count": message_count,
"interrupted": interrupted,
"ended_ns": now_ns(),
"session_info": session_info,
"session_info_emitted": True,
}
)
except Exception as exc:
try:
from tui_gateway import server
session = server._sessions.get(sid)
if session is not None:
with session.get("history_lock", threading.Lock()):
session["running"] = False
server._clear_inflight_turn(session)
except Exception:
pass
self.emit({"type": "turn.error", "sid": sid, "request_id": request_id, "reason": "exception", "message": str(exc)})
def _ensure_server_session(self, server: Any, frame: dict[str, Any]) -> dict:
sid = str(frame.get("sid") or "")
key = str(frame.get("session_key") or sid)
session = server._sessions.get(sid)
if session is not None:
session["transport"] = self._transport
if frame.get("cols") is not None:
session["cols"] = int(frame.get("cols") or 80)
if frame.get("cwd"):
session["cwd"] = str(frame.get("cwd"))
if frame.get("profile_home"):
session["profile_home"] = str(frame.get("profile_home"))
if isinstance(frame.get("attached_images"), list):
session["attached_images"] = list(frame.get("attached_images") or [])
return session
history = frame.get("history") if isinstance(frame.get("history"), list) else []
profile_home = str(frame.get("profile_home") or "")
session_db = None
owns_db = False
home_token = None
secret_token = None
try:
if profile_home:
from hermes_constants import set_hermes_home_override
from agent.secret_scope import build_profile_secret_scope, set_secret_scope
from hermes_state import SessionDB
home_token = set_hermes_home_override(profile_home)
secret_token = set_secret_scope(build_profile_secret_scope(Path(profile_home)))
# DEDICATED handle — ours only until _make_agent succeeds. Every
# path after that keeps the agent registered in
# server._sessions[sid] (via _init_session, or the fallback dict
# in the except below), so the agent is the right owner; a
# _make_agent that RAISES is the one path where nothing takes it.
from hermes_state import get_shared_session_db
session_db = get_shared_session_db(Path(profile_home) / "state.db")
owns_db = True
agent = server._make_agent(
sid,
key,
session_id=key,
model_override=frame.get("model_override"),
reasoning_config_override=frame.get("reasoning_config_override"),
service_tier_override=frame.get("service_tier_override"),
platform_override=frame.get("source"),
context_cwd_is_launch_artifact=bool(
frame.get("context_cwd_is_launch_artifact", False)
),
session_db=session_db,
)
if server._transfer_db_to_agent(agent, session_db):
owns_db = False
finally:
if owns_db and session_db is not None:
with contextlib.suppress(Exception):
from hermes_state import release_or_close
release_or_close(session_db)
if home_token is not None:
try:
from hermes_constants import reset_hermes_home_override
from agent.secret_scope import reset_secret_scope
reset_hermes_home_override(home_token)
reset_secret_scope(secret_token)
except Exception:
pass
try:
from tui_gateway.transport import bind_transport, reset_transport
token = bind_transport(self._transport)
try:
server._init_session(
sid,
key,
agent,
list(history),
cols=int(frame.get("cols") or 80),
cwd=str(frame.get("cwd") or "") or None,
session_db=session_db,
source=frame.get("source"),
)
finally:
reset_transport(token)
except Exception:
# If _init_session's side machinery (slash worker, approval notify) is
# unavailable, keep a minimal host-owned session rather than failing
# the turn after the expensive agent build succeeded.
server._sessions[sid] = {
"agent": agent,
"session_key": key,
"history": list(history),
"history_lock": threading.Lock(),
"history_version": int(frame.get("history_version") or 0),
"inflight_turn": None,
"created_at": time.time(),
"last_active": time.time(),
"running": False,
"attached_images": [],
"image_counter": 0,
"cwd": str(frame.get("cwd") or os.getcwd()),
"cols": int(frame.get("cols") or 80),
"slash_worker": None,
"show_reasoning": server._load_show_reasoning(),
"tool_progress_mode": server._load_tool_progress_mode(),
"edit_snapshots": {},
"tool_started_at": {},
"model_override": frame.get("model_override"),
"source": server._sanitize_client_source(frame.get("source")),
"transport": self._transport,
}
session = server._sessions[sid]
session["transport"] = self._transport
session["profile_home"] = profile_home or session.get("profile_home")
if isinstance(frame.get("attached_images"), list):
session["attached_images"] = list(frame.get("attached_images") or [])
if frame.get("model_override") is not None:
session["model_override"] = frame.get("model_override")
return session
def _handle_reload_mcp(self, frame: dict[str, Any]) -> None:
sid = str(frame.get("sid") or "")
request_id = frame.get("request_id")
try:
from tui_gateway import server
resp = server.handle_request({"id": request_id, "method": "reload.mcp", "params": {"session_id": sid, "confirm": True}})
self.emit({"type": "reload_mcp.ack", "sid": sid, "request_id": request_id, "response": resp})
except Exception as exc:
self.emit({"type": "control.error", "sid": sid, "request_id": request_id, "message": str(exc)})
def _handle_control(self, frame: dict[str, Any]) -> None:
sid = str(frame.get("sid") or "")
request_id = frame.get("request_id")
route_name = str(frame.get("route_name") or "")
try:
from tui_gateway import server
from tui_gateway.host_supervisor import MUTATOR_ROUTE_TABLE
route = MUTATOR_ROUTE_TABLE.get(route_name)
if route is None:
self.emit({"type": "control.error", "sid": sid, "request_id": request_id, "message": f"unclassified route: {route_name}"})
return
session = server._sessions.get(sid)
if session is None:
self.emit({"type": "control.error", "sid": sid, "request_id": request_id, "message": "session not found"})
return
if route == "idle-gated" and session.get("running"):
self.emit({"type": "control.error", "sid": sid, "request_id": request_id, "message": "session busy"})
return
if route_name == "reload.mcp":
self._handle_reload_mcp({**frame, "type": "reload_mcp"})
return
if route_name == "session.save":
response = server._methods["session.save"](
request_id,
{"session_id": sid},
)
if "error" in response:
self.emit(
{
"type": "control.error",
"sid": sid,
"request_id": request_id,
"message": str(response["error"].get("message") or "session save failed"),
}
)
return
self.emit(
{
"type": "control.ack",
"sid": sid,
"request_id": request_id,
"route_name": route_name,
"result": response.get("result") or {},
}
)
return
if route_name == "session.compress":
command = str(frame.get("command") or "")
focus_topic = command.removeprefix("/compress").strip()
response = server._methods["session.compress"](
request_id,
{
"session_id": sid,
**({"focus_topic": focus_topic} if focus_topic else {}),
},
)
if "error" in response:
self.emit(
{
"type": "control.error",
"sid": sid,
"request_id": request_id,
"message": str(response["error"].get("message") or "session compression failed"),
}
)
return
with session["history_lock"]:
session_key = str(session.get("session_key") or "")
history_version = int(session.get("history_version", 0))
message_count = len(session.get("history") or [])
self.emit(
{
"type": "control.ack",
"sid": sid,
"request_id": request_id,
"route_name": route_name,
"result": response.get("result") or {},
"session_key": session_key,
"history_version": history_version,
"message_count": message_count,
"session_info": server._session_info(session.get("agent"), session),
}
)
return
command = str(frame.get("command") or "")
output = ""
if command:
output = server._mirror_slash_side_effects(sid, session, command)
with session["history_lock"]:
messages = server._history_to_messages(list(session.get("history") or []))
history_version = int(session.get("history_version", 0))
message_count = len(session.get("history") or [])
session_key = str(session.get("session_key") or "")
self.emit(
{
"type": "control.ack",
"sid": sid,
"request_id": request_id,
"route_name": route_name,
"output": output,
"session_key": session_key,
"history_version": history_version,
"message_count": message_count,
"messages": messages,
"session_info": server._session_info(session.get("agent"), session),
}
)
except Exception as exc:
if route_name in {"session.compress", "slash.compress"}:
# The compress mirror defers the context-engine boundary
# notification until the host commits. If anything raises
# between queueing and finalize (e.g. building the ack's
# session_info), discard the pending notification so it can't
# leak onto the agent and fire against a rejected boundary on
# a later compress. finalize is exactly-once, so this is a
# no-op when the mirror already emitted or discarded it.
try:
from tui_gateway import server as _server
from agent.conversation_compression import (
finalize_context_engine_compression_notification,
)
_agent = (_server._sessions.get(sid) or {}).get("agent")
if _agent is not None:
finalize_context_engine_compression_notification(
_agent,
committed=False,
)
except Exception:
pass
self.emit({"type": "control.error", "sid": sid, "request_id": request_id, "message": str(exc)})
def _bump_progress(self) -> None:
with self._progress_lock:
self._progress_counter += 1
def _heartbeat_loop(self) -> None:
while not self._closed.wait(self._heartbeat_secs):
with self._turn_futures_lock:
active_turns = sum(1 for f in self._turn_futures if not f.done())
with self._progress_lock:
counter = self._progress_counter
self.emit(
{
"type": "hb",
"active_turns": active_turns,
"progress_counter": counter,
"rss_mb": _rss_mb(os.getpid()),
}
)
def _parent_guard_loop(self) -> None:
while not self._closed.wait(1.0):
ppid = os.getppid()
if ppid in {0, 1} or (self._parent_pid and ppid != self._parent_pid):
self.emit({"type": "orphan", "old_ppid": self._parent_pid, "ppid": ppid})
self.shutdown(reason="orphan")
os._exit(0)
def _rss_mb(pid: int) -> float:
try:
out = subprocess.check_output(["ps", "-o", "rss=", "-p", str(pid)], text=True, encoding="utf-8", errors="replace", stdin=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=2).strip()
return int(out.splitlines()[-1].strip()) / 1024.0 if out else 0.0
except Exception:
return 0.0
def _default_workers() -> int:
try:
return max(2, int(os.environ.get("HERMES_TUI_RPC_POOL_WORKERS") or "8"))
except (TypeError, ValueError):
return 8
def run_host(stdin: Any = None, stdout: Any = None) -> None:
os.environ["HERMES_COMPUTE_HOST_CHILD"] = "1"
stdin = stdin or sys.stdin
host = ComputeHost(stdout=stdout or sys.stdout)
shutting_down = threading.Event()
def _signal_handler(_signum, _frame) -> None:
if shutting_down.is_set():
return
shutting_down.set()
host.shutdown(reason="sigterm")
raise SystemExit(0)
try:
signal.signal(signal.SIGTERM, _signal_handler)
signal.signal(signal.SIGINT, _signal_handler)
except Exception:
pass
host.emit(
{
"type": "hello",
"host_pid": os.getpid(),
"boot_id": host._boot_id,
"build_sha": _build_sha(),
"cwd": os.getcwd(),
"hermes_home": os.environ.get("HERMES_HOME", ""),
}
)
def _reader() -> None:
for raw in stdin:
if host._closed.is_set():
break
try:
frame = json.loads(raw)
except json.JSONDecodeError as exc:
host.emit({"type": "error", "message": f"invalid json: {exc}"})
continue
if not isinstance(frame, dict):
host.emit({"type": "error", "message": "frame must be an object"})
continue
host.handle_frame(frame)
if frame.get("type") == "shutdown":
os._exit(0)
if host._closed.is_set():
break
reader = threading.Thread(target=_reader, name="compute-host-control-reader", daemon=True)
reader.start()
try:
while not host._closed.wait(0.2):
if not reader.is_alive():
break
finally:
host.shutdown(reason="stdin_closed", wait=2.0)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Dashboard compute-host process")
parser.parse_args(argv)
run_host()
return 0
if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())
+516
View File
@@ -0,0 +1,516 @@
import os
import sys
# Stop a ``utils/`` (or ``proxy/``, ``ui/``) package in the launch directory
# from shadowing Hermes's own top-level modules. ``hermes_bootstrap`` lives at
# the repo root next to this package, so importing it is safe before the guard
# runs (its name won't collide with a user package), and it owns the canonical
# path-hardening logic shared with the other entry points.
import hermes_bootstrap
hermes_bootstrap.harden_import_path()
import json
import logging
import signal
import threading
import time
import traceback
from tui_gateway._stdin_recovery import handle_spurious_eof
from tui_gateway import server
from tui_gateway.event_replay import replay_epoch
from tui_gateway.server import _CRASH_LOG, dispatch, resolve_skin, write_json
from tui_gateway.transport import TeeTransport
logger = logging.getLogger(__name__)
# Handle for the background MCP tool-discovery thread (see
# ensure_mcp_discovery_started). The first agent build briefly joins this so
# already-spawning fast servers land before the agent snapshots its tool list
# (see wait_for_mcp_discovery). Stays None when discovery is delegated to the
# shared owner in hermes_cli.mcp_startup — the wait/in-flight/join helpers
# below consult both owners.
_mcp_discovery_thread = None
# True once ensure_mcp_discovery_started decided this process has MCP servers
# configured and spawned discovery through the shared owner. Lets
# wait_for_mcp_discovery re-invoke the (idempotent) spawn on later agent
# builds so the retry-after-zero-connected allowance in
# hermes_cli.mcp_startup.start_background_mcp_discovery can actually fire —
# without this, the single spawn is the only call and a first run that
# connected nothing latches the process MCP-less. Kept as a flag (rather than
# re-probing config) so non-MCP sessions never pay the tools.mcp_tool import
# on the per-agent-build wait path.
_mcp_discovery_enabled = False
def _install_sidecar_publisher() -> None:
"""Mirror every dispatcher emit to the dashboard sidebar via WS.
Activated by `HERMES_TUI_SIDECAR_URL`, set by the dashboard's
``/api/pty`` endpoint when a chat tab passes a ``channel`` query param.
Best-effort: connect failure or runtime drop falls back to stdio-only.
"""
url = os.environ.get("HERMES_TUI_SIDECAR_URL")
if not url:
return
from tui_gateway.event_publisher import WsPublisherTransport
server._stdio_transport = TeeTransport(
server._stdio_transport, WsPublisherTransport(url)
)
# How long to wait for orderly shutdown (atexit + finalisers) before
# falling back to ``os._exit(0)`` so a wedged worker mid-flush can't
# strand the process. 1s covers the gateway's own shutdown work
# (thread-pool drain + session finalize) on every machine we've
# tested; override via ``HERMES_TUI_GATEWAY_SHUTDOWN_GRACE_S`` if a
# slower environment needs more headroom (e.g. encrypted disks
# flushing checkpoints) and accept that a longer grace also means a
# longer wait when shutdown actually deadlocks.
_DEFAULT_SHUTDOWN_GRACE_S = 1.0
def _shutdown_grace_seconds() -> float:
raw = (os.environ.get("HERMES_TUI_GATEWAY_SHUTDOWN_GRACE_S") or "").strip()
if not raw:
return _DEFAULT_SHUTDOWN_GRACE_S
try:
value = float(raw)
except ValueError:
return _DEFAULT_SHUTDOWN_GRACE_S
return value if value > 0 else _DEFAULT_SHUTDOWN_GRACE_S
def _log_signal(signum: int, frame) -> None:
"""Capture WHICH thread and WHERE a termination signal hit us.
SIG_DFL for SIGPIPE kills the process silently the instant any
background thread (TTS playback, beep, voice status emitter, etc.)
writes to a stdout the TUI has stopped reading. Without this
handler the gateway-exited banner in the TUI has no trace — the
crash log never sees a Python exception because the kernel reaps
the process before the interpreter runs anything.
Termination semantics: ``sys.exit(0)`` here used to race the worker
pool — a thread holding ``_stdout_lock`` mid-flush would block the
interpreter shutdown indefinitely. We now log the stack, give the
process the configured shutdown grace
(``HERMES_TUI_GATEWAY_SHUTDOWN_GRACE_S``, default
``_DEFAULT_SHUTDOWN_GRACE_S``) to drain naturally on a background
thread, and fall back to ``os._exit(0)`` so a wedged write/flush
can never strand the process.
"""
# SIGPIPE and SIGHUP don't exist on Windows — build the lookup
# dict from attributes that actually exist on the current platform.
_signal_names: dict[int, str] = {}
for _attr in ("SIGPIPE", "SIGTERM", "SIGHUP", "SIGINT", "SIGBREAK"):
_sig = getattr(signal, _attr, None)
if _sig is not None:
_signal_names[int(_sig)] = _attr
name = _signal_names.get(signum, f"signal {signum}")
try:
os.makedirs(os.path.dirname(_CRASH_LOG), exist_ok=True)
with open(_CRASH_LOG, "a", encoding="utf-8") as f:
f.write(
f"\n=== {name} received · {time.strftime('%Y-%m-%d %H:%M:%S')} ===\n"
)
if frame is not None:
f.write("main-thread stack at signal delivery:\n")
traceback.print_stack(frame, file=f)
# All live threads — signal may have been triggered by a
# background thread (write to broken stdout from TTS, etc.).
import threading as _threading
for tid, th in _threading._active.items():
f.write(f"\n--- thread {th.name} (id={tid}) ---\n")
f.write("".join(traceback.format_stack(sys._current_frames().get(tid))))
except Exception:
pass
print(f"[gateway-signal] {name}", file=sys.stderr, flush=True)
import threading as _threading
def _hard_exit() -> None:
# If a worker thread is still mid-flush on a half-closed pipe,
# ``sys.exit(0)`` would wait forever for it to drop the GIL on
# interpreter shutdown. ``os._exit`` skips atexit handlers but
# breaks the deadlock. The crash log + stderr line above are
# the forensic trail.
os._exit(0)
timer = _threading.Timer(_shutdown_grace_seconds(), _hard_exit)
timer.daemon = True
timer.start()
# ── Flush sessions before exit ───────────────────────────────────
# The atexit handler (_shutdown_sessions) is registered in
# tui_gateway/server.py, but a worker thread holding the GIL or
# _stdout_lock can block atexit from completing within the grace
# window. Explicitly finalize sessions here so that unpersisted
# messages reach state.db before the hard-exit timer fires.
try:
from tui_gateway.server import _shutdown_sessions
_shutdown_sessions()
except Exception:
pass
try:
sys.exit(0)
except SystemExit:
# Re-raise so the main-thread interpreter unwinds and runs
# atexit + finalisers inside the grace window. Python signal
# handlers always run on the main thread, but a worker thread
# holding ``_stdout_lock`` mid-flush can keep that unwind
# waiting indefinitely; the daemon timer above is the safety
# net for that exact case.
raise
# SIGPIPE: ignore, don't exit. The old SIG_DFL killed the process
# silently whenever a *background* thread (TTS playback chain, voice
# debug stderr emitter, beep thread) wrote to a pipe the TUI had gone
# quiet on — even though the main thread was perfectly fine waiting on
# stdin. Ignoring the signal lets Python raise BrokenPipeError on the
# offending write (write_json already handles that with a clean
# sys.exit(0) + _log_exit), which keeps the gateway alive as long as
# the main command pipe is still readable. Terminal signals still
# route through _log_signal so kills and hangups are diagnosable.
#
# SIGPIPE and SIGHUP don't exist on Windows; guard each installation
# with hasattr so ``python -m tui_gateway.entry`` (spawned by
# ``hermes --tui``) imports cleanly there. SIGBREAK (Windows' Ctrl+Break)
# is installed when available as a weaker equivalent of SIGHUP.
#
# signal.signal() is only legal in the MAIN thread. On the Desktop/WebSocket
# agent-build path, server._build() runs in a daemon thread and does
# ``from tui_gateway.entry import ensure_mcp_discovery_started`` as the first
# import of entry (entry.main() is never run there), which used to raise
# "ValueError: signal only works in main thread of the main interpreter" and
# abort MCP discovery startup. Install each handler only when we're in the
# main thread: handlers are process-global, so a main-thread import anywhere
# in the process still installs them for everyone, and an off-thread import
# (Desktop build path) simply no-ops instead of crashing the import. This
# preserves the original SIG_IGN/SIG_DFL behavior on the classic TUI/serve
# path while fixing the off-thread import crash.
def _install_signal(signame, handler):
"""Install a signal handler if legal in this thread.
signal.signal() raises ValueError outside the main thread; skip silently
there so a worker-thread import of this module (Desktop build path) does
not abort. On any main-thread import the handler is installed as before.
"""
if threading.current_thread() is not threading.main_thread():
return
sig = getattr(signal, signame, None)
if sig is None:
return # Windows: SIGPIPE/SIGHUP absent
try:
signal.signal(sig, handler)
except (ValueError, OSError, RuntimeError):
# Not in the main thread despite the check, or handler rejected.
# Skip rather than crash the import (see above).
pass
_install_signal("SIGPIPE", signal.SIG_IGN)
_install_signal("SIGTERM", _log_signal)
if hasattr(signal, "SIGHUP"):
_install_signal("SIGHUP", _log_signal)
elif hasattr(signal, "SIGBREAK"):
# Windows-only: Ctrl+Break in a console window delivers SIGBREAK.
# Route it through the same handler so kills are diagnosable.
_install_signal("SIGBREAK", _log_signal)
_install_signal("SIGINT", signal.SIG_IGN)
def _log_exit(reason: str) -> None:
"""Record why the gateway subprocess is shutting down.
Three exit paths (startup write fail, parse-error-response write fail,
dispatch-response write fail, stdin EOF) all collapse into a silent
sys.exit(0) here. Without this trail the TUI shows "gateway exited"
with no actionable clue about WHICH broken pipe or WHICH message
triggered it — the main reason voice-mode turns look like phantom
crashes when the real story is "TUI read pipe closed on this event".
"""
try:
os.makedirs(os.path.dirname(_CRASH_LOG), exist_ok=True)
with open(_CRASH_LOG, "a", encoding="utf-8") as f:
f.write(
f"\n=== gateway exit · {time.strftime('%Y-%m-%d %H:%M:%S')} "
f"· reason={reason} ===\n"
)
except Exception:
pass
print(f"[gateway-exit] {reason}", file=sys.stderr, flush=True)
def wait_for_mcp_discovery(timeout: "float | None" = None) -> None:
"""Block until background MCP discovery finishes, up to the resolved bound.
MCP discovery runs in a daemon thread spawned at startup (see main()) so a
slow/dead server can't freeze ``gateway.ready``. But the agent snapshots
its tool list ONCE at build time and never re-reads it, so a reachable-but-
slow server that finishes connecting *after* the first prompt would be
invisible for the whole session. Joining with a bounded timeout before the
first agent build lets already-spawning servers land without re-introducing
the startup hang: ``thread.join(timeout)`` returns the instant discovery
completes (so fast/no-MCP startups pay ~0s), and a dead server is simply not
waited on beyond the bound. No-op when no discovery thread was started.
The bound comes from ``mcp_discovery_timeout`` in config (shared with the
CLI path via ``hermes_cli.mcp_startup``); ``timeout`` overrides it.
"""
thread = _mcp_discovery_thread
if thread is not None and thread.is_alive():
try:
from hermes_cli.mcp_startup import _resolve_discovery_timeout
bound = _resolve_discovery_timeout(timeout)
except Exception:
bound = timeout if timeout is not None else 0.75
thread.join(timeout=bound)
return
# Discovery is spawned via the shared owner (ensure_mcp_discovery_started
# → hermes_cli.mcp_startup); wait on it so the first agent build still
# catches fast servers. Re-invoke the idempotent spawn first: if the
# previous run finished with zero connected servers,
# start_background_mcp_discovery's retry-after-zero-connected allowance
# kicks off a fresh discovery run here instead of leaving the process
# latched MCP-less for the session. In multi-profile processes this
# retry runs under the CALLER's profile context (agent build binds the
# session profile's HERMES_HOME first), so a launch profile with no
# mcp_servers no longer starves selected profiles of discovery (#67605).
# Gated on _mcp_discovery_enabled so non-MCP sessions never pay the
# tools.mcp_tool import on the per-agent-build wait path.
if not _mcp_discovery_enabled:
return
try:
from hermes_cli.mcp_startup import start_background_mcp_discovery
start_background_mcp_discovery(
logger=logger, thread_name="tui-mcp-discovery"
)
except Exception:
logger.debug(
"TUI MCP discovery retry-spawn failed", exc_info=True
)
try:
from hermes_cli.mcp_startup import (
wait_for_mcp_discovery as _startup_wait,
)
_startup_wait(timeout)
except Exception:
pass
def mcp_discovery_in_flight() -> bool:
"""Return True if ANY background MCP discovery thread is still running.
Used by the agent-build path to decide whether to schedule a late tool
snapshot refresh: if discovery didn't land within the bounded
``wait_for_mcp_discovery`` join, the agent was built without those tools
and the banner/tool count will be stale until they arrive.
There are two independent discovery-thread owners by surface: the stdio
``hermes --tui`` path spawns ITS thread here (``_mcp_discovery_thread``),
while the desktop app + dashboard WebSocket sidecar (``tui_gateway/ws.py``)
and ``hermes dashboard`` spawn theirs via
``hermes_cli.mcp_startup.start_background_mcp_discovery``. The late-refresh
scheduler imports this function regardless of surface, so it MUST consult
both — checking only the entry thread left the desktop/dashboard surfaces
with no late refresh, so a slow MCP server's tools never surfaced for the
whole session (#51587).
"""
thread = _mcp_discovery_thread
if thread is not None and thread.is_alive():
return True
try:
from hermes_cli.mcp_startup import (
mcp_discovery_in_flight as _startup_in_flight,
)
return _startup_in_flight()
except Exception:
return False
def join_mcp_discovery(timeout: float | None = None) -> bool:
"""Block until background MCP discovery finishes, up to ``timeout`` seconds.
Returns True if discovery has completed (both thread owners absent or no
longer alive), False if either is still running after the timeout. Unlike
``wait_for_mcp_discovery`` this accepts an unbounded/long wait and reports
the outcome, for the off-critical-path late-refresh waiter.
Joins both discovery-thread owners (see ``mcp_discovery_in_flight``): the
entry thread first, then the ``hermes_cli.mcp_startup`` thread used by the
desktop/dashboard surfaces. ``timeout`` bounds EACH join, mirroring the
pre-#51587 single-owner behavior for the entry thread.
"""
entry_done = True
thread = _mcp_discovery_thread
if thread is not None:
thread.join(timeout=timeout)
entry_done = not thread.is_alive()
try:
from hermes_cli.mcp_startup import join_mcp_discovery as _startup_join
startup_done = _startup_join(timeout=timeout)
except Exception:
startup_done = True
return entry_done and startup_done
# Spurious stdin-EOF recovery tracker (shared open-file-description O_NONBLOCK flip).
_recovery_times: list[float] = []
def _has_configured_mcp_servers() -> bool:
"""Delegate to the shared native and portable MCP startup gate."""
from hermes_cli.mcp_startup import _has_configured_mcp_servers as configured
return configured()
def ensure_mcp_discovery_started() -> None:
"""Start background MCP discovery for the current profile context, once.
``main()`` calls this for the stdio/TUI path. WebSocket/Desktop
entrypoints can accept sessions without running ``main()``, so the
agent-build path (``server._start_agent_build``) also calls it AFTER
binding the session profile's HERMES_HOME override — the shared owner in
``hermes_cli.mcp_startup`` captures the caller's context-local override
and propagates it into the discovery thread, so discovery reads the
SELECTED profile's ``mcp_servers``, not the launch profile's (#67605).
Delegating to the shared owner (instead of a hand-rolled thread) keeps
the process-wide start lock, the retry-after-zero-connected allowance,
and interactive-OAuth suppression.
Known limitation: MCP tool registration is process-global, so in a
multi-profile process the FIRST profile that builds an agent wins the
discovery slot. Full per-profile MCP registries are tracked in #67605.
"""
global _mcp_discovery_enabled
if not _has_configured_mcp_servers():
return
_mcp_discovery_enabled = True
try:
from hermes_cli.mcp_startup import start_background_mcp_discovery
start_background_mcp_discovery(
logger=logger, thread_name="tui-mcp-discovery"
)
except Exception:
logger.warning(
"Background MCP tool discovery failed to start", exc_info=True
)
def main():
_install_sidecar_publisher()
# Cross-backend liveness (#94895): register a heartbeat row so the
# startup orphan sweep can distinguish "row owned by a live but idle
# backend" from "row truly orphaned". Must run BEFORE the sweep so
# the sweep sees our row in the same transaction.
try:
server._start_backend_heartbeat_refresher()
except Exception:
logger.warning("backend heartbeat refresher start failed", exc_info=True)
# One-time sweep of session rows orphaned by a previous gateway process
# (#65194) — the in-process WS-orphan reap timer dies with the process.
# Desktop/dashboard reach the agent through handle_ws instead; the
# scheduler is once-per-process + config-gated so the second site is a
# no-op when this already ran.
try:
server._schedule_startup_orphan_sweep()
except Exception:
logger.warning("startup orphan sweep scheduling failed", exc_info=True)
# MCP tool discovery — backgrounded so a slow or unreachable MCP server
# can't freeze TUI startup (a dead stdio/http server burns 1+2+4s of
# connect retries → ~7s of dead air before the composer appears). The
# agent isn't built until the first prompt, at which point _make_agent
# briefly joins the discovery thread (wait_for_mcp_discovery, bounded) so
# already-spawning fast servers land in the tool snapshot. The config
# gate inside ensure_mcp_discovery_started keeps the ~200ms MCP SDK
# import cost entirely off the path for users with no mcp_servers.
ensure_mcp_discovery_started()
if not write_json({
"jsonrpc": "2.0",
"method": "event",
"params": {
"type": "gateway.ready",
# change_events: see tui_gateway/ws.py — clients demote legacy polls.
# replay_epoch: restart detection for the WS replay contract (the
# stdio TUI ignores it).
"payload": {
"skin": resolve_skin(),
"change_events": True,
"replay_epoch": replay_epoch(),
},
},
}):
_log_exit("startup write failed (broken stdout pipe before first event)")
sys.exit(0)
# Live-apply skins Hermes activates mid-conversation.
server._ensure_skin_watcher()
# Warm the /model picker's provider-models cache off-thread during this
# idle window (gateway.ready sent, user about to type). Mirrors the classic
# CLI run() loop — the stdio TUI otherwise never prewarms, so the first
# /model open blocks on serial /v1/models fetches. Fire-and-forget,
# guarded once-per-process, fully exception-isolated.
try:
from hermes_cli.model_switch import prewarm_picker_cache_async
prewarm_picker_cache_async()
except Exception:
logger.debug("picker cache prewarm (tui) failed to start", exc_info=True)
while True:
raw = sys.stdin.readline()
if not raw:
# Stdin fell through — check if spurious (O_NONBLOCK flip by a
# child on the shared open file description) or genuine EOF.
if not handle_spurious_eof(_recovery_times, _log_exit):
break
continue
line = raw.strip()
if not line:
continue
try:
req = json.loads(line)
except json.JSONDecodeError:
if not write_json({"jsonrpc": "2.0", "error": {"code": -32700, "message": "parse error"}, "id": None}):
_log_exit("parse-error-response write failed (broken stdout pipe)")
sys.exit(0)
continue
method = req.get("method") if isinstance(req, dict) else None
resp = dispatch(req)
if resp is not None:
if not write_json(resp):
_log_exit(f"response write failed for method={method!r} (broken stdout pipe)")
sys.exit(0)
if __name__ == "__main__":
main()
+126
View File
@@ -0,0 +1,126 @@
"""Best-effort WebSocket publisher transport for the PTY-side gateway.
The dashboard's `/api/pty` spawns `hermes --tui` as a child process, which
spawns its own ``tui_gateway.entry``. Tool/reasoning/status events fire on
*that* gateway's transport — three processes removed from the dashboard
server itself. To surface them in the dashboard sidebar (`/api/events`),
the PTY-side gateway opens a back-WS to the dashboard at startup and
mirrors every emit through this transport.
Wire protocol: newline-framed JSON dicts (the same shape the dispatcher
already passes to ``write``). No JSON-RPC envelope here — the dashboard's
``/api/pub`` endpoint just rebroadcasts the bytes verbatim to subscribers.
Failure mode: silent. The agent loop must never block waiting for the
sidecar to drain. A dead WS short-circuits all subsequent writes.
Actual ``send`` calls run on a daemon thread so the TeeTransport's
``write`` returns after enqueueing (best-effort; drop when the queue is full).
"""
from __future__ import annotations
import json
import logging
import queue
import threading
from typing import Optional
try:
from websockets.sync.client import connect as ws_connect
except ImportError: # pragma: no cover - websockets is a required install path
ws_connect = None # type: ignore[assignment]
_log = logging.getLogger(__name__)
_DRAIN_STOP = object()
_QUEUE_MAX = 256
class WsPublisherTransport:
__slots__ = ("_url", "_lock", "_ws", "_dead", "_q", "_worker")
def __init__(self, url: str, *, connect_timeout: float = 2.0) -> None:
self._url = url
self._lock = threading.Lock()
self._ws: Optional[object] = None
self._dead = False
self._q: queue.Queue[object] = queue.Queue(maxsize=_QUEUE_MAX)
self._worker: Optional[threading.Thread] = None
if ws_connect is None:
self._dead = True
return
try:
self._ws = ws_connect(url, open_timeout=connect_timeout, max_size=None)
except Exception as exc:
_log.debug("event publisher connect failed: %s", exc)
self._dead = True
self._ws = None
return
self._worker = threading.Thread(
target=self._drain,
name="hermes-ws-pub",
daemon=True,
)
self._worker.start()
def _drain(self) -> None:
while True:
item = self._q.get()
if item is _DRAIN_STOP:
return
if not isinstance(item, str):
continue
if self._ws is None:
continue
try:
with self._lock:
if self._ws is not None:
self._ws.send(item) # type: ignore[union-attr]
except Exception as exc:
_log.debug("event publisher write failed: %s", exc)
self._dead = True
self._ws = None
def write(self, obj: dict) -> bool:
if self._dead or self._ws is None or self._worker is None:
return False
line = json.dumps(obj, ensure_ascii=False)
try:
self._q.put_nowait(line)
return True
except queue.Full:
return False
def close(self) -> None:
self._dead = True
w = self._worker
if w is not None and w.is_alive():
try:
self._q.put_nowait(_DRAIN_STOP)
except queue.Full:
# Best-effort: if the queue is wedged, the daemon thread
# will be torn down with the process.
pass
w.join(timeout=3.0)
self._worker = None
if self._ws is None:
return
try:
with self._lock:
if self._ws is not None:
self._ws.close() # type: ignore[union-attr]
except Exception:
pass
self._ws = None
+125
View File
@@ -0,0 +1,125 @@
"""Per-session event sequencing + bounded replay for WS reconnects.
Every gateway event frame that flows through :func:`server.write_json` (and
therefore ``_emit``) is stamped with a per-session monotonic ``seq`` and
appended to a small ring buffer keyed by session id. A reconnecting client
calls the ``session.events.since`` RPC with its last observed seq; the server
replays everything newer from the buffer, then live events resume seamlessly.
Design constraints honored:
- stdio TUI path unaffected: frames gain a ``seq`` field only on event frames;
Ink ignores unknown params keys.
- Thread safety: a single module lock guards counters + buffers; write_json
already serializes per-transport writes, so stamping under the lock cannot
reorder frames relative to each other.
- Memory bound: _REPLAY_BUFFER_MAX events / _REPLAY_SESSIONS_MAX sessions,
oldest session evicted FIFO.
"""
from __future__ import annotations
import threading
import uuid
from collections import OrderedDict, deque
# Process identity for the replay contract. Seq counters live in-process, so
# a gateway restart silently resets them to 1 while clients still hold high
# watermarks — events_since(sid, 97) then returns [] with truncated=False and
# the client believes it missed nothing (and its stale watermark makes every
# future replay empty too). The epoch lets clients detect the restart and
# reset their watermarks.
_REPLAY_EPOCH = uuid.uuid4().hex
# Replay ring per session. A long turn emits ~hundreds of token events; this
# covers several minutes of streaming plus all control events.
_REPLAY_BUFFER_MAX = 512
# Distinct sessions remembered. Desktop users rarely exceed a dozen live chats.
_REPLAY_SESSIONS_MAX = 64
_replay_lock = threading.Lock()
# sid -> deque of (seq, event_object) where event_object is the frame's
# ``params`` dict (bare event: type/session_id/seq/payload) — the exact shape
# the client's dispatch path consumes.
_replay_buffers: "OrderedDict[str, deque]" = OrderedDict()
_replay_next_seq: dict[str, int] = {}
def replay_epoch() -> str:
"""Opaque token identifying this server process's seq numbering."""
return _REPLAY_EPOCH
def _stamp_event(obj: dict) -> None:
"""Stamp one outgoing event frame (mutates obj in place) and record it."""
if obj.get("method") != "event":
return
params = obj.get("params")
if not isinstance(params, dict):
return
sid = params.get("session_id") or ""
if not sid:
# Session-less global events (skin.changed etc.) are re-fetchable via
# their own RPCs; no replay contract for them.
return
with _replay_lock:
seq = _replay_next_seq.get(sid, 0) + 1
_replay_next_seq[sid] = seq
params["seq"] = seq
buf = _replay_buffers.get(sid)
if buf is None:
buf = deque(maxlen=_REPLAY_BUFFER_MAX)
_replay_buffers[sid] = buf
while len(_replay_buffers) > _REPLAY_SESSIONS_MAX:
_oldest_sid, _oldest_buf = _replay_buffers.popitem(last=False)
_replay_next_seq.pop(_oldest_sid, None)
buf.append((seq, params))
def events_since(sid: str, last_seen: int) -> list[dict]:
"""Return recorded EVENT OBJECTS with seq > last_seen for *sid*, in order.
Shape contract: each element is the frame's ``params`` dict — a bare event
object with top-level ``type`` / ``session_id`` / ``seq`` — because that is
exactly what the client's dispatch path consumes. Returning the full
JSON-RPC envelope here would make every replayed event fail the client's
``event.type`` gate and be silently dropped.
"""
with _replay_lock:
buf = _replay_buffers.get(sid or "")
if not buf:
return []
return [event for seq, event in buf if seq > last_seen]
def is_truncated(sid: str, last_seen: int) -> bool:
"""True when events between *last_seen* and the ring's oldest retained
seq were evicted — the client must refetch history instead of trusting
the replay to be gap-free."""
with _replay_lock:
buf = _replay_buffers.get(sid or "")
if not buf:
return False
return last_seen + 1 < buf[0][0]
def latest_seq(sid: str) -> int:
"""Current highest stamped seq for *sid* (0 when unknown)."""
with _replay_lock:
return _replay_next_seq.get(sid or "", 0)
def reset_replay_state() -> None:
"""Test hook."""
with _replay_lock:
_replay_buffers.clear()
_replay_next_seq.clear()
def replay_stats() -> dict:
"""Telemetry: buffer occupancy for the ops/debug surface."""
with _replay_lock:
return {
"sessions": len(_replay_buffers),
"events": sum(len(b) for b in _replay_buffers.values()),
"max_per_session": _REPLAY_BUFFER_MAX,
}
+202
View File
@@ -0,0 +1,202 @@
"""Git working-tree probing for the gateway: run git, resolve repo roots, fold
linked worktrees under their common root.
Probing runs where the gateway runs, so it resolves repos for both local and
remote backends (unlike the desktop's electron probe, which only sees the local
fs). Resolved roots are cached with a thread-safe, single-flight cache: the
gateway's long handlers run on worker threads, so concurrent identical probes
(e.g. two overlapping project-tree builds) share one `git` invocation instead of
racing an unguarded dict.
Positive results are cached for the process lifetime; negative results (a cwd
that isn't a git repo, or a deleted/nonexistent dir) are cached only for a short
TTL (`_NEG_TTL`). Caching negatives matters a lot for the desktop Projects tree:
``project_tree.build_tree`` resolves a cwd once *per session* (not per distinct
cwd), so a power user with hundreds of sessions in non-git/deleted dirs would
otherwise re-spawn ``git`` hundreds of times on *every* sidebar open — the cause
of the multi-second "Projects" load. The TTL keeps a not-yet-repo cwd
re-probable (we `git init` a new project's folder on its first worktree, and a
frozen "" would mislabel its main lane by the dir basename) — it just stops the
same "not a repo" answer from being re-derived dozens of times within one build
and across rapid re-opens. `invalidate()` drops everything after a known
mutation.
"""
from __future__ import annotations
import os
import threading
import time
from collections.abc import Iterable
from concurrent.futures import ThreadPoolExecutor
from hermes_cli._subprocess_compat import bounded_git_probe
_GIT_TIMEOUT = 1.5
_WARM_WORKERS = 8
# How long a "not a git repo" answer stays cached before it's re-probed. Short
# enough that a freshly `git init`-ed / newly-created folder shows correctly
# within a few seconds; long enough to collapse the hundreds of redundant probes
# a single project-tree build (and rapid re-opens) would otherwise fire.
_NEG_TTL = 30.0
def run_git(cwd: str, *args: str) -> str:
"""``git -C <cwd> <args>`` → stripped stdout, or ``""`` on any failure.
Uses the shared :func:`bounded_git_probe` so the post-kill cleanup is bounded
on Windows — a plain ``subprocess.run(timeout=...)`` here deadlocked Desktop
session readiness when a killed git left a suspended descendant holding the
pipe handles (issue #68609).
"""
if not cwd or not os.path.isdir(cwd):
# `git -C` on a directory that no longer exists can only fail, and it
# fails at the price of a fork. Deleted worktrees dominate the cwds a
# long-lived session history hands us, so the stat pays for itself many
# times over on every project-tree build.
return ""
return bounded_git_probe(["git", "-C", cwd, *args], timeout=_GIT_TIMEOUT)
def branch(cwd: str) -> str:
return run_git(cwd, "branch", "--show-current") or run_git(cwd, "rev-parse", "--short", "HEAD")
class _RootCache:
"""Thread-safe, single-flight cache of git-root probes. Positive results are
cached for the process lifetime; negative ("not a repo") results are cached
only for ``_NEG_TTL`` seconds so a not-yet-repo cwd stays re-probable.
Followers wait on the leader's probe instead of duplicating it."""
def __init__(self) -> None:
self._lock = threading.Lock()
self._roots: dict[str, str] = {}
self._neg: dict[str, float] = {} # key -> monotonic expiry
self._inflight: dict[str, threading.Event] = {}
def invalidate(self) -> None:
with self._lock:
self._roots.clear()
self._neg.clear()
self._inflight.clear()
def resolve(self, key: str, probe) -> str:
while True:
with self._lock:
hit = self._roots.get(key)
if hit:
return hit
expiry = self._neg.get(key)
if expiry is not None:
if expiry > time.monotonic():
# Recently probed as "not a repo" — trust it briefly
# instead of re-spawning git for the same dead/non-repo
# cwd on every session in the tree build.
return ""
# TTL elapsed: drop it and re-probe (it may be a repo now).
del self._neg[key]
gate = self._inflight.get(key)
if gate is None:
gate = threading.Event()
self._inflight[key] = gate
leader = True
else:
leader = False
if not leader:
# Another thread is probing this key — wait, then re-read.
gate.wait(timeout=_GIT_TIMEOUT + 0.5)
continue
value = ""
try:
value = probe()
finally:
with self._lock:
if value:
self._roots[key] = value
else:
self._neg[key] = time.monotonic() + _NEG_TTL
self._inflight.pop(key, None)
gate.set()
return value
_cache = _RootCache()
def invalidate() -> None:
"""Drop cached roots after a known mutation (e.g. a worktree was added)."""
_cache.invalidate()
def repo_root(cwd: str) -> str:
"""Top-level git repo root for ``cwd`` (``""`` when not a repo)."""
if not cwd:
return ""
return _cache.resolve(cwd, lambda: run_git(cwd, "rev-parse", "--show-toplevel"))
def common_repo_root(cwd: str) -> str:
"""The MAIN (common) repo root for ``cwd``, folding linked worktrees.
``--show-toplevel`` returns a linked worktree's OWN root, so grouping by it
splits every worktree into a separate "repo". The common ``.git`` dir
(``--git-common-dir``) is shared by a repo and all its worktrees, so its
parent is the one true repo root; fall back to the toplevel root otherwise.
The returned path is normalized to git's forward-slash spelling so it can be
compared against :func:`repo_root` (which returns raw ``--show-toplevel``
output). ``os.path.realpath`` rewrites separators to the platform's native
``\\`` on Windows, so without this the SAME directory came back spelled two
ways and the repo's own checkout compared unequal to its common root — the
main checkout was then misread as a linked worktree and the desktop sidebar
rendered it twice (a dir-labeled lane plus a branch-labeled ``main`` lane).
"""
if not cwd:
return ""
# No work tree, nothing to fold. Reading the (warmed, negative-cached)
# toplevel first spares every non-repo cwd a second `git` spawn — one the
# parallel warm can never absorb, since `resolve()` only reaches here for
# cwds that ARE repos.
if not repo_root(cwd):
return ""
def _probe() -> str:
gitdir = run_git(cwd, "rev-parse", "--path-format=absolute", "--git-common-dir")
if gitdir:
gitdir = os.path.realpath(gitdir)
if os.path.basename(gitdir) == ".git":
return os.path.dirname(gitdir).replace(os.sep, "/")
return repo_root(cwd)
return _cache.resolve(f"common:{cwd}", _probe)
def resolve(cwd: str) -> dict | None:
"""Inject-able resolver for ``project_tree.build_tree``.
Returns ``{"repo_root": <common root>, "worktree_root": <this checkout>}``
or ``None`` when ``cwd`` is not in a git repo. ``build_tree`` treats
``worktree_root == repo_root`` as the main checkout.
"""
worktree_root = repo_root(cwd)
if not worktree_root:
return None
return {"repo_root": common_repo_root(cwd) or worktree_root, "worktree_root": worktree_root}
def warm_roots(cwds: Iterable[str], max_workers: int = _WARM_WORKERS) -> None:
"""Pre-resolve many cwds' roots in parallel (bounded) so a cold first paint
doesn't serialize one git subprocess per session cwd. Single-flight dedupes
overlap; results land in the shared cache for the sequential consumers."""
pending = sorted({(cwd or "").strip() for cwd in cwds} - {""})
if not pending:
return
if len(pending) == 1:
resolve(pending[0])
return
with ThreadPoolExecutor(max_workers=min(max_workers, len(pending))) as pool:
list(pool.map(resolve, pending))
+652
View File
@@ -0,0 +1,652 @@
"""Supervisor for the dashboard compute-host child process.
The dashboard process owns sockets and JSON-RPC dispatch. When
``dashboard.turn_isolation`` is enabled, agent turns move behind one persistent
``python -m tui_gateway.compute_host`` child so compute-heavy agent threads do
not contend with the serving process' event loop for the same GIL.
"""
from __future__ import annotations
import json
import logging
import os
import queue
import signal
import subprocess
import sys
import threading
import time
import uuid
from collections.abc import Callable
from pathlib import Path
from typing import Any
from hermes_constants import get_hermes_home
from tools.environments.local import hermes_subprocess_env
logger = logging.getLogger(__name__)
_Thread = threading.Thread
MUTATOR_ROUTE_TABLE: dict[str, str] = {
"prompt.submit": "turn-path",
"session.interrupt": "turn-path",
"reload.mcp": "run-concurrent",
"session.save": "run-concurrent",
"session.compress": "idle-gated",
"prompt.submit.truncate": "idle-gated",
"slash.model": "idle-gated",
"slash.personality": "idle-gated",
"slash.prompt": "idle-gated",
"slash.compress": "idle-gated",
"session.reset": "idle-gated",
"session.history.reload": "idle-gated",
"slash.retry": "idle-gated",
}
_REGISTRY_NAME = "dashboard-compute-host.json"
_RESPAWN_WINDOW_SECS = 300.0
_SHUTDOWN_TIMEOUT_SECS = 10.0
# Late control-ack handlers (#97948): a compress that outlives its RPC waiter
# can legitimately run for the full compression ceiling plus a stall-fallback
# retry, so keep registrations around well past that — but bounded.
_LATE_CONTROL_TTL_SECS = 1800.0
_LATE_CONTROL_MAX = 64
def append_log_record(path: str | Path, record: str) -> None:
"""Append one log record using O_APPEND and exactly one os.write call."""
p = Path(path)
p.parent.mkdir(parents=True, exist_ok=True)
text = record if record.endswith("\n") else f"{record}\n"
data = text.encode("utf-8", errors="replace")
fd = os.open(str(p), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
try:
os.write(fd, data)
finally:
os.close(fd)
def _repo_root() -> Path:
return Path(__file__).resolve().parents[1]
def _build_sha() -> str:
try:
return subprocess.check_output(
["git", "rev-parse", "HEAD"],
cwd=str(_repo_root()),
text=True,
encoding="utf-8",
errors="replace",
stderr=subprocess.DEVNULL,
timeout=2,
).strip()
except Exception:
return "unknown"
def _default_registry_path() -> Path:
return get_hermes_home() / "state" / _REGISTRY_NAME
def _pid_alive(pid: int) -> bool:
if pid <= 0:
return False
try:
os.kill(pid, 0)
return True
except ProcessLookupError:
return False
except PermissionError:
return True
except Exception:
return False
def _pid_command(pid: int) -> str:
if pid <= 0:
return ""
# Linux fast path.
proc_cmdline = Path("/proc") / str(pid) / "cmdline"
try:
data = proc_cmdline.read_bytes()
if data:
return data.replace(b"\x00", b" ").decode("utf-8", errors="replace")
except Exception:
pass
try:
return subprocess.check_output(
["ps", "-p", str(pid), "-o", "command="],
text=True,
encoding="utf-8",
errors="replace",
stderr=subprocess.DEVNULL,
timeout=2,
).strip()
except Exception:
return ""
def is_compute_host_identity(pid: int) -> bool:
cmd = _pid_command(pid)
return "tui_gateway.compute_host" in cmd
class HostSupervisor:
"""Own one persistent compute-host child and relay its frames."""
def __init__(
self,
*,
registry_path: str | Path | None = None,
argv: list[str] | None = None,
cwd: str | Path | None = None,
env: dict[str, str] | None = None,
rpc_sink: Callable[[dict], None] | None = None,
respawn_max: int = 3,
heartbeat_secs: int = 15,
expected_build_sha: str | None = None,
expected_hermes_home: str | None = None,
autostart: bool = True,
) -> None:
self.registry_path = Path(registry_path) if registry_path is not None else _default_registry_path()
self.argv = argv or [sys.executable, "-m", "tui_gateway.compute_host"]
self.cwd = Path(cwd) if cwd is not None else _repo_root()
self.env = env
self.rpc_sink = rpc_sink or (lambda _obj: None)
self.respawn_max = max(0, int(respawn_max))
self.heartbeat_secs = max(1, int(heartbeat_secs))
self.expected_build_sha = expected_build_sha if expected_build_sha is not None else _build_sha()
self.expected_hermes_home = expected_hermes_home if expected_hermes_home is not None else str(get_hermes_home())
self._lock = threading.RLock()
self._proc: subprocess.Popen[str] | None = None
self._stdout_thread: threading.Thread | None = None
self._stderr_thread: threading.Thread | None = None
self._wait_thread: threading.Thread | None = None
self._hello_event = threading.Event()
self._hello: dict[str, Any] = {}
self._closing = False
self._stopped_respawning = False
self._restart_times: list[float] = []
self._pending_turns: dict[str, tuple[str, Callable[[dict], None] | None]] = {}
self._pending_controls: dict[str, queue.Queue[dict]] = {}
# request_id -> (registered_at, handler) for control waiters that timed
# out but whose host work is still running (#97948). The host emits
# its control.ack whenever it finishes; without this the ack matched
# no queue and was silently dropped.
self._late_control_handlers: dict[str, tuple[float, Callable[[dict], None]]] = {}
self._stderr_tail: list[str] = []
self._last_progress_counter = 0
if autostart:
self.start()
@property
def pid(self) -> int:
proc = self._proc
return int(proc.pid or 0) if proc is not None else 0
@property
def hello(self) -> dict[str, Any]:
return dict(self._hello)
def is_running(self) -> bool:
proc = self._proc
return proc is not None and proc.poll() is None and not self._stopped_respawning
def start(self) -> None:
with self._lock:
if self.is_running():
return
self._closing = False
self.reconcile_startup_orphan()
self._spawn_locked(reason="startup")
def shutdown(self) -> None:
with self._lock:
self._closing = True
proc = self._proc
if proc is None:
return
try:
if proc.poll() is None and proc.stdin is not None:
self._send_frame({"type": "shutdown", "request_id": f"shutdown-{uuid.uuid4().hex}"})
proc.wait(timeout=_SHUTDOWN_TIMEOUT_SECS)
except Exception:
self._terminate_process(proc)
finally:
self._remove_registry()
def reconcile_startup_orphan(self) -> str:
"""Terminate a stale registered host, guarding against PID reuse."""
try:
data = json.loads(self.registry_path.read_text(encoding="utf-8"))
except FileNotFoundError:
return "none"
except Exception:
self._remove_registry()
return "invalid-registry"
try:
pid = int(data.get("host_pid") or 0)
except Exception:
pid = 0
if pid <= 0 or not _pid_alive(pid):
self._remove_registry()
return "not-running"
if not self._pid_matches_compute_host(pid):
# PID was reused by another process. Never signal it.
self._remove_registry()
return "pid-reuse-ignored"
self._terminate_pid(pid, timeout=_SHUTDOWN_TIMEOUT_SECS)
self._remove_registry()
return "terminated"
def submit_turn(
self,
frame: dict[str, Any],
*,
on_complete: Callable[[dict], None] | None = None,
) -> str:
self.start()
request_id = str(frame.get("request_id") or uuid.uuid4().hex)
sid = str(frame.get("sid") or "")
payload = dict(frame)
payload["type"] = "turn.start"
payload["request_id"] = request_id
with self._lock:
self._pending_turns[request_id] = (sid, on_complete)
try:
self._send_frame(payload)
except Exception as exc:
with self._lock:
self._pending_turns.pop(request_id, None)
err = {
"type": "turn.error",
"sid": sid,
"request_id": request_id,
"reason": "send_failed",
"message": str(exc),
}
if on_complete is not None:
on_complete(err)
raise
return request_id
def interrupt(self, sid: str, *, request_id: str | None = None) -> None:
self.start()
self._send_frame({"type": "interrupt", "sid": sid, "request_id": request_id or uuid.uuid4().hex})
def respond(self, sid: str, params: dict[str, Any], *, timeout: float = 15.0) -> dict:
"""Deliver an interactive prompt response to the host that owns it."""
self.start()
request_id = uuid.uuid4().hex
q: queue.Queue[dict] = queue.Queue(maxsize=1)
with self._lock:
self._pending_controls[request_id] = q
try:
self._send_frame(
{
"type": "respond",
"sid": sid,
"request_id": request_id,
"params": dict(params),
}
)
return q.get(timeout=timeout)
finally:
with self._lock:
self._pending_controls.pop(request_id, None)
def reload_mcp(self, sid: str, *, request_id: str | None = None) -> dict:
return self.control(
sid,
route_name="reload.mcp",
payload={"type": "reload_mcp", "sid": sid, "request_id": request_id or uuid.uuid4().hex},
wait=True,
)
def control(
self,
sid: str,
*,
route_name: str,
payload: dict[str, Any] | None = None,
wait: bool = True,
timeout: float = 30.0,
on_late_ack: Callable[[dict], None] | None = None,
) -> dict:
"""Send a control frame; with ``wait`` block up to ``timeout`` for its ack.
``on_late_ack`` (only meaningful with ``wait``) keeps the request
adoptable after the waiter gives up: when the host's ``control.ack`` /
``control.error`` / ``error`` for this ``request_id`` eventually
arrives, the handler fires once instead of the frame being dropped.
Registrations are bounded by ``_LATE_CONTROL_TTL_SECS`` /
``_LATE_CONTROL_MAX``.
"""
if route_name not in MUTATOR_ROUTE_TABLE:
raise ValueError(f"unclassified host mutator route: {route_name}")
self.start()
request_id = str((payload or {}).get("request_id") or uuid.uuid4().hex)
frame = dict(payload or {})
frame.setdefault("type", "control")
frame["sid"] = sid
frame["route_name"] = route_name
frame["request_id"] = request_id
q: queue.Queue[dict] | None = None
if wait:
q = queue.Queue(maxsize=1)
with self._lock:
self._pending_controls[request_id] = q
self._send_frame(frame)
if not wait or q is None:
return {"status": "sent", "request_id": request_id}
try:
return q.get(timeout=timeout)
except queue.Empty:
if on_late_ack is not None:
self._register_late_control_handler(request_id, on_late_ack)
raise
finally:
with self._lock:
self._pending_controls.pop(request_id, None)
def _register_late_control_handler(self, request_id: str, handler: Callable[[dict], None]) -> None:
now = time.monotonic()
with self._lock:
expired = [
rid
for rid, (registered_at, _cb) in self._late_control_handlers.items()
if now - registered_at > _LATE_CONTROL_TTL_SECS
]
for rid in expired:
self._late_control_handlers.pop(rid, None)
while len(self._late_control_handlers) >= _LATE_CONTROL_MAX:
oldest = min(self._late_control_handlers, key=lambda rid: self._late_control_handlers[rid][0])
self._late_control_handlers.pop(oldest, None)
self._late_control_handlers[request_id] = (now, handler)
def _deliver_control_frame(self, request_id: str, frame: dict[str, Any]) -> None:
with self._lock:
q = self._pending_controls.get(request_id)
late = None if q is not None else self._late_control_handlers.pop(request_id, None)
if q is not None:
try:
q.put_nowait(frame)
except queue.Full:
pass
return
if late is None:
return
_registered_at, handler = late
try:
handler(frame)
except Exception:
logger.exception("compute host late control ack handler failed (request_id=%s)", request_id)
def _spawn_locked(self, *, reason: str) -> None:
if self._stopped_respawning:
raise RuntimeError("compute host respawn disabled after crash loop")
self._hello_event.clear()
self._hello = {}
env = hermes_subprocess_env(inherit_credentials=True)
env.update(os.environ)
if self.env:
env.update(self.env)
env["HERMES_COMPUTE_HOST_HEARTBEAT_SECS"] = str(self.heartbeat_secs)
env.setdefault("PYTHONPATH", str(_repo_root()))
if str(_repo_root()) not in env["PYTHONPATH"].split(os.pathsep):
env["PYTHONPATH"] = str(_repo_root()) + os.pathsep + env["PYTHONPATH"]
proc = subprocess.Popen(
self.argv,
cwd=str(self.cwd),
env=env,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
# Lossy UTF-8 decode — the compute host emits UTF-8; a
# locale-mismatched byte must not raise inside the drain
# threads and kill the supervisor (#52649).
encoding="utf-8",
errors="replace",
bufsize=1,
start_new_session=True,
)
self._proc = proc
self._stdout_thread = _Thread(target=self._drain_stdout, args=(proc,), name="compute-host-stdout", daemon=True)
self._stderr_thread = _Thread(target=self._drain_stderr, args=(proc,), name="compute-host-stderr", daemon=True)
self._wait_thread = _Thread(target=self._wait_for_exit, args=(proc,), name="compute-host-wait", daemon=True)
self._stdout_thread.start()
self._stderr_thread.start()
self._wait_thread.start()
if not self._hello_event.wait(timeout=10.0):
self._terminate_process(proc)
raise RuntimeError(f"compute host did not send hello; stderr={self._stderr_tail[-5:]}")
self._validate_hello()
self._persist_registry()
logger.info("compute host started pid=%s reason=%s", proc.pid, reason)
def _validate_hello(self) -> None:
hello = self._hello
if not hello:
raise RuntimeError("compute host missing hello")
got_home = str(hello.get("hermes_home") or "")
if got_home and got_home != self.expected_hermes_home:
raise RuntimeError(f"compute host HERMES_HOME mismatch: {got_home} != {self.expected_hermes_home}")
got_sha = str(hello.get("build_sha") or "")
if self.expected_build_sha != "unknown" and got_sha not in {"", "unknown", self.expected_build_sha}:
raise RuntimeError(f"compute host build mismatch: {got_sha} != {self.expected_build_sha}")
def _persist_registry(self) -> None:
self.registry_path.parent.mkdir(parents=True, exist_ok=True)
tmp = self.registry_path.with_suffix(self.registry_path.suffix + ".tmp")
payload = {
"host_pid": self.pid,
"boot_id": self._hello.get("boot_id") or "",
"build_sha": self._hello.get("build_sha") or "",
"started_at": time.time(),
"argv": self.argv,
}
tmp.write_text(json.dumps(payload, sort_keys=True), encoding="utf-8")
tmp.replace(self.registry_path)
def _remove_registry(self) -> None:
try:
self.registry_path.unlink()
except FileNotFoundError:
pass
except Exception:
logger.debug("failed to remove compute host registry", exc_info=True)
def _send_frame(self, frame: dict[str, Any]) -> None:
with self._lock:
proc = self._proc
if proc is None or proc.poll() is not None or proc.stdin is None:
raise RuntimeError("compute host is not running")
proc.stdin.write(json.dumps(frame, separators=(",", ":"), ensure_ascii=False) + "\n")
proc.stdin.flush()
def _drain_stdout(self, proc: subprocess.Popen[str]) -> None:
assert proc.stdout is not None
for raw in proc.stdout:
try:
frame = json.loads(raw)
except json.JSONDecodeError:
logger.warning("compute host emitted invalid json: %r", raw[:200])
continue
if isinstance(frame, dict):
self._handle_host_frame(frame)
def _drain_stderr(self, proc: subprocess.Popen[str]) -> None:
assert proc.stderr is not None
for raw in proc.stderr:
text = raw.rstrip("\n")
if text:
self._stderr_tail = (self._stderr_tail + [text])[-80:]
logger.warning("compute host stderr: %s", text)
def _handle_host_frame(self, frame: dict[str, Any]) -> None:
ftype = str(frame.get("type") or "")
if ftype == "hello":
self._hello = dict(frame)
self._hello_event.set()
return
if ftype == "hb":
self._last_progress_counter = int(frame.get("progress_counter") or self._last_progress_counter)
logger.debug("compute host heartbeat: %s", frame)
return
if ftype == "rpc":
message = frame.get("message")
if isinstance(message, dict):
self.rpc_sink(message)
return
if ftype in {"turn.end", "turn.error"}:
self._complete_turn(frame)
return
if ftype in {"control.ack", "control.error", "respond.ack", "respond.error", "interrupt.ack", "reload_mcp.ack", "shutdown.ack"}:
self._deliver_control_frame(str(frame.get("request_id") or ""), frame)
return
if ftype == "error" and frame.get("request_id"):
self._deliver_control_frame(str(frame.get("request_id") or ""), frame)
def _complete_turn(self, frame: dict[str, Any]) -> None:
request_id = str(frame.get("request_id") or "")
with self._lock:
pending = self._pending_turns.pop(request_id, None)
if pending is None:
return
_sid, cb = pending
if cb is not None:
try:
cb(frame)
except Exception:
logger.exception("compute host turn completion callback failed")
def _wait_for_exit(self, proc: subprocess.Popen[str]) -> None:
code = proc.wait()
if self._closing:
return
with self._lock:
if self._proc is not proc:
return
self._proc = None
self._remove_registry()
self._fail_pending_turns(reason="crash", message=f"compute host exited with code {code}")
self._maybe_respawn_after_crash()
def _fail_pending_turns(self, *, reason: str, message: str) -> None:
with self._lock:
pending = self._pending_turns
self._pending_turns = {}
for request_id, (sid, cb) in pending.items():
frame = {
"type": "turn.error",
"sid": sid,
"request_id": request_id,
"reason": reason,
"message": message,
}
self.rpc_sink(
{
"jsonrpc": "2.0",
"method": "event",
"params": {
"type": "error",
"session_id": sid,
"payload": {"message": message, "reason": reason},
},
}
)
if cb is not None:
try:
cb(frame)
except Exception:
logger.exception("compute host error callback failed")
# A crashed host will never emit the late acks the timed-out control
# waiters are still expecting; fail them the same way so the client's
# "still running in the background" notice does not hang forever.
with self._lock:
late = self._late_control_handlers
self._late_control_handlers = {}
for request_id, (_registered_at, handler) in late.items():
try:
handler({"type": "control.error", "request_id": request_id, "reason": reason, "message": message})
except Exception:
logger.exception("compute host late control error handler failed")
def _maybe_respawn_after_crash(self) -> None:
now = time.monotonic()
self._restart_times = [t for t in self._restart_times if now - t <= _RESPAWN_WINDOW_SECS]
if len(self._restart_times) >= self.respawn_max:
self._stopped_respawning = True
logger.error("compute host crash loop: max %s restarts per 5min reached; not respawning", self.respawn_max)
return
self._restart_times.append(now)
# Small bounded backoff; tests and first recovery stay quick.
delay = min(5.0, 0.25 * (2 ** max(0, len(self._restart_times) - 1)))
def _respawn() -> None:
time.sleep(delay)
with self._lock:
if self._closing or self._stopped_respawning or self._proc is not None:
return
try:
self._spawn_locked(reason="crash")
except Exception:
logger.exception("compute host respawn failed")
_Thread(target=_respawn, name="compute-host-respawn", daemon=True).start()
def _pid_matches_compute_host(self, pid: int) -> bool:
return is_compute_host_identity(pid)
def _terminate_pid(self, pid: int, *, timeout: float = _SHUTDOWN_TIMEOUT_SECS) -> None:
try:
os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
return
except Exception:
logger.debug("failed to SIGTERM compute host pid=%s", pid, exc_info=True)
return
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if not _pid_alive(pid):
return
time.sleep(0.05)
try:
os.kill(pid, signal.SIGKILL)
except ProcessLookupError:
return
except Exception:
logger.debug("failed to SIGKILL compute host pid=%s", pid, exc_info=True)
def _terminate_process(self, proc: subprocess.Popen[str]) -> None:
if proc.poll() is not None:
return
try:
proc.terminate()
proc.wait(timeout=_SHUTDOWN_TIMEOUT_SECS)
return
except Exception:
pass
try:
proc.kill()
except Exception:
pass
try:
proc.wait(timeout=2)
except Exception:
pass
__all__ = [
"MUTATOR_ROUTE_TABLE",
"HostSupervisor",
"append_log_record",
"is_compute_host_identity",
]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+368
View File
@@ -0,0 +1,368 @@
"""Peer-backed session transport for one hosted-room member task.
This adapter implements :class:`InternalSessionRPC` without using canonical
Bot Chat. The remote client must resolve a hidden ``Group: <room_id>`` session
with ``source=bot_room`` and verify the scoped grant at admission.
"""
from __future__ import annotations
import hashlib
import time
import uuid
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from typing import Any, Protocol
from gateway.hosted_room_driver import TaskIdentity
from gateway.hosted_room_peer import HostedMemberDispatch, PROTOCOL_VERSION
from tui_gateway.hosted_room_driver import (
ROOM_SESSION_SOURCE,
HostedRoomBinding,
InternalSessionRPC,
room_session_title,
)
class HostedRoomPeerClient(Protocol):
"""Authenticated client for a target gateway's narrow room-member API."""
def bind_room_scope(self, **scope: Any) -> None: ...
def prepare(
self,
*,
room_id: str,
profile: str,
source: str,
grant: str,
create: bool,
expected_session_id: str | None = None,
) -> Mapping[str, Any] | None: ...
def dispatch(
self,
*,
dispatch: Mapping[str, Any],
grant: str,
) -> Mapping[str, Any]: ...
def history(
self,
*,
room_id: str,
profile: str,
session_id: str,
grant: str,
) -> Sequence[Mapping[str, Any]]: ...
def status(
self,
*,
room_id: str,
profile: str,
session_id: str,
grant: str,
) -> Mapping[str, Any]: ...
def stop(
self,
*,
dispatch: Mapping[str, Any],
grant: str,
) -> Mapping[str, Any] | None: ...
def stop_receipt(
self,
*,
task_id: str,
execution_generation: int,
grant: str,
) -> Mapping[str, Any] | None: ...
@dataclass(frozen=True)
class RoomLinkCandidate:
"""One address/provider for the same authenticated target gateway."""
name: str
mode: str
target_install_id: str
client: HostedRoomPeerClient
class FailoverHostedRoomPeerClient:
"""Try alternate links without changing target or logical task identity."""
def __init__(
self,
candidates: Sequence[RoomLinkCandidate],
*,
reprobe_interval_seconds: float = 60,
clock: Callable[[], float] = time.monotonic,
) -> None:
if not candidates:
raise ValueError("at least one RoomLink candidate is required")
targets = {candidate.target_install_id for candidate in candidates}
if len(targets) != 1:
raise ValueError("RoomLink candidates must target one installation")
if reprobe_interval_seconds <= 0:
raise ValueError("reprobe_interval_seconds must be positive")
self.candidates = tuple(candidates)
self._active = 0
self.reprobe_interval_seconds = float(reprobe_interval_seconds)
self.clock = clock
self._last_primary_probe = 0.0
@property
def active_link(self) -> RoomLinkCandidate:
return self.candidates[self._active]
def _call(self, method: str, **kwargs):
now = self.clock()
probe_primary = (
self._active != 0
and now - self._last_primary_probe >= self.reprobe_interval_seconds
)
if probe_primary:
self._last_primary_probe = now
order = [0, self._active]
else:
order = [self._active]
order.extend(
index for index in range(len(self.candidates)) if index not in order
)
last_error = None
for index in order:
candidate = self.candidates[index]
try:
result = getattr(candidate.client, method)(**kwargs)
except Exception as exc:
if bool(getattr(exc, "ambiguous", False)):
raise
if not bool(getattr(exc, "retryable", False)):
raise
last_error = exc
continue
self._active = index
return result
if last_error is not None:
raise last_error
raise RuntimeError("no RoomLink candidate was attempted")
def prepare(self, **kwargs):
return self._call("prepare", **kwargs)
def dispatch(self, **kwargs):
return self._call("dispatch", **kwargs)
def history(self, **kwargs):
return self._call("history", **kwargs)
def status(self, **kwargs):
return self._call("status", **kwargs)
def stop(self, **kwargs):
return self._call("stop", **kwargs)
def bind_room_scope(self, **kwargs):
for candidate in self.candidates:
bind = getattr(candidate.client, "bind_room_scope", None)
if callable(bind):
bind(**kwargs)
@dataclass(frozen=True)
class PeerMemberRoute:
"""Secret-free target coordinates plus a separately stored room grant."""
home_install_id: str
member_id: str
target_install_id: str
target_profile: str
capability_digest: str
cancellation_scope_id: str
trace_id: str
grant: str
execution_policy_digest: str = ""
class PeerHostedRoomTransport(InternalSessionRPC):
"""Translate runtime session operations into recipient-validated peer RPC."""
def __init__(
self,
*,
binding: HostedRoomBinding,
route: PeerMemberRoute,
client: HostedRoomPeerClient,
source_event_seq: int = 1,
task_id: str | None = None,
execution_generation: int | None = None,
) -> None:
self.binding = binding
self.route = route
self.client = client
if isinstance(source_event_seq, bool) or source_event_seq < 1:
raise ValueError("peer room source_event_seq must be positive")
self.source_event_seq = int(source_event_seq)
self.task_id = task_id
self.execution_generation = execution_generation
self._session_id: str | None = None
self._dispatch: HostedMemberDispatch | None = None
bind_scope = getattr(self.client, "bind_room_scope", None)
if callable(bind_scope):
bind_scope(
room_id=self.binding.room_id,
home_install_id=self.route.home_install_id,
authority_gateway_id=self.binding.gateway_id,
authority_epoch=self.binding.authority_epoch,
member_id=self.route.member_id,
target_install_id=self.route.target_install_id,
target_profile=self.route.target_profile,
)
def _validate_coordinates(self, *, profile: str, source: str) -> None:
if source != ROOM_SESSION_SOURCE:
raise ValueError("peer room transport requires source=bot_room")
if profile != self.route.target_profile:
raise ValueError("peer room transport profile does not match its grant")
def resolve_exact(
self, *, profile: str, title: str, source: str
) -> Mapping[str, Any] | None:
self._validate_coordinates(profile=profile, source=source)
if title != room_session_title(self.binding.room_id):
raise ValueError("peer room transport title does not match room identity")
return self.client.prepare(
room_id=self.binding.room_id,
profile=profile,
source=source,
grant=self.route.grant,
create=False,
)
def create(self, *, profile: str, title: str, source: str) -> Mapping[str, Any]:
self._validate_coordinates(profile=profile, source=source)
if title != room_session_title(self.binding.room_id):
raise ValueError("peer room transport title does not match room identity")
session = self.client.prepare(
room_id=self.binding.room_id,
profile=profile,
source=source,
grant=self.route.grant,
create=True,
)
if session is None:
raise RuntimeError("peer did not create the room session")
self._session_id = str(session.get("session_id") or session.get("id") or "")
return session
def resume(
self, *, profile: str, session_id: str, source: str
) -> Mapping[str, Any]:
self._validate_coordinates(profile=profile, source=source)
session = self.client.prepare(
room_id=self.binding.room_id,
profile=profile,
source=source,
grant=self.route.grant,
create=False,
expected_session_id=session_id,
)
if session is None:
raise RuntimeError("peer room session is unavailable")
self._session_id = session_id
return session
def submit(
self,
*,
profile: str,
session_id: str,
prompt: str,
source: str,
task: TaskIdentity,
execution_generation: int,
on_terminal: Callable[[Mapping[str, Any]], None],
) -> Mapping[str, Any]:
self._validate_coordinates(profile=profile, source=source)
if self._session_id not in {None, session_id}:
raise ValueError("peer room session changed during admission")
dispatch = HostedMemberDispatch.from_mapping({
"protocol_version": PROTOCOL_VERSION,
"room_id": task.room_id,
"home_install_id": self.route.home_install_id,
"authority_gateway_id": self.binding.gateway_id,
"authority_epoch": self.binding.authority_epoch,
"member_id": self.route.member_id,
"target_install_id": self.route.target_install_id,
"target_profile": profile,
"task_id": task.task_id,
"execution_generation": execution_generation,
"source_event_seq": self.source_event_seq,
"cancellation_scope_id": self.route.cancellation_scope_id,
"prompt": prompt,
"prompt_digest": hashlib.sha256(prompt.encode("utf-8")).hexdigest(),
"capability_digest": self.route.capability_digest,
"execution_policy_digest": self.route.execution_policy_digest,
"trace_id": self.route.trace_id or f"trace-{uuid.uuid4().hex}",
})
self._dispatch = dispatch
self._session_id = session_id
result = self.client.dispatch(
dispatch=dispatch.as_mapping(),
grant=self.route.grant,
)
if result.get("status") in {"settled", "failed", "cancelled"}:
on_terminal(result)
return result
def history(
self, *, profile: str, session_id: str, source: str
) -> Sequence[Mapping[str, Any]]:
self._validate_coordinates(profile=profile, source=source)
return self.client.history(
room_id=self.binding.room_id,
profile=profile,
session_id=session_id,
grant=self.route.grant,
)
def info(self, *, profile: str, session_id: str, source: str) -> Mapping[str, Any]:
self._validate_coordinates(profile=profile, source=source)
return self.client.status(
room_id=self.binding.room_id,
profile=profile,
session_id=session_id,
grant=self.route.grant,
)
def interrupt(
self,
*,
profile: str,
session_id: str,
source: str,
expected_task_id: str,
) -> Mapping[str, Any] | None:
self._validate_coordinates(profile=profile, source=source)
dispatch = self._dispatch
if dispatch is None:
if (
self.task_id != expected_task_id
or not self.execution_generation
or not hasattr(self.client, "stop_receipt")
):
return None
return self.client.stop_receipt(
task_id=expected_task_id,
execution_generation=self.execution_generation,
grant=self.route.grant,
)
if dispatch.task_id != expected_task_id:
return None
return self.client.stop(
dispatch=dispatch.as_mapping(),
grant=self.route.grant,
)
+213
View File
@@ -0,0 +1,213 @@
"""In-process session adapter for the hosted room driver.
The room worker must not depend on a Desktop/WebSocket transport, but it should
still use the same session handlers as every other TUI/Desktop turn. This
adapter calls the installed handler registry directly and keeps the extra
task proof as an in-process-only Python object that JSON clients cannot forge.
"""
from __future__ import annotations
import itertools
import threading
from collections.abc import Mapping, Sequence
from types import ModuleType
from typing import Any, Callable
from gateway import hosted_room_driver as state
class HostedRoomSessionError(RuntimeError):
"""Raised when an in-process session operation is rejected."""
def __init__(self, method: str, code: int, message: str) -> None:
super().__init__(f"{method} failed: {message}")
self.method = method
self.code = code
class HostedRoomServerRPC:
"""Normalize the installed server handlers for :class:`HostedRoomRuntime`."""
def __init__(self, server: ModuleType) -> None:
self.server = server
self._ids = itertools.count(1)
def _call(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
handler = self.server._methods[method]
envelope = handler(f"hosted-room-{next(self._ids)}", params)
error = envelope.get("error") if isinstance(envelope, dict) else None
if isinstance(error, dict):
raise HostedRoomSessionError(
method,
int(error.get("code") or 5000),
str(error.get("message") or "gateway rejected the request"),
)
result = envelope.get("result") if isinstance(envelope, dict) else None
if not isinstance(result, dict):
raise HostedRoomSessionError(method, 5000, "gateway returned no result")
return result
def resolve_exact(
self, *, profile: str, title: str, source: str
) -> Mapping[str, Any] | None:
del source
result = self._call(
"session.list",
{"profile": profile, "title": title, "include_hidden": True},
)
rows = result.get("sessions")
if not isinstance(rows, list) or not rows:
return None
row = rows[0]
if not isinstance(row, dict):
return None
session_id = row.get("resolved_id") or row.get("id")
return {"session_id": session_id, "title": row.get("title") or title}
def create(self, *, profile: str, title: str, source: str) -> Mapping[str, Any]:
return self._call(
"session.create",
{
"profile": profile,
"title": title,
"source": source,
"hidden": True,
"room_plumbing": True,
"follow_profile_config": True,
"close_on_disconnect": False,
},
)
def resume(
self, *, profile: str, session_id: str, source: str
) -> Mapping[str, Any]:
return self._call(
"session.resume",
{
"profile": profile,
"session_id": session_id,
"omit_messages": True,
"source": source,
},
)
def submit(
self,
*,
profile: str,
session_id: str,
prompt: str,
source: str,
task: state.TaskIdentity,
execution_generation: int,
on_terminal: Callable[[Mapping[str, Any]], None],
) -> Mapping[str, Any]:
try:
return self._call(
"prompt.submit",
{
"profile": profile,
"session_id": session_id,
"text": prompt,
"source": source,
"_hosted_task": {
"room_id": task.room_id,
"task_id": task.task_id,
"thread_id": task.thread_id,
"turn_id": task.turn_id,
"execution_generation": execution_generation,
},
"_hosted_terminal_callback": on_terminal,
},
)
except HostedRoomSessionError as exc:
# In-process prompt.submit error envelopes are returned before the
# background turn is admitted. Preserve that proof so the driver
# can defer or requeue without waiting out an ambiguity lease.
exc.not_admitted = True
raise
def history(
self, *, profile: str, session_id: str, source: str
) -> Sequence[Mapping[str, Any]]:
del source
result = self._call(
"session.history",
{"profile": profile, "session_id": session_id},
)
rows = result.get("messages")
return tuple(row for row in rows if isinstance(row, dict)) if isinstance(rows, list) else ()
def _session_record(self, session_id: str) -> dict[str, Any] | None:
with self.server._sessions_lock:
record = self.server._sessions.get(session_id)
if record is not None:
return record
for candidate in self.server._sessions.values():
if str(candidate.get("session_key") or "") == session_id:
return candidate
return None
def info(self, *, profile: str, session_id: str, source: str) -> Mapping[str, Any]:
del profile, source
record = self._session_record(session_id)
if record is None:
return {"active": False, "task_id": None}
lock = record.get("history_lock")
if not isinstance(lock, type(threading.Lock())):
return {"active": bool(record.get("running")), "task_id": None}
with lock:
task = record.get("_hosted_room_task")
result = {
"active": bool(record.get("running")),
"task_id": task.get("task_id") if isinstance(task, dict) else None,
}
pending_reader = getattr(
self.server, "_pending_approval_request_payload", None
)
pending = (
pending_reader(str(record.get("session_key") or ""))
if callable(pending_reader)
else None
)
if pending:
result["status"] = "waiting_for_approval"
result["pending_approval"] = pending
return result
def approve(
self,
*,
session_id: str,
request_id: str,
choice: str,
) -> Mapping[str, Any]:
"""Resolve one exact local room approval without broad policy changes."""
return self._call(
"approval.respond",
{
"session_id": session_id,
"request_id": request_id,
"choice": choice,
"all": False,
},
)
def interrupt(
self,
*,
profile: str,
session_id: str,
source: str,
expected_task_id: str,
) -> Mapping[str, Any] | None:
del source
return self._call(
"session.interrupt",
{
"profile": profile,
"session_id": session_id,
"expected_hosted_task_id": expected_task_id,
},
)
File diff suppressed because it is too large Load Diff
+83
View File
@@ -0,0 +1,83 @@
"""Suppress benign event-loop teardown noise on the gateway serving loop.
When the Desktop client forcibly closes its WebSocket while the gateway still
has pending socket operations, asyncio's transport teardown logs a full
traceback for every pending ``_call_connection_lost`` callback. On Windows this
surfaces as ``ConnectionResetError: [WinError 10054]`` (and the rarer
``ConnectionAbortedError: [WinError 10053]``); on POSIX it is the equivalent
``ConnectionResetError``/``BrokenPipeError``. A single client disconnect can
emit 50+ identical tracebacks into ``errors.log`` (#50005).
These are not actionable — they are the expected side effect of the peer
hanging up before our writes drained. We install a loop exception handler that
collapses exactly this class of teardown error to one debug line and forwards
everything else to asyncio's default handler unchanged, so genuine loop bugs
still surface.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
_log = logging.getLogger(__name__)
# Connection-teardown errors that mean "the peer hung up mid-write". WinError
# 10054 (connection reset) and 10053 (connection aborted) raise as these.
_BENIGN_TEARDOWN_ERRORS = (
ConnectionResetError,
ConnectionAbortedError,
BrokenPipeError,
)
def _is_benign_teardown(context: dict[str, Any]) -> bool:
"""True when the loop error is a peer-hangup during transport teardown.
Gated on BOTH the exception type AND the ``_call_connection_lost``
callback so we only swallow the disconnect flood — any other place these
errors surface (a real handler, a custom callback) still goes to the
default handler.
"""
exc = context.get("exception")
if not isinstance(exc, _BENIGN_TEARDOWN_ERRORS):
return False
# The flood originates from the transport's connection-lost callback. Match
# on its repr so we don't suppress the same error type raised elsewhere.
callback = context.get("callback")
handle = context.get("handle")
marker = "_call_connection_lost"
return marker in repr(callback) or marker in repr(handle)
def install_loop_noise_filter(loop: asyncio.AbstractEventLoop) -> None:
"""Chain a teardown-noise filter ahead of the loop's existing handler.
Idempotent: re-installing on a loop that already has the filter is a no-op,
so it's safe to call on every reconnect/serve entry.
"""
if getattr(loop, "_hermes_noise_filter_installed", False):
return
previous = loop.get_exception_handler()
def _handler(loop: asyncio.AbstractEventLoop, context: dict[str, Any]) -> None:
if _is_benign_teardown(context):
_log.debug(
"ws peer hangup during teardown (suppressed): %s",
context.get("exception"),
)
return
if previous is not None:
previous(loop, context)
else:
loop.default_exception_handler(context)
loop.set_exception_handler(_handler)
# Mark on the loop instance so a second install (reconnect, re-serve) is a
# no-op rather than stacking handlers.
try:
loop._hermes_noise_filter_installed = True # type: ignore[attr-defined]
except (AttributeError, TypeError): # pragma: no cover - exotic loop impls
pass
+429
View File
@@ -0,0 +1,429 @@
"""Session-backed MCP OAuth flows for the gateway (mcp.servers.oauth.*).
This mirrors the *provider* OAuth model used by the dashboard
(``/api/providers/oauth/{id}/start`` + ``/poll/{session_id}``) rather than the
FastAPI-request-coupled MCP dashboard flow: a ``start`` primitive kicks off a
background worker and returns ``{session_id, auth_url, flow}``; a ``poll``
primitive reports ``{status: pending|approved|error}`` until the tokens land on
disk for that server in that profile.
The underlying token machinery is the *same* one the CLI ``hermes mcp login``
uses — ``hermes_cli.mcp_config._probe_single_server`` under
``tools.mcp_oauth.force_interactive_oauth`` — so no OAuth logic is reimplemented
here. The only new piece is decoupling the two browser callbacks (authorization
URL out, ``code``/``state`` back in) from a FastAPI ``Request``:
* ``tools.mcp_dashboard_oauth.DashboardOAuthFlow`` already provides the two
thread-safe rendezvous points (``publish_authorization_url`` /
``deliver_callback``). We reuse it verbatim as the bridge object.
* Instead of routing the browser redirect through a FastAPI callback route, we
run a tiny loopback HTTP listener on ``127.0.0.1:<port>/callback`` and set the
flow's ``redirect_uri`` to it. When the provider redirects the user's browser
there, the listener calls ``flow.deliver_callback(...)``. This is the same
loopback strategy the CLI uses by default, just wired to the shared bridge.
Client contract (what the desktop plugin does):
1. call ``mcp.servers.oauth.start(profile, name)`` → ``{session_id, auth_url}``
2. open ``auth_url`` in the native browser (``openExternal``)
3. poll ``mcp.servers.oauth.poll(profile, name, session_id)`` until
``status == "approved"`` (tokens persisted) or ``"error"``.
Remote-backend variant (client-side callback): when the desktop app runs on a
DIFFERENT machine than the gateway (SSH/Tailscale remote backend), the
gateway-side ``127.0.0.1`` listener is unreachable from the user's browser —
the redirect lands on the user's machine where nothing is listening, and the
flow times out. For that topology the client binds its OWN loopback listener
(same pattern as the desktop's native gateway login), passes its
``redirect_uri`` to ``start`` (``client_redirect_uri``), and relays the
provider redirect back via ``deliver_callback_flow`` /
``mcp.servers.oauth.callback``. State verification stays server-side in
``DashboardOAuthFlow.deliver_callback`` — a relayed code with the wrong
``state`` is rejected exactly like a forged loopback hit.
"""
from __future__ import annotations
import http.server
import secrets
import threading
import time
from pathlib import Path
from typing import Any, Dict, Optional
from urllib.parse import parse_qs, urlparse
# Session registry: session_id -> record. A record wraps the shared
# DashboardOAuthFlow bridge plus a bit of gateway bookkeeping.
_sessions: Dict[str, Dict[str, Any]] = {}
_sessions_lock = threading.Lock()
# How long a completed/abandoned session lingers before GC (seconds).
_SESSION_TTL_SECONDS = 900
# Cap concurrent in-flight flows so a runaway client can't exhaust ports/threads.
_MAX_PENDING = 12
def _gc_sessions() -> None:
"""Drop expired sessions. Called opportunistically on start."""
cutoff = time.time() - _SESSION_TTL_SECONDS
with _sessions_lock:
stale = [sid for sid, rec in _sessions.items() if rec["created_at"] < cutoff]
for sid in stale:
rec = _sessions.pop(sid, None)
if rec is not None:
_shutdown_listener(rec)
def _shutdown_listener(rec: Dict[str, Any]) -> None:
server = rec.get("httpd")
if server is not None:
try:
server.shutdown()
except Exception:
pass
try:
server.server_close()
except Exception:
pass
rec["httpd"] = None
def _validate_client_redirect_uri(uri: str) -> str:
"""Validate a client-supplied loopback redirect URI.
Only plain-http loopback URLs are accepted (``http://127.0.0.1:<port>/...``
or ``http://localhost:<port>/...``), mirroring RFC 8252 native-app rules —
the client hosts a one-shot listener on ITS machine, so anything else
(public hosts, https proxies, schemes) is rejected to keep the gateway from
pinning an attacker-controlled redirect into a DCR registration.
"""
parsed = urlparse(str(uri or "").strip())
host = (parsed.hostname or "").lower()
if (
parsed.scheme != "http"
or host not in ("127.0.0.1", "localhost", "::1")
or not parsed.port
or parsed.username is not None
or parsed.password is not None
):
raise ValueError(
"client_redirect_uri must be a loopback http URL like "
"http://127.0.0.1:<port>/callback"
)
return f"http://{'[' + host + ']' if ':' in host else host}:{parsed.port}{parsed.path or '/callback'}"
def _start_loopback_listener(flow) -> "http.server.HTTPServer":
"""Bind a loopback callback listener that feeds the flow's deliver_callback.
Returns the running HTTPServer (already serving on a daemon thread). The
bound port is read back off ``server.server_address`` so the caller can set
``flow.redirect_uri`` to the matching ``/callback`` URL BEFORE the worker
starts the OAuth flow (the redirect URI must be pinned at authorization).
"""
class _Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self): # noqa: N802 — stdlib naming
parsed = urlparse(self.path)
if parsed.path.rstrip("/") not in ("/callback", ""):
self.send_response(404)
self.end_headers()
return
qs = parse_qs(parsed.query)
code = (qs.get("code") or [None])[0]
state = (qs.get("state") or [None])[0]
error = (qs.get("error") or [None])[0]
body = b"<h1>Authorization received</h1><p>You can close this tab and return to Hermes.</p>"
status = 200
try:
flow.deliver_callback(code=code, state=state, error=error)
except Exception:
body = b"<h1>OAuth callback rejected</h1><p>The callback was invalid or already used.</p>"
status = 400
self.send_response(status)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
try:
self.wfile.write(body)
except Exception:
pass
def log_message(self, *_a): # silence stdlib request logging
return
httpd = http.server.HTTPServer(("127.0.0.1", 0), _Handler)
threading.Thread(
target=httpd.serve_forever,
kwargs={"poll_interval": 0.5},
daemon=True,
name=f"mcp-oauth-cb-{flow.server_name}",
).start()
return httpd
def _worker(session_id: str, hermes_home: str, server_name: str, cfg: dict, reconnect_live: bool) -> None:
"""Drive the interactive MCP OAuth probe under the shared dashboard bridge.
Structurally identical to ``web_server._run_dashboard_mcp_oauth`` — the same
HERMES_HOME override + secret-scope + force_interactive_oauth +
dashboard_oauth_flow wrapping around ``_probe_single_server`` — but keyed to
our session record instead of a FastAPI request. On success the token file
exists on disk (verified via ``_oauth_tokens_present``) and the server config
is (re)saved into the profile's config.yaml.
"""
from hermes_cli.mcp_config import (
_oauth_tokens_present,
_probe_single_server,
_save_mcp_server,
)
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
rec = _sessions.get(session_id)
flow = rec["flow"] if rec else None
try:
from agent.secret_scope import (
build_profile_secret_scope,
reset_secret_scope,
set_secret_scope,
)
from tools.mcp_dashboard_oauth import dashboard_oauth_flow
from tools.mcp_oauth import force_interactive_oauth
from tools.mcp_oauth_manager import get_manager
home_token = set_hermes_home_override(hermes_home)
secret_token = set_secret_scope(build_profile_secret_scope(Path(hermes_home)))
try:
with force_interactive_oauth(), dashboard_oauth_flow(flow):
from tools.mcp_oauth import HermesTokenStorage
manager = get_manager()
storage = HermesTokenStorage(server_name)
backup = storage.snapshot()
previous_entry = None
try:
previous_entry = manager.remove(server_name, hermes_home=hermes_home)
tools = _probe_single_server(
server_name,
cfg,
connect_timeout=max(float(cfg.get("connect_timeout", 0) or 0), 315),
)
if not _oauth_tokens_present(server_name):
raise RuntimeError(
"The server responded, but no OAuth token was obtained — "
"this provider may require a manually-registered OAuth client."
)
_save_mcp_server(server_name, cfg)
if flow is not None:
flow.tools = [{"name": t, "description": d} for t, d in tools]
flow.mark_approved()
if reconnect_live:
from tools.mcp_tool import reconnect_mcp_server
reconnect_mcp_server(server_name)
except Exception:
storage.restore(backup, only_if_absent=True)
manager.restore_entry(server_name, previous_entry, hermes_home=hermes_home)
raise
finally:
reset_secret_scope(secret_token)
reset_hermes_home_override(home_token)
except Exception as exc:
msg = str(exc)
try:
from tools.mcp_oauth import humanize_oauth_registration_error
humanized = humanize_oauth_registration_error(
server_name, exc, server_url=cfg.get("url") if isinstance(cfg, dict) else None
)
if humanized:
msg = humanized
except Exception:
pass
if flow is not None:
flow.mark_error(msg)
finally:
if flow is not None:
flow.mark_worker_done()
if rec is not None:
_shutdown_listener(rec)
def start_flow(
hermes_home: str,
server_name: str,
cfg: dict,
*,
reconnect_live: bool = False,
url_timeout: float = 30.0,
client_redirect_uri: Optional[str] = None,
) -> Dict[str, Any]:
"""Begin an MCP OAuth flow and return ``{session_id, auth_url, flow}``.
``cfg`` is the server's resolved config dict (must have ``url`` and be
OAuth-capable). ``hermes_home`` is the already-resolved profile home dir
string. Blocks up to ``url_timeout`` for the worker to publish the browser
authorization URL, then returns it.
``client_redirect_uri`` (remote-backend variant): a loopback callback URL
the CLIENT hosts on its own machine. When set (and valid), no gateway-side
listener is bound — the OAuth ``redirect_uri`` is pinned to the client's
listener, and the client relays the redirect's ``code``/``state`` back via
``deliver_callback_flow``. Invalid values raise ``ValueError``.
"""
from tools.mcp_dashboard_oauth import DashboardOAuthFlow
if client_redirect_uri is not None:
client_redirect_uri = _validate_client_redirect_uri(client_redirect_uri)
_gc_sessions()
with _sessions_lock:
pending = sum(
1
for r in _sessions.values()
if not r["flow"].worker_done
)
if pending >= _MAX_PENDING:
raise RuntimeError("Too many MCP OAuth flows are already in progress")
if any(
r["server_name"] == server_name
and r["hermes_home"] == hermes_home
and not r["flow"].worker_done
for r in _sessions.values()
):
raise RuntimeError(f"MCP OAuth for '{server_name}' is already in progress")
session_id = secrets.token_urlsafe(24)
flow = DashboardOAuthFlow(
flow_id=session_id,
server_name=server_name,
profile=None,
hermes_home=hermes_home,
redirect_uri="", # set below once the loopback port is known
reconnect_live=reconnect_live,
)
if client_redirect_uri:
# Remote-backend variant: the CLIENT hosts the callback listener on its
# own machine and relays the code via deliver_callback_flow(). No
# gateway-side listener is bound — a 127.0.0.1 port here would be
# unreachable from the user's browser anyway.
httpd = None
flow.redirect_uri = client_redirect_uri
else:
httpd = _start_loopback_listener(flow)
port = httpd.server_address[1]
flow.redirect_uri = f"http://127.0.0.1:{port}/callback"
rec = {
"session_id": session_id,
"server_name": server_name,
"hermes_home": hermes_home,
"flow": flow,
"httpd": httpd,
"created_at": time.time(),
}
with _sessions_lock:
_sessions[session_id] = rec
threading.Thread(
target=_worker,
args=(session_id, hermes_home, server_name, dict(cfg), reconnect_live),
daemon=True,
name=f"mcp-oauth-{server_name}",
).start()
try:
auth_url = None
# wait_for_authorization_url is async; run its wait synchronously.
deadline = time.time() + url_timeout
while time.time() < deadline:
snap = flow.snapshot()
if snap.get("authorization_url"):
auth_url = snap["authorization_url"]
break
if snap.get("status") == "error":
raise RuntimeError(snap.get("error") or "MCP OAuth flow failed before authorization")
time.sleep(0.1)
if not auth_url:
raise TimeoutError("Timed out waiting for MCP authorization URL")
except Exception:
flow.mark_error("Timed out waiting for MCP authorization URL")
_shutdown_listener(rec)
raise
return {
"session_id": session_id,
"auth_url": auth_url,
# "pkce" mirrors the provider-OAuth ``flow`` discriminator: the client
# opens a URL then polls (no user_code to type, unlike device_code).
"flow": "pkce",
}
def poll_flow(session_id: str, server_name: str) -> Dict[str, Any]:
"""Poll a session's status → ``{status, error_message?, auth_url?, tools?}``.
``status`` is one of ``pending`` | ``approved`` | ``error`` — the same
vocabulary as the provider poll endpoint (``authorization_required`` from
the underlying bridge maps to ``pending`` since the client only needs to
know whether to keep waiting).
"""
with _sessions_lock:
rec = _sessions.get(session_id)
if rec is None:
return {"status": "error", "error_message": "OAuth session not found or expired"}
if rec["server_name"] != server_name:
return {"status": "error", "error_message": "server name mismatch for session"}
flow = rec["flow"]
snap = flow.snapshot()
raw = snap.get("status")
if raw == "approved":
status = "approved"
elif raw == "error":
status = "error"
else:
status = "pending"
out: Dict[str, Any] = {
"session_id": session_id,
"status": status,
"error_message": snap.get("error"),
"auth_url": snap.get("authorization_url"),
}
if status == "approved":
out["tools"] = list(getattr(flow, "tools", []) or [])
return out
def deliver_callback_flow(
session_id: str,
server_name: str,
*,
code: Optional[str],
state: Optional[str],
error: Optional[str] = None,
) -> Dict[str, Any]:
"""Relay a client-captured OAuth redirect into a session's flow.
Remote-backend companion to ``start_flow(client_redirect_uri=...)``: the
desktop app's loopback listener caught the provider redirect on the USER'S
machine and forwards ``code``/``state`` (or ``error``) here. Security
properties are unchanged from the gateway-listener path — the underlying
``DashboardOAuthFlow.deliver_callback`` verifies ``state`` against the
pinned authorization request (constant-time compare) and rejects replays,
so a forged or replayed relay fails identically to a forged loopback hit.
Returns ``{ok: true}`` on acceptance or ``{ok: false, error_message}``.
"""
with _sessions_lock:
rec = _sessions.get(session_id)
if rec is None:
return {"ok": False, "error_message": "OAuth session not found or expired"}
if rec["server_name"] != server_name:
return {"ok": False, "error_message": "server name mismatch for session"}
flow = rec["flow"]
try:
flow.deliver_callback(code=code, state=state, error=error)
except ValueError as exc:
return {"ok": False, "error_message": str(exc)}
return {"ok": True, "session_id": session_id}
+74
View File
@@ -0,0 +1,74 @@
"""Shared helpers for the per-profile MCP lifecycle RPCs (mcp.servers.*).
These live in their own module (not methods_tools) because methods_tools
handlers are rebound onto ``tui_gateway.server``'s globals at install time
(see method_ctx.HandlerRegistry.install); a plain module-level def in
methods_tools would not be reachable from a rebound handler body. Handlers
import these at call time instead.
"""
from __future__ import annotations
from typing import Any, Dict, Optional, Tuple
def resolve_profile(rid, params, err_fn) -> Tuple[Optional[Any], Optional[dict]]:
"""Resolve the optional ``profile`` param to a HERMES_HOME override token.
Returns ``(token, error)``: ``token`` is None for the launch profile (no
override) or an opaque reset token; ``error`` is a JSON-RPC error dict
(built via ``err_fn``) when the named profile doesn't exist. Callers reset
``token`` in a finally via :func:`reset_profile`.
"""
profile = str(params.get("profile") or "").strip()
if not profile:
return None, None
from hermes_cli.profiles import get_profile_dir
from hermes_constants import set_hermes_home_override
profile_dir = get_profile_dir(profile)
if not profile_dir or not profile_dir.is_dir():
return None, err_fn(rid, 4064, f"profile '{profile}' not found")
return set_hermes_home_override(str(profile_dir)), None
def reset_profile(token) -> None:
if token is not None:
try:
from hermes_constants import reset_hermes_home_override
reset_hermes_home_override(token)
except Exception:
pass
def summarize_server(name: str, cfg: dict) -> Dict[str, Any]:
"""Serialize one server's config for a UI (no secret values).
Mirrors web_server._mcp_server_summary plus an ``oauth_tokens_present``
flag so a UI can tell an OAuth server that still needs authentication from
one already authenticated.
"""
from hermes_cli.mcp_config import _oauth_tokens_present
cfg = cfg if isinstance(cfg, dict) else {}
transport = "http" if cfg.get("url") else ("stdio" if cfg.get("command") else "unknown")
auth = cfg.get("auth")
headers = cfg.get("headers") or {}
if not auth and isinstance(headers, dict) and any(
str(key).lower() == "authorization" for key in headers
):
auth = "header"
tokens_present = _oauth_tokens_present(name) if auth == "oauth" else None
return {
"name": name,
"transport": transport,
"url": cfg.get("url"),
"command": cfg.get("command"),
"args": list(cfg.get("args") or []),
"env": sorted(str(k) for k in (cfg.get("env") or {})),
"auth": auth,
"oauth_tokens_present": tokens_present,
"enabled": cfg.get("enabled", True) is not False,
"tools": cfg.get("tools"),
}
+53
View File
@@ -0,0 +1,53 @@
"""Seam for the server.py @method handler split (mechanical move).
server.py's ~130 JSON-RPC handlers close over its module globals
(``_sessions``, ``_ok``, ``_err``, config helpers, ...). To move them
out of the 19K-line module without rewriting a single handler body,
each ``methods_*`` module defines its handlers under a local
:class:`HandlerRegistry` and server.py calls :meth:`HandlerRegistry.install`
at the end of its own import, once every global the handlers close over
exists. ``install()`` rebinds each handler's ``__globals__`` to
server.py's namespace with ``types.FunctionType``, so handler bodies
stay byte-identical and ``global X`` statements inside handlers keep
mutating server.py state exactly as before the split.
No import cycle: ``methods_*`` modules never import server at module
level — server imports them and passes itself to ``register()``.
"""
import types
class HandlerRegistry:
"""Deferred @method registrar used by the methods_* split modules."""
def __init__(self) -> None:
self._pending: list[tuple[str, types.FunctionType]] = []
def method(self, name: str):
"""Drop-in for server.py's ``@method`` decorator (defers registration)."""
def dec(fn):
self._pending.append((name, fn))
return fn
return dec
def profile_scoped(self, fn):
"""Drop-in for server.py's ``@_profile_scoped`` (applied at install)."""
fn._hermes_profile_scoped = True
return fn
def install(self, server) -> None:
"""Rebind pending handlers onto ``server``'s globals and register them."""
g = vars(server)
for name, fn in self._pending:
real = types.FunctionType(
fn.__code__, g, fn.__name__, fn.__defaults__, fn.__closure__
)
real.__kwdefaults__ = fn.__kwdefaults__
real.__doc__ = fn.__doc__
real.__dict__.update(fn.__dict__)
if getattr(fn, "_hermes_profile_scoped", False):
real = server._profile_scoped(real)
server._methods[name] = real
+262
View File
@@ -0,0 +1,262 @@
"""Bot-relay JSON-RPC handlers — the gateway side of cross-connection A2A.
Connections ARE the peer set: every gateway the Desktop holds a socket to
(local, remote URL, SSH, Hermes Cloud, docker) must be able to find every
other connection's agents and message them. The Desktop is the relay — it
owns every socket — and these four methods are the door it uses on EACH
connected gateway:
- ``bot_relay.roster.sync`` — Desktop pushes the union roster of agents on
the OTHER connections into this gateway's ``bot_relay/roster.json``, so
``message_agent`` can resolve cross-connection targets and Bot Chat
prompts list them (capability-epoch refresh picks up changes).
- ``bot_relay.outbox.drain`` — Desktop collects envelopes queued here by
``message_agent`` for targets on other connections.
- ``bot_relay.deliver`` — Desktop hands an envelope to the TARGET
gateway; this method runs the same one-turn Bot Chat delivery local DMs
use and returns the reply text.
- ``bot_relay.reply`` — Desktop writes the reply (or a delivery
error) back on the SENDER gateway; the waiter spawned at send time picks
it up and wakes the sending agent via the standard completion path.
Storage/validation plumbing lives in ``tools/bot_relay.py``. Handlers are
rebound onto server.py's globals at install time (see method_ctx.py) and may
reference server module globals (``_ok``, ``_err``) not imported here.
"""
from .method_ctx import HandlerRegistry
_registry = HandlerRegistry()
method = _registry.method
@method("bot_relay.roster.sync")
def _(rid, params: dict) -> dict:
"""Replace this gateway's view of agents on OTHER connections.
Params: ``agents`` — list of rows ``{profile, handle, connection_id,
connection_label?, title?, description?}``. Rows failing validation are
dropped, not fatal. Result: ``{count}`` (accepted rows).
"""
try:
import os
from pathlib import Path
from tools.bot_relay import write_remote_roster
home = Path(os.getenv("HERMES_HOME") or os.path.expanduser("~/.hermes"))
root = home.parent.parent if home.parent.name == "profiles" else home
count = write_remote_roster(root, params.get("agents"))
return _ok(rid, {"count": count})
except Exception as e:
return _err(rid, 5090, str(e))
@method("bot_relay.outbox.drain")
def _(rid, params: dict) -> dict:
"""Claim every pending cross-connection envelope queued on this gateway.
Claimed envelopes move to ``claimed/`` atomically, so concurrent drains
(two Desktop windows) can't double-deliver. Result: ``{envelopes}``.
"""
try:
import os
from pathlib import Path
from tools.bot_relay import claim_pending_envelopes
home = Path(os.getenv("HERMES_HOME") or os.path.expanduser("~/.hermes"))
root = home.parent.parent if home.parent.name == "profiles" else home
return _ok(rid, {"envelopes": claim_pending_envelopes(root)})
except Exception as e:
return _err(rid, 5091, str(e))
@method("bot_relay.deliver")
def _(rid, params: dict) -> dict:
"""Deliver a relayed DM into a profile's Bot Chat ON THIS GATEWAY.
Params: ``profile`` (target on this install), ``message`` (already
attribution-prefixed by the sender gateway). Runs the same one-turn
``hermes -p <profile> chat -c "Bot Chat"`` transport local DMs use and
returns ``{reply}`` — the target agent's response text. Blocking by
design (the Desktop calls it from its relay worker, off any UI path;
the RPC pool keeps it off the WS reader thread).
"""
import os
import subprocess
import tempfile
from pathlib import Path
profile = str(params.get("profile") or "").strip()
message = str(params.get("message") or "").strip()
if not profile or not message:
return _err(rid, 4090, "profile and message required")
try:
from tools.bot_mode_dm import MESSAGE_MAX_CHARS
from tools.bot_relay import acquire_turn_lock, local_delivery_command
if len(message) > MESSAGE_MAX_CHARS + 200: # + attribution headroom
return _err(rid, 4091, "message too long")
home = Path(os.getenv("HERMES_HOME") or os.path.expanduser("~/.hermes"))
root = home.parent.parent if home.parent.name == "profiles" else home
known = {"default"}
profiles_dir = root / "profiles"
if profiles_dir.is_dir():
known.update(c.name for c in profiles_dir.iterdir() if c.is_dir())
resolved = "default" if profile.lower() == "hermes" else profile
if resolved not in known:
return _err(rid, 4092, f"no profile '{profile}' on this gateway")
# #100523: when THIS gateway already hosts the target's Bot Chat live
# (the Desktop has it open), the subprocess transport is fenced out by
# the single-owner lease ("already has a live owner") and the payload
# is dropped. Land the DM in the live session as a normal user turn
# via prompt.submit instead — same choke point the composer uses, so
# role alternation, persistence and streaming all behave as a typed
# message would. (Nested per method_ctx rebinding.)
def _live_bot_chat_sid(profile_name: str) -> str:
from tools.bot_mode_probe import BOT_CHAT_TITLE
live_home = _profile_home(profile_name)
want_home = str(live_home) if live_home is not None else None
for live_sid, record in list(_sessions.items()):
if not isinstance(record, dict):
continue
if (record.get("profile_home") or None) != want_home:
continue
key = _session_lookup_key(record, fallback=live_sid)
if _session_live_title(record, key) == BOT_CHAT_TITLE:
return live_sid
return ""
live_sid = _live_bot_chat_sid(resolved)
if live_sid:
# queued=True: a teammate's DM runs as the NEXT turn. It must never
# interrupt or steer a turn already in flight (the default busy
# mode does); hundreds of arrivals simply queue in arrival order.
submitted = _methods["prompt.submit"](rid, {"session_id": live_sid, "text": message, "queued": True})
if "error" in submitted:
return submitted
return _ok(
rid,
{"reply": f"Delivered into @{resolved}'s open Bot Chat; the reply will appear there."},
)
fd, tmp = tempfile.mkstemp(prefix="hermes-relay-dm-", suffix=".txt", text=True)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(message)
# Per-profile turn lock (#93091): serialize with any other
# delivery turn into this profile (relay or local message_agent).
# The lock covers only the turn execution window. Worst-case
# handler hold is lock wait (bot_mode.turn_wait_seconds, default
# 120s) + the 600s turn timeout below — doubled when the retry
# policy grants one bounded re-run — so clients calling
# bot_relay.deliver must tolerate ~1320s before assuming failure.
with acquire_turn_lock(root, resolved):
proc = subprocess.run(
local_delivery_command(resolved, tmp),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=600,
)
if proc.returncode != 0:
# Retry session policy (#93091 item 5): transient classes
# re-run the SAME session once; context_overflow also
# re-runs the same session — the retried turn's pre-API
# compaction pass (agent/conversation_loop.py) compacts
# the over-threshold Bot Chat transcript first, which is
# the sanctioned compression lever (no fresh session is
# ever minted). Auth/quota/config classes never retry.
from tools.bot_failure_reasons import (
RETRY_NONE,
classify_agent_error,
retry_action,
)
first_detail = (proc.stderr or proc.stdout or "").strip()[-500:]
if retry_action(classify_agent_error(first_detail)) != RETRY_NONE:
proc = subprocess.run(
local_delivery_command(resolved, tmp),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=600,
)
finally:
try:
os.unlink(tmp)
except OSError:
pass
if proc.returncode != 0:
from tools.bot_failure_reasons import classify_agent_error
detail = (proc.stderr or proc.stdout or "").strip()[-500:]
return _err(
rid,
5092,
f"delivery turn failed: {detail or proc.returncode}",
data={"reason": classify_agent_error(detail)},
)
return _ok(rid, {"reply": (proc.stdout or "").strip()})
except subprocess.TimeoutExpired:
return _err(rid, 5093, "delivery turn timed out")
except Exception as e:
# 'target_busy' extends the #93091 item-1 structured refusal enum.
if getattr(e, "reason", "") == "target_busy":
return _err(rid, 5096, str(e))
return _err(rid, 5094, str(e))
@method("bot_relay.reply")
def _(rid, params: dict) -> dict:
"""Write a relayed reply (or delivery error) for a sender-side waiter.
Params: ``id`` (envelope id), ``reply`` and/or ``error``, optional
``reason`` (typed failure code, see ``tools.bot_failure_reasons``).
"""
envelope_id = str(params.get("id") or "").strip()
if not envelope_id:
return _err(rid, 4093, "id required")
try:
import os
from pathlib import Path
from tools.bot_relay import write_reply
home = Path(os.getenv("HERMES_HOME") or os.path.expanduser("~/.hermes"))
root = home.parent.parent if home.parent.name == "profiles" else home
write_reply(
root,
envelope_id,
reply=str(params.get("reply") or ""),
error=str(params.get("error") or ""),
reason=str(params.get("reason") or ""),
)
return _ok(rid, {"ok": True})
except ValueError as e:
return _err(rid, 4094, str(e))
except Exception as e:
return _err(rid, 5095, str(e))
def register(server) -> None:
_registry.install(server)
from . import methods_groups
server._LONG_HANDLERS = server._LONG_HANDLERS | methods_groups.LONG_HANDLERS
server.get_hosted_room_service = methods_groups.get_hosted_room_service
server._WORKER_UNAVAILABLE = methods_groups._WORKER_UNAVAILABLE
server._profile_name = methods_groups._profile_name
server._requested_profile = methods_groups._requested_profile
server._api_server_key = methods_groups._api_server_key
server._room_link_run_storage_durable = (
methods_groups._room_link_run_storage_durable
)
methods_groups.bind_server(server)
methods_groups.register(server)
+382
View File
@@ -0,0 +1,382 @@
"""Browser controller registration and result routing for the dashboard.
The dashboard's browser controller (the extension that physically drives a
browser) registers itself over the authenticated ``/api/ws`` JSON-RPC
gateway. Everything here is bound to the **server-minted identity** that the
dashboard auth layer stamped onto the WS connection: ``hermes_cli.web_server``
consumes the single-use ticket and records ``ws._hermes_auth_identity``, the
WS transport carries it as ``WSTransport.auth_identity``, and the client can
never name its own principal (a spoofed ``principal_id`` param is ignored and
replaced by a server-derived digest of the authenticated identity).
Registration attaches the shared transport-neutral broker
(:mod:`gateway.browser_control_broker`) with the calling transport as owner;
broker command/cancel frames are wrapped as standard Gateway ``event`` frames
(``type`` = broker method name, ``payload`` = broker params, plus the owning
``session_id``) so the dashboard consumes the same envelope as every other
gateway event. ``browser.controller.result`` resolves a pending command only
when the request arrives on the same transport that owns the session, and
only for the exact attached scope — the broker's exact-scope ``complete`` is
the last line of defense against cross-tenant completion.
Both dashboard and local API transports use the broker's shared, explicit
capability allowlist. Raw CDP, script evaluation, console access, uploads, and
other privileged surfaces are not controller capabilities.
Note on handler globals: ``HandlerRegistry.install`` (method_ctx.py) rebinds
each handler's ``__globals__`` onto server.py's namespace, so handler bodies
may only reference names server.py defines/imports (``_ok``, ``_err``,
``_sessions``, ``_sessions_lock``, ``current_transport``, ``logger``, ...).
This module's own helpers and constants are therefore captured through
keyword-default arguments, which ``install`` preserves.
"""
from __future__ import annotations
import hashlib
import logging
from gateway.browser_control_broker import (
BROWSER_CONTROL_PROTOCOL_VERSION,
browser_control_protocol_supported,
filter_browser_control_capabilities,
)
from hermes_cli.dashboard_auth.ws_tickets import (
INTERNAL_PROVIDER as _INTERNAL_PROVIDER,
INTERNAL_USER_ID as _INTERNAL_USER_ID,
)
from .method_ctx import HandlerRegistry
logger = logging.getLogger(__name__)
_registry = HandlerRegistry()
method = _registry.method
#: Transport family stamped into every scope attached from this gateway. The
#: broker's exact-match contract treats it as an identity field, so an API
#: transport can never address a dashboard controller (and vice versa).
_CLOUD_TRANSPORT_FAMILY = "cloud-ticket-ws"
#: JSON-RPC error code for identity / session / flag denials (forbidden).
_ERR_FORBIDDEN = 4403
def _is_authenticated_identity(identity: object) -> bool:
"""True for a server-minted, non-internal ``{user_id, provider}`` identity."""
if not isinstance(identity, dict):
return False
user_id = identity.get("user_id")
provider = identity.get("provider")
if not isinstance(user_id, str) or not user_id.strip():
return False
if not isinstance(provider, str) or not provider.strip():
return False
if user_id == _INTERNAL_USER_ID and provider == _INTERNAL_PROVIDER:
return False
return True
def _principal_digest(identity: dict) -> str:
"""Server-derived principal id: a digest of the server-minted identity.
The client-supplied ``principal_id`` RPC param is never trusted; the
digest is deterministic (stable across reconnects for the same user) but
unspoofable by a peer that does not hold the authenticated identity.
"""
raw = f"{identity.get('provider')}\x00{identity.get('user_id')}"
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()
return f"principal:dashboard:{digest[:32]}"
def _broker_event_writer(transport: object, session_id: str):
"""Wrap broker command/cancel frames as standard Gateway event frames.
The broker's send callback receives transport-neutral envelopes like
``{"method": "browser.controller.command", "params": {...}}``; the
dashboard speaks Gateway events, so we re-envelope them:
``{"jsonrpc": "2.0", "method": "event", "params": {"type": <method>,
"session_id": <owner>, "payload": <params>}}``.
"""
def send(frame: dict) -> None:
try:
accepted = transport.write(
{
"jsonrpc": "2.0",
"method": "event",
"params": {
"type": frame.get("method"),
"session_id": session_id,
"payload": frame.get("params"),
},
}
)
except Exception:
logger.exception(
"browser controller event write failed session=%s frame=%s",
session_id,
frame.get("method"),
)
raise
if accepted is False:
raise ConnectionError("browser controller event write failed")
return send
@method("browser.controller.register")
def _(
rid,
params: dict,
_family=_CLOUD_TRANSPORT_FAMILY,
_protocol_version=BROWSER_CONTROL_PROTOCOL_VERSION,
_protocol_supported=browser_control_protocol_supported,
_filter_capabilities=filter_browser_control_capabilities,
_forbidden=_ERR_FORBIDDEN,
_identity_ok=_is_authenticated_identity,
_digest=_principal_digest,
_event_writer=_broker_event_writer,
) -> dict:
"""Attach this connection as the browser controller for one session.
Fails closed (4403) unless *every* gate passes:
* the ``browser.extension_control.enabled`` feature flag is on;
* the calling transport holds a server-authenticated, non-internal
identity (``WSTransport.auth_identity`` — never the RPC params);
* the named session exists in the live session registry and its
``transport`` is exactly the calling transport;
* at least one requested capability survives the shared allowlist.
The returned ``scope`` names a server-derived ``principal_id``, the
``cloud-ticket-ws`` transport family, and the filtered capability set.
"""
from gateway import browser_control_broker
if not browser_control_broker.browser_control_enabled():
return _err(
rid,
_forbidden,
"browser.extension_control.enabled is not set",
)
if not _protocol_supported(params.get("protocol_version")):
return _err(
rid,
_forbidden,
f"unsupported browser-control protocol version; expected {_protocol_version}",
)
transport = current_transport()
identity = getattr(transport, "auth_identity", None)
if not _identity_ok(identity):
return _err(
rid,
_forbidden,
"browser.controller.register requires an authenticated "
"non-internal identity",
)
session_id = str(params.get("session_id") or "")
with _sessions_lock:
session = _sessions.get(session_id)
if session is None or session.get("transport") is not transport:
return _err(
rid,
_forbidden,
"session is not owned by this transport",
)
controller_id = str(params.get("controller_id") or "").strip()
browser_profile_id = str(params.get("browser_profile_id") or "").strip()
profile_id = str(session.get("profile") or "").strip()
if not controller_id or not browser_profile_id or not profile_id:
return _err(
rid,
_forbidden,
"controller_id, browser_profile_id, and server session profile are required",
)
capabilities = _filter_capabilities(params.get("capabilities"))
if not capabilities:
return _err(
rid,
_forbidden,
"no permitted controller capabilities requested",
)
scope = browser_control_broker.ControllerScope(
principal_id=_digest(identity),
profile_id=profile_id,
session_id=session_id,
controller_id=controller_id,
browser_profile_id=browser_profile_id,
transport_family=_family,
capabilities=capabilities,
)
broker = browser_control_broker.get_browser_control_broker()
broker.attach(
scope,
_event_writer(transport, session_id),
owner=transport,
)
return _ok(
rid,
{
"scope": {
"principal_id": scope.principal_id,
"profile_id": scope.profile_id,
"session_id": scope.session_id,
"controller_id": scope.controller_id,
"browser_profile_id": scope.browser_profile_id,
"transport_family": scope.transport_family,
"capabilities": sorted(scope.capabilities),
}
},
)
@method("browser.controller.result")
def _(
rid,
params: dict,
_family=_CLOUD_TRANSPORT_FAMILY,
_forbidden=_ERR_FORBIDDEN,
_identity_ok=_is_authenticated_identity,
_digest=_principal_digest,
) -> dict:
"""Deliver one controller command result back to the broker.
Only the transport that owns the session may resolve its commands, and
only against the exact scope attached for that session (the broker's
exact-scope ``complete`` rejects any other scope). ``accepted`` is
``False`` for unknown / already-resolved / cancelled command ids — the
broker's idempotent answer, surfaced verbatim.
"""
from gateway import browser_control_broker
transport = current_transport()
identity = getattr(transport, "auth_identity", None)
if not _identity_ok(identity):
return _err(rid, _forbidden, "authenticated controller identity required")
session_id = str(params.get("session_id") or "")
with _sessions_lock:
session = _sessions.get(session_id)
if session is None or session.get("transport") is not transport:
return _err(
rid,
_forbidden,
"session is not owned by this transport",
)
command_id = str(params.get("command_id") or "")
if not command_id:
return _err(rid, _forbidden, "command_id required")
broker = browser_control_broker.get_browser_control_broker()
scope = broker.scope_for_session(
session_id=session_id,
principal_id=_digest(identity),
transport_family=_family,
)
if scope is None:
return _err(
rid,
_forbidden,
"no controller registered for this session",
)
# Defense in depth: the exact-scope complete below already rejects any
# foreign scope, but the owner check makes the "same transport" rule
# explicit at this layer too.
if not broker.is_owner(scope, transport):
return _err(
rid,
_forbidden,
"controller is not owned by this transport",
)
ok = params.get("ok") is True
accepted = broker.complete(
command_id,
scope=scope,
ok=ok,
result=params.get("result") if ok else params.get("error"),
)
return _ok(rid, {"accepted": accepted})
@method("browser.controller.heartbeat")
def _(
rid,
params: dict,
_family=_CLOUD_TRANSPORT_FAMILY,
_forbidden=_ERR_FORBIDDEN,
_identity_ok=_is_authenticated_identity,
_digest=_principal_digest,
) -> dict:
"""Acknowledge a heartbeat only for this transport's attached controller."""
from gateway import browser_control_broker
transport = current_transport()
identity = getattr(transport, "auth_identity", None)
if not _identity_ok(identity):
return _err(rid, _forbidden, "authenticated controller identity required")
session_id = str(params.get("session_id") or "")
with _sessions_lock:
session = _sessions.get(session_id)
if session is None or session.get("transport") is not transport:
return _err(rid, _forbidden, "session is not owned by this transport")
broker = browser_control_broker.get_browser_control_broker()
scope = broker.scope_for_session(
session_id=session_id,
principal_id=_digest(identity),
transport_family=_family,
)
if scope is None:
return _err(rid, _forbidden, "no controller registered for this session")
if not broker.is_owner(scope, transport):
return _err(rid, _forbidden, "controller is not owned by this transport")
return _ok(rid, {"ok": True})
@method("browser.controller.detach")
def _(
rid,
params: dict,
_family=_CLOUD_TRANSPORT_FAMILY,
_forbidden=_ERR_FORBIDDEN,
_identity_ok=_is_authenticated_identity,
_digest=_principal_digest,
) -> dict:
"""Hard-detach only the controller owned by this authenticated transport."""
from gateway import browser_control_broker
transport = current_transport()
identity = getattr(transport, "auth_identity", None)
if not _identity_ok(identity):
return _err(rid, _forbidden, "authenticated controller identity required")
session_id = str(params.get("session_id") or "")
with _sessions_lock:
session = _sessions.get(session_id)
if session is None or session.get("transport") is not transport:
return _err(rid, _forbidden, "session is not owned by this transport")
broker = browser_control_broker.get_browser_control_broker()
scope = broker.scope_for_session(
session_id=session_id,
principal_id=_digest(identity),
transport_family=_family,
)
if scope is None or not broker.is_owner(scope, transport):
return _err(rid, _forbidden, "controller is not owned by this transport")
broker.detach(scope, owner=transport, notify_controller=False)
return _ok(rid, {"detached": True})
def register(server) -> None:
"""Bind this module's handlers onto ``server``'s globals and registry."""
_registry.install(server)
+627
View File
@@ -0,0 +1,627 @@
"""Completion / model-key / paste JSON-RPC handlers (moved verbatim from server.py).
Handler bodies are byte-identical to their pre-split server.py form; they
are rebound onto server.py's globals at install time — see method_ctx.py.
"""
from .method_ctx import HandlerRegistry
_registry = HandlerRegistry()
method = _registry.method
_profile_scoped = _registry.profile_scoped
@method("paste.collapse")
def _(rid, params: dict) -> dict:
global _paste_counter
text = params.get("text", "")
if not text:
return _err(rid, 4004, "empty paste")
_paste_counter += 1
line_count = text.count("\n") + 1
paste_dir = _hermes_home / "pastes"
paste_dir.mkdir(parents=True, exist_ok=True)
from datetime import datetime
paste_file = (
paste_dir / f"paste_{_paste_counter}_{datetime.now().strftime('%H%M%S')}.txt"
)
paste_file.write_text(text, encoding="utf-8")
placeholder = (
f"[Pasted text #{_paste_counter}: {line_count} lines \u2192 {paste_file}]"
)
return _ok(
rid, {"placeholder": placeholder, "path": str(paste_file), "lines": line_count}
)
@method("complete.path")
def _(rid, params: dict) -> dict:
word = params.get("word", "")
if not word:
return _ok(rid, {"items": []})
items: list[dict] = []
def _profile_mention_items(prefix: str) -> list[dict]:
"""`@<profile>` completions: agent profiles as mentionable names.
Multi-agent UIs (and the Bot Mode plugin) route `@<profile>` text to
another agent profile; completing profile names alongside path refs
makes that discoverable. Bare-word matches only — never for
`@kind:` directive queries. The primary profile is also offered
under the 'hermes' alias when no real profile claims that name.
"""
out: list[dict] = []
try:
from hermes_cli.profiles import list_profiles
seen: set[str] = set()
for p in list_profiles():
name = (p.name or "").strip()
if not name:
continue
seen.add(name.lower())
desc = (getattr(p, "description", "") or "").strip()
if name.lower().startswith(prefix.lower()):
out.append(
{
"text": f"@{name}",
"display": f"@{name}",
"meta": desc or "agent profile",
}
)
if "hermes".startswith(prefix.lower()) and "hermes" not in seen:
out.append(
{
"text": "@hermes",
"display": "@hermes",
"meta": "agent profile (primary)",
}
)
except Exception:
return []
return out
try:
root = _completion_cwd(params)
is_context = word.startswith("@")
query = word[1:] if is_context else word
if is_context and not query:
items = [
{"text": "@diff", "display": "@diff", "meta": "git diff"},
{"text": "@staged", "display": "@staged", "meta": "staged diff"},
{"text": "@file:", "display": "@file:", "meta": "attach file"},
{"text": "@folder:", "display": "@folder:", "meta": "attach folder"},
{"text": "@url:", "display": "@url:", "meta": "fetch url"},
{"text": "@git:", "display": "@git:", "meta": "git log"},
]
# Agent profiles are mentionable — list them alongside the
# directive hints so `@` alone reveals them.
items.extend(_profile_mention_items(""))
# Append plugin-registered context reference prefixes
try:
from agent.context_references import get_context_reference_providers
for _pfx, _prov in sorted(get_context_reference_providers().items()):
items.append(
{
"text": f"@{_pfx}:",
"display": f"@{_pfx}:",
"meta": _prov.description or f"plugin: {_pfx}",
}
)
except Exception:
pass
return _ok(rid, {"items": items})
# Plugin context reference autocomplete: `@<prefix>:<query>` where the
# prefix belongs to a plugin-registered ContextReferenceProvider.
# Handled before the built-in file/folder branching so the elif/else
# chain below stays intact for built-in prefixes.
if is_context and ":" in query:
_pfx, _, _qval = query.partition(":")
if _pfx not in {"file", "folder", "url", "git", "diff", "staged"}:
try:
from agent.context_references import (
get_context_reference_providers as _gcr,
)
_prov = _gcr().get(_pfx)
if _prov is not None:
import asyncio as _asyncio
_coro = _prov.autocomplete(_qval, limit=20)
try:
_loop = _asyncio.get_running_loop()
except RuntimeError:
_loop = None
if _loop and _loop.is_running():
import concurrent.futures as _cf
with _cf.ThreadPoolExecutor(max_workers=1) as _pool:
_ac = _pool.submit(_asyncio.run, _coro).result()
else:
_ac = _asyncio.run(_coro)
items = [
{
"text": f"@{_pfx}:{it.text}",
"display": it.display,
"meta": it.meta,
}
for it in _ac
]
return _ok(rid, {"items": items})
except Exception:
pass
# Accept both `@folder:path` and the bare `@folder` form so the user
# sees directory listings as soon as they finish typing the keyword,
# without first accepting the static `@folder:` hint.
if is_context and query in {"file", "folder"}:
prefix_tag, path_part = query, ""
elif is_context and query.startswith(("file:", "folder:")):
prefix_tag, _, tail = query.partition(":")
path_part = tail
else:
prefix_tag = ""
path_part = query if is_context else query
# `@/foo` almost always means "foo, from here" rather than the absolute
# `/foo`: the `@` already says "this is a path", so the slash reads as a
# separator people type out of habit. Take the absolute reading only
# when something is actually there, else drop the slash and resolve
# relative to the cwd — otherwise `@/Desktop` dead-ends on a directory
# that exists one level down. Real absolute paths (`@/usr/local`,
# `@/etc/hosts`) still resolve, since those prefixes do exist.
if (
is_context
and path_part.startswith("/")
and not path_part.startswith("//")
and not _abs_completion_prefix_exists(path_part)
):
path_part = path_part.lstrip("/")
# Fuzzy basename search across the repo when the user types a bare
# name with no path separator — `@appChrome` surfaces every file
# whose basename matches, regardless of directory depth. Matches what
# editors like Cursor / VS Code do for Cmd-P. Path-ish queries (with
# `/`, `./`, `~/`, `/abs`) fall through to the directory-listing
# path so explicit navigation intent is preserved.
if (
is_context
and path_part
and len(path_part.strip()) >= 2
and "/" not in path_part
and prefix_tag != "folder"
):
ranked: list[tuple[tuple[int, int], str, str, bool]] = []
walked_dirs: set[str] = set()
seen: set[str] = set()
want_hidden = path_part.startswith(".")
def _consider(rel: str, name: str, is_dir: bool) -> None:
if rel in seen or (name.startswith(".") and not want_hidden):
return
rank = _fuzzy_basename_rank(name, path_part)
if rank is not None:
seen.add(rel)
ranked.append((rank, rel, name, is_dir))
# Seed with root's immediate children. `_list_repo_files` is capped
# at _FUZZY_CACHE_MAX_FILES, and outside a git repo the fallback
# walk can burn that whole budget on one deep subtree before ever
# reaching a sibling — which is why `@Desk` in a non-repo $HOME
# found nothing. One listdir keeps the top level always reachable.
try:
for entry in os.listdir(root):
if entry not in _FUZZY_FALLBACK_EXCLUDES:
_consider(entry, entry, os.path.isdir(os.path.join(root, entry)))
except OSError:
pass
for rel in _list_repo_files(root):
_consider(rel, os.path.basename(rel), False)
# Directories are only implied by the file listing, so rank each
# ancestor too. Without this a bare `@Desktop` finds nothing —
# a folder with no name-matching file inside it is invisible to
# a file-only scan, which is the "can't @ a folder by name" bug.
parent = os.path.dirname(rel)
while parent and parent not in walked_dirs:
walked_dirs.add(parent)
_consider(parent, os.path.basename(parent), True)
parent = os.path.dirname(parent)
# Same rank tier: folders first, so `@Desktop` leads with the folder
# rather than a file that merely fuzzy-matches the same letters.
ranked.sort(key=lambda r: (r[0], not r[3], len(r[1]), r[1]))
tag = prefix_tag or "file"
for _, rel, basename, is_dir in ranked[:30]:
items.append(
{
"text": f"@{'folder' if is_dir else tag}:{rel}{'/' if is_dir else ''}",
"display": basename + ("/" if is_dir else ""),
"meta": "dir" if is_dir else os.path.dirname(rel),
}
)
# Bare-word `@name` may equally be an agent mention — surface
# matching profiles ABOVE file hits (there are at most a handful,
# and a user typing `@tur` for a bot shouldn't have to dig).
if not prefix_tag:
items = _profile_mention_items(path_part) + items
return _ok(rid, {"items": items})
expanded = _normalize_completion_path(path_part) if path_part else "."
if expanded == "." or not expanded:
search_dir, match = ".", ""
elif expanded.endswith("/"):
search_dir, match = expanded, ""
else:
search_dir = os.path.dirname(expanded) or "."
match = os.path.basename(expanded)
search_dir = (
search_dir if os.path.isabs(search_dir) else os.path.join(root, search_dir)
)
if not os.path.isdir(search_dir):
return _ok(rid, {"items": []})
want_dir = prefix_tag == "folder"
match_lower = match.lower()
for entry in sorted(os.listdir(search_dir)):
if match and not entry.lower().startswith(match_lower):
continue
if is_context and entry in _FUZZY_FALLBACK_EXCLUDES:
continue
if is_context and not prefix_tag and entry.startswith("."):
continue
full = os.path.join(search_dir, entry)
is_dir = os.path.isdir(full)
# Explicit `@folder:` / `@file:` — honour the user's filter. Skip
# the opposite kind instead of auto-rewriting the completion tag,
# which used to defeat the prefix and let `@folder:` list files.
if prefix_tag and want_dir != is_dir:
continue
rel = os.path.relpath(full, root).replace(os.sep, "/")
suffix = "/" if is_dir else ""
if is_context and prefix_tag:
text = f"@{prefix_tag}:{rel}{suffix}"
elif is_context:
kind = "folder" if is_dir else "file"
text = f"@{kind}:{rel}{suffix}"
elif word.startswith("~"):
text = "~/" + os.path.relpath(full, os.path.expanduser("~")) + suffix
elif word.startswith("./"):
text = "./" + rel + suffix
else:
text = rel + suffix
items.append(
{
"text": text,
"display": entry + suffix,
"meta": "dir" if is_dir else "",
}
)
if len(items) >= 30:
break
except Exception as e:
return _err(rid, 5021, str(e))
# Bare-word `@name` (including single characters, which skip the fuzzy
# branch) may be an agent mention — profiles rank above path entries.
try:
if is_context and not prefix_tag and path_part and "/" not in path_part:
items = _profile_mention_items(path_part) + items
except Exception:
pass
return _ok(rid, {"items": items})
@method("complete.slash")
def _(rid, params: dict) -> dict:
text = params.get("text", "")
if not text.startswith("/"):
return _ok(rid, {"items": []})
try:
from hermes_cli.commands import SlashCommandCompleter
from prompt_toolkit.document import Document
from prompt_toolkit.formatted_text import to_plain_text
from agent.skill_commands import get_skill_commands
from agent.skill_bundles import get_skill_bundles
completer = SlashCommandCompleter(
skill_commands_provider=lambda: get_skill_commands(),
skill_bundles_provider=lambda: get_skill_bundles(),
)
doc = Document(text, len(text))
# Skill commands and bundles are the only completions offered for an
# inline `/skill` reference typed mid-message, so the class has to
# reach the TUI as data. Derived from the same providers the completer
# uses — no sniffing the ⚡/▣ meta glyphs, which are display text.
skill_names = {
key.lstrip("/").lower()
for key in (*get_skill_commands(), *get_skill_bundles())
}
items = [
{
"text": c.text,
# prompt_toolkit gives us FormattedText (a list of (style,
# text) tuples) for display/display_meta. Serialize both as
# plain strings — the TUI's CompletionItem.display contract
# is a string, and sending the raw list trips Ink's row
# layout into 1-char truncation of the next column.
"display": to_plain_text(c.display) if c.display else c.text,
"meta": to_plain_text(c.display_meta) if c.display_meta else "",
"kind": (
"skill"
if c.text.strip().lstrip("/").lower() in skill_names
else "command"
),
}
for c in completer.get_completions(doc, None)
]
# Rank and bound the list (see _rank_slash_completions) while a
# `/token` is under the cursor — the one stage skills are offered at.
# An argument stage (`/personality `, `/details c`) keeps the order
# its own command chose.
if text.rsplit(" ", 1)[-1].startswith("/"):
score_of = None
# Description-aware fuzzy scoring (ported from grok-cli's slash
# menu) at the command-token stage: the completer above only
# emits name-prefix matches, so merge in catalog entries whose
# name SUBSTRING or DESCRIPTION words match the query — typing
# `/summary` surfaces a command whose description mentions
# summaries. Command matches always outrank description matches.
if " " not in text and len(text) > 1:
from tui_gateway.slash_fuzzy import (
fuzzy_rank_slash_items,
normalize_slash_search_query,
)
universe = [
{
"text": c.text,
"display": to_plain_text(c.display) if c.display else c.text,
"meta": to_plain_text(c.display_meta) if c.display_meta else "",
"kind": (
"skill"
if c.text.strip().lstrip("/").lower() in skill_names
else "command"
),
}
for c in completer.get_completions(Document("/", 1), None)
]
items, score_of = fuzzy_rank_slash_items(
items, universe, normalize_slash_search_query(text)
)
usage, origin_of = _skill_usage_lookup()
items = _rank_slash_completions(
items, usage, origin_of, browsing=text == "/", score_of=score_of
)
else:
items = items[:_SLASH_COMPLETION_LIMIT]
text_lower = text.lower()
extras = [
{
"text": "/density",
"display": "/density",
"meta": "Toggle compact display mode",
"kind": "command",
},
{
"text": "/details",
"display": "/details",
"meta": "Control agent detail visibility",
"kind": "command",
},
{
"text": "/logs",
"display": "/logs",
"meta": "Show recent gateway log lines",
"kind": "command",
},
{
"text": "/mouse",
"display": "/mouse",
"meta": "Set mouse tracking preset [on|off|toggle|wheel|buttons|all]",
"kind": "command",
},
]
for extra in extras:
if extra["text"].startswith(text_lower) and not any(
item["text"] == extra["text"] for item in items
):
items.append(extra)
details_items = _details_completions(text)
if details_items is not None:
return _ok(
rid,
{
"items": details_items,
"replace_from": text.rfind(" ") + 1 if " " in text else len(text),
},
)
return _ok(
rid,
{"items": items, "replace_from": text.rfind(" ") + 1 if " " in text else 1},
)
except Exception as e:
return _err(rid, 5020, str(e))
@method("model.options")
@_profile_scoped
def _(rid, params: dict) -> dict:
try:
from hermes_cli.inventory import build_model_options_payload
session = _sessions.get(params.get("session_id", ""))
agent = session.get("agent") if session else None
# Layer agent-session state on top of disk config — once an agent
# is spawned, IT owns the live provider/model/base_url. Empty
# agent attributes must NOT clobber disk config (with_overrides
# is truthy-only).
ctx = _model_picker_context(agent)
payload = build_model_options_payload(
ctx,
explicit_only=bool(params.get("explicit_only")),
include_unconfigured=bool(params.get("include_unconfigured")),
refresh=bool(params.get("refresh")),
)
return _ok(rid, payload)
except Exception as e:
return _err(rid, 5033, str(e))
@method("model.save_key")
def _(rid, params: dict) -> dict:
"""Save an API key for a provider, then return its refreshed model list.
Params:
slug: provider slug (e.g. "deepseek", "xai")
api_key: the key value to save
Returns the provider dict with models populated (same shape as
model.options entries) on success.
"""
try:
from hermes_cli.auth import PROVIDER_REGISTRY
from hermes_cli.config import is_managed
from hermes_cli.inventory import build_models_payload
slug = (params.get("slug") or "").strip()
api_key = (params.get("api_key") or "").strip()
if not slug or not api_key:
return _err(rid, 4001, "slug and api_key are required")
if is_managed():
return _err(rid, 4006, "managed install — credentials are read-only")
pconfig = PROVIDER_REGISTRY.get(slug)
if not pconfig:
return _err(rid, 4002, f"unknown provider: {slug}")
if pconfig.auth_type != "api_key":
return _err(
rid,
4003,
f"{pconfig.name} uses {pconfig.auth_type} auth — "
f"run `hermes model` to configure",
)
if not pconfig.api_key_env_vars:
return _err(rid, 4004, f"no env var defined for {pconfig.name}")
# Save the key to ~/.hermes/.env via the unified credential lifecycle
# so any stale config.yaml mirror of the previous key (model.api_key,
# custom_providers[*].api_key) is rotated in the same action (#62269).
env_var = pconfig.api_key_env_vars[0]
from hermes_cli.credential_lifecycle import save_provider_env_credential
save_provider_env_credential(env_var, api_key)
# Also set in current process so the refreshed inventory sees it.
import os
os.environ[env_var] = api_key
# Refresh provider data via the shared inventory builder so this
# surface stays in lock-step with model.options + dashboard
# /api/model/options. picker_hints=True ensures the returned row
# carries `authenticated` for the TUI frontend.
session = _sessions.get(params.get("session_id", ""))
agent = session.get("agent") if session else None
ctx = _model_picker_context(agent)
payload = build_models_payload(
ctx, picker_hints=True, max_models=50,
)
provider_data = next(
(p for p in payload["providers"] if p["slug"] == slug), None
)
if provider_data is None:
# Key was saved but provider didn't appear — still return success.
provider_data = {
"slug": slug,
"name": pconfig.name,
"is_current": False,
"models": [],
"total_models": 0,
"authenticated": True,
}
# picker_hints sets `authenticated` from the row state, but the
# synthetic fallback above doesn't go through that path.
provider_data["authenticated"] = True
return _ok(rid, {"provider": provider_data})
except Exception as e:
return _err(rid, 5034, str(e))
@method("model.disconnect")
def _(rid, params: dict) -> dict:
"""Remove credentials for a provider.
Params:
slug: provider slug (e.g. "deepseek", "xai")
Returns success status and the provider's slug.
"""
try:
from hermes_cli.auth import PROVIDER_REGISTRY, clear_provider_auth
from hermes_cli.credential_lifecycle import remove_provider_env_credential
slug = (params.get("slug") or "").strip()
if not slug:
return _err(rid, 4001, "slug is required")
pconfig = PROVIDER_REGISTRY.get(slug)
cleared_env = False
cleared_auth = False
# Remove API key env vars from .env and process, plus every mirror
# (env-seeded credential_pool entries, provider model cache rows,
# value-matched config.yaml api_key copies) via the unified helper —
# otherwise the provider resurrects in the picker after restart
# (#51071 / #59761).
if pconfig and pconfig.api_key_env_vars:
for ev in pconfig.api_key_env_vars:
if remove_provider_env_credential(ev).get("found"):
cleared_env = True
# Clear OAuth / credential pool state. This is a full provider
# disconnect (TUI "disconnect" action), so removing OAuth grants
# here is the documented intent — unlike the key-only delete paths.
cleared_auth = clear_provider_auth(slug)
if not cleared_env and not cleared_auth:
return _err(rid, 4005, f"no credentials found for {slug}")
provider_name = pconfig.name if pconfig else slug
return _ok(
rid,
{
"slug": slug,
"name": provider_name,
"disconnected": True,
},
)
except Exception as e:
return _err(rid, 5035, str(e))
def register(server) -> None:
"""Bind this module's handlers onto ``server``'s globals and registry."""
_registry.install(server)
+623
View File
@@ -0,0 +1,623 @@
"""Config / projects / setup JSON-RPC handlers (moved verbatim from server.py).
NOTE: ``config.set`` stays in server.py for now — the in-flight
opt/model-resolution-core PR touches it; move it in a follow-up once merged.
Handler bodies are byte-identical to their pre-split server.py form; they
are rebound onto server.py's globals at install time — see method_ctx.py.
"""
from .method_ctx import HandlerRegistry
from hermes_constants import DEFAULT_INDICATOR_STYLE, INDICATOR_STYLES
_registry = HandlerRegistry()
method = _registry.method
_profile_scoped = _registry.profile_scoped
@method("projects.discover_repos")
@_profile_scoped
def _(rid, params: dict) -> dict:
"""Repos for the desktop overview: scanned-from-disk (cached) session-derived."""
try:
with _profile_db(params) as db:
if db is None:
return _ok(rid, {"repos": []})
from hermes_cli import projects_db as pdb
policy = _repo_discovery_policy()
policy_key = _repo_discovery_policy_key(policy)
with pdb.connect_closing() as conn:
pdb.reconcile_discovered_repos_policy(
conn,
policy_key,
preserve_unversioned=_repo_discovery_policy_is_default(policy),
)
# `scan=true` (set by the desktop in remote-gateway mode): run a
# backend-side filesystem scan of the policy roots so repos with
# zero Hermes sessions still surface. The desktop's native scan
# only runs on the local filesystem; on a remote connection it
# must ask the host to scan itself (#81723).
if params.get("scan") and policy["enabled"]:
_scan_discovered_repos_remote(conn, policy)
repos = _discover_repos_payload(
db, conn=conn, include_cached=policy["enabled"]
)
return _ok(rid, {"repos": repos, "discovery_policy": policy})
except Exception as e:
return _err(rid, 5061, str(e))
@method("projects.record_repos")
@_profile_scoped
def _(rid, params: dict) -> dict:
"""Persist git repo roots found by the client's filesystem scan, then return
the merged repo list. The native crawl runs on the desktop (local fs); this
caches the result so later reads are instant instead of re-walking disk."""
try:
from hermes_cli import projects_db as pdb
policy = _repo_discovery_policy()
policy_key = _repo_discovery_policy_key(policy)
incoming_raw = params.get("discovery_policy")
incoming_policy = (
_repo_discovery_policy(incoming_raw)
if isinstance(incoming_raw, dict)
else None
)
incoming_matches = (
incoming_policy is not None
and _repo_discovery_policy_key(incoming_policy) == policy_key
)
accept_legacy_default = (
incoming_policy is None and _repo_discovery_policy_is_default(policy)
)
pairs: list[tuple[str, str | None]] = []
for item in params.get("repos") or []:
if isinstance(item, str):
pairs.append((item, None))
elif isinstance(item, dict) and item.get("root"):
pairs.append((str(item["root"]), item.get("label")))
with pdb.connect_closing() as conn:
pdb.reconcile_discovered_repos_policy(
conn,
policy_key,
preserve_unversioned=_repo_discovery_policy_is_default(policy),
)
accepted = bool(
policy["enabled"] and (incoming_matches or accept_legacy_default)
)
if accepted:
pdb.record_discovered_repos(
conn, pairs, replace=True, policy_key=policy_key
)
elif not policy["enabled"]:
pdb.clear_discovered_repos(conn, policy_key=policy_key)
with _profile_db(params) as db:
return _ok(
rid,
{
"repos": _discover_repos_payload(
db, include_cached=policy["enabled"]
)
if db is not None
else [],
"accepted": accepted,
"discovery_policy": policy,
},
)
except Exception as e:
return _err(rid, 5061, str(e))
@method("projects.tree")
@_profile_scoped
def _(rid, params: dict) -> dict:
"""Authoritative project overview: project -> repo -> lane structure with
counts + a few preview sessions per project, plus the flat set of session
ids claimed by any project (so the desktop excludes them from flat Recents).
Lanes carry no session rows here; drill-in uses ``projects.project_sessions``.
"""
try:
from tui_gateway.project_tree import stamp_profile
from tui_gateway.server import _response_profile_name
with _profile_db(params) as db:
if db is None:
return _ok(
rid, {"projects": [], "active_id": None, "scoped_session_ids": []}
)
tree, active_id = _build_project_tree(
db,
preview_limit=int(params.get("preview_limit") or 3),
hydrate=False,
session_limit=int(params.get("session_limit") or 2000),
include_discovered=True,
)
stamp_profile(
tree["projects"], _response_profile_name(params.get("profile"))
)
return _ok(
rid,
{
"projects": tree["projects"],
"active_id": active_id,
"scoped_session_ids": tree["scoped_session_ids"],
},
)
except Exception as e:
return _err(rid, 5061, str(e))
@method("projects.project_sessions")
@_profile_scoped
def _(rid, params: dict) -> dict:
"""Fully hydrated lanes (repo -> lane -> session rows) for one project,
built from the same authoritative grouping as ``projects.tree`` so ids and
membership match exactly. Used when the user enters a project."""
try:
from tui_gateway.project_tree import stamp_profile
from tui_gateway.server import _response_profile_name
project_id = str(params.get("project_id") or "")
if not project_id:
return _err(rid, 5063, "project_id required")
with _profile_db(params) as db:
if db is None:
return _ok(rid, {"project": None})
# Drill-in only needs the entered project (which has sessions), so skip
# the zero-session discovery tier entirely.
tree, _active = _build_project_tree(
db,
preview_limit=0,
hydrate=True,
session_limit=int(params.get("session_limit") or 5000),
include_discovered=False,
)
stamp_profile(
tree["projects"], _response_profile_name(params.get("profile"))
)
proj = next((p for p in tree["projects"] if p["id"] == project_id), None)
return _ok(rid, {"project": proj})
except Exception as e:
return _err(rid, 5061, str(e))
@method("config.get")
@_profile_scoped
def _(rid, params: dict) -> dict:
key = params.get("key", "")
if key == "provider":
try:
from hermes_cli.models import list_available_providers, normalize_provider
model = _resolve_model()
parts = model.split("/", 1)
return _ok(
rid,
{
"model": model,
"provider": (
normalize_provider(parts[0]) if len(parts) > 1 else "unknown"
),
"providers": list_available_providers(),
},
)
except Exception as e:
return _err(rid, 5013, str(e))
if key == "profile":
from hermes_constants import display_hermes_home
return _ok(rid, {"home": str(_hermes_home), "display": display_hermes_home()})
if key == "project":
cfg_terminal = _load_cfg().get("terminal") or {}
raw = str(params.get("cwd", "") or cfg_terminal.get("cwd", "") or "").strip()
cwd = _completion_cwd({"cwd": raw} if raw else {})
return _ok(rid, {"cwd": cwd, "branch": _git_branch_for_cwd(cwd)})
if key == "full":
return _ok(rid, {"config": _load_cfg()})
if key == "prompt":
return _ok(rid, {"prompt": _load_cfg().get("custom_prompt", "")})
if key == "skin":
return _ok(
rid, {"value": (_load_cfg().get("display") or {}).get("skin", "default")}
)
if key == "indicator":
# Normalize so a hand-edited config.yaml with stray casing or
# an unknown value reads back the SAME value the TUI actually
# rendered (frontend's `normalizeIndicatorStyle` falls back to
# `DEFAULT_INDICATOR_STYLE` for the same inputs). Otherwise
# `/indicator` would print one thing while the UI shows another.
raw = (_load_cfg().get("display") or {}).get("tui_status_indicator", "")
norm = str(raw).strip().lower()
return _ok(
rid,
{"value": norm if norm in INDICATOR_STYLES else DEFAULT_INDICATOR_STYLE},
)
if key == "personality":
# Report the EFFECTIVE personality via the single owner — a stale or
# unknown name in config must not display as active.
from hermes_cli.personality import active_personality_name
return _ok(
rid,
{"value": active_personality_name(_load_cfg()) or "none"},
)
if key == "reasoning":
cfg = _load_cfg()
session = _sessions.get(params.get("session_id", ""))
reasoning_config = None
if session is not None:
if isinstance(session.get("create_reasoning_override"), dict):
reasoning_config = session.get("create_reasoning_override")
else:
agent = session.get("agent")
agent_reasoning = getattr(agent, "reasoning_config", None)
if isinstance(agent_reasoning, dict):
reasoning_config = agent_reasoning
if isinstance(reasoning_config, dict):
if reasoning_config.get("enabled") is False:
effort = "none"
else:
effort = str(reasoning_config.get("effort") or "medium")
else:
raw_effort = (cfg.get("agent") or {}).get("reasoning_effort", "")
if raw_effort is False:
# YAML `reasoning_effort: false`/`off`/`no` — thinking
# disabled, not "unset, show the medium default".
effort = "none"
else:
effort = str(raw_effort or "medium")
display = (
"show"
if bool((cfg.get("display") or {}).get("show_reasoning", True))
else "hide"
)
return _ok(rid, {"value": effort, "display": display})
if key == "fast":
# Prefer the session's live/pinned value — `config.set fast` is
# session-scoped, so the global key may not reflect this chat. A
# pre-build session keeps its pin in create_service_tier_override.
session = _sessions.get(params.get("session_id", ""))
tier = None
if session is not None:
agent = session.get("agent")
if agent is not None:
tier = getattr(agent, "service_tier", None)
elif session.get("create_service_tier_override") is not None:
tier = session["create_service_tier_override"]
if tier is None:
tier = _load_service_tier()
return _ok(rid, {"value": "fast" if tier == "priority" else "normal"})
if key == "busy":
return _ok(rid, {"value": _load_busy_input_mode()})
if key in {"approval_mode", "approvals.mode"}:
try:
return _ok(rid, {"value": _load_approval_mode()})
except Exception as e:
return _err(rid, 5001, str(e))
if key == "details_mode":
allowed_dm = frozenset({"hidden", "collapsed", "expanded"})
raw = (
str(
(_load_cfg().get("display") or {}).get("details_mode", "collapsed")
or "collapsed"
)
.strip()
.lower()
)
nv = raw if raw in allowed_dm else "collapsed"
return _ok(rid, {"value": nv})
if key == "thinking_mode":
allowed_tm = frozenset({"collapsed", "truncated", "full"})
cfg = _load_cfg()
raw = (
str((cfg.get("display") or {}).get("thinking_mode", "") or "")
.strip()
.lower()
)
if raw in allowed_tm:
nv = raw
else:
dm = (
str(
(cfg.get("display") or {}).get("details_mode", "collapsed")
or "collapsed"
)
.strip()
.lower()
)
nv = "full" if dm == "expanded" else "collapsed"
return _ok(rid, {"value": nv})
if key == "density":
on = bool((_load_cfg().get("display") or {}).get("tui_compact", False))
return _ok(rid, {"value": "on" if on else "off"})
if key == "theme":
display = _load_cfg().get("display")
raw = (
str(
display.get("tui_theme", "auto")
if isinstance(display, dict)
else "auto"
)
.strip()
.lower()
)
return _ok(rid, {"value": raw if raw in {"auto", "light", "dark"} else "auto"})
if key == "statusbar":
display = _load_cfg().get("display")
raw = (
display.get("tui_statusbar", "top") if isinstance(display, dict) else "top"
)
return _ok(rid, {"value": _coerce_statusbar(raw)})
if key == "focus":
display = _load_cfg().get("display")
on = (
bool(display.get("focus_view", False))
if isinstance(display, dict)
else False
)
return _ok(
rid,
{
"value": "on" if on else "off",
"tool_progress": _load_tool_progress_mode(),
},
)
if key == "mouse":
display = _load_cfg().get("display")
return _ok(rid, {"value": _display_mouse_tracking(display)})
if key == "mtime":
cfg_path = _hermes_home / "config.yaml"
try:
mtime = cfg_path.stat().st_mtime if cfg_path.exists() else 0
except Exception:
return _ok(rid, {"mtime": 0})
# Revision hash of the MCP-relevant config sections. The TUI's
# config-change poller uses it to reload MCP servers only when their
# config actually changed — a /skin or /statusbar write bumps mtime
# but must not cost a multi-second MCP reconnect.
return _ok(rid, {"mtime": mtime, "mcp_rev": _compute_mcp_rev()})
return _err(rid, 4002, f"unknown config key: {key}")
def _readiness_profile_scope(params: dict):
"""Resolve the optional ``profile`` param of the setup readiness RPCs.
Returns ``(profile, scope)`` where ``scope`` is a context manager binding
that profile's HERMES_HOME and ``.env`` secret scope (ContextVars, so
concurrent checks for different profiles stay isolated). The launch
profile / no param yields ``("", nullcontext())``. A profile unknown to
this host raises ``FileNotFoundError`` — a readiness check must never
quietly answer for the launch profile instead (#94071).
"""
import contextlib
profile = str(params.get("profile") or "").strip() if isinstance(params, dict) else ""
if not profile:
return "", contextlib.nullcontext()
from hermes_cli import profiles as profiles_mod
from tui_gateway import server as _server
if not profiles_mod.profile_exists(profile):
raise FileNotFoundError(f"Profile '{profile}' does not exist on this backend.")
home = _server._profile_home(profile)
if home is None:
return profile, contextlib.nullcontext()
return profile, _server._session_profile_runtime_scope({"profile_home": str(home)})
@method("setup.status")
def _(rid, params: dict) -> dict:
"""Loose provider check; ``profile`` (optional) scopes it to that profile's home."""
try:
from hermes_cli.main import _has_any_provider_configured
from tui_gateway.methods_config import _readiness_profile_scope
try:
profile, scope = _readiness_profile_scope(params)
except FileNotFoundError as e:
return _ok(rid, {"ok": False, "profile": params.get("profile"), "error": str(e)})
with scope:
configured = bool(_has_any_provider_configured(strict_profile_scope=bool(profile)))
payload = {"provider_configured": configured}
if profile:
payload["profile"] = profile
return _ok(rid, payload)
except Exception as e:
return _err(rid, 5016, str(e))
@method("setup.runtime_check")
def _(rid, params: dict) -> dict:
"""Strict provider check: does the configured/default model actually resolve to a usable runtime?
Unlike setup.status (which returns True if ANY provider auth state is
discoverable, including indirect fallbacks like ``gh auth token`` for
Copilot), this runs the same resolve_runtime_provider() call the agent
uses on session creation. It returns ok=False with the auth error message
when the user's configured model cannot actually be served, so UIs can
surface onboarding before the user submits a doomed prompt.
``profile`` (optional): answer for THAT profile's home on this host — its
config.yaml model pin and its ``.env`` — instead of the launch profile's
(#94071). A profile unknown to this backend answers ``ok=False`` rather
than reporting the launch profile's readiness.
"""
try:
from hermes_cli.runtime_provider import resolve_runtime_provider
from hermes_cli.auth import has_usable_secret
from hermes_cli.main import _has_any_provider_configured
from tui_gateway.methods_config import _readiness_profile_scope
requested = str(params.get("provider") or "").strip() or None
try:
profile, scope = _readiness_profile_scope(params)
except FileNotFoundError as e:
return _ok(rid, {"ok": False, "profile": params.get("profile"), "error": str(e)})
with scope:
runtime = resolve_runtime_provider(requested=requested)
provider_configured = bool(_has_any_provider_configured(strict_profile_scope=bool(profile)))
scoped = {"profile": profile} if profile else {}
provider = runtime.get("provider") or "provider"
source = str(runtime.get("source") or "")
if (
not provider_configured
and provider == "bedrock"
and source
in {
"iam-role",
"aws-sdk-default-chain",
}
):
return _ok(
rid,
{
"ok": False,
"provider": provider,
"model": runtime.get("model"),
"source": source,
"error": "No Hermes provider is configured.",
**scoped,
},
)
api_key = runtime.get("api_key")
api_key_text = "" if callable(api_key) else str(api_key or "").strip()
credential_ok = (
callable(api_key)
or api_key_text in {"aws-sdk", "no-key-required"}
or has_usable_secret(api_key_text)
or bool(runtime.get("command"))
)
if not credential_ok:
return _ok(
rid,
{
"ok": False,
"provider": provider,
"model": runtime.get("model"),
"source": runtime.get("source"),
"error": f"No usable credentials found for {provider}.",
**scoped,
},
)
return _ok(
rid,
{
"ok": True,
"provider": runtime.get("provider"),
"model": runtime.get("model"),
"source": runtime.get("source"),
**scoped,
},
)
except Exception as e:
return _ok(rid, {"ok": False, "error": str(e)})
@method("diagnostics.share_nous")
def _(rid, params: dict) -> dict:
"""Upload a redacted debug bundle to Nous-internal diagnostics storage.
Desktop's "Send Diagnostics" action (error card / diagnostics UI). Same
collection + force-redaction pipeline as ``hermes debug share --nous``
(collect_share_bundle → build_nous_bundle → share_to_nous); redaction is
NOT client-controllable — this handler always redacts.
Params (all optional):
- ``error_context``: short client-supplied text describing the failure
that prompted the report (the error card's layer/code/message blob).
Redacted server-side and attached as ``error-context.txt``.
- ``extra_files``: {label → text} of client-side artifacts the backend
can't see (e.g. the local desktop.log when this backend is remote).
Each value is force-redacted server-side before inclusion; labels are
sanitized and size-capped.
- ``log_lines``: report excerpt length (default 200).
Consent lives with the CALLER: the desktop shows the privacy notice and
an explicit Upload button before invoking this. Structured envelope
(``ok``/``error``) rather than JSON-RPC errors so the client can render
upload failures inline.
"""
try:
from hermes_cli.debug import (
_redact_log_text,
build_nous_bundle,
collect_share_bundle,
)
from hermes_cli.diagnostics_upload import share_to_nous
log_lines = params.get("log_lines")
if not isinstance(log_lines, int) or not (10 <= log_lines <= 2000):
log_lines = 200
bundle = collect_share_bundle(log_lines=log_lines, redact=True)
# Client-supplied text goes through the SAME upload-safe log redactor
# as backend-collected logs (_redact_log_text = force secret redaction
# + email masking) — never the weaker bare secret pass, so the remote
# path can't upload what the CLI pipeline would have removed.
error_context = params.get("error_context")
if isinstance(error_context, str) and error_context.strip():
bundle["error-context.txt"] = _redact_log_text(
error_context.strip()[:8_000]
)
# Client-side artifacts (local desktop.log on remote connections).
# Bounded: at most 4 files, 512KB of text each, sanitized labels —
# this is a diagnostics channel, not an arbitrary upload surface.
extra_files = params.get("extra_files")
if isinstance(extra_files, dict):
for label, text in list(extra_files.items())[:4]:
if not isinstance(label, str) or not isinstance(text, str):
continue
safe_label = "".join(
ch for ch in label if ch.isalnum() or ch in "._- ()"
).strip()[:64]
# Collapse dot-runs and leading dots so traversal-shaped labels
# ("../../etc/passwd") can't survive even cosmetically.
while ".." in safe_label:
safe_label = safe_label.replace("..", ".")
safe_label = safe_label.lstrip(".").strip()
if not safe_label or not text.strip():
continue
bundle[f"client/{safe_label}"] = _redact_log_text(text[:524_288])
blob = build_nous_bundle(bundle, redact=True)
res = share_to_nous(blob)
view_url = res.get("viewUrl") or res.get("view_url")
upload_id = res.get("id")
if not view_url and not upload_id:
# An upload the user can't reference is useless to support —
# surface it as a failure instead of a linkless success.
return _ok(
rid,
{"ok": False, "error": "upload succeeded but returned no view URL or id"},
)
return _ok(
rid,
{
"ok": True,
"view_url": view_url,
"upload_id": upload_id,
"expires_at": res.get("expiresAt") or res.get("expires_at"),
},
)
except Exception as e:
return _ok(rid, {"ok": False, "error": str(e)})
def register(server) -> None:
"""Bind this module's handlers onto ``server``'s globals and registry."""
_registry.install(server)
+866
View File
@@ -0,0 +1,866 @@
"""Hosted-room JSON-RPC contract.
These methods expose durable room identity, replay, and the process-owned
same-gateway Discussion driver. ``groups.capabilities`` keeps that boundary
machine-readable so older clients stay on the renderer-owned room path.
"""
from .method_ctx import HandlerRegistry
import os
import threading
_registry = HandlerRegistry()
method = _registry.method
LONG_HANDLERS = frozenset({
"groups.list",
"groups.capabilities",
"groups.create",
"groups.state",
"groups.send",
"groups.rename",
"groups.log",
"groups.disband",
"groups.replicate",
"groups.replica_state",
"groups.promote",
"groups.demote",
"groups.stop",
"groups.retry",
"groups.approve",
"groups.peer.invite",
"groups.peer.revoke",
"groups.peer.register",
})
_service_lock = threading.Lock()
_run_store_lock = threading.Lock()
_bound_server = None
_service = None
def bind_server(server) -> None:
"""Bind the fully initialized server module without starting a worker."""
global _bound_server
_bound_server = server
server._profile_execution_policy = _profile_execution_policy
def start_hosted_room_service():
"""Start one process-owned hosted room service idempotently."""
global _service
if _bound_server is None:
return None
from gateway.hosted_rooms import default_db_path
from tui_gateway.hosted_room_service import HostedRoomService
db_path = default_db_path()
with _service_lock:
if _service is not None and _service.db_path != db_path:
_service.stop(timeout=1.0)
_service = None
if _service is None:
_service = HostedRoomService(_bound_server, db_path=db_path)
_service.start()
return _service
def stop_hosted_room_service(*, timeout: float = 5.0) -> bool:
"""Stop the process-owned worker without interrupting accepted turns."""
global _service
with _service_lock:
service = _service
if service is None:
return True
stopped = service.stop(timeout=timeout)
if stopped and _service is service:
_service = None
return stopped
def get_hosted_room_service():
"""Return the active service, if its lifecycle owner started it."""
service = _service
if service is None:
return None
try:
status = service.runtime.status()
except Exception:
return None
return service if status.get("running") and not status.get("stopping") else None
_WORKER_UNAVAILABLE = (
"Group Chat worker is unavailable. Restart the Hermes gateway and try again."
)
def _profile_name() -> str:
return (os.getenv("HERMES_PROFILE") or "default").strip() or "default"
def _requested_profile(params: dict) -> str:
requested = str(params.get("profile") or "").strip()
if not requested:
return _profile_name()
if _bound_server is None:
raise ValueError("profile routing is unavailable")
current = str(_bound_server._current_profile_name() or "").strip()
if requested == current:
return current
home = _bound_server._profile_home(requested)
if home is None:
raise ValueError(f"profile '{requested}' is unavailable")
return str(_bound_server._response_profile_name(requested) or requested)
def _api_server_key(profile: str | None = None) -> str:
if profile and _bound_server is not None:
current = str(_bound_server._current_profile_name() or "").strip()
if profile != current:
from agent.secret_scope import build_profile_secret_scope
home = _bound_server._profile_home(profile)
if home is None:
return ""
# An explicit routed profile is authoritative. Never borrow the
# process/default profile's API key on a multiplexed gateway.
return str(
build_profile_secret_scope(home).get("API_SERVER_KEY") or ""
).strip()
try:
from agent.secret_scope import get_secret
scoped = (get_secret("API_SERVER_KEY", "") or "").strip()
if scoped:
return scoped
except Exception:
pass
return (os.getenv("API_SERVER_KEY") or "").strip()
def _profile_execution_policy(profile: str) -> dict:
"""Resolve execution policy under the exact multiplexed profile home."""
from gateway.hosted_room_execution_policy import execution_policy_mapping
from hermes_constants import (
reset_hermes_home_override,
set_hermes_home_override,
)
token = None
if _bound_server is not None:
current = str(_bound_server._current_profile_name() or "").strip()
if profile not in {current, _profile_name()}:
home = _bound_server._profile_home(profile)
if home is None:
raise ValueError(f"profile '{profile}' is unavailable")
token = set_hermes_home_override(str(home))
try:
return execution_policy_mapping(target_profile=profile)
finally:
if token is not None:
reset_hermes_home_override(token)
def _room_link_run_storage_durable() -> bool:
"""Return whether peer-run replay survives this gateway process."""
if _bound_server is None:
# Direct method-contract tests and embedded callers without a bound API
# server do not expose peer-run transport. The production server always
# binds before advertising capabilities.
return True
store = getattr(_bound_server, "_run_idempotency_store", None)
if store is None:
# The dashboard/TUI process owns groups.* but does not construct the
# API adapter that normally owns this store. Open the same shared
# SQLite-backed store lazily so capability negotiation reflects the
# real /v1/runs replay boundary instead of depending on test-only
# injection. A separately enabled API adapter uses the same file.
from gateway.platforms.api_server import RunIdempotencyStore
with _run_store_lock:
store = getattr(_bound_server, "_run_idempotency_store", None)
if store is None:
store = RunIdempotencyStore()
_bound_server._run_idempotency_store = store
return bool(getattr(store, "durable", False))
@method("groups.capabilities")
def _(rid, params: dict) -> dict:
"""Describe the hosted-room protocol implemented by this gateway."""
from gateway.hosted_rooms import (
MAX_LOG_LIMIT,
PROTOCOL_VERSION,
local_authority_gateway_id,
)
service = get_hosted_room_service()
driver_ready = bool(service and service.runtime.status()["running"])
try:
from gateway.hosted_room_peer import (
PROTOCOL_VERSION as ROOM_LINK_PROTOCOL_VERSION,
gateway_room_grant_secret,
local_catalog_mapping,
)
profile = _requested_profile(params)
if not _room_link_run_storage_durable():
raise ValueError("durable run idempotency storage is required")
gateway_room_grant_secret()
catalog = local_catalog_mapping(
installation_id=local_authority_gateway_id(),
protocol_versions=(ROOM_LINK_PROTOCOL_VERSION,),
link_modes=("direct",),
text=True,
attachments=False,
target_profile=profile,
execution_policy=_profile_execution_policy(profile),
)
room_link = {
"enabled": True,
"profile": profile,
"catalog": catalog,
"endpoint": catalog["endpoint"],
}
except Exception:
room_link = {
"enabled": False,
"reason": (
"durable_run_storage_required"
if not _room_link_run_storage_durable()
else "gateway_roomlink_secret_unavailable"
),
}
return _ok(
rid,
{
"protocol_version": PROTOCOL_VERSION,
"driver": driver_ready,
"persistent_process": bool(
room_link.get("catalog", {}).get("persistent_process", False)
),
"authority_gateway_id": local_authority_gateway_id(),
"room_link": room_link,
"features": [
"authority_epoch",
"coordinator_fencing",
"room_identity",
"monotonic_log",
"idempotent_send",
"replayable_disband",
"typed_events",
"actor_identity",
"log_replication",
"authority_takeover",
],
"methods": [
"groups.capabilities",
"groups.list",
"groups.create",
"groups.state",
"groups.send",
"groups.rename",
"groups.log",
"groups.disband",
"groups.replicate",
"groups.replica_state",
"groups.promote",
"groups.demote",
"groups.stop",
"groups.retry",
"groups.approve",
"groups.peer.invite",
"groups.peer.revoke",
"groups.peer.register",
],
"max_log_limit": MAX_LOG_LIMIT,
},
)
@method("groups.peer.invite")
def _(rid, params: dict) -> dict:
"""Mint one target-issued room/profile grant for a prospective home."""
try:
from gateway.hosted_room_peer import (
PROTOCOL_VERSION as ROOM_LINK_PROTOCOL_VERSION,
decode_room_grant,
gateway_room_grant_secret,
issue_room_grant,
local_catalog_mapping,
)
from gateway import hosted_rooms
if not _room_link_run_storage_durable():
raise ValueError("durable run idempotency storage is required")
installation_id = hosted_rooms.local_authority_gateway_id()
profile = _requested_profile(params)
ttl = float(params.get("ttl_seconds", 3600))
if not 60 <= ttl <= 24 * 60 * 60:
raise ValueError("ttl_seconds must be between 60 and 86400")
grant_secret = gateway_room_grant_secret()
execution_policy = _profile_execution_policy(profile)
token = issue_room_grant(
grant_secret,
grant_id=str(params.get("grant_id") or f"grant-{os.urandom(16).hex()}"),
room_id=str(params.get("room_id") or ""),
home_install_id=str(params.get("home_install_id") or ""),
authority_gateway_id=str(
params.get("authority_gateway_id") or ""
),
authority_epoch=int(params.get("authority_epoch") or 0),
member_id=str(params.get("member_id") or ""),
target_install_id=installation_id,
target_profile=profile,
execution_policy_digest=execution_policy["policy_digest"],
ttl_seconds=ttl,
)
claims = decode_room_grant(grant_secret, token, permission="status")
hosted_rooms.reserve_peer_room(
hosted_rooms.default_db_path(),
claims=claims,
expires_at=float(claims.get("status_expires_at", claims["expires_at"])),
)
catalog = local_catalog_mapping(
installation_id=installation_id,
protocol_versions=(ROOM_LINK_PROTOCOL_VERSION,),
link_modes=("direct",),
text=True,
attachments=False,
target_profile=profile,
execution_policy=execution_policy,
)
return _ok(
rid,
{
"grant": token,
"target_profile": profile,
"catalog": catalog,
"endpoint": catalog["endpoint"],
},
)
except Exception as exc:
return _err(rid, 4120, str(exc))
@method("groups.peer.revoke")
def _(rid, params: dict) -> dict:
"""Revoke one target-issued grant using its exact profile scope."""
try:
from gateway import hosted_rooms
from gateway.hosted_room_peer import decode_room_grant, gateway_room_grant_secret
profile = _requested_profile(params)
claims = decode_room_grant(
gateway_room_grant_secret(),
str(params.get("grant") or ""),
permission="status",
)
if (
claims["target_profile"] != profile
or claims["target_install_id"]
!= hosted_rooms.local_authority_gateway_id()
):
raise ValueError("room grant target does not match this profile")
hosted_rooms.revoke_room_grant_scope(
hosted_rooms.default_db_path(),
claims=claims,
expires_at=float(
claims.get("status_expires_at", claims["expires_at"])
),
)
return _ok(rid, {"revoked": True})
except Exception as exc:
return _err(rid, 4122, str(exc))
@method("groups.peer.register")
def _(rid, params: dict) -> dict:
"""Register and probe one scoped target route on the room home."""
service = get_hosted_room_service()
if service is None:
return _err(rid, 4121, "hosted room driver is unavailable")
try:
from gateway.hosted_room_peer import (
GatewayRoomCatalog,
PROTOCOL_VERSION as ROOM_LINK_PROTOCOL_VERSION,
validate_room_link_url,
)
from gateway.hosted_rooms import local_authority_gateway_id, room_state
from tui_gateway.hosted_room_peer_http import PeerRunsHTTPClient
from tui_gateway.hosted_room_peer_transport import PeerMemberRoute
target_url, transport_security = validate_room_link_url(
params.get("target_url")
)
catalog = GatewayRoomCatalog.from_mapping(params.get("catalog"))
if ROOM_LINK_PROTOCOL_VERSION not in catalog.protocol_versions:
raise ValueError(
f"target does not support RoomLink protocol v{ROOM_LINK_PROTOCOL_VERSION}"
)
if "direct" not in catalog.link_modes:
raise ValueError("target does not support a direct RoomLink")
target_profile = str(params.get("target_profile") or "")
grant = str(params.get("grant") or "")
client = PeerRunsHTTPClient(
base_url=target_url,
api_key="",
receipt_db_path=service.db_path,
)
probe = client.probe(grant=grant)
live_catalog = GatewayRoomCatalog.from_mapping(probe.get("catalog"))
if live_catalog != catalog:
raise ValueError("target capability catalog changed during setup")
if (
ROOM_LINK_PROTOCOL_VERSION not in live_catalog.protocol_versions
or "direct" not in live_catalog.link_modes
):
raise ValueError("target RoomLink capability is incompatible")
room_id = str(params.get("room_id") or "")
member_id = str(params.get("member_id") or "")
home_install_id = local_authority_gateway_id()
home_room = room_state(service.db_path, room_id=room_id)
if (
probe.get("room_id") != room_id
or probe.get("home_install_id") != home_install_id
or probe.get("authority_gateway_id")
!= home_room.get("authority_gateway_id")
or int(probe.get("authority_epoch") or 0)
!= int(home_room.get("authority_epoch") or 0)
or probe.get("member_id") != member_id
or probe.get("target_profile") != target_profile
):
raise ValueError("room grant scope does not match this route")
route = PeerMemberRoute(
home_install_id=home_install_id,
member_id=member_id,
target_install_id=catalog.installation_id,
target_profile=target_profile,
capability_digest=catalog.catalog_digest,
execution_policy_digest=catalog.execution_policy.policy_digest,
cancellation_scope_id=str(
params.get("cancellation_scope_id")
or f"cancel-{params.get('room_id') or ''}"
),
trace_id=str(params.get("trace_id") or f"trace-{os.urandom(16).hex()}"),
grant=grant,
)
service.register_peer_route(
room_id=room_id,
member_id=member_id,
route=route,
client=client,
target_url=target_url,
catalog=catalog,
)
return _ok(
rid,
{
"registered": True,
"mode": "direct",
"transport_security": transport_security,
"target_install_id": catalog.installation_id,
"target_profile": target_profile,
},
)
except Exception as exc:
return _err(rid, 5120, str(exc))
@method("groups.list")
def _(rid, params: dict) -> dict:
"""List rooms hosted by this gateway."""
try:
from gateway.hosted_rooms import (
MAX_ROOM_LIST_LIMIT,
default_db_path,
list_rooms,
)
limit = params.get("limit", MAX_ROOM_LIST_LIMIT)
offset = params.get("offset", 0)
rooms = list_rooms(
default_db_path(),
include_disbanded=params.get("include_disbanded") is True,
limit=limit,
offset=offset,
)
return _ok(
rid,
{
"rooms": rooms,
"next_offset": offset + limit if len(rooms) == limit else None,
},
)
except Exception as exc:
return _err(rid, 5110, str(exc))
@method("groups.create")
def _(rid, params: dict) -> dict:
"""Create a hosted room idempotently.
Required params: ``room_id``, ``name``, and ``members``. Authority is
derived from this gateway's stable install identity, never from the client.
"""
from gateway.hosted_rooms import HostedRoomError
try:
service = get_hosted_room_service()
if service is None:
return _err(rid, 4123, _WORKER_UNAVAILABLE)
room = service.create_room(
room_id=params.get("room_id"),
name=params.get("name"),
members=params.get("members"),
)
return _ok(rid, {"room": room})
except HostedRoomError as exc:
reason = getattr(exc, "reason", None)
return _err(rid, 4110, str(exc), {"reason": reason} if reason else None)
except Exception as exc:
return _err(rid, 5111, str(exc))
@method("groups.state")
def _(rid, params: dict) -> dict:
"""Return one hosted room's replay cursor and fenced authority state."""
from gateway.hosted_rooms import HostedRoomError, default_db_path, room_state
try:
room = room_state(
default_db_path(),
room_id=params.get("room_id"),
include_disbanded=params.get("include_disbanded") is True,
)
service = get_hosted_room_service()
return _ok(
rid,
{
"room": room,
**(
{"driver_status": service.status(str(room["room_id"]))}
if service is not None and room.get("disbanded_at") is None
else {}
),
},
)
except HostedRoomError as exc:
reason = getattr(exc, "reason", None)
return _err(rid, 4114, str(exc), {"reason": reason} if reason else None)
except Exception as exc:
return _err(rid, 5115, str(exc))
@method("groups.send")
def _(rid, params: dict) -> dict:
"""Append one typed event to a hosted room idempotently.
Required params: ``room_id``, ``event_id``, and object ``payload``. Only
inert ``message.user`` events are accepted through this client-facing
method. The actor is server-owned rather than trusted from params.
Admission is durable; no Bot turn is started by this slice.
"""
from gateway.hosted_rooms import HostedRoomError, user_event_id
try:
client_event_id = params.get("event_id")
service = get_hosted_room_service()
if service is None:
return _err(rid, 4123, _WORKER_UNAVAILABLE)
event = service.send(
room_id=params.get("room_id"),
event_id=user_event_id(client_event_id),
payload=params.get("payload"),
)
return _ok(
rid,
{
"event": event,
"client_event_id": client_event_id,
"accepted": True,
"driver_started": True,
},
)
except HostedRoomError as exc:
reason = getattr(exc, "reason", None)
return _err(rid, 4111, str(exc), {"reason": reason} if reason else None)
except Exception as exc:
return _err(rid, 5112, str(exc))
@method("groups.rename")
def _(rid, params: dict) -> dict:
"""Rename one hosted room atomically with its replay event."""
from gateway.hosted_rooms import HostedRoomError, default_db_path, rename_room
try:
renamed = rename_room(
default_db_path(),
room_id=params.get("room_id"),
event_id=params.get("event_id"),
name=params.get("name"),
)
return _ok(rid, {"room": renamed})
except HostedRoomError as exc:
reason = getattr(exc, "reason", None)
return _err(rid, 4117, str(exc), {"reason": reason} if reason else None)
except Exception as exc:
return _err(rid, 5117, str(exc))
@method("groups.disband")
def _(rid, params: dict) -> dict:
"""Permanently tombstone a hosted room id."""
from gateway.hosted_rooms import (
AuthorityConflictError,
HostedRoomError,
RoomHistoryExpiredError,
disband_room,
local_authority_gateway_id,
room_state,
)
try:
service = get_hosted_room_service()
if service is None:
return _err(rid, 4123, _WORKER_UNAVAILABLE)
def disband_with_state(state: dict | None = None) -> dict:
local_gateway_id = local_authority_gateway_id()
if state is not None and (
str(state["authority_gateway_id"]) != local_gateway_id
):
raise AuthorityConflictError(
"This Group Chat is managed by another gateway."
)
return disband_room(
service.db_path,
room_id=params.get("room_id"),
expected_gateway_id=str(
local_gateway_id
),
expected_epoch=int(
state["authority_epoch"] if state is not None else 1
),
)
try:
existing = room_state(
service.db_path,
room_id=params.get("room_id"),
include_disbanded=True,
)
except RoomHistoryExpiredError:
tombstone = disband_with_state()
return _ok(rid, {"tombstone": tombstone})
if existing.get("disbanded_at") is not None:
tombstone = disband_with_state(existing)
return _ok(rid, {"tombstone": tombstone})
service.stop_room(
str(params.get("room_id") or ""),
cancel_id=str(params.get("cancel_id") or "room-disbanded"),
require_acknowledged=True,
)
service.revoke_room_routes(str(params.get("room_id") or ""))
tombstone = disband_with_state(existing)
return _ok(rid, {"tombstone": tombstone})
except HostedRoomError as exc:
reason = getattr(exc, "reason", None)
return _err(rid, 4113, str(exc), {"reason": reason} if reason else None)
except Exception as exc:
return _err(rid, 5114, str(exc))
@method("groups.stop")
def _(rid, params: dict) -> dict:
"""Durably cancel queued or running work for one hosted room."""
service = get_hosted_room_service()
if service is None:
return _err(rid, 4115, "hosted room driver is unavailable")
try:
count = service.stop_room(
str(params.get("room_id") or ""),
cancel_id=str(params.get("cancel_id") or "desktop-stop"),
)
return _ok(rid, {"cancelled": count})
except Exception as exc:
return _err(rid, 5116, str(exc))
@method("groups.approve")
def _(rid, params: dict) -> dict:
"""Resolve one exact approval requested by a local or peer room member."""
service = get_hosted_room_service()
if service is None:
return _err(rid, 4115, "hosted room driver is unavailable")
try:
result = service.approve_room_task(
str(params.get("room_id") or ""),
member_id=str(params.get("member_id") or ""),
task_id=str(params.get("task_id") or ""),
execution_generation=int(params.get("execution_generation") or 0),
choice=str(params.get("choice") or ""),
request_id=str(params.get("request_id") or ""),
)
return _ok(rid, {"approved": True, "result": result})
except Exception as exc:
return _err(rid, 5119, str(exc))
@method("groups.retry")
def _(rid, params: dict) -> dict:
"""Retry one indeterminate room task after explicit user confirmation."""
service = get_hosted_room_service()
if service is None:
return _err(rid, 4115, "hosted room driver is unavailable")
try:
task = service.retry_room_task(
str(params.get("room_id") or ""),
task_id=str(params.get("task_id") or ""),
)
identity = task.get("identity") if isinstance(task, dict) else None
receipt = {
"room_id": str(getattr(identity, "room_id", "") or ""),
"task_id": str(getattr(identity, "task_id", "") or ""),
"thread_id": str(getattr(identity, "thread_id", "") or ""),
"turn_id": str(getattr(identity, "turn_id", "") or ""),
"status": str(task.get("status") or "") if isinstance(task, dict) else "",
"execution_generation": int(task.get("execution_generation") or 0)
if isinstance(task, dict)
else 0,
"cancel_generation": int(task.get("cancel_generation") or 0)
if isinstance(task, dict)
else 0,
}
return _ok(rid, {"retried": True, "task": receipt})
except Exception as exc:
return _err(rid, 5118, str(exc))
@method("groups.log")
def _(rid, params: dict) -> dict:
"""Return a monotonic room-log delta after ``since_seq``."""
from gateway.hosted_rooms import HostedRoomError, default_db_path, read_events
try:
delta = read_events(
default_db_path(),
room_id=params.get("room_id"),
since_seq=params.get("since_seq", 0),
limit=params.get("limit", 100),
include_disbanded=params.get("include_disbanded") is True,
)
return _ok(rid, delta)
except HostedRoomError as exc:
reason = getattr(exc, "reason", None)
return _err(rid, 4112, str(exc), {"reason": reason} if reason else None)
except Exception as exc:
return _err(rid, 5113, str(exc))
@method("groups.replicate")
def _(rid, params: dict) -> dict:
"""Persist one authority-stamped replay page into the local replica store.
``page`` is the verbatim ``groups.log`` result read from the room's
authority gateway; ingest is idempotent and refuses sequence gaps and
authority-epoch regressions.
"""
from gateway.hosted_room_replicas import ReplicaError, ingest_page
from gateway.hosted_rooms import default_db_path
try:
result = ingest_page(
default_db_path(),
room_id=params.get("room_id"),
room_name=params.get("room_name"),
members=params.get("members"),
page=params.get("page"),
)
return _ok(rid, result)
except ReplicaError as exc:
return _err(rid, 4116, str(exc))
except Exception as exc:
return _err(rid, 5116, str(exc))
@method("groups.replica_state")
def _(rid, params: dict) -> dict:
"""Report the local replica's coverage and authority lineage."""
from gateway.hosted_room_replicas import ReplicaError, replica_state
from gateway.hosted_rooms import default_db_path
try:
return _ok(rid, replica_state(default_db_path(), room_id=params.get("room_id")))
except ReplicaError as exc:
return _err(rid, 4117, str(exc))
except Exception as exc:
return _err(rid, 5117, str(exc))
@method("groups.promote")
def _(rid, params: dict) -> dict:
"""Continue a replicated room on THIS gateway at ``epoch + 1``.
Requires ``confirm: true`` — the caller asserts the previous authority can
no longer commit (explicit user action; a lease/quorum driver later).
"""
from gateway.hosted_room_replicas import ReplicaError, promote_replica
from gateway.hosted_rooms import HostedRoomError, default_db_path
if params.get("confirm") is not True:
return _err(
rid,
4118,
"promotion requires confirm=true acknowledging the previous "
"authority can no longer commit",
)
try:
result = promote_replica(
default_db_path(),
room_id=params.get("room_id"),
reason=params.get("reason", "authority-unreachable"),
)
return _ok(rid, result)
except ReplicaError as exc:
return _err(rid, 4118, str(exc))
except HostedRoomError as exc:
return _err(rid, 4118, str(exc))
except Exception as exc:
return _err(rid, 5118, str(exc))
@method("groups.demote")
def _(rid, params: dict) -> dict:
"""Fence this gateway's stale room authority against a proven newer epoch."""
from gateway.hosted_room_replicas import ReplicaError, demote_room
from gateway.hosted_rooms import default_db_path
try:
result = demote_room(
default_db_path(),
room_id=params.get("room_id"),
observed_gateway_id=params.get("observed_gateway_id"),
observed_epoch=params.get("observed_epoch"),
)
return _ok(rid, result)
except ReplicaError as exc:
return _err(rid, 4119, str(exc))
except Exception as exc:
return _err(rid, 5119, str(exc))
def register(server) -> None:
_registry.install(server)
+130
View File
@@ -0,0 +1,130 @@
"""Image-generation JSON-RPC handler (ws twin of the image_generate tool).
Desktop plugins reach the backend only through ws JSON-RPC; the image
generation capability existed solely as a model tool. ``image.generate``
lets UI surfaces (avatar pickers, artifact panes) generate directly.
The result image is returned as a data URL (``image_data``): a remote
desktop client cannot read a file path on the gateway host, and hosted
result URLs are often CORS-opaque to a renderer canvas. Data URLs work
identically over local and remote gateways.
Handlers are rebound onto server.py's globals at install time (see
method_ctx.py) — helpers must stay nested inside the handler body.
"""
from .method_ctx import HandlerRegistry
_registry = HandlerRegistry()
method = _registry.method
@method("image.generate")
def _(rid, params: dict) -> dict:
"""Generate an image with the configured backend.
Params: ``prompt`` (required unless ``probe``), ``aspect_ratio``
(landscape|square|portrait), ``probe`` (return availability only),
``max_bytes`` (cap on the returned data URL payload, default 8MB).
Result: ``{available, success, image, image_data, error}`` where
``image`` is the backend's URL/path and ``image_data`` is a data URL
of the downloaded bytes (omitted when the download fails — callers
should fall back to ``image``).
"""
def _availability() -> bool:
try:
from tools.image_generation_tool import check_image_generation_requirements
return bool(check_image_generation_requirements())
except Exception:
return False
def _to_data_url(ref: str, cap: int):
"""Fetch a URL or read a local path into a data URL, size-capped."""
import base64
import mimetypes
import os
try:
if ref.startswith(("http://", "https://")):
import urllib.request
req = urllib.request.Request(ref, headers={"User-Agent": "hermes-agent"})
with urllib.request.urlopen(req, timeout=60) as resp:
if resp.length is not None and resp.length > cap:
return None
data = resp.read(cap + 1)
mime = resp.headers.get_content_type() or "image/png"
elif os.path.isfile(ref):
if os.path.getsize(ref) > cap:
return None
with open(ref, "rb") as fh:
data = fh.read(cap + 1)
mime = mimetypes.guess_type(ref)[0] or "image/png"
else:
return None
if len(data) > cap:
return None
if not mime.startswith("image/"):
mime = "image/png"
return f"data:{mime};base64,{base64.b64encode(data).decode('ascii')}"
except Exception:
return None
available = _availability()
if is_truthy_value(params.get("probe", False)):
return _ok(rid, {"available": available})
if not available:
return _ok(
rid,
{
"available": False,
"success": False,
"error": "No image generation backend configured (run `hermes tools` to enable one).",
},
)
prompt = str(params.get("prompt") or "").strip()
if not prompt:
return _err(rid, 4071, "prompt required")
aspect = str(params.get("aspect_ratio") or "square").strip().lower()
try:
cap = min(int(params.get("max_bytes", 8_000_000) or 8_000_000), 16_000_000)
except (TypeError, ValueError):
cap = 8_000_000
try:
from tools.image_generation_tool import _handle_image_generate
# Full provider dispatcher — the same path the model tool takes:
# source-image confinement, plugin-registered providers, managed
# Krea routing, then the in-tree FAL fallback. Calling the FAL leaf
# (image_generate_tool) directly here bypassed configured providers.
raw = _handle_image_generate({"prompt": prompt, "aspect_ratio": aspect})
result = json.loads(raw)
except Exception as e:
return _err(rid, 5071, str(e))
if not result.get("success"):
return _ok(
rid,
{
"available": True,
"success": False,
"error": str(result.get("error") or "generation failed"),
},
)
image_ref = str(result.get("image") or "")
payload = {"available": True, "success": True, "image": image_ref}
data_url = _to_data_url(image_ref, cap) if image_ref else None
if data_url:
payload["image_data"] = data_url
return _ok(rid, payload)
def register(server) -> None:
_registry.install(server)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+808
View File
@@ -0,0 +1,808 @@
"""Authoritative project -> repo -> lane -> session tree builder.
This is the single source of truth for how the desktop sidebar groups sessions
into projects, repos, and lanes. It is pure (all git resolution is injected via
``resolve``) so it can be unit-tested with fixtures and reused by the gateway's
``projects.tree`` / ``projects.project_sessions`` RPCs.
It deliberately mirrors the desktop's former client-side grouping (the old
``workspace-groups.ts``) so the emitted ids and lane keys stay byte-compatible
with the renderer's persisted state (pins, manual ordering, dismissal), which
all key off these exact strings:
- explicit project id .......... ``p_<hex>`` (from projects.db)
- auto/discovered project id ... the repo root path
- home (no-project) bucket ..... ``__no_project__``
- repo node id ................. the repo root path
- main branch lane id .......... ``<repoRoot>::branch::<branch>`` (or ``::branch::``)
- kanban bucket lane id ........ ``<repoRoot>::kanban``
- linked worktree lane id ...... the worktree path
The one correctness upgrade over the client version: linked worktrees are folded
under their MAIN repo via a git common-dir probe (injected as ``resolve``),
instead of being treated as separate repos (``git rev-parse --show-toplevel``
returns the worktree's own root, which is why the client double-counted them).
"""
from __future__ import annotations
import re
from typing import Any, Callable, Optional
# A cwd -> git identity resolver. Returns ``{"repo_root", "worktree_root"}`` where
# ``repo_root`` is the COMMON (main) repo root shared across worktrees and
# ``worktree_root`` is this cwd's own checkout root. Returns ``None`` when the
# cwd is not in a git repo (or cannot be probed, e.g. a remote backend).
Resolve = Callable[[str], Optional[dict]]
# A "does this directory still exist?" predicate, injected for the same reason
# ``Resolve`` is: the builder stays pure and unit-testable. Defaults to assuming
# everything exists, which preserves the previous behavior for callers that
# can't stat (remote backends), where guessing "gone" would wrongly hide a
# project that lives on the other host.
Exists = Callable[[str], bool]
# Only KANBAN-TASK worktrees (`<repo>/.worktrees/t_<hex>`, the `t_…` id kanban_db
# mints) collapse into one lane; user-named "New worktree" dirs under
# `.worktrees/` stay as their own lanes.
_KANBAN_DIR_RE = re.compile(r"^(.*[/\\]\.worktrees)[/\\]t_[0-9a-f]+[/\\]?$")
_TRUNK_BRANCHES = {"main", "master", "trunk", "develop"}
DEFAULT_BRANCH_LABEL = "main"
# The synthetic bucket holding every session no project claimed — a chat with no
# cwd at all, or one whose folder can't be promoted (the bare home dir, HERMES
# state, a workspace that has since been deleted). Without it those sessions are
# invisible in the grouped view. The desktop labels it "Home"; the id/flag stay
# named for what the bucket MEANS, since that's what membership keys off.
NO_PROJECT_ID = "__no_project__"
NO_PROJECT_LABEL = "Home"
# How many sibling candidates to try when recovering a deleted worktree's parent
# repo (see ``_probe_sibling_worktree``). Each miss costs a git probe, so keep it
# tight — real suffixes are one or two segments.
_MAX_SIBLING_PROBES = 4
def stamp_profile(projects: list[dict], profile: str) -> None:
"""Make every session row self-describing for cross-profile routing.
A scoped project tree is built from one profile's state.db, so the request
scope is authoritative even for legacy rows whose ``profile_name`` is NULL.
"""
for project in projects:
for session in project.get("previewSessions") or []:
session["profile"] = profile
for repo in project.get("repos") or []:
for group in repo.get("groups") or []:
for session in group.get("sessions") or []:
session["profile"] = profile
def _branch_lane_id(repo_root: str, branch: str = "") -> str:
"""The one definition of a main-checkout lane id (must match the desktop)."""
return f"{repo_root}::branch::{(branch or '').strip()}"
def _kanban_lane_id(repo_root: str) -> str:
return f"{repo_root}::kanban"
# ---------------------------------------------------------------------------
# Path helpers (match the TS segment logic so labels/ids line up)
# ---------------------------------------------------------------------------
def _segments(path: str) -> list[str]:
return [s for s in re.split(r"[/\\]", (path or "").rstrip("/\\")) if s]
def _is_windows_path(path: str) -> bool:
value = (path or "").strip()
# Drive-letter (`C:\…`), UNC (`\\srv`, `//srv`), or any backslash-rooted path
# — the root-relative `\wsl.localhost\…` / `\Users\…` spellings included. A
# single leading `/` stays POSIX (case-sensitive).
return bool(re.match(r"^[A-Za-z]:[/\\]", value)) or value.startswith(("\\", "//"))
def _comparison_segments(path: str) -> list[str]:
"""Path segments suitable for identity comparisons on any host.
Windows paths remain case-insensitive even when tests or remote backends run
on POSIX. Display paths and emitted IDs keep their original spelling.
"""
segs = _segments(path)
return [segment.casefold() for segment in segs] if _is_windows_path(path) else segs
def _path_key(path: str) -> str:
"""Canonical comparison key (separator/trailing-slash agnostic)."""
return "/".join(_comparison_segments(path))
def _lane_key(path_or_lane: str) -> str:
"""Canonicalize only the path portion of a lane id.
Branch labels remain byte-preserved; repo/worktree paths follow platform path
identity so equivalent Windows spellings do not create duplicate lanes.
"""
for marker in ("::branch::", "::kanban"):
if marker in path_or_lane:
root, suffix = path_or_lane.split(marker, 1)
return f"{_path_key(root)}{marker}{suffix}"
return _path_key(path_or_lane)
def base_name(path: str) -> str:
segs = _segments(path)
return segs[-1] if segs else ""
def kanban_worktree_dir(path: str) -> Optional[str]:
"""The ``<repo>/.worktrees`` dir for a ``.../.worktrees/<task>`` path, else None."""
m = _KANBAN_DIR_RE.match(path or "")
return m.group(1) if m else None
def _is_path_under(folder: str, target: str) -> bool:
"""True when ``target`` equals ``folder`` or is nested under it (segment-wise)."""
f = _comparison_segments(folder)
t = _comparison_segments(target)
if not f or len(f) > len(t):
return False
return all(f[i] == t[i] for i in range(len(f)))
def _with_base_name(path: str, name: str) -> str:
stripped = re.sub(r"[/\\]+$", "", path)
return re.sub(r"[^/\\]+$", name, stripped)
# ---------------------------------------------------------------------------
# Lane placement
# ---------------------------------------------------------------------------
def _placement(
repo_root: str,
lane_key: str,
lane_label: str,
lane_path: str,
is_main: bool,
is_kanban: bool,
) -> dict:
return {
"repo_key": repo_root,
"repo_label": base_name(repo_root) or repo_root,
"repo_path": repo_root,
"lane_key": lane_key,
"lane_label": lane_label,
"lane_path": lane_path,
"is_main": is_main,
"is_kanban": is_kanban,
}
def _parent_dir(path: str) -> str:
"""The containing directory of ``path`` (``""`` once the root is passed)."""
stripped = re.sub(r"[/\\]+$", "", path or "")
return re.sub(r"[/\\]+$", "", re.sub(r"[^/\\]+$", "", stripped))
def _probe_sibling_worktree(cwd: str, resolve: Resolve) -> str:
"""The parent repo root of a deleted ``<repo>-<suffix>`` worktree, else ``""``.
A deleted worktree dir can't be probed, so walk back up its name — trimming
one ``-<segment>`` at a time and return the first sibling that resolves.
The session's cwd is frequently a SUBDIR of the deleted worktree (an agent
that ``cd``-ed into ``<repo>-<suffix>/apps/desktop``), whose basename shares
nothing with the repo. So the trim is applied to each ANCESTOR, deepest
first, not just to the leaf otherwise the probe silently no-ops and the
dead path gets minted as its own top-level project. Probes are bounded in
total (each costs a git invocation) and served from the shared probe cache.
"""
probes = 0
path = re.sub(r"[/\\]+$", "", cwd or "")
while path and probes < _MAX_SIBLING_PROBES:
parts = base_name(path).split("-")
for i in range(len(parts) - 1, 0, -1):
if probes >= _MAX_SIBLING_PROBES:
break
probes += 1
info = resolve(_with_base_name(path, "-".join(parts[:i])))
if info and info.get("repo_root"):
return (info["repo_root"] or "").strip()
path = _parent_dir(path)
return ""
def _place_by_heuristic(path: str) -> Optional[dict]:
"""Path-only fallback when there is no git probe and no persisted root."""
base = base_name(path)
if not base:
return None
kanban_dir = kanban_worktree_dir(path)
if kanban_dir:
repo_path = re.sub(r"[/\\]+$", "", _with_base_name(kanban_dir, ""))
return _placement(repo_path, _kanban_lane_id(repo_path), "kanban", kanban_dir, False, True)
m = re.match(r"^(.+)-wt-(.+)$", base)
if m:
repo_path = _with_base_name(path, m.group(1))
return _placement(repo_path, path, m.group(2), path, False, False)
return _placement(path, _branch_lane_id(path, DEFAULT_BRANCH_LABEL), base, path, True, False)
def _place(cwd: str, branch: str, resolve: Optional[Resolve], persisted_root: str) -> Optional[dict]:
info = resolve(cwd) if resolve else None
if info and info.get("repo_root") and info.get("worktree_root"):
repo_root = info["repo_root"]
worktree_root = info["worktree_root"]
is_main = _path_key(worktree_root) == _path_key(repo_root) or bool(info.get("is_main"))
if is_main:
# Unrecorded branch folds into the one trunk lane, so a repo never
# shows two "main" lanes (recorded "main" + the empty-branch bucket).
b = (branch or "").strip() or DEFAULT_BRANCH_LABEL
return _placement(repo_root, _branch_lane_id(repo_root, b), b, repo_root, True, False)
kanban_dir = kanban_worktree_dir(worktree_root)
if kanban_dir:
return _placement(repo_root, _kanban_lane_id(repo_root), "kanban", kanban_dir, False, True)
label = base_name(worktree_root) or worktree_root
return _placement(repo_root, worktree_root, label, worktree_root, False, False)
# No live probe: trust the backend-persisted root (group by it, split main by
# the session's recorded branch). Kanban tasks still collapse by path shape.
if persisted_root:
kanban_dir = kanban_worktree_dir(cwd)
if kanban_dir:
return _placement(persisted_root, _kanban_lane_id(persisted_root), "kanban", kanban_dir, False, True)
b = (branch or "").strip() or DEFAULT_BRANCH_LABEL
return _placement(persisted_root, _branch_lane_id(persisted_root, b), b, persisted_root, True, False)
# Unresolvable cwd: a deleted ``<repo>-<suffix>`` worktree still belongs to
# its parent. It has no checkout to return to, so absorb it into the trunk
# lane rather than stranding a dead-path lane in the project forever.
sibling_root = _probe_sibling_worktree(cwd, resolve) if resolve else ""
if sibling_root:
b = (branch or "").strip() or DEFAULT_BRANCH_LABEL
return _placement(sibling_root, _branch_lane_id(sibling_root, b), b, sibling_root, True, False)
return _place_by_heuristic(cwd)
def _session_repo_root(session: dict, resolve: Optional[Resolve]) -> str:
"""The COMMON repo root a session belongs to (folds linked worktrees)."""
cwd = (session.get("cwd") or "").strip()
if cwd and resolve:
info = resolve(cwd)
if info and info.get("repo_root"):
return info["repo_root"]
return (session.get("git_repo_root") or "").strip()
# ---------------------------------------------------------------------------
# Ordering + label disambiguation (parity with the old client tree)
# ---------------------------------------------------------------------------
def _lane_sort_key(group: dict) -> tuple:
# Trunk pins to the top; the kanban aggregate sinks to the bottom; the rest
# (branches + linked worktrees) sort by most-recent activity, then label.
is_trunk = bool(group.get("isMain")) and group["label"].lower() in _TRUNK_BRANCHES
is_kanban = bool(group.get("isKanban"))
activity = max((_session_time(s) for s in group.get("sessions") or []), default=0.0)
return (
0 if is_trunk else 1,
1 if is_kanban else 0,
-activity,
group["label"].lower(),
)
def _sort_lanes(groups: list[dict]) -> list[dict]:
return sorted(groups, key=_lane_sort_key)
def _disambiguate_labels(items: list[dict]) -> None:
"""Grow colliding basenames into path-prefixed labels (in place)."""
by_label: dict[str, list[dict]] = {}
for item in items:
by_label.setdefault(item["label"], []).append(item)
for bucket in by_label.values():
pathed = [g for g in bucket if g.get("path")]
if len(pathed) < 2:
continue
parents = {id(g): _segments(g["path"])[:-1] for g in pathed}
max_depth = max(len(p) for p in parents.values())
depth = 1
while depth <= max_depth:
counts: dict[str, int] = {}
for g in pathed:
segs = parents[id(g)]
prefix = "/".join(segs[-depth:]) if depth else ""
base = base_name(g["path"]) or g["path"]
g["label"] = f"{prefix}/{base}" if prefix else base
counts[g["label"]] = counts.get(g["label"], 0) + 1
if all(c == 1 for c in counts.values()):
break
depth += 1
# ---------------------------------------------------------------------------
# Repo subtree assembly
# ---------------------------------------------------------------------------
def _session_time(session: dict) -> float:
return float(session.get("last_active") or session.get("started_at") or 0)
def _build_repos(sessions: list[dict], resolve: Optional[Resolve], hydrate: bool) -> list[dict]:
"""Build the ``repo -> lane -> sessions`` subtree for a set of sessions."""
lanes: dict[str, dict] = {} # lane_key -> {group, repo_key, repo_label, repo_path}
for session in sessions:
cwd = (session.get("cwd") or "").strip()
if not cwd:
continue
placement = _place(
cwd,
(session.get("git_branch") or "").strip(),
resolve,
(session.get("git_repo_root") or "").strip(),
)
if not placement:
continue
lane_identity = _lane_key(placement["lane_key"])
entry = lanes.get(lane_identity)
if entry is None:
entry = {
"group": {
"id": placement["lane_key"],
"label": placement["lane_label"],
"path": placement["lane_path"],
"isMain": placement["is_main"],
"isKanban": placement["is_kanban"],
"sessions": [],
},
"repo_key": placement["repo_key"],
"repo_label": placement["repo_label"],
"repo_path": placement["repo_path"],
}
lanes[lane_identity] = entry
entry["group"]["sessions"].append(session)
repos: dict[str, dict] = {}
for entry in lanes.values():
group = entry["group"]
group["sessions"].sort(key=_session_time, reverse=True)
count = len(group["sessions"])
repo_identity = _path_key(entry["repo_key"])
repo = repos.get(repo_identity)
if repo is None:
repo = {
"id": entry["repo_key"],
"label": entry["repo_label"],
"path": entry["repo_path"],
"groups": [],
"sessionCount": 0,
}
repos[repo_identity] = repo
repo["groups"].append(group)
repo["sessionCount"] += count
repo_list = list(repos.values())
for repo in repo_list:
repo["groups"] = _sort_lanes(repo["groups"])
_disambiguate_labels(repo["groups"])
# Drop per-lane session rows only AFTER sorting: _lane_sort_key ranks
# non-trunk lanes by most-recent activity, which it derives from the
# session rows. Clearing them earlier makes every lane look inactive on
# the overview (hydrate=False) path and collapses the sort to
# alphabetical. Counts were already captured above, so the payload stays
# slim without losing the recency order.
if not hydrate:
for group in repo["groups"]:
group["sessions"] = []
_disambiguate_labels(repo_list)
return repo_list
def _seed_folder_repos(
repos: list[dict], folders: list[dict], resolve: Optional[Resolve]
) -> list[dict]:
"""Ensure every declared project folder shows as a repo, even with 0 sessions.
A brand-new project (or any project whose sessions haven't loaded yet) has an
empty session-derived ``repos`` list. That breaks two things on the desktop:
the entered-project view renders blank (it early-returns on no repos), and the
optimistic live-session overlay has no lane to drop a freshly-created session
into so a new session in the project only appears after a full tree refresh.
Seeding each folder as an empty repo fixes both: the overlay matches a new
session's cwd under the folder root, and the drill-in renders a real (if
empty) project body. Folders already covered by a session-derived repo (same
git root) are left untouched.
"""
seen = {
_path_key(value)
for repo in repos
for value in (repo.get("id"), repo.get("path"))
if value
}
seeded = list(repos)
for folder in folders or []:
raw = (folder.get("path") or "").strip()
if not raw:
continue
info = resolve(raw) if resolve else None
root = (info or {}).get("repo_root") or re.sub(r"[/\\]+$", "", raw)
root_key = _path_key(root)
if not root_key or root_key in seen:
continue
seeded.append({"id": root, "label": base_name(root) or root, "path": root, "groups": [], "sessionCount": 0})
seen.add(root_key)
if len(seeded) != len(repos):
_disambiguate_labels(seeded)
return seeded
# ---------------------------------------------------------------------------
# Explicit-project ownership
# ---------------------------------------------------------------------------
class _FolderIndex:
"""Maps a normalized folder path → (owning project, depth), so a session is
matched to its project by walking its cwd's ancestors (O(path depth) dict
lookups) instead of scanning every project × folder per session the
difference between O(sessions × projects) and O(sessions) at power-user scale.
"""
def __init__(self, projects: list[dict]) -> None:
self._by_path: dict[str, tuple[dict, int]] = {}
for project in projects:
for folder in project.get("folders") or []:
segs = _comparison_segments(folder.get("path") or "")
if not segs:
continue
key = "/".join(segs)
depth = len(segs)
# Deepest folder wins; ties keep the first project (scan order).
existing = self._by_path.get(key)
if existing is None or depth > existing[1]:
self._by_path[key] = (project, depth)
def match(self, target: str) -> tuple[Optional[dict], int]:
"""Owning project for ``target`` by longest ancestor folder, + its depth."""
segs = _comparison_segments(target or "")
# Longest prefix first → deepest (most specific) folder wins.
for end in range(len(segs), 0, -1):
hit = self._by_path.get("/".join(segs[:end]))
if hit:
return hit
return None, -1
def _project_for_path(index: _FolderIndex, target: str) -> Optional[dict]:
return index.match(target)[0]
def _project_for_session(session: dict, index: _FolderIndex, resolve: Optional[Resolve]) -> Optional[dict]:
cwd = (session.get("cwd") or "").strip()
if not cwd:
return None
repo_root = _session_repo_root(session, resolve)
candidates = [cwd, repo_root] if repo_root and repo_root != cwd else [cwd]
best: Optional[dict] = None
best_len = -1
for target in candidates:
match, length = index.match(target)
if match and length > best_len:
best_len = length
best = match
return best
# ---------------------------------------------------------------------------
# Public builder
# ---------------------------------------------------------------------------
def _session_cost(session: dict) -> float:
"""A session's spend, billed if the provider reported it, else estimated."""
for key in ("actual_cost_usd", "estimated_cost_usd"):
value = session.get(key)
if value:
return float(value)
return 0.0
def _project_node(
*,
pid: str,
label: str,
path: Optional[str],
repos: list[dict],
session_count: int,
last_active: float,
preview_sessions: list[dict],
sessions: Optional[list[dict]] = None,
color: Any = None,
icon: Any = None,
is_auto: bool = False,
is_no_project: bool = False,
) -> dict:
return {
"id": pid,
"label": label,
"path": path,
"color": color,
"icon": icon,
"isAuto": is_auto,
"isNoProject": is_no_project,
"sessionCount": session_count,
"lastActive": last_active,
# Totals over the same sessions `sessionCount` counts, so a project's
# header can add up what its rows show. The window the caller loaded is
# the whole truth either way — count and totals can't disagree.
"totalTokens": sum((s.get("input_tokens") or 0) + (s.get("output_tokens") or 0) for s in sessions or []),
"totalCostUsd": sum(_session_cost(s) for s in sessions or []),
"repos": repos,
"previewSessions": preview_sessions,
}
def build_tree(
projects: list[dict],
sessions: list[dict],
discovered_repos: list[dict],
resolve: Optional[Resolve] = None,
*,
preview_limit: int = 3,
hydrate: bool = False,
is_junk_root: Optional[Callable[[str], bool]] = None,
is_junk_cwd: Optional[Callable[[str], bool]] = None,
exists: Optional[Exists] = None,
) -> dict:
"""Build the authoritative project tree.
``projects`` are ``projects_db.Project.to_dict()`` shapes (non-archived).
``sessions`` are projected session-row dicts (must carry ``id``, ``cwd``,
``git_branch``, ``git_repo_root``, ``started_at``, ``last_active``).
``discovered_repos`` are ``{"root", "label", "sessions", "last_active"}``.
``is_junk_root`` flags git roots that must never become an AUTO project (the
bare home dir, the HERMES_HOME subtree). ``is_junk_cwd`` is the narrower
policy for non-git session folders: selected descendants may be intentional
workspaces even when their parent tree contains Hermes state. User-created
projects are honored regardless. ``exists`` reports whether a directory is
still on disk, so a session whose workspace was DELETED (a removed worktree,
a scratch dir under /tmp) doesn't get promoted to a phantom AUTO project;
omit it (remote backends) to keep every candidate.
Returns ``{"projects": [...], "scoped_session_ids": [...]}``. When
``hydrate`` is False (overview), lane ``sessions`` arrays are emptied but
every count is preserved and each project carries up to ``preview_limit``
``previewSessions``. When True (drill-in), lanes carry full session rows.
"""
active_projects = [p for p in projects if not p.get("archived")]
_junk = is_junk_root or (lambda _root: False)
_junk_cwd = is_junk_cwd or (lambda _cwd: False)
_exists = exists or (lambda _path: True)
folder_index = _FolderIndex(active_projects)
by_project: dict[str, list[dict]] = {}
unowned: list[dict] = []
for session in sessions:
owner = _project_for_session(session, folder_index, resolve)
if owner:
by_project.setdefault(owner["id"], []).append(session)
else:
unowned.append(session)
scoped_ids: list[str] = []
def _previews(project_sessions: list[dict]) -> list[dict]:
if preview_limit <= 0:
return []
ordered = sorted(project_sessions, key=_session_time, reverse=True)
return ordered[:preview_limit]
def _last_active(project_sessions: list[dict]) -> float:
return max((_session_time(s) for s in project_sessions), default=0.0)
result: list[dict] = []
# Tier 1: explicit, user-created projects (always shown, even with 0 sessions).
for project in active_projects:
psessions = by_project.get(project["id"], [])
scoped_ids.extend(s["id"] for s in psessions if s.get("id"))
repos = _seed_folder_repos(
_build_repos(psessions, resolve, hydrate), project.get("folders") or [], resolve
)
result.append(
_project_node(
pid=project["id"],
label=project.get("name") or project["id"],
path=project.get("primary_path"),
color=project.get("color"),
icon=project.get("icon"),
repos=repos,
session_count=len(psessions),
last_active=_last_active(psessions),
preview_sessions=_previews(psessions),
sessions=psessions,
)
)
# Tier 2: auto projects from leftover sessions. Prefer the common git repo
# root, then fall back to the session cwd for historical/non-git workspaces.
# The pre-Projects desktop grouped every non-empty cwd; keeping that fallback
# prevents upgrades from flattening those sessions into Recents.
by_auto_root: dict[str, dict] = {}
# Every session no tier could place. These are the Home bucket's rows.
homeless: list[dict] = []
def _add_auto(root: str, session: dict) -> None:
key = _path_key(root)
if not key:
homeless.append(session)
return
bucket = by_auto_root.setdefault(key, {"root": root, "sessions": []})
bucket["sessions"].append(session)
for session in unowned:
root = _session_repo_root(session, resolve)
if root:
# A real git root uses the stricter repo policy. Do not reinterpret a
# filtered internal repo as a cwd-only project. A root that no longer
# exists is a stale persisted value (the repo was deleted after the
# session ran) and must not resurrect as a project.
if not _junk(root) and _exists(root):
_add_auto(root, session)
else:
homeless.append(session)
continue
cwd = (session.get("cwd") or "").strip()
if not cwd or _junk_cwd(cwd):
homeless.append(session)
continue
placement = _place(
cwd,
(session.get("git_branch") or "").strip(),
resolve,
(session.get("git_repo_root") or "").strip(),
)
# A placement that only echoes back the unresolvable cwd is the
# path-only heuristic guessing — it never found a repo. When that dir is
# also gone from disk (a deleted worktree whose name shares no prefix
# with its parent, a removed /tmp scratch dir), promoting it mints a
# phantom project that can never be opened and can only be dismissed by
# hand. The session goes to Home instead.
if placement and _exists(placement["repo_key"]):
_add_auto(placement["repo_key"], session)
else:
homeless.append(session)
seen: set[str] = set()
for bucket in by_auto_root.values():
auto_root = bucket["root"]
auto_sessions = bucket["sessions"]
auto_key = _path_key(auto_root)
repos = _build_repos(auto_sessions, resolve, hydrate)
repo_node = next(
(
repo
for repo in repos
if _path_key(repo.get("id") or repo.get("path") or "") == auto_key
),
None,
)
if repo_node is None:
homeless.extend(auto_sessions)
continue
seen.add(auto_key)
scoped_ids.extend(s["id"] for s in auto_sessions if s.get("id"))
result.append(
_project_node(
pid=auto_root,
label=base_name(auto_root) or auto_root,
path=auto_root,
repos=repos,
session_count=repo_node["sessionCount"],
last_active=_last_active(auto_sessions),
preview_sessions=_previews(auto_sessions),
sessions=auto_sessions,
is_auto=True,
)
)
# Tier 3: repos discovered from full history / disk scan with no loaded
# sessions, folded to their common root and not owned by an explicit project.
for repo in discovered_repos or []:
raw_root = (repo.get("root") or "").strip()
if not raw_root:
continue
info = resolve(raw_root) if resolve else None
root = (info or {}).get("repo_root") or raw_root
root_key = _path_key(root)
if root_key in seen or _junk(root) or _project_for_path(folder_index, root):
continue
seen.add(root_key)
label = repo.get("label") or base_name(root) or root
result.append(
_project_node(
pid=root,
label=label,
path=root,
repos=[{"id": root, "label": label, "path": root, "groups": [], "sessionCount": 0}],
session_count=int(repo.get("sessions") or 0),
last_active=float(repo.get("last_active") or 0),
preview_sessions=[],
is_auto=True,
)
)
# Auto projects are labelled by repo basename, which can collide (two "app"
# repos in different parents). Grow path prefixes so each is distinct.
# Explicit projects keep their user-chosen names untouched.
_disambiguate_labels([p for p in result if p.get("isAuto")])
# Tier 0: everything the tiers above could not place, so the grouped view
# loses no session. It has no folder, hence no repo/lane structure — the one
# synthetic lane exists purely to carry the rows in the tree's shape. Leads
# the list; omitted entirely when empty, so a project-less install is blank.
if homeless:
homeless.sort(key=_session_time, reverse=True)
scoped_ids.extend(s["id"] for s in homeless if s.get("id"))
lane = {
"id": NO_PROJECT_ID,
"label": NO_PROJECT_LABEL,
"path": None,
"isMain": False,
"isKanban": False,
"sessions": homeless if hydrate else [],
}
result.insert(
0,
_project_node(
pid=NO_PROJECT_ID,
label=NO_PROJECT_LABEL,
path=None,
repos=[
{
"id": NO_PROJECT_ID,
"label": NO_PROJECT_LABEL,
"path": None,
"groups": [lane],
"sessionCount": len(homeless),
}
],
session_count=len(homeless),
last_active=_last_active(homeless),
preview_sessions=_previews(homeless),
sessions=homeless,
is_no_project=True,
),
)
return {"projects": result, "scoped_session_ids": scoped_ids}
+49
View File
@@ -0,0 +1,49 @@
"""Rendering bridge — routes TUI content through Python-side renderers.
When agent.rich_output exists, its functions are used. When it doesn't,
everything returns None and the TUI falls back to its own markdown.tsx.
"""
from __future__ import annotations
def render_message(text: str, cols: int = 80) -> str | None:
try:
from agent.rich_output import format_response
except ImportError:
return None
try:
return format_response(text, cols=cols)
except TypeError:
return format_response(text)
except Exception:
return None
def render_diff(text: str, cols: int = 80) -> str | None:
try:
from agent.rich_output import render_diff as _rd
except ImportError:
return None
try:
return _rd(text, cols=cols)
except TypeError:
return _rd(text)
except Exception:
return None
def make_stream_renderer(cols: int = 80):
try:
from agent.rich_output import StreamingRenderer
except ImportError:
return None
try:
return StreamingRenderer(cols=cols)
except TypeError:
return StreamingRenderer()
except Exception:
return None
+18517
View File
File diff suppressed because it is too large Load Diff
+91
View File
@@ -0,0 +1,91 @@
"""Description-aware fuzzy scoring for slash-menu completions.
Ported from superagent-ai/grok-cli ``src/ui/slash-menu.ts`` (mirrored on the
TUI client in ``ui-tui/src/app/slash/fuzzyScore.ts``): candidates are scored
in tiers exact match on the command token (0), prefix (1), substring (2)
and the DESCRIPTION text is tokenized and matched at a +3 offset (exact word
3, word prefix 4, word substring 5). Typing ``/summary`` thus surfaces a
command whose description mentions summaries even though no command name
starts with it. Lower score wins; ``math.inf`` means no match.
"""
from __future__ import annotations
import math
import re
from typing import Callable
_TOKEN_SPLIT = re.compile(r"[^a-z0-9]+")
def tokenize_search_text(value: str) -> list[str]:
"""Lowercase ``value`` and return it alongside its alphanumeric words."""
normalized = value.lower()
return [normalized, *[t for t in _TOKEN_SPLIT.split(normalized) if t]]
def normalize_slash_search_query(query: str) -> str:
"""Trim, drop leading slashes, lowercase — ``/Model`` and ``model`` alike."""
return query.strip().lstrip("/").lower()
def _score_fields(fields: list[str], query: str, offset: int) -> float:
for field in fields:
if field == query or f"/{field}" == query:
return offset
for field in fields:
if field.startswith(query) or f"/{field}".startswith(query):
return offset + 1
for field in fields:
if query in field:
return offset + 2
return math.inf
def score_slash_completion_item(item: dict, query: str) -> float:
"""Score one completion item dict (``text`` + ``meta``) against ``query``.
``text`` is the replacement token (may carry a leading slash or trailing
space); ``meta`` is the human description. Lower is better; ``math.inf``
means no match at all.
"""
name = str(item.get("text", "")).strip().lstrip("/")
command_fields = tokenize_search_text(name)
description_fields = tokenize_search_text(str(item.get("meta", "")))
return min(
_score_fields(command_fields, query, 0),
_score_fields(description_fields, query, 3),
)
def fuzzy_rank_slash_items(
items: list[dict], catalog: list[dict], query: str
) -> tuple[list[dict], Callable[[dict], float]]:
"""Merge description/substring matches into ``items`` and sort by score.
``items`` are the completer's own (prefix-filtered) rows and keep their
identity; ``catalog`` is the full command/skill universe, from which any
entry the prefix filter missed but the fuzzy scorer matches is appended.
Returns the score-sorted rows (stable within a tier) plus a ``score_of``
lookup for downstream rankers to use as a leading sort key.
"""
seen = {str(item.get("text", "")).strip() for item in items}
merged = list(items)
for item in catalog:
if str(item.get("text", "")).strip() in seen:
continue
if not math.isinf(score_slash_completion_item(item, query)):
merged.append(item)
scores: dict[int, float] = {}
scored: list[tuple[float, int, dict]] = []
for index, item in enumerate(merged):
score = score_slash_completion_item(item, query)
if math.isinf(score):
continue
scores[id(item)] = score
scored.append((score, index, item))
scored.sort(key=lambda entry: (entry[0], entry[1]))
ranked = [item for _, _, item in scored]
return ranked, lambda item: scores.get(id(item), math.inf)
+196
View File
@@ -0,0 +1,196 @@
"""Persistent slash-command worker — one HermesCLI per TUI session.
Protocol: reads JSON lines from stdin {id, command}, writes {id, ok, output|error} to stdout.
"""
# Stop a ``utils/`` (or ``proxy/``, ``ui/``) package in the launch directory
# from shadowing Hermes's own top-level modules. This worker is spawned as
# ``-m tui_gateway.slash_worker`` and inherits the user's CWD, so the ``import
# cli`` below would otherwise resolve ``utils`` to a colliding local package
# and crash the child in a retry loop (issue #51286). ``hermes_bootstrap``
# lives at the repo root, so importing it is safe before the guard runs (its
# name won't collide with a user package), and it owns the canonical
# path-hardening logic shared with the other entry points — #51693 added the
# guard to ``entry.py``/``acp_adapter/entry.py`` but missed this child.
import hermes_bootstrap
hermes_bootstrap.harden_import_path()
import argparse
import contextlib
import io
import json
import logging
import os
import sys
import threading
import time
import cli as cli_mod
from cli import HermesCLI
from tui_gateway._stdin_recovery import handle_spurious_eof
from rich.console import Console
# Env-overridable so the integration test can drive sub-second timing.
def _env_float(name: str, default: float) -> float:
"""Parse a float env knob, falling back to ``default`` on absent/malformed
values. A bare ``float(os.environ.get(...))`` would raise ValueError at
import time on a typo (e.g. ``HERMES_SLASH_WATCHDOG_POLL_S=2s``) and kill
the worker before it can serve a single command."""
raw = os.environ.get(name)
if not raw:
return default
try:
return float(raw)
except (TypeError, ValueError):
return default
_WATCHDOG_POLL_S = max(0.05, _env_float("HERMES_SLASH_WATCHDOG_POLL_S", 2.0))
_ORPHAN_GRACE_S = max(0.0, _env_float("HERMES_SLASH_WATCHDOG_GRACE_S", 5.0))
_in_flight = threading.Event() # set while a command is executing
logger = logging.getLogger(__name__)
def _is_orphaned(original_ppid, getppid=os.getppid) -> bool:
"""Return whether this worker no longer has its original POSIX parent."""
return getppid() != original_ppid
def _prepare_slash_worker_runtime() -> None:
"""Start bounded MCP discovery before HermesCLI snapshots tools.
Each slash_worker child is its own process the parent ``hermes serve``
discovery thread does not populate this registry (issue #61891).
"""
import logging
from hermes_cli.mcp_startup import (
start_background_mcp_discovery,
wait_for_mcp_discovery,
)
logger = logging.getLogger(__name__)
start_background_mcp_discovery(
logger=logger,
thread_name="slash-worker-mcp-discovery",
)
wait_for_mcp_discovery()
def _start_parent_death_watchdog(original_ppid) -> None:
def _loop():
while not _is_orphaned(original_ppid):
time.sleep(_WATCHDOG_POLL_S)
deadline = time.monotonic() + _ORPHAN_GRACE_S
while _in_flight.is_set() and time.monotonic() < deadline:
time.sleep(0.05) # let an in-flight command finish/flush
os._exit(0)
threading.Thread(target=_loop, daemon=True).start()
def _run(cli: HermesCLI, command: str) -> str:
cmd = (command or "").strip()
if not cmd:
return ""
if not cmd.startswith("/"):
cmd = f"/{cmd}"
buf = io.StringIO()
# Rich Console captures its file handle at construction time, so
# contextlib.redirect_stdout won't affect it. Swap the console's
# underlying file to our buffer so self.console.print() is captured.
cli.console = Console(file=buf, force_terminal=True, width=120)
old = getattr(cli_mod, "_cprint", None)
if old is not None:
cli_mod._cprint = lambda text: print(text)
try:
with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf):
cli.process_command(cmd)
finally:
if old is not None:
cli_mod._cprint = old
# Desktop chat bubbles render plain text, not ANSI. A worker-routed command
# that emits Rich color (e.g. /journey building its own Console, which picks
# up truecolor from the gateway's inherited COLORTERM) would otherwise leak
# raw escapes; strip them at the single choke point. (The TUI opens /journey
# as an overlay, so it never travels this path.)
from tools.ansi_strip import strip_ansi
return strip_ansi(buf.getvalue().rstrip())
def main():
p = argparse.ArgumentParser(add_help=False)
p.add_argument("--session-key", required=True)
p.add_argument("--model", default="")
args = p.parse_args()
os.environ["HERMES_SESSION_KEY"] = args.session_key
os.environ["HERMES_INTERACTIVE"] = "1"
# Start before the (hundreds-of-ms) HermesCLI build — that window is itself
# an orphan risk if the gateway dies mid-spawn.
orig_ppid = os.getppid()
_start_parent_death_watchdog(orig_ppid)
_prepare_slash_worker_runtime()
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
cli = HermesCLI(model=args.model or None, compact=True, resume=args.session_key, verbose=False)
# Spurious stdin-EOF recovery (same O_NONBLOCK shared file-description
# issue as the gateway entry point — any child inheriting fd 0 can flip
# the flag and launder EAGAIN into an apparent EOF).
_sw_recovery_times: list[float] = []
def _sw_log(reason: str) -> None:
print(f"[slash-worker] {reason}", file=sys.stderr, flush=True)
while True:
raw = sys.stdin.readline()
if not raw:
if not handle_spurious_eof(_sw_recovery_times, _sw_log):
break
continue
line = raw.strip()
if not line:
continue
_in_flight.set()
rid = None
try:
req = json.loads(line)
rid = req.get("id")
out = _run(cli, req.get("command", ""))
sys.stdout.write(json.dumps({"id": rid, "ok": True, "output": out}) + "\n")
sys.stdout.flush()
except Exception as e:
sys.stdout.write(json.dumps({"id": rid, "ok": False, "error": str(e)}) + "\n")
sys.stdout.flush()
finally:
_in_flight.clear()
# Workers persist for the TUI session, so release allocator pages at
# the same command boundary as other long-lived gateway processes.
# trim_memory's shared cooldown coalesces this with nearby activity.
try:
from hermes_cli.mem_trim import trim_memory
trim_memory(reason="slash worker command completion")
except Exception as exc:
# debug, not warning — a persistent failure would repeat on
# every slash command forever.
logger.debug(
"slash worker memory trim failed: %s: %s",
type(exc).__name__,
exc,
)
if __name__ == "__main__":
main()
+231
View File
@@ -0,0 +1,231 @@
"""Synthetic GIL-heavy turn driver for the AC-4 isolation certify harness.
Mechanism B (the class ``docs/desktop/2026-07-04-dashboard-process-isolation-PRD.md``
targets) is interpreter-wide GIL starvation: concurrent heavy agent turns run
compute in threads of the SERVING process, and CPython's single GIL lets those
threads starve the event loop that flushes WebSocket frames for MINUTES. A
2026-07-04 ``sample`` showed the loop thread parked in ``take_gil`` while worker
threads burned the interpreter NOT blocked on I/O.
To certify the fix (AC-4) without spending real tokens on 6 concurrent 100K+
context model calls, the harness needs a turn driver that reproduces THAT
regime: sustained pure-Python CPU that holds the GIL for the turn's duration.
A network/sleep stub is WRONG here it would release the GIL during I/O and
never reproduce ``take_gil`` contention, so a dry-run green off it is a fake
green (the spec says so explicitly).
This module is a **test seam**: it is dead unless ``HERMES_ISO_CERTIFY_SYNTH_TURN``
is set. When armed, ``tui_gateway.server._make_agent`` returns a
:class:`SyntheticHeavyAgent` instead of a real ``AIAgent``. Because both the
in-process ``_pool`` path (isolation OFF) and the compute-host child path
(isolation ON) build their agent through ``_make_agent``, the SAME synthetic
turn exercises whichever dispatch path is under test the isolation boundary
is the only variable between an OFF run and an ON run.
The per-turn intensity (wall duration, CPU chunk size, streamed-delta cadence,
token accounting) is carried in the prompt text as a small JSON spec so the
harness has full control and the server seam stays dumb. Any prompt that is not
a JSON object falls back to env / built-in defaults.
"""
from __future__ import annotations
import json
import os
import threading
import time
from typing import Any, Callable, Optional
def synth_turn_armed() -> bool:
"""True when the synthetic-turn test seam is armed via env."""
return os.environ.get("HERMES_ISO_CERTIFY_SYNTH_TURN") == "1"
def _env_float(name: str, default: float) -> float:
try:
return float(os.environ.get(name, "") or default)
except (TypeError, ValueError):
return default
def _env_int(name: str, default: int) -> int:
try:
return int(os.environ.get(name, "") or default)
except (TypeError, ValueError):
return default
class SyntheticHeavyAgent:
"""An AIAgent-shaped object whose turn is a GIL-holding CPU burn.
Presents only the surface ``tui_gateway.server``'s turn path and status
helpers read: ``run_conversation``/``interrupt``/``clear_interrupt`` plus a
handful of ``model``/``provider``/``session_*`` attributes consumed by
``_get_usage`` and ``_session_info``. It never opens a socket or spawns a
subprocess, so the only work it does is the deterministic Python loop below
exactly the ``take_gil`` regime under test.
"""
def __init__(self, session_id: str, *, model: str = "synthetic-heavy") -> None:
self.session_id = session_id
self.model = model
self.provider = "synthetic"
self.api_mode = "chat_completions"
self.base_url = ""
self.api_key = ""
self.platform = ""
self.tools: list[Any] = []
self.reasoning_config: dict | None = None
self.service_tier: str | None = None
self.context_compressor = None
self._config_context_length = 200_000
self._cached_system_prompt = ""
# Cumulative session counters (read by _get_usage → status bar).
self.session_input_tokens = 0
self.session_output_tokens = 0
self.session_prompt_tokens = 0
self.session_completion_tokens = 0
self.session_reasoning_tokens = 0
self.session_total_tokens = 0
self.session_api_calls = 0
self.history: list[dict[str, str]] = []
self._interrupt = threading.Event()
# ── interrupt contract (mirrors AIAgent) ───────────────────────────
def clear_interrupt(self) -> None:
self._interrupt.clear()
def interrupt(self) -> None:
self._interrupt.set()
def _has_stream_consumers(self) -> bool: # defensive; not used by our loop
return True
def close(self) -> None:
"""No-op teardown (session lifecycle calls agent.close() on some paths)."""
self._interrupt.set()
# ── spec parsing ───────────────────────────────────────────────────
@staticmethod
def _parse_spec(message: Any) -> dict[str, Any]:
spec: dict[str, Any] = {}
if isinstance(message, str):
text = message.strip()
if text.startswith("{"):
try:
parsed = json.loads(text)
if isinstance(parsed, dict):
spec = parsed
except (ValueError, TypeError):
spec = {}
return {
# Primary control: wall-clock seconds of GIL-holding compute.
"duration_s": float(spec.get("duration_s", _env_float("HERMES_ISO_CERTIFY_DURATION_S", 8.0))),
# Pure-Python integer ops per interrupt-check chunk. Small enough
# that an interrupt is honored within a few ms; large enough that
# the loop stays hot on the GIL between checks.
"chunk": int(spec.get("chunk", _env_int("HERMES_ISO_CERTIFY_CHUNK", 20_000))),
# Streamed-delta cadence (seconds). Each delta is a loop wakeup that
# marshals a frame across the transport — the serving-path pressure.
"delta_interval_s": float(spec.get("delta_interval_s", _env_float("HERMES_ISO_CERTIFY_DELTA_S", 0.05))),
# Notional output tokens attributed per streamed delta (drives the
# 100K+-token "heavy turn" proxy in usage/metadata).
"tokens_per_delta": int(spec.get("tokens_per_delta", _env_int("HERMES_ISO_CERTIFY_TPD", 512))),
# Optional per-chunk sleep to model a lighter/mixed regime (0 = pure
# burn). --dry-run uses a short duration, NOT a sleep, so the smoke
# path still exercises the real dispatch seam.
"sleep_s": float(spec.get("sleep_s", 0.0)),
}
# ── the turn ───────────────────────────────────────────────────────
def run_conversation(
self,
message: Any,
*,
conversation_history: Optional[list[dict[str, str]]] = None,
stream_callback: Optional[Callable[[str], None]] = None,
task_id: Optional[str] = None,
**_kwargs: Any,
) -> dict[str, Any]:
spec = self._parse_spec(message)
duration = max(0.0, spec["duration_s"])
chunk = max(1, spec["chunk"])
interval = max(0.001, spec["delta_interval_s"])
tokens_per_delta = max(0, spec["tokens_per_delta"])
sleep_s = max(0.0, spec["sleep_s"])
base_history = list(conversation_history if conversation_history is not None else self.history)
start = time.monotonic()
last_delta = start
acc = 0
deltas = 0
interrupted = False
while True:
if self._interrupt.is_set():
interrupted = True
break
now = time.monotonic()
if now - start >= duration:
break
# GIL-holding pure-Python work. A tight integer loop runs one
# bytecode step per iteration and NEVER releases the GIL — this is
# the exact interpreter contention that starves the serving loop.
for _ in range(chunk):
acc = (acc * 1_000_003 + 12_345) & 0xFFFFFFFFFFFFFFFF
if sleep_s:
time.sleep(sleep_s)
if now - last_delta >= interval:
deltas += 1
self.session_output_tokens += tokens_per_delta
self.session_completion_tokens += tokens_per_delta
self.session_total_tokens += tokens_per_delta
if stream_callback is not None:
stream_callback(f"synthtok-{deltas:05d} ")
last_delta = now
self.session_api_calls += 1
# Fold the checksum into the reply so the loop is not dead-code-eliminated
# and the turn produces a deterministic, inspectable result.
final = (
f"[synthetic heavy turn] deltas={deltas} "
f"out_tokens={self.session_output_tokens} "
f"interrupted={interrupted} checksum={acc & 0xFFFF:04x}"
)
messages = [
*base_history,
{"role": "user", "content": str(message)[:200]},
{"role": "assistant", "content": final},
]
self.history = messages
return {
"final_response": final,
"messages": messages,
"interrupted": interrupted,
"error": None,
"last_reasoning": None,
}
def maybe_build_synthetic_agent(session_id: str, model_override: Any = None) -> SyntheticHeavyAgent | None:
"""Return a :class:`SyntheticHeavyAgent` when the seam is armed, else ``None``.
``model_override`` (dict or str) only influences the reported ``model`` label
so status frames look plausible; it never changes the compute.
"""
if not synth_turn_armed():
return None
model = "synthetic-heavy"
if isinstance(model_override, dict) and model_override.get("model"):
model = str(model_override["model"])
elif isinstance(model_override, str) and model_override:
model = model_override
return SyntheticHeavyAgent(session_id, model=model)
__all__ = [
"SyntheticHeavyAgent",
"maybe_build_synthetic_agent",
"synth_turn_armed",
]
+219
View File
@@ -0,0 +1,219 @@
"""Transport abstraction for the tui_gateway JSON-RPC server.
Historically the gateway wrote every JSON frame directly to real stdout. This
module decouples the I/O sink from the handler logic so the same dispatcher
can be driven over stdio (``tui_gateway.entry``) or WebSocket
(``tui_gateway.ws``) without duplicating code.
A :class:`Transport` is anything that can accept a JSON-serialisable dict and
forward it to its peer. The active transport for the current request is
tracked in a :class:`contextvars.ContextVar` so handlers including those
dispatched onto the worker pool route their writes to the right peer.
Backward compatibility
----------------------
``tui_gateway.server.write_json`` still works without any transport bound.
When nothing is on the contextvar and no session-level transport is found,
it falls back to the module-level :class:`StdioTransport`, which wraps the
original ``_real_stdout`` + ``_stdout_lock`` pair. Tests that monkey-patch
``server._real_stdout`` continue to work because the stdio transport resolves
the stream lazily through a callback.
"""
from __future__ import annotations
import contextvars
import errno
import json
import logging
import os
import threading
from typing import Any, Callable, Optional, Protocol, runtime_checkable
# Errno values that mean "the peer is gone" rather than "the host has a
# real I/O problem". Anything outside this set re-raises so it surfaces
# in the crash log instead of looking like a clean disconnect.
_PEER_GONE_ERRNOS = frozenset({
errno.EPIPE, # write to closed pipe (POSIX)
errno.ECONNRESET, # peer reset the connection
errno.EBADF, # fd closed under us
errno.ESHUTDOWN, # transport endpoint shut down
getattr(errno, "WSAECONNRESET", -1), # win32 mapping (no-op on POSIX)
getattr(errno, "WSAESHUTDOWN", -1),
} - {-1})
logger = logging.getLogger(__name__)
# Optional knob: when true, StdioTransport does not call ``stream.flush``
# after writing. Use this on environments where a half-closed pipe (TUI
# Node parent quit while the gateway is still emitting events) makes
# flush block long enough to starve the rest of the worker pool.
#
# IMPORTANT: Python text stdout is fully buffered when attached to a
# pipe (the TUI case), so this knob ONLY makes sense when the gateway
# is launched with ``-u`` or ``PYTHONUNBUFFERED=1``. Without one of
# those, JSON-RPC frames will accumulate in the buffer and the TUI
# will hang waiting for ``gateway.ready``. Default stays off so the
# existing flush-after-write behaviour is unchanged.
_DISABLE_FLUSH = (os.environ.get("HERMES_TUI_GATEWAY_NO_FLUSH", "") or "").strip().lower() in {
"1",
"true",
"yes",
"on",
}
@runtime_checkable
class Transport(Protocol):
"""Minimal interface every transport implements."""
def write(self, obj: dict) -> bool:
"""Emit one JSON frame. Return ``False`` when the peer is gone."""
def close(self) -> None:
"""Release any resources owned by this transport."""
_current_transport: contextvars.ContextVar[Optional[Transport]] = (
contextvars.ContextVar(
"hermes_gateway_transport",
default=None,
)
)
def current_transport() -> Optional[Transport]:
"""Return the transport bound for the current request, if any."""
return _current_transport.get()
def bind_transport(transport: Optional[Transport]):
"""Bind *transport* for the current context. Returns a token for :func:`reset_transport`."""
return _current_transport.set(transport)
def reset_transport(token) -> None:
"""Restore the transport binding captured by :func:`bind_transport`."""
_current_transport.reset(token)
class StdioTransport:
"""Writes JSON frames to a stream (usually ``sys.stdout``).
The stream is resolved via a callable so runtime monkey-patches of the
underlying stream continue to work this preserves the behaviour the
existing test suite relies on (``monkeypatch.setattr(server, "_real_stdout", ...)``).
"""
__slots__ = ("_stream_getter", "_lock")
def __init__(self, stream_getter: Callable[[], Any], lock: threading.Lock) -> None:
self._stream_getter = stream_getter
self._lock = lock
def write(self, obj: dict) -> bool:
"""Return ``True`` on success, ``False`` ONLY when the peer is gone.
Returning ``False`` is the dispatcher's "broken stdout pipe" signal
``entry.py`` calls ``sys.exit(0)`` when ``write_json`` reports
``False``. So programming errors (non-JSON-safe payloads, encoding
misconfig, unexpected ValueErrors, host I/O bugs like ENOSPC) MUST
NOT return ``False``, otherwise a real bug looks like a clean
disconnect and is harder to diagnose. Those re-raise so the
existing crash-log infrastructure records the traceback.
Peer-gone branches:
* ``BrokenPipeError``
* ``ValueError("...closed file...")``
* ``OSError`` whose errno is in :data:`_PEER_GONE_ERRNOS`
(EPIPE / ECONNRESET / EBADF / ESHUTDOWN; plus WSA mappings
on Windows). Other OSError errnos (ENOSPC, EACCES, ...) are
real host problems and re-raise.
"""
# Serialization is OUTSIDE the lock so a large payload can't
# block other threads emitting their own frames. A non-JSON-safe
# payload is a programming error: re-raise so the crash log
# captures it instead of silently exiting via the False path.
line = json.dumps(obj, ensure_ascii=False) + "\n"
with self._lock:
stream = self._stream_getter()
try:
stream.write(line)
except BrokenPipeError:
return False
except ValueError as e:
# ValueError("I/O operation on closed file") is the
# ONLY ValueError that means "peer gone". Anything
# else — including UnicodeEncodeError, which is a
# ValueError subclass for misconfigured locales —
# is a real bug; re-raise so it surfaces in the crash log.
if isinstance(e, UnicodeEncodeError) or "closed file" not in str(e):
raise
return False
except OSError as e:
if e.errno not in _PEER_GONE_ERRNOS:
raise
logger.debug("StdioTransport write peer gone: %s", e)
return False
# A flush that *raises* with a peer-gone errno means the
# dispatcher should exit cleanly. A flush that *hangs* on
# a half-closed pipe holds the lock until it returns — see
# ``_DISABLE_FLUSH`` for the "skip flush entirely" escape
# hatch.
if not _DISABLE_FLUSH:
try:
stream.flush()
except BrokenPipeError:
return False
except ValueError as e:
if isinstance(e, UnicodeEncodeError) or "closed file" not in str(e):
raise
return False
except OSError as e:
if e.errno not in _PEER_GONE_ERRNOS:
raise
logger.debug("StdioTransport flush peer gone: %s", e)
return False
return True
def close(self) -> None:
return None
class TeeTransport:
"""Mirrors writes to one primary plus N best-effort secondaries.
The primary's return value (and exceptions) determine the result —
secondaries swallow failures so a wedged sidecar never stalls the
main IO path. Used by the PTY child so every dispatcher emit lands
on stdio (Ink) AND on a back-WS feeding the dashboard sidebar.
"""
__slots__ = ("_primary", "_secondaries")
def __init__(self, primary: "Transport", *secondaries: "Transport") -> None:
self._primary = primary
self._secondaries = secondaries
def write(self, obj: dict) -> bool:
# Primary first so a slow sidecar (WS publisher) never delays Ink/stdio.
ok = self._primary.write(obj)
for sec in self._secondaries:
try:
sec.write(obj)
except Exception:
pass
return ok
def close(self) -> None:
try:
self._primary.close()
finally:
for sec in self._secondaries:
try:
sec.close()
except Exception:
pass
+159
View File
@@ -0,0 +1,159 @@
"""Durable interrupted-turn markers for the desktop/TUI auto-continue path.
A running turn's progress lives only in process memory (the agent flushes to
SQLite at turn end, not mid-turn), so an app/backend/machine death mid-turn
leaves no durable trace of the interrupted prompt. This sidecar is that
trace: a marker is written when a turn starts running and cleared when the
turn concludes success, handled error, or interrupt all clear it, so only
a process death leaves one behind. ``session.resume`` reads the marker to
decide whether to auto-continue the interrupted turn (see
``_maybe_schedule_auto_continue`` in ``tui_gateway/server.py``).
Markers are stored per ``HERMES_HOME`` (callers pass the session's home so
profile sessions keep their state in their own profile directory) and the
file is bounded: writes prune entries older than ``_MAX_AGE_SECS`` and cap
the total count, so an unlucky streak of crashes can't grow it unboundedly.
Every function is best-effort by design marker bookkeeping must never
break a turn so I/O errors degrade to "no marker" instead of raising.
"""
from __future__ import annotations
import json
import logging
import os
import tempfile
import threading
import time
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
_MARKER_DIR = "desktop"
_MARKER_FILE = "interrupted_turns.json"
_MAX_AGE_SECS = 24 * 3600
_MAX_ENTRIES = 32
# Enough to re-submit any realistic prompt; guards the sidecar against a
# pathological multi-megabyte paste being journaled on every turn.
_MAX_PROMPT_CHARS = 64_000
_lock = threading.Lock()
def _marker_path(home: Path | str) -> Path:
return Path(home) / _MARKER_DIR / _MARKER_FILE
def _load(path: Path) -> dict[str, dict]:
try:
with open(path, encoding="utf-8") as f:
data = json.load(f)
except FileNotFoundError:
return {}
except Exception:
logger.debug("unreadable turn-marker file %s; starting fresh", path, exc_info=True)
return {}
if not isinstance(data, dict):
return {}
return {k: v for k, v in data.items() if isinstance(v, dict)}
def _prune(entries: dict[str, dict], now: float) -> dict[str, dict]:
fresh = {
key: entry
for key, entry in entries.items()
if now - float(entry.get("started_at") or 0) <= _MAX_AGE_SECS
}
if len(fresh) <= _MAX_ENTRIES:
return fresh
newest = sorted(
fresh.items(),
key=lambda item: float(item[1].get("started_at") or 0),
reverse=True,
)[:_MAX_ENTRIES]
return dict(newest)
def _store(path: Path, entries: dict[str, dict]) -> None:
if not entries:
path.unlink(missing_ok=True)
return
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".turn-marker-")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(entries, f)
os.replace(tmp, path)
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
def record_turn_start(
home: Path | str, session_key: str, prompt: str, *, attempts: int = 0
) -> None:
"""Persist the marker for a turn that is about to run.
``attempts`` counts how many auto-continues led to this run: 0 for a
user-initiated turn, N for the Nth automatic re-run the crash-loop
breaker reads it back on the next resume.
"""
if not session_key or not prompt:
return
now = time.time()
entry = {
"attempts": max(0, int(attempts)),
"prompt": prompt[:_MAX_PROMPT_CHARS],
"started_at": now,
}
try:
with _lock:
path = _marker_path(home)
entries = _prune(_load(path), now)
entries[session_key] = entry
_store(path, entries)
except Exception:
logger.debug("failed to record turn marker for %s", session_key, exc_info=True)
def clear_turn_marker(home: Path | str, session_key: str) -> None:
"""Remove the marker once its turn concluded (any outcome the client saw)."""
if not session_key:
return
try:
with _lock:
path = _marker_path(home)
entries = _load(path)
if session_key not in entries:
return
del entries[session_key]
_store(path, entries)
except Exception:
logger.debug("failed to clear turn marker for %s", session_key, exc_info=True)
def read_turn_marker(home: Path | str, session_key: str) -> dict[str, Any] | None:
"""The marker left by a turn that never concluded, or None."""
if not session_key:
return None
try:
with _lock:
entry = _load(_marker_path(home)).get(session_key)
except Exception:
return None
if not isinstance(entry, dict):
return None
prompt = str(entry.get("prompt") or "")
if not prompt.strip():
return None
try:
started_at = float(entry.get("started_at") or 0)
attempts = max(0, int(entry.get("attempts") or 0))
except (TypeError, ValueError):
return None
return {"attempts": attempts, "prompt": prompt, "started_at": started_at}
+647
View File
@@ -0,0 +1,647 @@
"""WebSocket transport for the tui_gateway JSON-RPC server.
Reuses :func:`tui_gateway.server.dispatch` verbatim so every RPC method, every
slash command, every approval/clarify/sudo flow, and every agent event flows
through the same handlers whether the client is Ink over stdio or an iOS /
web client over WebSocket.
Wire protocol
-------------
Identical to stdio: newline-delimited JSON-RPC in both directions. The server
emits a ``gateway.ready`` event immediately after connection accept, then
echoes responses/events for inbound requests. No framing differences.
Mounting
--------
from fastapi import WebSocket
from tui_gateway.ws import handle_ws
@app.websocket("/api/ws")
async def ws(ws: WebSocket):
await handle_ws(ws)
"""
from __future__ import annotations
import asyncio
import concurrent.futures
import json
import logging
import socket
import threading
import time
from typing import Any
from tui_gateway import server
from agent.message_sanitization import _sanitize_surrogates
from tui_gateway.event_replay import replay_epoch
_log = logging.getLogger(__name__)
# Scale-to-zero: tell the (separate) gateway process that a dashboard/desktop/
# TUI client is attached, via the mtime of a marker file it reads in its idle
# predicate. Clients ping every 15s; one mtime write per 5s per process is
# plenty and keeps the volume quiet. See gateway/scale_to_zero.py.
_DASHBOARD_CLIENT_TOUCH_MIN_INTERVAL_S = 5.0
_dashboard_client_touched_at = 0.0
_dashboard_client_touch_lock = threading.Lock()
def _note_dashboard_client_activity(*, force: bool = False) -> None:
"""Refresh the dashboard-client liveness marker (throttled, best-effort)."""
global _dashboard_client_touched_at
now = time.monotonic()
with _dashboard_client_touch_lock:
if not force and now - _dashboard_client_touched_at < _DASHBOARD_CLIENT_TOUCH_MIN_INTERVAL_S:
return
_dashboard_client_touched_at = now
try:
from gateway.scale_to_zero import touch_dashboard_client_heartbeat
touch_dashboard_client_heartbeat()
except Exception: # noqa: BLE001 - liveness garnish must never break the WS
_log.debug("dashboard client heartbeat touch failed", exc_info=True)
def _sanitize_ws_text(text: str) -> str:
"""Return *text* that can be UTF-8 encoded for a WebSocket frame.
``json.dumps(..., ensure_ascii=False)`` happily emits lone UTF-16
surrogates; Starlette's ``send_text`` then raises ``UnicodeEncodeError``,
which used to latch the whole connection closed (#97288). Same U+FFFD
replacement every other Hermes transport applies.
"""
return _sanitize_surrogates(text) if text else text
# Max seconds a pool-dispatched handler will block waiting for the event loop
# to flush a WS frame before we mark the transport dead. Protects handler
# threads from a wedged socket.
_WS_WRITE_TIMEOUT_S = 10.0
_WS_LOG_PAYLOAD_PREVIEW = 240
# Per-token streaming frames are coalesced: buffered and flushed as a batch on
# a short timer instead of waking the event loop once per token. A model reply
# emits hundreds of these in a burst, and each one is a loop wakeup competing
# with the agent turn for the GIL — coalescing cuts that churn (CF-2). The task
# that introduced this called them "agent.token"/"agent.thinking"; in this
# codebase the per-token frames are the ``*.delta`` stream events below. Keep
# this set to genuinely high-frequency, display-only events — anything a client
# must see promptly (tool/approval/status/completion frames) is non-streaming
# and flushes the buffer ahead of itself, so ordering is preserved.
_STREAMING_EVENT_TYPES = frozenset({
"message.delta",
"reasoning.delta",
"thinking.delta",
})
# Max time a streamed token waits in the buffer before flush (~30 fps). Short
# enough to stay imperceptible to the live token cadence.
_TOKEN_COALESCE_S = 0.033
# Keep starlette optional at import time; handle_ws uses the real class when
# it's available and falls back to a generic Exception sentinel otherwise.
try:
from starlette.websockets import WebSocketDisconnect as _WebSocketDisconnect
except ImportError: # pragma: no cover - starlette is a required install path
_WebSocketDisconnect = Exception # type: ignore[assignment]
class WSTransport:
"""Per-connection WS transport.
``write`` is safe to call from any thread *other than* the event loop
thread that owns the socket. Pool workers (the only real caller) run in
their own threads, so marshalling onto the loop via
:func:`asyncio.run_coroutine_threadsafe` + ``future.result()`` is correct
and deadlock-free there.
When called from the loop thread itself (e.g. by ``handle_ws`` for an
inline response) the same call would deadlock: we'd schedule work onto
the loop we're currently blocking. We detect that case and fire-and-
forget instead. Callers that need to know when the bytes are on the wire
should use :meth:`write_async` from the loop thread.
"""
def __init__(
self,
ws: Any,
loop: asyncio.AbstractEventLoop,
*,
peer: str = "unknown",
auth_identity: dict | None = None,
) -> None:
self._ws = ws
self._loop = loop
self._peer = peer
#: Server-verified identity carried from the WS-upgrade credential
#: (dashboard ticket / internal credential) — stamped by
#: ``hermes_cli.web_server._ws_auth_reason`` onto the WS object and
#: passed through ``handle_ws``. None for transports that
#: authenticated via the legacy token path or stdio. RPC params can
#: never populate this: it is the only identity authority for
#: browser-controller registration.
self.auth_identity = auth_identity
self._closed = False
self._last_inbound_at = time.monotonic()
# Token-coalescing buffer (CF-2). Streamed token frames land here and a
# short timer flushes the batch. The lock guards the buffer + the
# "armed" flag against the worker threads that call write(); the timer
# handle is only ever touched on the loop thread.
self._token_lock = threading.Lock()
self._pending_tokens: list[str] = []
self._token_flush_handle: asyncio.TimerHandle | None = None
self._token_flush_armed = False
# Buffer mutation is protected by the thread lock above; actual socket
# writes need an async boundary because several batches can be queued on
# the owning loop while it recovers from a stall.
self._send_lock = asyncio.Lock()
@property
def closed(self) -> bool:
return self._closed
@property
def last_inbound_at(self) -> float:
return self._last_inbound_at
def mark_inbound(self) -> None:
self._last_inbound_at = time.monotonic()
@staticmethod
def _is_streaming_frame(obj: dict) -> bool:
"""True for high-frequency per-token frames eligible for coalescing."""
params = obj.get("params") if isinstance(obj, dict) else None
if not isinstance(params, dict):
return False
return params.get("type") in _STREAMING_EVENT_TYPES
def write(self, obj: dict) -> bool:
if self._closed:
return False
line = json.dumps(obj, ensure_ascii=False)
try:
on_loop = asyncio.get_running_loop() is self._loop
except RuntimeError:
on_loop = False
# Coalesce streamed token frames: buffer this frame and arm a short
# flush timer instead of waking the loop right now. Cheap and
# non-blocking — the worker returns immediately. Ordering is preserved
# because every non-streaming frame (below) drains the buffer ahead of
# itself.
if self._is_streaming_frame(obj):
with self._token_lock:
self._pending_tokens.append(line)
if not self._token_flush_armed:
self._token_flush_armed = True
# call_soon_threadsafe arms the call_later timer on the loop
# thread and is safe to call from a worker or the loop.
self._loop.call_soon_threadsafe(self._arm_token_flush)
return not self._closed
# Non-streaming frame (RPC response, control frame, non-token event):
# append it behind any buffered tokens and flush the whole batch NOW so
# it can never overtake the tokens that preceded it. The send is
# scheduled INSIDE the lock so the on-the-wire order matches the buffer
# order even if the coalesce timer fires on the loop at the same moment.
from agent.async_utils import safe_schedule_threadsafe
with self._token_lock:
self._pending_tokens.append(line)
batch = self._pending_tokens
self._pending_tokens = []
if on_loop:
# Fire-and-forget — don't block the loop waiting on itself.
self._loop.create_task(self._safe_send_many(batch))
return True
fut = safe_schedule_threadsafe(
self._safe_send_many(batch), self._loop
)
if fut is None:
self._closed = True
return False
try:
fut.result(timeout=_WS_WRITE_TIMEOUT_S)
return not self._closed
except concurrent.futures.TimeoutError: # builtin TimeoutError on 3.11+
# The event loop is stalled (GIL-heavy agent turn, delegation
# running N children), NOT the socket dead. The send coroutine is
# already scheduled and will flush once the loop breathes — latching
# _closed here permanently silenced live windows after one slow
# write (the "subagent window shows zero streaming" bug). Unblock
# the worker thread and keep the transport alive; _safe_send_many
# latches on a real socket error when the frame actually fails.
_log.warning(
"ws write slow (loop stalled >%ss) peer=%s — frame left in flight",
_WS_WRITE_TIMEOUT_S, self._peer,
)
return not self._closed
except Exception as exc:
self._closed = True
_log.warning(
"ws write failed peer=%s error_type=%s error=%s",
self._peer, type(exc).__name__, exc,
)
return False
def _arm_token_flush(self) -> None:
"""Arm the coalesce timer. Runs on the loop thread (call_soon_threadsafe)."""
if self._closed:
return
self._token_flush_handle = self._loop.call_later(
_TOKEN_COALESCE_S, self._flush_tokens
)
def _flush_tokens(self) -> None:
"""Send buffered tokens as one batch. Runs on the loop thread (timer).
The send is scheduled under the lock so its wire order is fixed relative
to a concurrent non-streaming flush in :meth:`write`.
"""
with self._token_lock:
self._token_flush_handle = None
self._token_flush_armed = False
if not self._pending_tokens or self._closed:
self._pending_tokens = []
return
batch = self._pending_tokens
self._pending_tokens = []
self._loop.create_task(self._safe_send_many(batch))
async def write_async(self, obj: dict) -> bool:
"""Send from the owning event loop. Awaits until the frame is on the wire."""
if self._closed:
return False
# Flush any buffered streamed tokens ahead of this frame (RPC response /
# control frame) as ONE serialized batch. Sending them in two lock
# acquisitions would let a later batch slip between the pending tokens
# and the frame that drained them.
with self._token_lock:
batch = self._pending_tokens
self._pending_tokens = []
batch.append(json.dumps(obj, ensure_ascii=False))
await self._safe_send_many(batch)
return not self._closed
async def _safe_send_many(self, lines: list[str]) -> None:
"""Send one indivisible batch of pre-serialized frames in wire order."""
async with self._send_lock:
if self._closed:
return
for line in lines:
if self._closed:
return
payload = _sanitize_ws_text(line)
try:
await self._ws.send_text(payload)
except UnicodeEncodeError as exc:
# A single illegal UTF-8 frame (lone surrogate in a
# status/ready payload) must not tear down the socket.
# Fresh Desktop installs looped on this (#97288).
_log.warning(
"ws send skipped invalid utf-8 frame peer=%s error=%s",
self._peer, exc,
)
continue
except Exception as exc:
# Latch while still holding the writer lock so queued
# batches observe the failure before they touch the socket.
self._closed = True
_log.warning(
"ws send failed peer=%s error_type=%s error=%s",
self._peer, type(exc).__name__, exc,
)
return
def close(self) -> None:
self._closed = True
# Cancel any pending coalesce flush. close() runs on the loop thread
# (the handle_ws finally), so touching the TimerHandle here is safe.
handle = self._token_flush_handle
if handle is not None:
handle.cancel()
self._token_flush_handle = None
def _ws_peer_label(ws: Any) -> str:
"""Return ``host:port`` when available, else a stable placeholder."""
client = getattr(ws, "client", None)
if client is None:
return "unknown"
host = getattr(client, "host", None) or "unknown"
port = getattr(client, "port", None)
return f"{host}:{port}" if port is not None else host
def _disable_nagle(ws: Any) -> None:
"""Disable Nagle so streamed JSON-RPC frames go out individually.
Without it the kernel coalesces the small per-token frames, so a burst after
the model's think-pause lands on the client in one tick and no client-side
smoothing can recover the cadence. GUI/WS only; chat platforms don't hit
this path. Best-effort skip silently if the socket isn't reachable.
"""
try:
scope = getattr(ws, "scope", None) or {}
transport = (scope.get("extensions") or {}).get("transport") or getattr(ws, "transport", None)
sock = transport.get_extra_info("socket") if transport is not None else None
if sock is not None:
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
# Dead-peer detection: without keepalive a silently-dropped client
# (SSH tunnel reset, client sleep) leaves the TCP leg half-open
# forever, receive_text() blocks indefinitely, and the disconnect
# teardown (detach + orphan reap + resume replay) never runs.
sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
if hasattr(socket, "TCP_KEEPIDLE"): # Linux
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 30)
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 10)
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3)
elif hasattr(socket, "TCP_KEEPALIVE"): # macOS idle seconds
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPALIVE, 30)
except Exception as exc: # pragma: no cover - best-effort tuning
_log.debug("ws TCP_NODELAY skip: %s", exc)
async def handle_ws(
ws: Any,
*,
auth_identity: dict | None = None,
subprotocol: str | None = None,
) -> None:
"""Run one WebSocket session. Wire-compatible with ``tui_gateway.entry``.
*auth_identity* is the server-minted ``{user_id, provider}`` recorded at
WS-upgrade authentication (``hermes_cli.web_server._ws_auth_reason``); it
is stored on the transport as ``WSTransport.auth_identity`` and is the
only identity authority for browser-controller registration. Existing
callers (stdio-free harnesses, the embedded TUI child) omit it and get a
``None`` transport identity unchanged behaviour.
"""
peer = _ws_peer_label(ws)
transport: WSTransport | None = None
messages = 0
parse_errors = 0
dispatch_crashes = 0
send_failures = 0
disconnect_reason = "not_connected"
try:
if subprotocol:
await ws.accept(subprotocol=subprotocol)
else:
await ws.accept()
disconnect_reason = "connected"
# A client is attached from the moment the upgrade is accepted — mark it
# before the (possibly slow) ready/skin setup so scale-to-zero sees it.
_note_dashboard_client_activity(force=True)
# Push small streamed frames out immediately instead of letting Nagle
# batch them — keeps the live token cadence intact for GUI clients.
_disable_nagle(ws)
_log.info("ws accepted peer=%s", peer)
transport = WSTransport(
ws,
asyncio.get_running_loop(),
peer=peer,
auth_identity=auth_identity,
)
# resolve_skin() reads config + initializes the skin engine —
# synchronous I/O + CPU work that should not block the event loop
# during the cold-start window. Run it in the thread pool so the
# WS read loop stays free to drain the frontend's initial RPC
# burst (setup.status, session.list, ...) without a stall
# (#60800). The skin payload is small (a dict of strings/arrays),
# so the to_thread overhead is negligible.
skin_payload = await asyncio.to_thread(server.resolve_skin)
ready_ok = await transport.write_async(
{
"jsonrpc": "2.0",
"method": "event",
"params": {
"type": "gateway.ready",
# change_events: this backend broadcasts pet.changed /
# cron.changed / sessions.changed, so clients can demote
# their legacy polls to slow backstops.
"payload": {
"skin": skin_payload,
"change_events": True,
"heartbeat": True,
# Replay-contract process identity: lets reconnecting
# clients detect a backend restart and reset their
# per-session seq watermarks (see event_replay).
"replay_epoch": replay_epoch(),
},
},
}
)
if ready_ok:
# Live-apply skins Hermes activates mid-conversation.
server._ensure_skin_watcher()
# Track this peer for session-less global broadcasts (skin.changed
# from the background watcher) — write_json can't route those.
server.register_live_transport(transport)
# Cross-backend liveness (#94895): register a heartbeat row so
# the startup orphan sweep can distinguish "row owned by a live
# but idle backend" from "row truly orphaned". The stdio TUI's
# entry.main() does the same; idempotent + once-per-process so a
# stdio TUI that already started the refresher is a no-op here.
try:
server._start_backend_heartbeat_refresher()
except Exception:
_log.warning("backend heartbeat refresher start failed", exc_info=True)
# Same once-per-process startup pass for session rows orphaned by a
# previous gateway process (#65194): the desktop app and web dashboard
# reach the agent through this WS sidecar, not entry.main(). Idempotent
# + config-gated inside, so a stdio TUI that already scheduled is a
# no-op.
try:
server._schedule_startup_orphan_sweep()
except Exception:
_log.warning("startup orphan sweep scheduling failed", exc_info=True)
if not ready_ok:
disconnect_reason = "ready_send_failed"
send_failures += 1
_log.error("ws ready frame send failed peer=%s", peer)
return
while True:
try:
raw = await ws.receive_text()
_note_dashboard_client_activity()
except _WebSocketDisconnect as exc:
disconnect_reason = (
"client_disconnect("
f"code={getattr(exc, 'code', None)},"
f"reason={getattr(exc, 'reason', None)})"
)
break
except Exception:
disconnect_reason = "receive_failed"
_log.exception("ws receive failed peer=%s", peer)
break
line = raw.strip()
if not line:
continue
transport.mark_inbound()
messages += 1
try:
req = json.loads(line)
except json.JSONDecodeError as exc:
parse_errors += 1
_log.warning(
"ws parse error peer=%s index=%d error=%s payload=%r",
peer,
messages,
exc,
line[:_WS_LOG_PAYLOAD_PREVIEW],
)
ok = await transport.write_async(
{
"jsonrpc": "2.0",
"error": {"code": -32700, "message": "parse error"},
"id": None,
}
)
if not ok:
disconnect_reason = "send_failed_after_parse_error"
send_failures += 1
_log.warning("ws parse-error reply send failed peer=%s", peer)
break
continue
# dispatch() may schedule long handlers on the pool; it returns
# None in that case and the worker writes the response itself via
# the transport we pass in (a separate thread, so transport.write
# is the safe path there). For inline handlers it returns the
# response dict, which we write here from the loop.
req_id = req.get("id") if isinstance(req, dict) else None
req_method = req.get("method") if isinstance(req, dict) else None
if req_method == "gateway.ping":
ok = await transport.write_async(
{
"jsonrpc": "2.0",
"result": {"ok": True},
"id": req_id,
}
)
if not ok:
disconnect_reason = "send_failed_after_heartbeat"
send_failures += 1
_log.warning("ws heartbeat reply send failed peer=%s id=%s", peer, req_id)
break
continue
try:
resp = await asyncio.to_thread(server.dispatch, req, transport)
except Exception:
dispatch_crashes += 1
_log.exception(
"ws dispatch crash peer=%s id=%s method=%s",
peer,
req_id,
req_method,
)
ok = await transport.write_async(
{
"jsonrpc": "2.0",
"error": {"code": -32603, "message": "internal error"},
"id": req_id if req_id is not None else None,
}
)
if not ok:
disconnect_reason = "send_failed_after_dispatch_crash"
send_failures += 1
_log.warning(
"ws dispatch-crash reply send failed peer=%s id=%s method=%s",
peer,
req_id,
req_method,
)
break
continue
if resp is not None and not await transport.write_async(resp):
disconnect_reason = "send_failed_after_response"
send_failures += 1
_log.warning(
"ws response send failed peer=%s id=%s method=%s",
peer,
req_id,
req_method,
)
break
finally:
reaped_sessions = 0
detached_sessions = 0
if transport is not None:
server.unregister_live_transport(transport)
# Owner-safely park browser controllers this transport registered.
# A reconnect with the same stable identity may deliver a terminal
# result for work already in flight; no new dispatch is admitted
# while the controller is offline.
#
# Offloaded via to_thread: disconnect acquires the controller's
# send_lock, which a worker-thread dispatch may hold while blocking
# on THIS loop to transmit its frame (run_coroutine_threadsafe +
# result(timeout=10)). Acquiring it synchronously here would park
# the whole event loop behind that 10s send bridge.
try:
from gateway.browser_control_broker import (
get_browser_control_broker,
)
await asyncio.to_thread(
get_browser_control_broker().disconnect_owner, transport
)
except Exception:
_log.exception("ws browser-controller disconnect failed peer=%s", peer)
transport.close()
try:
await asyncio.to_thread(server._release_wake_for_transport, transport)
except Exception:
_log.exception("ws wake-word teardown failed peer=%s", peer)
# Reap sessions this transport owned (close_on_disconnect sidecar
# sessions) or detach the rest to the drop sentinel so later emits
# don't crash into a closed socket or fall through to desktop stdout
# logs. Detached sessions are handed to the grace-windowed WS-orphan
# reaper inside _close_sessions_for_transport (a quick reconnect /
# session.resume cancels it). This is the single WS-disconnect
# teardown path.
#
# Offloaded: _close_session_by_id does a blocking worker.close()
# (terminate + waits) plus a synchronous DB write — inline that
# would freeze the uvicorn event loop for every other live
# connection.
try:
reaped_sessions, detached_sessions = await asyncio.to_thread(
server._close_sessions_for_transport,
transport,
end_reason="ws_disconnect",
)
except Exception:
_log.exception("ws transport teardown failed peer=%s", peer)
try:
await ws.close()
except Exception as exc:
_log.debug("ws close failed peer=%s error=%s", peer, exc)
_log.info(
"ws closed peer=%s reason=%s messages=%d parse_errors=%d "
"dispatch_crashes=%d send_failures=%d reaped_sessions=%d detached_sessions=%d",
peer,
disconnect_reason,
messages,
parse_errors,
dispatch_crashes,
send_failures,
reaped_sessions,
detached_sessions,
)