refactor(tui_gateway): table-drive billing error kinds, share pet selection, compact group H docstrings

This commit is contained in:
Teknium
2026-09-02 23:25:59 -07:00
parent 2b538e535a
commit 052a5dec66
11 changed files with 207 additions and 273 deletions

View File

@@ -23,28 +23,20 @@ def _serialize_billing_error(exc) -> dict:
"""Map a BillingError into the result.error envelope the TUI branches on."""
from hermes_cli.nous_billing import (
BillingRemoteSpendingRevoked, BillingScopeRequired, BillingSessionRevoked, BillingTransient)
kind = "error"
if isinstance(exc, BillingRemoteSpendingRevoked):
kind = "remote_spending_revoked"
elif isinstance(exc, BillingSessionRevoked):
kind = "session_revoked"
elif isinstance(exc, BillingScopeRequired):
kind = "insufficient_scope"
elif isinstance(exc, BillingTransient):
kind = str(exc.error) if getattr(exc, "error", None) else "rate_limited"
elif getattr(exc, "error", None):
kind = str(exc.error)
typed = {BillingRemoteSpendingRevoked: "remote_spending_revoked",
BillingSessionRevoked: "session_revoked", BillingScopeRequired: "insufficient_scope"}
kind = next((k for cls, k in typed.items() if isinstance(exc, cls)), None)
if kind is None:
error = getattr(exc, "error", None)
fallback = "rate_limited" if isinstance(exc, BillingTransient) else "error"
kind = str(error) if error else fallback
return {
"ok": False,
"error": kind,
"message": str(exc),
"ok": False, "error": kind, "message": str(exc),
"portal_url": getattr(exc, "portal_url", None),
"retry_after": getattr(exc, "retry_after", None),
"payload": getattr(exc, "payload", {}) or {},
# Remote-Spending contract extras (threaded so the TUI can render
# actor-aware copy + route recovery without re-parsing the message).
"actor": getattr(exc, "actor", None),
"code": getattr(exc, "code", None),
# Remote-Spending contract extras: the TUI renders actor-aware copy + recovery from these.
"actor": getattr(exc, "actor", None), "code": getattr(exc, "code", None),
"recovery": getattr(exc, "recovery", None)}
@@ -55,7 +47,8 @@ def _serialize_payment_method(pm) -> dict | None:
return None
if pm.kind == "card":
return {
"kind": "card", "brand": pm.brand, "last4": pm.last4, "wallet": pm.wallet, "resolved_via": pm.resolved_via
"kind": "card", "brand": pm.brand, "last4": pm.last4, "wallet": pm.wallet,
"resolved_via": pm.resolved_via,
}
if pm.kind == "link":
return {"kind": "link", "email": pm.email, "resolved_via": pm.resolved_via}
@@ -87,13 +80,9 @@ def _serialize_billing_state(state) -> dict:
card = None
if state.card is not None:
card = {
"brand": state.card.brand,
"last4": state.card.last4,
"masked": state.card.masked,
# None/False on older NAS payloads; resolved_via is the resolution
# rung for rung-gated surfaces (/subscription confirm).
"display": state.card.display,
"resolved_via": state.card.resolved_via}
"brand": state.card.brand, "last4": state.card.last4, "masked": state.card.masked,
# None/False on older NAS payloads; resolved_via = rung for rung-gated surfaces.
"display": state.card.display, "resolved_via": state.card.resolved_via}
monthly_cap = None
if state.monthly_cap is not None:
mc = state.monthly_cap
@@ -103,34 +92,25 @@ def _serialize_billing_state(state) -> dict:
"spent_display": format_money(mc.spent_this_month_usd),
"is_default_ceiling": mc.is_default_ceiling}
return {
"ok": True,
"logged_in": state.logged_in,
"org_name": state.org_name,
"org_slug": state.org_slug,
"role": state.role,
"is_admin": state.is_admin,
"can_change_plan": state.can_change_plan,
"can_charge": state.can_charge,
"ok": True, "logged_in": state.logged_in, "org_name": state.org_name,
"org_slug": state.org_slug, "role": state.role, "is_admin": state.is_admin,
"can_change_plan": state.can_change_plan, "can_charge": state.can_charge,
"balance_usd": _wire_str(state.balance_usd),
"balance_display": format_money(state.balance_usd),
"cli_billing_enabled": state.cli_billing_enabled,
"charge_presets": [_wire_str(p) for p in state.charge_presets],
"charge_presets_display": [format_money(p) for p in state.charge_presets],
"min_usd": _wire_str(state.min_usd),
"max_usd": _wire_str(state.max_usd),
"card": card,
"payment_method": _serialize_payment_method(state.payment_method),
"min_usd": _wire_str(state.min_usd), "max_usd": _wire_str(state.max_usd),
"card": card, "payment_method": _serialize_payment_method(state.payment_method),
"monthly_cap": monthly_cap,
"auto_reload": _serialize_auto_reload(state.auto_reload, format_money),
"portal_url": state.portal_url,
"error": state.error,
# Shared two-bar dollar usage model so /topup matches /usage and
# /subscription from one fetch; fail-open.
"portal_url": state.portal_url, "error": state.error,
# Shared two-bar dollar usage model so /topup matches /usage and /subscription; fail-open.
"usage": _usage_payload(state)}
def _usage_payload(state) -> dict:
"""Shared usage model for the /topup + /subscription bars: fetched only when logged in, fail-open."""
"""Shared usage model for the /topup + /subscription bars: only when logged in, fail-open."""
if not getattr(state, "logged_in", False):
return {"available": False}
try:
@@ -152,7 +132,7 @@ def _serialize_usage_bar(bar) -> Optional[dict]:
def _serialize_usage_model(model) -> dict:
"""Serialize a UsageModel for the wire — the shared two-bar dollar view (fail-open {ok, available:false})."""
"""Serialize a UsageModel for the wire — the shared two-bar dollar view (fail-open)."""
from agent.billing_usage import _fmt_usd, format_renews
if model is None or not getattr(model, "available", False):
return {"ok": True, "available": False}
@@ -160,17 +140,13 @@ def _serialize_usage_model(model) -> dict:
def _usd(value):
return None if value is None else _fmt_usd(value)
return {
"ok": True,
"available": True,
"status": model.status,
"plan_name": model.plan_name,
"ok": True, "available": True, "status": model.status, "plan_name": model.plan_name,
"renews_at": model.renews_at,
"renews_display": getattr(model, "renews_display", None) or format_renews(model.renews_at),
"subscription_remaining_display": _usd(model.subscription_remaining_usd),
"topup_remaining_display": _usd(model.topup_remaining_usd),
"total_spendable_display": _usd(model.total_spendable_usd),
"has_topup": model.has_topup,
"plan_bar": _serialize_usage_bar(model.plan_bar),
"has_topup": model.has_topup, "plan_bar": _serialize_usage_bar(model.plan_bar),
"topup_bar": _serialize_usage_bar(model.topup_bar)}
@@ -185,54 +161,39 @@ def _serialize_subscription_state(state) -> dict:
current = {
"tier_id": c.tier_id, "tier_name": c.tier_name,
"monthly_credits": _wire_str(c.monthly_credits),
"credits_remaining": _wire_str(c.credits_remaining),
"cycle_ends_at": c.cycle_ends_at,
"credits_remaining": _wire_str(c.credits_remaining), "cycle_ends_at": c.cycle_ends_at,
"pending_downgrade_tier_name": c.pending_downgrade_tier_name,
"pending_downgrade_at": c.pending_downgrade_at,
"pending_downgrade_display": format_renews(c.pending_downgrade_at),
"cancel_at_period_end": c.cancel_at_period_end,
"cancellation_effective_at": c.cancellation_effective_at,
"cancellation_effective_display": format_renews(c.cancellation_effective_at)}
# Selectable catalog for the in-terminal tier picker; price is pre-formatted
# ($X / $X.YY) so the TUI renders it directly.
# Selectable catalog for the in-terminal tier picker; price pre-formatted ($X / $X.YY).
tiers = [
{
"tier_id": t.tier_id, "name": t.name, "tier_order": t.tier_order,
"dollars_per_month_display": format_money(t.dollars_per_month),
"monthly_credits": _wire_str(t.monthly_credits), "is_current": t.is_current,
"is_enabled": t.is_enabled}
{"tier_id": t.tier_id, "name": t.name, "tier_order": t.tier_order,
"dollars_per_month_display": format_money(t.dollars_per_month),
"monthly_credits": _wire_str(t.monthly_credits), "is_current": t.is_current,
"is_enabled": t.is_enabled}
for t in state.tiers]
return {
"ok": True,
"logged_in": state.logged_in,
"is_admin": state.is_admin,
"can_change_plan": state.can_change_plan,
"org_name": state.org_name,
"ok": True, "logged_in": state.logged_in, "is_admin": state.is_admin,
"can_change_plan": state.can_change_plan, "org_name": state.org_name,
"org_id": state.org_id,
"role": state.role,
"context": state.context,
"current": current,
"tiers": tiers,
"portal_url": state.portal_url,
"error": state.error,
# Shared two-bar usage model (account-info is the only source with
# top-up dollars); fail-open → {available:false}; lazy when logged out.
"role": state.role, "context": state.context, "current": current, "tiers": tiers,
"portal_url": state.portal_url, "error": state.error,
# Shared two-bar usage model (account-info is the only source with top-up dollars);
# fail-open → {available:false}; lazy when logged out.
"usage": _usage_payload(state)}
def _serialize_subscription_preview(p) -> dict:
"""Serialize a SubscriptionChangePreview for the wire (Decimal → string)."""
return {
"ok": True,
"effect": p.effect,
"reason": p.reason,
"current_tier_id": p.current_tier_id,
"current_tier_name": p.current_tier_name,
"target_tier_id": p.target_tier_id,
"target_tier_name": p.target_tier_name,
"ok": True, "effect": p.effect, "reason": p.reason,
"current_tier_id": p.current_tier_id, "current_tier_name": p.current_tier_name,
"target_tier_id": p.target_tier_id, "target_tier_name": p.target_tier_name,
"monthly_credits_delta": _wire_str(p.monthly_credits_delta),
"amount_due_now_cents": p.amount_due_now_cents,
"effective_at": p.effective_at}
"amount_due_now_cents": p.amount_due_now_cents, "effective_at": p.effective_at}
def register(server) -> None:

View File

@@ -18,22 +18,18 @@ def resolve_skin() -> dict:
init_skin_from_config(_load_cfg())
skin = get_active_skin()
return {
"name": skin.name,
"colors": skin.colors,
"name": skin.name, "colors": skin.colors,
# Paired palettes: the TUI prefers the block matching terminal polarity.
"light_colors": skin.light_colors,
"dark_colors": skin.dark_colors,
"branding": skin.branding,
"banner_logo": skin.banner_logo,
"banner_hero": skin.banner_hero,
"tool_prefix": skin.tool_prefix,
"light_colors": skin.light_colors, "dark_colors": skin.dark_colors,
"branding": skin.branding, "banner_logo": skin.banner_logo,
"banner_hero": skin.banner_hero, "tool_prefix": skin.tool_prefix,
"help_header": (skin.branding or {}).get("help_header", "")}
except Exception:
return {}
# (name, user-file mtime) of the last skin broadcast: ``skin.changed`` fires on a
# name switch OR a live color edit of the active skin, and nothing else.
# (name, user-file mtime) of the last skin broadcast: ``skin.changed`` fires on a name
# switch OR a live color edit of the active skin, and nothing else.
_last_skin_sig: tuple[str, float | None] | None = None
@@ -52,7 +48,7 @@ def _watcher_mtime_ns(path: Path):
def _newest_mtime_ns(paths) -> int | None:
"""Max ``st_mtime_ns`` across ``paths`` (unstat-able ones ignored); None when none could be stat'ed."""
"""Max ``st_mtime_ns`` across ``paths`` (unstat-able ignored); None when none stat'ed."""
mtimes = (_watcher_mtime_ns(p) for p in paths)
return max((m for m in mtimes if m is not None), default=None)
@@ -90,6 +86,12 @@ def _broadcast_skin_if_changed() -> None:
_broadcast_global_event("skin.changed", resolve_skin())
def _active_pet():
"""(pet, scale) when an enabled pet with an existing sheet is selected, else None."""
enabled, pet, scale = _pet_active_selection()
return (pet, scale) if enabled and pet is not None and pet.exists else None
def _pet_sig() -> tuple:
"""(slug, spritesheet revision, scale) of the active pet — ("off",) when none."""
display = _load_cfg().get("display") or {}
@@ -97,9 +99,10 @@ def _pet_sig() -> tuple:
if not pet_cfg or not is_truthy_value(pet_cfg.get("enabled"), default=False):
return ("off",)
try:
enabled, pet, scale = _pet_active_selection()
if not enabled or pet is None or not pet.exists:
active = _active_pet()
if not active:
return ("off",)
pet, scale = active
return (pet.slug, _pet_sheet_revision(pet.spritesheet), scale)
except Exception: # noqa: BLE001 - cosmetic, never break the watcher
return ("off",)
@@ -108,14 +111,12 @@ def _pet_sig() -> tuple:
def _pet_changed_payload() -> dict:
"""``pet.info.meta``-shaped payload so the renderer can decide whether to refetch sprites."""
try:
enabled, pet, scale = _pet_active_selection()
if not enabled or pet is None or not pet.exists:
active = _active_pet()
if not active:
return {"enabled": False}
pet, scale = active
return {
"enabled": True,
"slug": pet.slug,
"displayName": pet.display_name,
"scale": scale,
"enabled": True, "slug": pet.slug, "displayName": pet.display_name, "scale": scale,
"spritesheetRevision": _pet_sheet_revision(pet.spritesheet)}
except Exception: # noqa: BLE001 - cosmetic, never break the watcher
return {"enabled": False}
@@ -131,7 +132,9 @@ def _sessions_sig():
cron runs (which never touch this gateway's transports) all move. Served sibling
profile homes are probed too, else a routed profile's Bot Chat never refreshes."""
return _newest_mtime_ns(
root / name for root in (_watcher_home(), *_served_profile_homes) for name in ("state.db", "state.db-wal")
root / name
for root in (_watcher_home(), *_served_profile_homes)
for name in ("state.db", "state.db-wal")
)

View File

@@ -1,13 +1,11 @@
"""Best-effort WebSocket publisher transport for the PTY-side gateway.
The dashboard's `/api/pty` spawns `hermes --tui`, which spawns its own
``tui_gateway.entry`` — three processes removed from the dashboard server. To surface
tool/reasoning/status events in the sidebar (`/api/events`), that gateway opens a
back-WS to the dashboard at startup and mirrors every emit through this transport as
newline-framed JSON (no JSON-RPC envelope; ``/api/pub`` rebroadcasts bytes verbatim).
Failure mode: silent. The agent loop must never block on the sidecar — ``send`` runs on
a daemon thread, ``write`` returns after enqueueing (drop when full), a dead WS
short-circuits all subsequent writes.
The dashboard's `/api/pty` spawns `hermes --tui`, which spawns ``tui_gateway.entry`` — three
processes from the dashboard server. To surface events in the sidebar (`/api/events`), that gateway
opens a back-WS to the dashboard at startup and mirrors every emit through this transport as
newline-framed JSON (no JSON-RPC envelope; ``/api/pub`` rebroadcasts bytes verbatim). Failure mode:
silent — the agent loop must never block on the sidecar: ``send`` runs on a daemon thread, ``write``
returns after enqueueing (drop when full), a dead WS short-circuits all later writes.
"""
from __future__ import annotations

View File

@@ -1,13 +1,11 @@
"""Per-session event sequencing + bounded replay for WS reconnects.
Every event frame through :func:`server.write_json` (hence ``_emit``) is stamped with
a per-session monotonic ``seq`` and appended to a small ring per session id; a
reconnecting client calls ``session.events.since`` with its last seen seq and gets
everything newer, then live events resume. Invariants: stdio TUI unaffected (``seq``
only on event frames; Ink ignores unknown keys); one module lock guards counters +
buffers, and write_json already serializes per-transport writes so stamping cannot
reorder frames; memory bound = _REPLAY_BUFFER_MAX events x _REPLAY_SESSIONS_MAX
sessions, oldest session evicted FIFO.
Every event frame through :func:`server.write_json` (hence ``_emit``) gets a per-session monotonic
``seq`` and lands in a small ring per session; a reconnecting client calls ``session.events.since``
with its last seen seq and gets everything newer. Invariants: stdio TUI unaffected (``seq`` only on
event frames; Ink ignores unknown keys); one lock guards counters + buffers, and write_json already
serializes per-transport writes so stamping cannot reorder frames; memory bound =
_REPLAY_BUFFER_MAX events x _REPLAY_SESSIONS_MAX sessions, oldest session evicted FIFO.
"""
from __future__ import annotations
@@ -63,7 +61,7 @@ def _stamp_event(obj: dict) -> None:
def events_since(sid: str, last_seen: int) -> list[dict]:
"""Recorded EVENT OBJECTS (each frame's ``params`` dict) with seq > last_seen for *sid*, in order.
"""Recorded EVENT OBJECTS (each frame's ``params`` dict) with seq > last_seen for *sid*.
Returning the full JSON-RPC envelope would make every replayed event fail the
client's ``event.type`` gate and be silently dropped.

View File

@@ -1,12 +1,10 @@
"""Git working-tree probing for the gateway: run git, resolve repo roots, fold linked
worktrees under their common root.
"""Git working-tree probing for the gateway: run git, resolve repo roots, fold linked worktrees.
Probing runs where the gateway runs, so it covers local and remote backends. Roots go
through a thread-safe single-flight cache: concurrent identical probes from worker
threads share one ``git`` spawn. Positives are cached for the process lifetime;
negatives (not a repo / deleted dir) only for ``_NEG_TTL`` — ``build_tree`` resolves a
cwd once *per session*, so hundreds of non-git cwds would otherwise re-spawn ``git`` on
every sidebar open, while the TTL keeps a not-yet-``git init``-ed folder re-probable.
Probing runs where the gateway runs (covers remote backends). Roots go through a thread-safe
single-flight cache so concurrent identical probes share one ``git`` spawn. Positives are cached
for the process lifetime; negatives (not a repo / deleted dir) only for ``_NEG_TTL`` —
``build_tree`` resolves a cwd once *per session*, so hundreds of non-git cwds would otherwise
re-spawn ``git`` on every sidebar open, while the TTL keeps a fresh ``git init`` re-probable.
"""
from __future__ import annotations
@@ -29,12 +27,11 @@ _NEG_TTL = 30.0
def run_git(cwd: str, *args: str) -> str:
"""``git -C <cwd> <args>`` → stripped stdout, or ``""`` on any failure.
``bounded_git_probe`` bounds post-kill cleanup on Windows — a plain
``subprocess.run(timeout=...)`` deadlocked Desktop readiness when a killed git left
a suspended descendant holding the pipe handles.
``bounded_git_probe`` bounds post-kill cleanup on Windows — a plain ``subprocess.run(timeout)``
deadlocked Desktop readiness when a killed git left a suspended descendant holding the pipes.
"""
# `git -C` on a missing dir can only fail, at the price of a fork; deleted
# worktrees dominate a long session history's cwds, so the stat pays off.
# `git -C` on a missing dir can only fail, at the price of a fork; deleted worktrees
# dominate a long session history's cwds, so the stat pays off.
if not cwd or not os.path.isdir(cwd):
return ""
return bounded_git_probe(["git", "-C", cwd, *args], timeout=_GIT_TIMEOUT)
@@ -112,13 +109,12 @@ 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; the parent of the shared
``--git-common-dir`` is the one true root (fallback: the toplevel root). Normalized
to git's forward-slash spelling so it compares equal to :func:`repo_root` — with
native ``\\`` on Windows the main checkout was misread as a linked worktree and the
desktop sidebar rendered it twice.
``--git-common-dir`` is the one true root (fallback: the toplevel root). Normalized to git's
forward-slash spelling so it compares equal to :func:`repo_root` — with native ``\\`` on
Windows the main checkout was misread as a linked worktree and the sidebar rendered it twice.
"""
# Not a repo: nothing to fold. Checking the (warmed, negative-cached) toplevel
# first spares every non-repo cwd a second `git` spawn the parallel warm can't absorb.
# Not a repo: nothing to fold. Checking the (warmed, negative-cached) toplevel first spares
# every non-repo cwd a second `git` spawn the parallel warm can't absorb.
if not cwd or not repo_root(cwd):
return ""

View File

@@ -1,12 +1,11 @@
"""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 logs a full traceback for every pending
``_call_connection_lost`` callback — ``ConnectionResetError`` (WinError 10054),
``ConnectionAbortedError`` (10053), or ``BrokenPipeError`` on POSIX; one disconnect can
emit 50+ identical tracebacks. They are the expected side effect of the peer hanging up
before our writes drained, so the loop exception handler installed here collapses exactly
that class to one debug line and forwards everything else to the previous handler.
When the Desktop client forcibly closes its WebSocket while the gateway still has pending socket
operations, asyncio logs a traceback per pending ``_call_connection_lost`` callback —
``ConnectionResetError`` (WinError 10054), ``ConnectionAbortedError`` (10053) or ``BrokenPipeError``
(POSIX); one disconnect can emit 50+. They are the expected side effect of the peer hanging up
before our writes drained, so the handler here collapses exactly that class to one debug line and
forwards everything else to the previous handler unchanged.
"""
from __future__ import annotations

View File

@@ -124,7 +124,7 @@ def bind_module(module_globals: dict, server, *, skip=()) -> None:
if obj.__module__ == mod_name:
obj = rebind(obj, g, seen)
elif name == obj.__name__:
continue # plain import; server has its own (an ``_alias = other.fn`` publishes as-is)
continue # plain import; server has its own (``_alias = other.fn`` publishes as-is)
elif isinstance(obj, (dict, tuple, list)) and _has_own_fn(obj):
obj = module_globals[name] = _rebind_in(obj) # keep the split module's own view in sync
elif isinstance(obj, type):

View File

@@ -1,13 +1,11 @@
"""Bot-relay JSON-RPC handlers — the gateway side of cross-connection A2A.
Connections ARE the peer set: the Desktop owns every gateway socket (local, remote,
SSH, Cloud, docker) and relays between them through these four doors on EACH gateway:
``roster.sync`` (push the union roster of OTHER connections' agents so ``message_agent``
resolves them), ``outbox.drain`` (collect envelopes queued here for other connections),
``deliver`` (run a one-turn Bot Chat delivery on the TARGET gateway, return the reply),
``reply`` (write the reply/error back on the SENDER gateway for the waiter to pick up).
Storage/validation plumbing lives in ``tools/bot_relay.py``. Handlers are rebound onto
server.py's globals (method_ctx.py) and reference ``_ok``/``_err`` etc. bare.
Connections ARE the peer set: the Desktop owns every gateway socket and relays between them via
four doors on EACH gateway: ``roster.sync`` (push OTHER connections' agents so ``message_agent``
resolves them), ``outbox.drain`` (collect envelopes queued here for other connections), ``deliver``
(one-turn Bot Chat delivery on the TARGET gateway, returns the reply), ``reply`` (write the
reply/error back on the SENDER gateway for its waiter). Plumbing: ``tools/bot_relay.py``.
Handlers are rebound onto server.py's globals (method_ctx.py) and reference ``_ok``/``_err`` bare.
"""
import os
@@ -36,10 +34,10 @@ def _run_delivery(profile: str, tmp: str) -> subprocess.CompletedProcess:
@method("bot_relay.roster.sync")
def _(rid, params: dict, _root=_relay_root) -> dict:
"""Replace this gateway's view of agents on OTHER connections.
"""Replace this gateway's view of agents on OTHER connections → ``{count}`` accepted rows.
Params: ``agents`` — rows ``{profile, handle, connection_id, connection_label?, title?,
description?}``; rows failing validation are dropped, not fatal. Result: ``{count}``.
``agents``: rows ``{profile, handle, connection_id, connection_label?, title?, description?}``;
rows failing validation are dropped, not fatal.
"""
try:
from tools.bot_relay import write_remote_roster
@@ -51,7 +49,7 @@ def _(rid, params: dict, _root=_relay_root) -> dict:
@method("bot_relay.outbox.drain")
def _(rid, params: dict, _root=_relay_root) -> dict:
"""Claim every pending cross-connection envelope queued on this gateway. Result: ``{envelopes}``.
"""Claim every pending cross-connection envelope queued on this gateway → ``{envelopes}``.
Claimed envelopes move to ``claimed/`` atomically, so concurrent drains can't double-deliver.
"""
@@ -65,13 +63,9 @@ def _(rid, params: dict, _root=_relay_root) -> dict:
@method("bot_relay.deliver")
def _(rid, params: dict, _root=_relay_root, _run=_run_delivery) -> dict:
"""Deliver a relayed DM into a profile's Bot Chat ON THIS GATEWAY.
Params: ``profile`` (target on this install), ``message`` (already attribution-prefixed).
Runs the same one-turn ``hermes -p <profile> chat -c "Bot Chat"`` transport local DMs
use and returns ``{reply}``. Blocking by design (the Desktop calls it from its relay
worker; the RPC pool keeps it off the WS reader thread).
"""
"""Deliver a relayed DM (``profile``, attribution-prefixed ``message``) into a Bot Chat ON THIS
GATEWAY via the one-turn ``hermes -p <profile> chat -c "Bot Chat"`` transport local DMs use →
``{reply}``. Blocking by design (Desktop relay worker; the RPC pool keeps it off the reader)."""
import os
import subprocess
import tempfile
@@ -95,11 +89,10 @@ def _(rid, params: dict, _root=_relay_root, _run=_run_delivery) -> dict:
if resolved not in known:
return _err(rid, 4092, f"no profile '{profile}' on this gateway")
# When THIS gateway already hosts the target's Bot Chat live, the subprocess
# transport is fenced out by the single-owner lease and the payload dropped. Land
# the DM in the live session via prompt.submit — the composer's choke point, so
# role alternation, persistence and streaming behave as a typed message would.
# (Nested: needs server globals via method_ctx rebinding.)
# When THIS gateway already hosts the target's Bot Chat live, the subprocess transport is
# fenced out by the single-owner lease and the payload dropped. Land the DM in the live
# session via prompt.submit — the composer's choke point, so role alternation, persistence
# and streaming behave as a typed message would. (Nested: needs server globals via rebind.)
def _live_bot_chat_sid(profile_name: str) -> str:
from tools.bot_mode_probe import BOT_CHAT_TITLE
@@ -117,30 +110,27 @@ def _(rid, params: dict, _root=_relay_root, _run=_run_delivery) -> dict:
live_sid = _live_bot_chat_sid(resolved)
if live_sid:
# queued=True: a teammate's DM runs as the NEXT turn and never interrupts or
# steers a turn in flight (the default busy mode does); arrivals queue in order.
# queued=True: a teammate's DM runs as the NEXT turn and never interrupts or steers a
# turn in flight (the default busy mode does); arrivals queue in 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."}
)
reply = f"Delivered into @{resolved}'s open Bot Chat; the reply will appear there."
return _ok(rid, {"reply": reply})
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 serializes with any other delivery turn into this
# profile and covers only the turn window. Worst-case hold is lock wait
# (bot_mode.turn_wait_seconds, default 120s) + the 600s turn timeout, doubled
# when the retry policy grants one re-run — callers must tolerate ~1320s.
# Per-profile turn lock serializes with any other delivery turn into this profile and
# covers only the turn window. Worst-case hold is lock wait (bot_mode.turn_wait_seconds,
# default 120s) + the 600s turn timeout, doubled on one retry — callers tolerate ~1320s.
with acquire_turn_lock(root, resolved):
proc = _run(resolved, tmp)
if proc.returncode != 0:
# Retry policy: transient classes re-run the SAME session once;
# context_overflow too — the retried turn's pre-API compaction pass
# compacts the over-threshold transcript first (no fresh session is
# ever minted). Auth/quota/config classes never retry.
# Retry policy: transient classes re-run the SAME session once; context_overflow
# too — the retried turn's pre-API compaction pass compacts the over-threshold
# transcript first (no fresh session is minted). Auth/quota/config never retry.
from tools.bot_failure_reasons import (
RETRY_NONE, classify_agent_error, retry_action)
@@ -169,11 +159,8 @@ def _(rid, params: dict, _root=_relay_root, _run=_run_delivery) -> dict:
@method("bot_relay.reply")
def _(rid, params: dict, _root=_relay_root) -> 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``).
"""
"""Write a relayed ``reply`` and/or ``error`` (+ optional typed ``reason``, see
``tools.bot_failure_reasons``) for envelope ``id`` so the sender-side waiter picks it up."""
envelope_id = str(params.get("id") or "").strip()
if not envelope_id:
return _err(rid, 4093, "id required")

View File

@@ -16,11 +16,10 @@ _CDP_SCHEMES = {"http", "https", "ws", "wss"}
def _resolve_browser_cdp_url() -> str:
"""Configured browser CDP override without network I/O.
``/browser status`` must be fast: ``tools.browser_tool._get_cdp_override`` runs an
HTTP probe with a multi-second timeout for discovery-style URLs. Mirrors its
precedence (env var, then ``browser.cdp_url``) minus the WS-resolution step, so the
answer reflects user intent even when the host is unreachable; ``browser_navigate``
normalizes on the next tool call.
``/browser status`` must be fast: ``tools.browser_tool._get_cdp_override`` runs an HTTP
probe with a multi-second timeout for discovery-style URLs. Mirrors its precedence (env var,
then ``browser.cdp_url``) minus the WS-resolution step, so the answer reflects user intent
even when the host is unreachable; ``browser_navigate`` normalizes on the next tool call.
"""
env_url = os.environ.get("BROWSER_CDP_URL", "").strip()
if env_url:
@@ -39,17 +38,14 @@ def _resolve_browser_cdp_url() -> str:
def _is_default_local_cdp(parsed) -> bool:
"""Match the discovery-style local default; never the concrete WS form — a
``ws://127.0.0.1:9222/devtools/browser/<id>`` is connectable as-is and collapsing
it to bare ``http://...:9222`` would break the connect."""
``ws://127.0.0.1:9222/devtools/browser/<id>`` is connectable as-is and collapsing it to bare
``http://...:9222`` would break the connect."""
try:
port = parsed.port or 80
except ValueError:
return False
return (
parsed.scheme in {"http", "ws"}
and parsed.hostname in {"127.0.0.1", "localhost"}
and port == 9222
and parsed.path in {"", "/", "/json", "/json/version"})
return (parsed.scheme in {"http", "ws"} and parsed.hostname in {"127.0.0.1", "localhost"}
and port == 9222 and parsed.path in {"", "/", "/json", "/json/version"})
def _cdp_http_reachable(parsed, timeout: float = 2.0) -> bool:
@@ -94,13 +90,13 @@ def _launch_failure_hints(port: int, system: str) -> list[str]:
def _connect_local_default(port: int, system: str, announce) -> str | None:
"""Discover (or launch) the default local debug browser → its CDP URL, or None after announcing failure."""
"""Discover (or launch) the default local debug browser → CDP URL, or None after announcing."""
from hermes_cli.browser_connect import (
discover_local_cdp_url, find_free_debug_port, launch_chrome_debug, local_port_in_use)
# Dual-stack discovery: when another app squats the IPv4 loopback on the debug
# port, a browser bound there comes up on [::1] only. An IPv4-only probe misses
# it AND hangs against squatters that accept TCP but never answer HTTP.
# Dual-stack discovery: when another app squats the IPv4 loopback on the debug port, a
# browser bound there comes up on [::1] only; an IPv4-only probe misses it AND hangs
# against squatters that accept TCP but never answer HTTP.
discovered = discover_local_cdp_url(port, timeout=2.0)
if discovered is not None:
announce(f"Chromium-family browser is already listening at {discovered}")
@@ -169,8 +165,8 @@ def _browser_connect(rid, params: dict) -> dict:
parsed = urlparse(url)
port = parsed.port or 9222
try:
# Hosted ws[s]://.../devtools/browser/<id> endpoints don't serve the HTTP discovery
# path: check TCP reachability only and let browser_navigate handshake.
# Hosted ws[s]://.../devtools/browser/<id> endpoints don't serve the HTTP discovery path:
# check TCP reachability only and let browser_navigate handshake.
if parsed.scheme in {"ws", "wss"} and parsed.path.startswith("/devtools/browser/"):
import socket
@@ -189,8 +185,8 @@ def _browser_connect(rid, params: dict) -> dict:
elif not _cdp_http_reachable(parsed):
return _err(rid, 5031, f"could not reach browser CDP at {url}")
normalized = _normalize_cdp_url(parsed)
# Reap BEFORE publishing the new env (an in-flight tool call sees the old supervisor
# closed) and AFTER (the default task's cached supervisor drains against the new URL).
# Reap BEFORE publishing the new env (an in-flight tool call sees the old supervisor closed)
# and AFTER (the default task's cached supervisor drains against the new URL).
cleanup_all_browsers()
os.environ["BROWSER_CDP_URL"] = normalized
cleanup_all_browsers()

View File

@@ -1,15 +1,12 @@
"""Browser controller registration and result routing for the dashboard.
The dashboard's browser controller (the extension driving a browser) registers over
the authenticated ``/api/ws`` gateway. Everything binds to the SERVER-MINTED identity
(``WSTransport.auth_identity``, stamped by ``hermes_cli.web_server`` from the single-use
ticket); a client-supplied ``principal_id`` is ignored and replaced by a digest of it.
Broker command/cancel frames are re-enveloped as standard Gateway ``event`` frames;
``browser.controller.result`` resolves a command only on the owning transport and only
for the exact attached scope (the broker's exact-scope ``complete`` is the backstop).
Both transports share the broker's explicit capability allowlist (no raw CDP/eval/uploads).
Handler bodies are rebound onto server.py's globals (method_ctx.bind_module publishes
this module's helpers/constants there too), so they reference both bare.
The controller extension registers over the authenticated ``/api/ws`` gateway. Everything
binds to the SERVER-MINTED identity (``WSTransport.auth_identity``, stamped from the single-use
ticket); a client-supplied ``principal_id`` is ignored and replaced by a digest of it. Broker
frames are re-enveloped as Gateway ``event`` frames; ``result`` resolves a command only on the
owning transport for the exact attached scope (the broker's exact-scope ``complete`` is the
backstop). Capabilities come from the broker's explicit allowlist (no raw CDP/eval/uploads).
Bodies are rebound onto server.py's globals (bind_module publishes this module's helpers too).
"""
from __future__ import annotations
@@ -49,7 +46,7 @@ def _is_authenticated_identity(identity: object) -> bool:
def _principal_digest(identity: dict) -> str:
"""Server-derived principal id: stable per user, unspoofable without the authenticated identity."""
"""Server-derived principal id: stable per user, unspoofable without the minted identity."""
raw = f"{identity.get('provider')}\x00{identity.get('user_id')}"
return f"principal:dashboard:{hashlib.sha256(raw.encode('utf-8')).hexdigest()[:32]}"
@@ -63,11 +60,13 @@ def _broker_event_writer(transport: object, session_id: str):
accepted = transport.write({
"jsonrpc": "2.0", "method": "event",
"params": {
"type": frame.get("method"), "session_id": session_id, "payload": frame.get("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")
"browser controller event write failed session=%s frame=%s",
session_id, frame.get("method"),
)
raise
if accepted is False:
@@ -130,9 +129,13 @@ def _register_precheck(rid, params: dict):
if not browser_control_broker.browser_control_enabled():
return _err(rid, _ERR_FORBIDDEN, "browser.extension_control.enabled is not set")
if not browser_control_broker.browser_control_protocol_supported(params.get("protocol_version")):
expected = browser_control_broker.BROWSER_CONTROL_PROTOCOL_VERSION
return _err(rid, _ERR_FORBIDDEN, f"unsupported browser-control protocol version; expected {expected}")
broker_mod = browser_control_broker
if not broker_mod.browser_control_protocol_supported(params.get("protocol_version")):
expected = broker_mod.BROWSER_CONTROL_PROTOCOL_VERSION
return _err(
rid, _ERR_FORBIDDEN,
f"unsupported browser-control protocol version; expected {expected}",
)
return None
@@ -141,12 +144,8 @@ def _register_precheck(rid, params: dict):
identity_message="browser.controller.register requires an authenticated non-internal identity",
lookup_scope=False, precheck=_register_precheck)
def _(rid, params: dict, transport, identity, session_id, broker, _scope, session) -> dict:
"""Attach this connection as the browser controller for one session.
Fails closed (4403) unless the ``browser.extension_control.enabled`` flag is on, the
protocol version is supported, the identity/session gates pass, and at least one
requested capability survives the allowlist.
"""
"""Attach this connection as the browser controller for one session; fails closed (4403) unless
the flag is on, the protocol version is supported, the gates pass and a capability survives."""
from gateway import browser_control_broker
controller_id = str(params.get("controller_id") or "").strip()
@@ -154,9 +153,12 @@ def _(rid, params: dict, transport, identity, session_id, broker, _scope, sessio
profile_id = str(session.get("profile") or "").strip()
if not controller_id or not browser_profile_id or not profile_id:
return _err(
rid, _ERR_FORBIDDEN, "controller_id, browser_profile_id, and server session profile are required"
rid, _ERR_FORBIDDEN,
"controller_id, browser_profile_id, and server session profile are required",
)
capabilities = browser_control_broker.filter_browser_control_capabilities(params.get("capabilities"))
capabilities = browser_control_broker.filter_browser_control_capabilities(
params.get("capabilities")
)
if not capabilities:
return _err(rid, _ERR_FORBIDDEN, "no permitted controller capabilities requested")
scope = browser_control_broker.ControllerScope(
@@ -166,10 +168,8 @@ def _(rid, params: dict, transport, identity, session_id, broker, _scope, sessio
broker.attach(scope, _broker_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,
"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)}})
@@ -177,11 +177,8 @@ def _(rid, params: dict, transport, identity, session_id, broker, _scope, sessio
@_controller_method("browser.controller.result")
def _(rid, params: dict, _transport, _identity, _session_id, broker, scope, _session) -> dict:
"""Deliver one controller command result back to the broker.
``accepted`` is ``False`` for unknown / already-resolved / cancelled command ids —
the broker's idempotent answer, surfaced verbatim.
"""
"""Deliver one command result to the broker; ``accepted`` is False for unknown / resolved /
cancelled command ids (the broker's idempotent answer, surfaced verbatim)."""
command_id = str(params.get("command_id") or "")
if not command_id:
return _err(rid, _ERR_FORBIDDEN, "command_id required")

View File

@@ -14,14 +14,14 @@ _registry = HandlerRegistry()
def _persist_model_switch(result) -> None:
# Targeted key writes: a full `model:` block rewrite via save_config() would
# destroy sibling keys the user set there (`model_slots`, `model_fallback`, ...).
# Targeted key writes: a full `model:` block rewrite via save_config() would destroy
# sibling keys the user set there (`model_slots`, `model_fallback`, ...).
from cli import save_config_value
save_config_value("model.default", result.new_model)
save_config_value("model.provider", result.target_provider)
# A provider without a base_url must clear the stale one (custom endpoint ->
# native) or the new model routes at the old host; reads coalesce null to absent.
# A provider without a base_url must clear the stale one (custom endpoint -> native)
# or the new model routes at the old host; reads coalesce null to absent.
save_config_value("model.base_url", result.base_url or None)
@@ -104,9 +104,10 @@ def _restart_completed_failed_agent_build(sid: str, session: dict, failed_ready:
def _switch_request(raw_input: str, parsed_flags, persist_override) -> tuple[str, str, bool, bool]:
"""Normalize /model flags → (model_input, explicit_provider, one_turn, persist_global); raises on conflict."""
"""Normalize /model flags → (model_input, explicit_provider, one_turn, persist_global)."""
from hermes_cli.model_switch import (
MODEL_SWITCH_ERR_ONCE_WITH_GLOBAL, MODEL_SWITCH_ERROR_TEXT, parse_model_switch_args, resolve_persist_behavior
MODEL_SWITCH_ERR_ONCE_WITH_GLOBAL, MODEL_SWITCH_ERROR_TEXT, parse_model_switch_args,
resolve_persist_behavior,
)
if parsed_flags is None:
@@ -130,9 +131,11 @@ def _switch_request(raw_input: str, parsed_flags, persist_override) -> tuple[str
def _current_model_runtime(agent, explicit_provider: str) -> tuple:
"""(provider, model, base_url, api_key) the switch starts from: the live agent's, else the configured runtime."""
"""(provider, model, base_url, api_key) to switch from: live agent, else configured runtime."""
if agent:
return tuple(getattr(agent, k, "") or "" for k in ("provider", "model", "base_url", "api_key"))
return tuple(
getattr(agent, k, "") or "" for k in ("provider", "model", "base_url", "api_key")
)
current_model = _resolve_model()
if explicit_provider:
return explicit_provider.strip(), current_model, "", ""
@@ -149,7 +152,7 @@ def _current_model_runtime(agent, explicit_provider: str) -> tuple:
def _provider_context() -> tuple:
"""(user providers, compatible custom providers, cfg) from config; all None when config fails to load."""
"""(user providers, compatible custom providers, cfg) from config; all None on load failure."""
user_provs = custom_provs = cfg = None
try:
from hermes_cli.config import get_compatible_custom_providers, load_config
@@ -199,17 +202,16 @@ def _expensive_model_confirm(result, current_base_url: str, current_api_key) ->
return {"value": result.new_model, "warning": confirm_msg, "confirm_required": True, "confirm_message": confirm_msg}
def _commit_agent_switch(sid: str, session: dict, agent, result, current_model: str, restore_snapshot):
"""Swap the live agent in place, then restart/persist/mark/announce; a failed swap aborts it all."""
def _commit_agent_switch(sid: str, session: dict, agent, result, current_model: str, snapshot):
"""Swap the live agent in place, then restart/persist/mark/announce; a failed swap aborts."""
try:
agent.switch_model(
new_model=result.new_model, new_provider=result.target_provider, api_key=result.api_key,
base_url=result.base_url, api_mode=result.api_mode,
capabilities=getattr(result, "runtime_capabilities", None))
except Exception as exc:
# The in-place swap rolled the agent back and re-raised. Abort the whole
# commit (worker restart, persist, marker, override, config write) or the
# session stays pinned to a broken model. A failed switch is a no-op.
# The in-place swap rolled the agent back and re-raised. Abort the whole commit (worker
# restart, persist, marker, override, config write) or the session pins a broken model.
logger.warning("In-place model switch failed for TUI agent: %s", exc)
raise ValueError(
f"Model switch to {result.new_model} failed ({exc}); "
@@ -220,8 +222,8 @@ def _commit_agent_switch(sid: str, session: dict, agent, result, current_model:
_persist_live_session_system_prompt(session)
_append_model_switch_marker(session, model=result.new_model, provider=result.target_provider)
_emit_session_info(sid, session)
if restore_snapshot is not None:
session["one_turn_model_restore"] = restore_snapshot
if snapshot is not None:
session["one_turn_model_restore"] = snapshot
else:
session.pop("one_turn_model_restore", None)
@@ -258,10 +260,9 @@ def _apply_model_switch(
return confirm
if agent:
_commit_agent_switch(sid, session, agent, result, current_model, restore_snapshot)
# PER-SESSION override so a rebuild of THIS session (/new, resume) re-derives
# the chosen model. Deliberately NOT written to process-global env vars
# (HERMES_MODEL & co.): the desktop hosts every same-profile session in one
# process, so os.environ would leak the switch into every other session.
# PER-SESSION override so a rebuild of THIS session (/new, resume) re-derives the model.
# Deliberately NOT written to process-global env (HERMES_MODEL & co.): the desktop hosts
# every same-profile session in one process, so os.environ would leak the switch to all.
if pin_session_override and isinstance(session, dict) and not one_turn:
session["model_override"] = {
"model": result.new_model, "provider": result.target_provider,
@@ -269,8 +270,7 @@ def _apply_model_switch(
if persist_global:
_persist_model_switch(result)
return {
"value": result.new_model,
"warning": result.warning_message or "",
"value": result.new_model, "warning": result.warning_message or "",
"confirm_required": False,
"scope": "once" if one_turn else ("global" if persist_global else "session")}
@@ -278,10 +278,9 @@ def _apply_model_switch(
def _sync_bot_capabilities(sid: str, session: dict) -> None:
"""Rebuild a Bot Chat session's agent when its capability surface changed.
Bot Chats are eternal sessions with toolsets/MCP baked in at construction, so a
capability edit would otherwise wait for /new. At turn start, fingerprint the
profile's capabilities and on change swap in a fresh agent for the SAME session
(history is DB-backed). One rebuild per change; identical state is a no-op.
Bot Chats are eternal sessions with toolsets/MCP baked in at construction, so a capability
edit would otherwise wait for /new. At turn start, fingerprint the profile's capabilities
and on change swap in a fresh agent for the SAME session (history is DB-backed).
"""
agent = session.get("agent")
if agent is None:
@@ -340,14 +339,14 @@ def _sync_agent_model_with_config(sid: str, session: dict) -> None:
if target == seen:
return
model, provider = target
# Already on the configured model (resumed before first sync, or a config
# revert after a failed switch): adopt without switching.
# Already on the configured model (resumed before first sync, or a config revert after
# a failed switch): adopt without switching.
if model == getattr(agent, "model", "") and (not provider or provider == getattr(agent, "provider", "")):
return
raw = f"{model} --provider {provider}" if provider else model
try:
# This sync ADOPTS a config.yaml change; it must never write config back
# (that is how `hermes --tui -m` once leaked into config.yaml).
# This sync ADOPTS a config.yaml change; it must never write config back (that is
# how `hermes --tui -m` once leaked into config.yaml).
_apply_model_switch(
sid, session, raw, confirm_expensive_model=True, pin_session_override=False, persist_override=False
)
@@ -358,9 +357,9 @@ def _sync_agent_model_with_config(sid: str, session: dict) -> None:
def _pending_switch_selection_warning(model: str, provider: str) -> str | None:
"""Selection-guard message for a model queued mid-turn, or ``None``.
Runs BEFORE the pick is stashed, while the client can still turn the response
into a confirm prompt. Only pre-resolution inputs exist here, so this can only
under-fire; ``_apply_model_switch`` is the backstop. Exceptions mean "no warning".
Runs BEFORE the pick is stashed, while the client can still turn the response into a
confirm prompt. Only pre-resolution inputs exist, so this can only under-fire;
``_apply_model_switch`` is the backstop. Exceptions mean "no warning".
"""
if not model:
return None