diff --git a/tui_gateway/billing_view.py b/tui_gateway/billing_view.py index ad65d0a75f..ba5400c17b 100644 --- a/tui_gateway/billing_view.py +++ b/tui_gateway/billing_view.py @@ -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: diff --git a/tui_gateway/change_watcher.py b/tui_gateway/change_watcher.py index 90a7479428..395cee5a31 100644 --- a/tui_gateway/change_watcher.py +++ b/tui_gateway/change_watcher.py @@ -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") ) diff --git a/tui_gateway/event_publisher.py b/tui_gateway/event_publisher.py index eba2ff5deb..57800bf018 100644 --- a/tui_gateway/event_publisher.py +++ b/tui_gateway/event_publisher.py @@ -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 diff --git a/tui_gateway/event_replay.py b/tui_gateway/event_replay.py index 8611ae817a..b99b8700b7 100644 --- a/tui_gateway/event_replay.py +++ b/tui_gateway/event_replay.py @@ -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. diff --git a/tui_gateway/git_probe.py b/tui_gateway/git_probe.py index 2cc6d5cce9..5930e90374 100644 --- a/tui_gateway/git_probe.py +++ b/tui_gateway/git_probe.py @@ -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 `` → 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 "" diff --git a/tui_gateway/loop_noise.py b/tui_gateway/loop_noise.py index bf6944ba36..7ae41a9830 100644 --- a/tui_gateway/loop_noise.py +++ b/tui_gateway/loop_noise.py @@ -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 diff --git a/tui_gateway/method_ctx.py b/tui_gateway/method_ctx.py index 3c6c20e589..3141b2f2bc 100644 --- a/tui_gateway/method_ctx.py +++ b/tui_gateway/method_ctx.py @@ -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): diff --git a/tui_gateway/methods_bot_relay.py b/tui_gateway/methods_bot_relay.py index 6576c113a9..31b46a2264 100644 --- a/tui_gateway/methods_bot_relay.py +++ b/tui_gateway/methods_bot_relay.py @@ -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 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 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") diff --git a/tui_gateway/methods_browser.py b/tui_gateway/methods_browser.py index f161b3ad4c..1751434327 100644 --- a/tui_gateway/methods_browser.py +++ b/tui_gateway/methods_browser.py @@ -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/`` is connectable as-is and collapsing - it to bare ``http://...:9222`` would break the connect.""" + ``ws://127.0.0.1:9222/devtools/browser/`` 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/ endpoints don't serve the HTTP discovery - # path: check TCP reachability only and let browser_navigate handshake. + # Hosted ws[s]://.../devtools/browser/ 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() diff --git a/tui_gateway/methods_browser_control.py b/tui_gateway/methods_browser_control.py index 2c393610b3..cff73ec875 100644 --- a/tui_gateway/methods_browser_control.py +++ b/tui_gateway/methods_browser_control.py @@ -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") diff --git a/tui_gateway/model_switch.py b/tui_gateway/model_switch.py index b4ef15f410..5acee40840 100644 --- a/tui_gateway/model_switch.py +++ b/tui_gateway/model_switch.py @@ -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