Files
hermes-agent/tools/browser_supervisor.py
teknium1 aefa503479 fix(browser): gave-up supervisor unregisters itself; tests trimmed to two invariants
Follow-up to the cherry-picked reconnect cap (#114178, @KoNit-K); supersedes the
earlier #44460 (@plcunha), whose bounded-reconnect design this lands in slimmer form:

- Factor the two give-up branches into ``CDPSupervisor._reconnect_budget_spent``,
  which logs the single final warning AND drops the supervisor from
  ``SUPERVISOR_REGISTRY`` when it is still the registered one. Consumers
  (``browser_cdp`` frame routing, the eval fast path) then see "no supervisor
  attached" instead of a dead entry, and the next browser call for the task
  starts a fresh one via ``get_or_start``.
- Move the unit tests to ``tests/tools/test_browser_supervisor_reconnect.py``
  (two invariants: bounded + unregistered after attach; first-dial failure
  still fatal for ``start()``) and restore ``test_browser_supervisor.py`` to
  main — that file is the opt-in real-Chrome E2E suite and its module-level
  skip marker did not need to be rewritten to host unit tests.
- Docs: lifecycle section of the developer guide describes the bounded
  reconnect.

Live: fake CDP endpoint killed after attach — base logs connect-failed
warnings indefinitely (7 in 45 s, thread alive, still registered); fixed head
logs 4 + one "stopped after 5 failed reconnect attempts" line, thread exits,
registry entry gone. Control: endpoint back within 2.5 s re-attaches and the
budget resets.

Co-authored-by: plcunha <jvsantos.cunha@gmail.com>
2026-09-18 10:28:40 -07:00

592 lines
29 KiB
Python

"""Persistent CDP supervisor for browser dialog + frame detection.
One ``CDPSupervisor`` per Hermes ``task_id`` with a reachable CDP endpoint: one
persistent WebSocket, ``Page`` / ``Runtime`` / ``Target`` events on every attached
session (top page + auto-attached OOPIF / worker targets), pending dialogs + frame
tree exposed via a thread-safe snapshot. Not in the tool schema — output reaches the
agent via ``browser_snapshot`` / ``browser_dialog``. Dialog capture and frame tracking
are mixins (``browser_supervisor_dialogs`` / ``browser_supervisor_frames``).
Design spec: ``website/docs/developer-guide/browser-supervisor.md``.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import threading
import time
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Tuple, TYPE_CHECKING
from tools.browser_supervisor_dialogs import (
DEFAULT_DIALOG_POLICY, DEFAULT_DIALOG_TIMEOUT_S, RECENT_DIALOGS_MAX, _VALID_POLICIES, DialogRecord,
DialogSupervisionMixin, PendingDialog,
)
from tools.browser_supervisor_frames import FrameInfo, FrameTrackingMixin
# ``websockets`` costs ~22 ms at import and is only needed once a supervisor connects.
if TYPE_CHECKING:
from websockets.asyncio.client import ClientConnection
logger = logging.getLogger(__name__)
# Browserbase can transiently drop a CDP socket while a short-lived client
# reconnects. A locally owned browser endpoint, however, is gone for good
# once its process exits; leave enough room for the former without leaking a
# supervisor thread and warnings forever for the latter.
MAX_POST_ATTACH_RECONNECT_FAILURES = 5
def _redact_cdp_error_text(exc: object) -> str:
"""Redact CDP endpoint credentials from an exception's (or URL's) string form.
``websockets`` bakes the raw URL (``?token=`` / ``user:pass@``) into its exception
messages, so every egress point turning one into log/re-raise text MUST route through
here; falls back to a fixed sentinel if redaction itself raises (err toward masking)."""
try:
from agent.redact import redact_cdp_url
return redact_cdp_url(str(exc))
except Exception:
return "<error redacted>"
class _LoopUnavailable(RuntimeError):
"""The supervisor loop refused new work (closed / shutting down)."""
def _schedule(coro, loop, *, timeout: float):
"""Run ``coro`` on the supervisor loop from a sync caller and wait for its result."""
from agent.async_utils import safe_schedule_threadsafe
fut = safe_schedule_threadsafe(coro, loop)
if fut is None:
raise _LoopUnavailable("Browser supervisor loop unavailable")
return fut.result(timeout=timeout)
def _fail(error: str) -> Dict[str, Any]:
return {"ok": False, "error": error}
def _err(exc: BaseException) -> Dict[str, Any]:
return _fail(f"{type(exc).__name__}: {exc}")
@dataclass(frozen=True)
class SupervisorSnapshot:
"""Read-only snapshot of supervisor state for tool handlers."""
pending_dialogs: Tuple[PendingDialog, ...]
recent_dialogs: Tuple[DialogRecord, ...]
frame_tree: Dict[str, Any]
active: bool # False if supervisor is detached/stopped
cdp_url: str
task_id: str
def to_dict(self) -> Dict[str, Any]:
"""Serialize for inclusion in ``browser_snapshot`` output."""
out: Dict[str, Any] = {"pending_dialogs": [d.to_dict() for d in self.pending_dialogs], "frame_tree": self.frame_tree}
if self.recent_dialogs:
out["recent_dialogs"] = [d.to_dict() for d in self.recent_dialogs]
return out
class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin):
"""One supervisor per (task_id, cdp_url) pair. ``start()`` spawns a daemon thread
running its own asyncio loop, connects, attaches to the first page target, enables
domains and auto-attach. ``snapshot()`` / ``respond_to_dialog()`` / ``evaluate_runtime()``
are sync, thread-safe bridges onto that loop; all CDP I/O lives on the loop."""
def __init__(self, task_id: str, cdp_url: str, *, dialog_policy: str = DEFAULT_DIALOG_POLICY,
dialog_timeout_s: float = DEFAULT_DIALOG_TIMEOUT_S) -> None:
if dialog_policy not in _VALID_POLICIES:
raise ValueError(f"Invalid dialog_policy {dialog_policy!r}; must be one of {sorted(_VALID_POLICIES)}")
self.task_id = task_id
self.cdp_url = cdp_url
self.dialog_policy = dialog_policy
self.dialog_timeout_s = float(dialog_timeout_s)
# State protected by ``_state_lock`` for cross-thread reads.
self._state_lock = threading.Lock()
self._pending_dialogs: Dict[str, PendingDialog] = {}
self._recent_dialogs: List[DialogRecord] = []
self._frames: Dict[str, FrameInfo] = {}
self._active = False
# Supervisor loop machinery — populated in start().
self._loop: Optional[asyncio.AbstractEventLoop] = None
self._thread: Optional[threading.Thread] = None
self._ready_event = threading.Event()
self._start_error: Optional[BaseException] = None
self._stop_requested = False
# CDP call tracking (runs on supervisor loop only).
self._next_call_id = 1
self._pending_calls: Dict[int, asyncio.Future] = {}
self._ws: Optional[ClientConnection] = None
self._page_session_id: Optional[str] = None
# Dialog auto-dismiss watchdog handles (per dialog id) + id generator.
self._dialog_watchdogs: Dict[str, asyncio.TimerHandle] = {}
self._dialog_seq = 0
# ── Public sync API ──────────────────────────────────────────────────────
def start(self, timeout: float = 15.0) -> None:
"""Launch the background loop and block until attachment completes; raises what attach failed with (redacted)."""
if self._thread and self._thread.is_alive():
return
self._ready_event.clear()
self._start_error, self._stop_requested = None, False
self._thread = threading.Thread(target=self._thread_main, name=f"cdp-supervisor-{self.task_id}", daemon=True)
self._thread.start()
if not self._ready_event.wait(timeout=timeout):
self.stop()
raise TimeoutError(f"CDP supervisor did not attach within {timeout}s "
f"(cdp_url={_redact_cdp_error_text(self.cdp_url)[:80]}...)")
if (err := self._start_error) is not None:
self.stop()
# ``err`` is a raw ``websockets`` exception embedding the full cdp_url (token /
# userinfo): re-raise redacted, ``from None`` so the traceback chain leaks nothing.
raise RuntimeError(f"CDP supervisor failed to start: {_redact_cdp_error_text(err)}") from None
def stop(self, timeout: float = 5.0) -> None:
"""Cancel the supervisor task and join the thread."""
self._stop_requested = True
loop = self._loop
if loop is not None and loop.is_running():
# Close the WebSocket from inside the loop so ``async for raw in self._ws``
# returns cleanly, ``_run`` hits its ``finally``, THEN the thread exits.
with contextlib.suppress(Exception): # loop already shutting down / close timed out
_schedule(self._close_ws(), loop, timeout=2.0)
if self._thread is not None:
self._thread.join(timeout=timeout)
self._set_active(False)
def _set_active(self, value: bool) -> None:
with self._state_lock:
self._active = value
def snapshot(self) -> SupervisorSnapshot:
"""Return an immutable snapshot of current state."""
with self._state_lock:
return SupervisorSnapshot(
pending_dialogs=tuple(self._pending_dialogs.values()),
recent_dialogs=tuple(self._recent_dialogs[-RECENT_DIALOGS_MAX:]),
frame_tree=self._build_frame_tree_locked(),
active=self._active, cdp_url=self.cdp_url, task_id=self.task_id,
)
def respond_to_dialog(self, action: str, *, prompt_text: Optional[str] = None,
dialog_id: Optional[str] = None, timeout: float = 10.0) -> Dict[str, Any]:
"""Accept/dismiss a pending dialog (sync bridge onto the supervisor loop). Returns
``{"ok": True, "dialog"}`` or ``{"ok": False, "error"}`` for recoverable errors."""
if action not in {"accept", "dismiss"}:
return _fail(f"action must be 'accept' or 'dismiss', got {action!r}")
with self._state_lock:
if not self._active:
return _fail("supervisor is not active")
pending = list(self._pending_dialogs.values())
if not pending:
return _fail("no dialog is currently open")
if dialog_id:
dialog = self._pending_dialogs.get(dialog_id)
if dialog is None:
return _fail(f"dialog_id {dialog_id!r} not found (known: {sorted(self._pending_dialogs)})")
elif len(pending) > 1:
return _fail(f"{len(pending)} pending dialogs; specify dialog_id. Candidates: {[d.id for d in pending]}")
else:
dialog = pending[0]
loop = self._loop
if loop is None:
return _fail("supervisor loop is not running")
try:
coro = self._handle_dialog_cdp(dialog, accept=(action == "accept"), prompt_text=prompt_text or "")
_schedule(coro, loop, timeout=timeout)
except _LoopUnavailable as e:
return _fail(str(e))
except Exception as e:
return _err(e)
return {"ok": True, "dialog": dialog.to_dict()}
def evaluate_runtime(self, expression: str, *, return_by_value: bool = True,
await_promise: bool = True, timeout: float = 10.0) -> Dict[str, Any]:
"""Evaluate ``expression`` in the page's Runtime context over the live WS.
Returns ``{"ok": True, "result", "result_type"}`` or ``{"ok": False, "error"}``.
``return_by_value=True`` JSON-serializes the result (DevTools-console
semantics); non-serializable objects come back as a description string."""
loop = self._loop
if loop is None or not loop.is_running():
return _fail("supervisor loop is not running")
with self._state_lock:
active, session_id = self._active, self._page_session_id
if not active:
return _fail("supervisor is not active")
if not session_id:
return _fail("supervisor has no attached page session")
def _run_eval(by_value: bool) -> Dict[str, Any]:
# userGesture: clipboard / fullscreen APIs need user activation.
params = {"expression": expression, "returnByValue": by_value,
"awaitPromise": await_promise, "userGesture": True}
coro = self._cdp("Runtime.evaluate", params, session_id=session_id, timeout=timeout)
return _schedule(coro, loop, timeout=timeout + 1)
try:
response = _run_eval(return_by_value)
except Exception as exc:
# Deep-serializing live DOM nodes / NodeLists / Window can blow past
# CDP's recursion guard (``Object reference chain is too long``).
# Retry once with returnByValue=False so Chrome returns the description.
if not (return_by_value and "reference chain is too long" in str(exc).lower()):
return _err(exc)
try:
response = _run_eval(False)
except Exception as exc2:
return _err(exc2)
# Response: {"result": {"result": {"type", "value", ...}, "exceptionDetails"?}}
result_payload = response.get("result", {}) if isinstance(response, dict) else {}
exception_details = result_payload.get("exceptionDetails")
if exception_details:
exc_text = exception_details.get("text") or "JavaScript exception"
description = (exception_details.get("exception") or {}).get("description")
return _fail(f"{exc_text}: {description}" if description else exc_text)
result_obj = result_payload.get("result", {})
result_type = result_obj.get("type", "undefined")
if "value" in result_obj:
value = result_obj["value"]
elif result_type == "undefined":
value = None
else:
# Non-serializable (functions, DOM nodes…) — give the model the browser's description.
value = result_obj.get("description") or result_obj.get("unserializableValue")
return {"ok": True, "result": value, "result_type": result_type}
def focus_page(self, origin: str, *, accept: Optional[str] = None, timeout: float = 10.0) -> Dict[str, Any]:
"""Re-attach the supervisor's page session to an open page target on ``origin``
(``scheme://host[:port]``). The initial attach picks the FIRST page target, but tools
that open their own tabs (browser_exec) put the login form somewhere else. With
``accept`` (a JS expression) the first same-origin tab where it evaluates truthy wins,
so a login and a checkout tab on one site resolve to the right one. Returns
``{"ok": True, "url"}`` or ``{"ok": False, "error"}``; on failure the previous session stays."""
loop = self._loop
if loop is None or not loop.is_running():
return _fail("supervisor loop is not running")
async def _attach(target_id: str) -> str:
attach = await self._cdp("Target.attachToTarget", {"targetId": target_id, "flatten": True}, timeout=timeout)
sid = attach["result"]["sessionId"]
await self._enable_page_domains(sid, timeout=timeout)
await self._install_dialog_bridge(sid)
return sid
async def _focus() -> Dict[str, Any]:
from agent.vault_store import normalize_origin
targets = (await self._cdp("Target.getTargets", timeout=timeout)).get("result", {}).get("targetInfos", [])
candidates = []
for t in targets:
url = str(t.get("url") or "")
try:
# origin="" = any http(s) page (used to FIND the login tab before its origin is known)
if t.get("type") == "page" and url.startswith(("http://", "https://")) \
and (not origin or normalize_origin(url) == origin):
candidates.append((t["targetId"], url))
except Exception:
continue
for target_id, url in candidates:
sid = await _attach(target_id)
if accept:
probe = await self._cdp("Runtime.evaluate", {"expression": accept, "returnByValue": True},
session_id=sid, timeout=timeout)
if not probe.get("result", {}).get("result", {}).get("value"):
await self._cdp("Target.detachFromTarget", {"sessionId": sid}, timeout=timeout)
continue
with self._state_lock:
self._page_session_id = sid
return {"ok": True, "url": url}
return _fail(f"no open page on {origin or 'any site'}" + (" with the expected form" if accept and candidates else ""))
try:
return _schedule(_focus(), loop, timeout=timeout + 1)
except Exception as exc:
return _err(exc)
# ── Supervisor loop internals ────────────────────────────────────────────
def _thread_main(self) -> None:
"""Entry point for the supervisor's dedicated thread."""
loop = asyncio.new_event_loop()
self._loop = loop
try:
asyncio.set_event_loop(loop)
loop.run_until_complete(self._run())
except BaseException as e: # noqa: BLE001 — propagate via _start_error
if not self._fail_start(e):
logger.warning("CDP supervisor %s crashed: %s", self.task_id, e)
finally:
# Cancel + flush remaining tasks before closing the loop to avoid
# "Task was destroyed but it is pending" warnings.
with contextlib.suppress(Exception):
pending = [t for t in asyncio.all_tasks(loop) if not t.done()]
for t in pending:
t.cancel()
if pending:
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
with contextlib.suppress(Exception):
loop.close()
self._set_active(False)
def _fail_start(self, e: BaseException) -> bool:
"""Propagate ``e`` to ``start()`` if we never got ready; True if it was consumed."""
if self._ready_event.is_set():
return False
self._start_error = e
self._ready_event.set()
return True
async def _close_ws(self) -> None:
"""Detach and close the current WebSocket, swallowing close errors."""
ws, self._ws = self._ws, None
if ws is not None:
with contextlib.suppress(Exception):
await ws.close()
def _reconnect_budget_spent(self, failures: int, e: BaseException) -> bool:
"""True once ``failures`` consecutive post-attach reconnects failed: log ONE final line
and drop this supervisor from the registry so a dead endpoint (its Chrome exited with
the task) leaves neither a retrying thread nor a stale registry entry behind. A later
browser call for the task starts a fresh supervisor via ``get_or_start``."""
if failures < MAX_POST_ATTACH_RECONNECT_FAILURES:
return False
logger.warning("CDP supervisor %s: stopped after %s failed reconnect attempts: %s",
self.task_id, failures, _redact_cdp_error_text(e))
if SUPERVISOR_REGISTRY.get(self.task_id) is self:
SUPERVISOR_REGISTRY._pop(self.task_id)
return True
async def _run(self) -> None:
"""Top-level reconnecting supervisor coroutine. Browserbase tears down the CDP
socket whenever a short-lived client (agent-browser's per-command CDP client)
disconnects, so on drop we reset per-session ids, re-attach, and keep going.
A failure before the first successful attach is fatal for ``start()``."""
reconnect_failures, last_success_at, backoff = 0, 0.0, 0.5
import websockets # deferred: only supervisors that connect pay the import
from agent.proxy_bypass import loopback_connect_kwargs
connect_kwargs = {"max_size": 50 * 1024 * 1024, **loopback_connect_kwargs(self.cdp_url)}
while not self._stop_requested:
try:
self._ws = await asyncio.wait_for(websockets.connect(self.cdp_url, **connect_kwargs), timeout=10.0)
except Exception as e:
if self._fail_start(e):
return
reconnect_failures += 1
if self._reconnect_budget_spent(reconnect_failures, e):
return
logger.warning("CDP supervisor %s: connect failed (attempt %s/%s): %s",
self.task_id, reconnect_failures, MAX_POST_ATTACH_RECONNECT_FAILURES,
_redact_cdp_error_text(e))
await asyncio.sleep(min(backoff, 10.0))
backoff = min(backoff * 2, 10.0)
continue
reader_task = asyncio.create_task(self._read_loop(), name="cdp-reader")
try:
# Reset the per-connection page session id; ``_pending_dialogs`` / ``_frames``
# are deliberately kept — they reconcile as fresh events arrive (worst case a
# stale dialog entry is rejected with "no dialog is showing", logged only).
self._page_session_id = None
await self._attach_initial_page()
self._set_active(True)
last_success_at = time.time()
reconnect_failures = 0
backoff = 0.5 # reset after a successful attach
self._ready_event.set()
await reader_task
except BaseException as e:
if self._fail_start(e):
raise
reconnect_failures += 1
if self._reconnect_budget_spent(reconnect_failures, e):
return
logger.warning("CDP supervisor %s: session dropped after %.1fs (attempt %s/%s): %s",
self.task_id, time.time() - last_success_at, reconnect_failures,
MAX_POST_ATTACH_RECONNECT_FAILURES, _redact_cdp_error_text(e))
finally:
self._set_active(False)
if not reader_task.done():
reader_task.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await reader_task
for handle in self._dialog_watchdogs.values():
handle.cancel()
self._dialog_watchdogs.clear()
await self._close_ws()
if self._stop_requested:
return
logger.debug("CDP supervisor %s: reconnecting in %.1fs...", self.task_id, backoff)
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 10.0)
async def _attach_initial_page(self) -> None:
"""Find (or create) a page target, attach flattened, enable domains, install dialog bridge."""
targets = (await self._cdp("Target.getTargets")).get("result", {}).get("targetInfos", [])
page_target = next((t for t in targets if t.get("type") == "page"), None)
if page_target is None:
page_target = (await self._cdp("Target.createTarget", {"url": "about:blank"}))["result"]
attach = await self._cdp("Target.attachToTarget", {"targetId": page_target["targetId"], "flatten": True})
self._page_session_id = sid = attach["result"]["sessionId"]
await self._enable_page_domains(sid, timeout=10.0)
await self._install_dialog_bridge(sid)
async def _cdp(self, method: str, params: Optional[Dict[str, Any]] = None, *,
session_id: Optional[str] = None, timeout: float = 10.0) -> Dict[str, Any]:
"""Send a CDP command and await its response."""
if self._ws is None:
raise RuntimeError("supervisor WebSocket is not connected")
call_id, self._next_call_id = self._next_call_id, self._next_call_id + 1
payload: Dict[str, Any] = {"id": call_id, "method": method}
payload.update({k: v for k, v in (("params", params), ("sessionId", session_id)) if v})
fut: asyncio.Future = asyncio.get_running_loop().create_future()
self._pending_calls[call_id] = fut
await self._ws.send(json.dumps(payload))
try:
return await asyncio.wait_for(fut, timeout=timeout)
finally:
self._pending_calls.pop(call_id, None)
async def _read_loop(self) -> None:
"""Continuously dispatch incoming CDP frames (responses → futures, events → handlers)."""
assert self._ws is not None
try:
async for raw in self._ws:
if self._stop_requested:
break
try:
msg = json.loads(raw)
except Exception:
logger.debug("CDP supervisor: non-JSON frame dropped")
continue
if "id" in msg:
fut = self._pending_calls.pop(msg["id"], None)
if fut is None or fut.done():
continue
if "error" in msg:
fut.set_exception(RuntimeError(f"CDP error on id={msg['id']}: {msg['error']}"))
else:
fut.set_result(msg)
elif handler := self._EVENT_HANDLERS.get(msg.get("method")):
result = handler(self, msg.get("params", {}), msg.get("sessionId"))
if result is not None:
await result
except Exception as e:
logger.debug("CDP read loop exited: %s", e)
# CDP event → handler(self, params, session_id). Async handlers return an
# awaitable that ``_read_loop`` awaits; sync handlers return None.
_EVENT_HANDLERS: Dict[str, Callable[..., Any]] = {
**DialogSupervisionMixin.EVENT_HANDLERS, **FrameTrackingMixin.EVENT_HANDLERS
}
class _SupervisorRegistry:
"""Process-global (task_id → supervisor) map with idempotent start/stop (``SUPERVISOR_REGISTRY``)."""
def __init__(self) -> None:
self._lock = threading.Lock()
self._by_task: Dict[str, CDPSupervisor] = {}
def get(self, task_id: str) -> Optional[CDPSupervisor]:
with self._lock:
return self._by_task.get(task_id)
def _pop(self, task_id: str) -> Optional[CDPSupervisor]:
with self._lock:
return self._by_task.pop(task_id, None)
def get_or_start(self, task_id: str, cdp_url: str, *, dialog_policy: str = DEFAULT_DIALOG_POLICY,
dialog_timeout_s: float = DEFAULT_DIALOG_TIMEOUT_S, start_timeout: float = 15.0) -> CDPSupervisor:
"""Idempotently ensure a supervisor runs for ``(task_id, cdp_url)``; one bound to a
different ``cdp_url`` or unhealthy (dead thread / stopped loop) is stopped and replaced."""
with self._lock:
existing = self._by_task.get(task_id)
if existing is not None:
thread, loop = existing._thread, existing._loop
healthy = thread is not None and thread.is_alive() and loop is not None and loop.is_running()
if existing.cdp_url == cdp_url and healthy:
return existing
self._by_task.pop(task_id, None)
if existing is not None:
existing.stop()
supervisor = CDPSupervisor(task_id=task_id, cdp_url=cdp_url,
dialog_policy=dialog_policy, dialog_timeout_s=dialog_timeout_s)
supervisor.start(timeout=start_timeout)
with self._lock:
# Guard against a concurrent get_or_start from another thread.
already = self._by_task.get(task_id)
if already is not None and already.cdp_url == cdp_url:
supervisor.stop()
return already
self._by_task[task_id] = supervisor
return supervisor
def stop(self, task_id: str) -> None:
supervisor = self._pop(task_id)
if supervisor is not None:
supervisor.stop()
def stop_all(self) -> None:
"""Stop every running supervisor. For shutdown / test teardown."""
with self._lock:
items = list(self._by_task.values())
self._by_task.clear()
for supervisor in items:
supervisor.stop()
SUPERVISOR_REGISTRY = _SupervisorRegistry()
__all__ = ["CDPSupervisor", "SUPERVISOR_REGISTRY", "SupervisorSnapshot", "_SupervisorRegistry"]
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
CONSOLE_HISTORY_MAX = 50
@dataclass
class ConsoleEvent:
"""Ring buffer entry for console + exception traffic."""
ts: float
level: str # "log" | "error" | "warning" | "exception"
text: str
url: Optional[str] = None
_PLUGIN_COMPAT_LAZY = {
'DIALOG_BRIDGE_HOST': ('tools.browser_supervisor_dialogs', 'DIALOG_BRIDGE_HOST'),
'DIALOG_BRIDGE_URL_PATTERN': ('tools.browser_supervisor_dialogs', 'DIALOG_BRIDGE_URL_PATTERN'),
'DIALOG_POLICY_AUTO_ACCEPT': ('tools.browser_supervisor_dialogs', 'DIALOG_POLICY_AUTO_ACCEPT'),
'DIALOG_POLICY_AUTO_DISMISS': ('tools.browser_supervisor_dialogs', 'DIALOG_POLICY_AUTO_DISMISS'),
'DIALOG_POLICY_MUST_RESPOND': ('tools.browser_supervisor_dialogs', 'DIALOG_POLICY_MUST_RESPOND'),
'FRAME_TREE_MAX_ENTRIES': ('tools.browser_supervisor_frames', 'FRAME_TREE_MAX_ENTRIES'),
'FRAME_TREE_MAX_OOPIF_DEPTH': ('tools.browser_supervisor_frames', 'FRAME_TREE_MAX_OOPIF_DEPTH'),
}
def __getattr__(name): # PEP 562 — lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
from hermes_cli.plugin_compat import warn_once
warn_once(__name__, name, *target)
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----