browserwright 0.6.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. browserwright/__init__.py +33 -0
  2. browserwright/__main__.py +6 -0
  3. browserwright/_executor/__init__.py +47 -0
  4. browserwright/_executor/__main__.py +9 -0
  5. browserwright/_executor/client.py +127 -0
  6. browserwright/_executor/process.py +652 -0
  7. browserwright/_executor/protocol.py +152 -0
  8. browserwright/api.py +66 -0
  9. browserwright/cdp.py +285 -0
  10. browserwright/cli.py +741 -0
  11. browserwright/daemon/__init__.py +8 -0
  12. browserwright/daemon/_ipc.py +444 -0
  13. browserwright/daemon/active_tab.py +183 -0
  14. browserwright/daemon/auth.py +395 -0
  15. browserwright/daemon/backends/__init__.py +59 -0
  16. browserwright/daemon/backends/base.py +120 -0
  17. browserwright/daemon/backends/cloud.py +222 -0
  18. browserwright/daemon/backends/env.py +119 -0
  19. browserwright/daemon/backends/extension.py +185 -0
  20. browserwright/daemon/backends/rdp.py +214 -0
  21. browserwright/daemon/cli.py +1437 -0
  22. browserwright/daemon/config.py +380 -0
  23. browserwright/daemon/doctor.py +179 -0
  24. browserwright/daemon/errors.py +34 -0
  25. browserwright/daemon/launch_chrome.py +353 -0
  26. browserwright/daemon/observability.py +181 -0
  27. browserwright/daemon/platforms.py +234 -0
  28. browserwright/daemon/resolver.py +72 -0
  29. browserwright/daemon/server/__init__.py +6 -0
  30. browserwright/daemon/server/daemon.py +229 -0
  31. browserwright/daemon/server/executor_registry.py +434 -0
  32. browserwright/daemon/server/extension_upstream.py +677 -0
  33. browserwright/daemon/server/facade.py +375 -0
  34. browserwright/daemon/server/facade_extension.py +969 -0
  35. browserwright/daemon/server/listener.py +1058 -0
  36. browserwright/daemon/server/proxy.py +1991 -0
  37. browserwright/daemon/server/relay.py +783 -0
  38. browserwright/daemon/server/state.py +432 -0
  39. browserwright/daemon/server/upstream.py +266 -0
  40. browserwright/daemon/userscripts.py +150 -0
  41. browserwright/discovery.py +213 -0
  42. browserwright/errors.py +177 -0
  43. browserwright/health.py +169 -0
  44. browserwright/install.py +628 -0
  45. browserwright/memory/__init__.py +15 -0
  46. browserwright/memory/_md.py +120 -0
  47. browserwright/memory/_yaml.py +217 -0
  48. browserwright/memory/global_mem.py +201 -0
  49. browserwright/memory/repl_mem.py +28 -0
  50. browserwright/memory/session_decisions.py +53 -0
  51. browserwright/memory/site_mem.py +381 -0
  52. browserwright/mode_b_client.py +590 -0
  53. browserwright/multitask.py +131 -0
  54. browserwright/output_schema.py +99 -0
  55. browserwright/primitives/__init__.py +67 -0
  56. browserwright/primitives/discovery_api.py +79 -0
  57. browserwright/primitives/http.py +42 -0
  58. browserwright/primitives/inspect.py +876 -0
  59. browserwright/primitives/interact.py +518 -0
  60. browserwright/primitives/page.py +556 -0
  61. browserwright/primitives/site.py +143 -0
  62. browserwright/release_install.py +466 -0
  63. browserwright/repl/__init__.py +6 -0
  64. browserwright/repl/_namespace.py +106 -0
  65. browserwright/repl/_smart_goto.py +236 -0
  66. browserwright/repl/inline.py +180 -0
  67. browserwright/repl/playwright_handle.py +449 -0
  68. browserwright/repl/snapshot.py +150 -0
  69. browserwright/session.py +229 -0
  70. browserwright/session_create.py +252 -0
  71. browserwright/session_ctx.py +24 -0
  72. browserwright/session_registry.py +133 -0
  73. browserwright/session_runtime.py +133 -0
  74. browserwright/site_skills_starter/github.com/SKILL.md +14 -0
  75. browserwright/site_skills_starter/github.com/memory.md +29 -0
  76. browserwright/site_skills_starter/github.com/tasks/list_issues.py +55 -0
  77. browserwright/site_skills_starter/google.com/SKILL.md +16 -0
  78. browserwright/site_skills_starter/google.com/memory.md +27 -0
  79. browserwright/site_skills_starter/google.com/tasks/search.py +53 -0
  80. browserwright/site_skills_starter/producthunt.com/SKILL.md +7 -0
  81. browserwright/site_skills_starter/producthunt.com/memory.md +26 -0
  82. browserwright/site_skills_starter/producthunt.com/tasks/today.py +64 -0
  83. browserwright/site_skills_starter/wikipedia.org/SKILL.md +7 -0
  84. browserwright/site_skills_starter/wikipedia.org/memory.md +22 -0
  85. browserwright/site_skills_starter/wikipedia.org/tasks/lookup.py +55 -0
  86. browserwright/site_skills_starter/ycombinator.com/SKILL.md +8 -0
  87. browserwright/site_skills_starter/ycombinator.com/memory.md +25 -0
  88. browserwright/site_skills_starter/ycombinator.com/tasks/front_page.py +63 -0
  89. browserwright/skill_doc.py +140 -0
  90. browserwright/skill_runtime.md +194 -0
  91. browserwright/subscriptions.py +213 -0
  92. browserwright/task_runner.py +125 -0
  93. browserwright/version.py +117 -0
  94. browserwright-0.6.2.dist-info/METADATA +12 -0
  95. browserwright-0.6.2.dist-info/RECORD +98 -0
  96. browserwright-0.6.2.dist-info/WHEEL +5 -0
  97. browserwright-0.6.2.dist-info/entry_points.txt +3 -0
  98. browserwright-0.6.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,590 @@
1
+ """Mode B daemon client — long-lived socket connection (spec §10 v0.2, §D).
2
+
3
+ Mode B is the v0.2 happy path:
4
+
5
+ - Skill connects to a running ``browserwright-daemon serve`` instance via its
6
+ unix-socket (POSIX) or TCP+token (Windows) endpoint.
7
+ - Standard CDP commands are tunnelled through. ``BrowserwrightDaemon.*`` RPCs
8
+ (``getActiveTab``, ``disconnect``, ``subscribeFocus``, ``uiState``) are
9
+ answered by the daemon itself, not forwarded upstream.
10
+ - Events fan out to the client: ``upstreamClosed``, ``activeTabChanged``,
11
+ ``upstreamReady`` etc.
12
+
13
+ The Skill side here is a single-threaded sync wrapper that ``Session`` holds
14
+ as its sole daemon client (Mode A — the one-shot subprocess resolver — was
15
+ removed; the skill always talks to a running daemon over its socket).
16
+
17
+ Discovery:
18
+ - Endpoint path comes from ``browserwright-daemon status --json`` (or directly
19
+ ``${XDG_RUNTIME_DIR:-/tmp}/browserwright-daemon.sock``).
20
+ - On connect, the client appends ``?client=skill-repl&session=<id>`` to the URL.
21
+
22
+ The CDPSession transport connects to our Mode B unix endpoint (translated to
23
+ ``ws+unix://``). :func:`client_for_session` builds the client from a resolved
24
+ ledger record; ``DaemonUnavailable`` surfaces lazily when no daemon socket is
25
+ reachable.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import os
31
+ import socket
32
+ import subprocess
33
+ import threading
34
+ from pathlib import Path
35
+ from typing import Any, Optional
36
+
37
+ from .errors import DaemonUnavailable
38
+
39
+
40
+ def _default_socket_path() -> Path:
41
+ base = os.environ.get("XDG_RUNTIME_DIR") or "/tmp"
42
+ return Path(base) / "browserwright-daemon.sock"
43
+
44
+
45
+ def _windows_port_file() -> Path:
46
+ return Path(os.environ.get("TEMP", "/tmp")) / "browserwright-daemon.port"
47
+
48
+
49
+ class ModeBClient:
50
+ """Mode B daemon endpoint. Use ``connect()`` to confirm reachability;
51
+ ``ws_url()`` returns the CDP-compatible URL Skill's ``CDPSession`` can
52
+ open. Active-tab / disconnect / uiState are sent over the same socket.
53
+ """
54
+
55
+ def __init__(self) -> None:
56
+ self._endpoint: Optional[str] = None
57
+ self._transport: Optional[str] = None # "unix" or "tcp"
58
+ self._token: Optional[str] = None
59
+ self._cached_ws: Optional[str] = None
60
+ # client label sent on the ws query string for daemon observability;
61
+ # session-bound clients override this with ``skill-s<id>``.
62
+ self._client_label: str = "skill-repl"
63
+ # The session id, emitted on the ws query as ``?session=<id>`` — this is
64
+ # the key the daemon's dispatcher routes on (rdp sessions reach their
65
+ # own UpstreamContext through it). None for the bare REPL client.
66
+ self._session_id: Optional[str] = None
67
+
68
+ # ---- endpoint discovery ---------------------------------------------
69
+
70
+ def discover(self) -> dict:
71
+ """Return ``{"transport": ..., "path": ..., "host": ..., "port": ...,
72
+ "token": ...}``. Probes the daemon's ``status --json`` first; falls
73
+ back to direct path inspection on POSIX so we still work when
74
+ daemon CLI is on a slow path."""
75
+ try:
76
+ proc = subprocess.run(
77
+ ["browserwright-daemon", "status", "--json"],
78
+ capture_output=True, text=True, timeout=3,
79
+ )
80
+ if proc.returncode == 0 and proc.stdout.strip():
81
+ info = json.loads(proc.stdout)
82
+ if info.get("alive"):
83
+ return self._normalize_endpoint_info(info)
84
+ except (FileNotFoundError, subprocess.TimeoutExpired, json.JSONDecodeError):
85
+ pass
86
+
87
+ # POSIX fallback: just look at the well-known socket path.
88
+ if os.name != "nt":
89
+ sock_path = _default_socket_path()
90
+ if sock_path.exists():
91
+ return {"transport": "unix", "path": str(sock_path)}
92
+
93
+ # Windows fallback: look at the port file.
94
+ port_file = _windows_port_file()
95
+ if port_file.exists():
96
+ try:
97
+ data = json.loads(port_file.read_text(encoding="utf-8"))
98
+ if "port" in data and "token" in data:
99
+ return {
100
+ "transport": "tcp",
101
+ "host": data.get("host", "127.0.0.1"),
102
+ "port": int(data["port"]),
103
+ "token": data["token"],
104
+ }
105
+ except (OSError, ValueError):
106
+ pass
107
+ raise DaemonUnavailable("no Mode B endpoint — the daemon is not running")
108
+
109
+ @staticmethod
110
+ def _normalize_endpoint_info(info: dict) -> dict:
111
+ # `status --json` may nest the transport details or flatten them; be
112
+ # tolerant of both shapes daemon-implementer may ship.
113
+ out = dict(info)
114
+ if "endpoint" in info and isinstance(info["endpoint"], dict):
115
+ out.update(info["endpoint"])
116
+ out.pop("alive", None)
117
+ # Drop everything outside our known schema so callers don't pin on it.
118
+ return {k: out[k] for k in ("transport", "path", "host", "port", "token", "name")
119
+ if k in out}
120
+
121
+ # ---- connect probe + ws_url ----------------------------------------
122
+
123
+ def is_alive(self) -> bool:
124
+ """Cheap reachability check. Returns True iff the daemon's socket
125
+ accepts a `ping`-style request."""
126
+ try:
127
+ ep = self.discover()
128
+ except DaemonUnavailable:
129
+ return False
130
+ try:
131
+ return self._ping(ep)
132
+ except OSError:
133
+ return False
134
+
135
+ def wait_until_alive(self, timeout: float = 8.0, interval: float = 0.2) -> bool:
136
+ """Poll :meth:`is_alive` until the daemon answers or ``timeout`` passes.
137
+ Used after a respawn so the caller doesn't race the new daemon's bind.
138
+ Returns whether the daemon came up in time."""
139
+ import time
140
+ deadline = time.monotonic() + timeout
141
+ while time.monotonic() < deadline:
142
+ self.invalidate()
143
+ if self.is_alive():
144
+ return True
145
+ time.sleep(interval)
146
+ return False
147
+
148
+ def _ping(self, ep: dict) -> bool:
149
+ """Open a short-lived raw socket to the daemon endpoint and verify
150
+ it's responsive. We avoid a CDP request because the upstream may
151
+ not be open yet — we just want to know the daemon's accept loop is
152
+ live."""
153
+ if ep["transport"] == "unix":
154
+ s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
155
+ s.settimeout(1.5)
156
+ try:
157
+ s.connect(ep["path"])
158
+ except OSError:
159
+ s.close()
160
+ return False
161
+ s.close()
162
+ return True
163
+ # TCP / windows
164
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
165
+ s.settimeout(1.5)
166
+ try:
167
+ s.connect((ep.get("host", "127.0.0.1"), int(ep["port"])))
168
+ except OSError:
169
+ s.close()
170
+ return False
171
+ s.close()
172
+ return True
173
+
174
+ def ws_url(self, *, client_label: Optional[str] = None) -> str:
175
+ """Return a ``ws+unix://`` or ``ws://`` URL the ``CDPSession`` can open.
176
+
177
+ Caches the result; call ``invalidate()`` to force a re-resolve (e.g.
178
+ after a 1011 close).
179
+ """
180
+ if client_label is None:
181
+ client_label = self._client_label
182
+ if self._cached_ws:
183
+ return self._cached_ws
184
+ ep = self.discover()
185
+ # Session-bound clients carry ``?session=<id>`` — the daemon dispatcher
186
+ # routes on this (not on the client label). Without it an rdp session
187
+ # resolves to None → the shared (extension) context.
188
+ session_q = f"&session={self._session_id}" if self._session_id else ""
189
+ if ep["transport"] == "unix":
190
+ # websockets.sync.client.connect doesn't support ws+unix:// natively;
191
+ # we hand it a pre-built socket via the `sock=` kwarg instead.
192
+ # Return a sentinel URL the CDPSession layer recognises.
193
+ url = f"ws+unix://{ep['path']}?client={client_label}{session_q}"
194
+ else:
195
+ tok = ep.get("token", "")
196
+ host = ep.get("host", "127.0.0.1")
197
+ port = ep["port"]
198
+ url = f"ws://{host}:{port}?token={tok}&client={client_label}{session_q}"
199
+ self._cached_ws = url
200
+ self._endpoint = ep.get("path") or f"{ep.get('host')}:{ep.get('port')}"
201
+ self._transport = ep["transport"]
202
+ self._token = ep.get("token")
203
+ return url
204
+
205
+ def invalidate(self) -> None:
206
+ self._cached_ws = None
207
+
208
+ # Mode A / Mode B protocol alias — Session._resolve_ws_url() picks this.
209
+ def resolve_ws_url(self) -> str:
210
+ return self.ws_url()
211
+
212
+ # ---- backend identity ----------------------------------------------
213
+
214
+ def get_backend_info(self) -> Optional[dict]:
215
+ """Return the running daemon's reported backend, or ``None`` if the
216
+ daemon doesn't support the ``BrowserwrightDaemon.getBackendInfo`` RPC.
217
+ Surfaced via ``Session.backend_name`` so primitives can branch on
218
+ backend quirks (e.g. extension's explicit-attach model).
219
+
220
+ We use the CLI shim ``browserwright-daemon backend-info --name <X>
221
+ --json`` (zero-side-effect, mirrors doctor's contract) because that's
222
+ the easiest path that doesn't require us to open a ws first.
223
+ """
224
+ try:
225
+ cmd = ["browserwright-daemon", "backend-info", "--json"]
226
+ if self._session_id:
227
+ cmd += ["--session", self._session_id]
228
+ proc = subprocess.run(
229
+ cmd,
230
+ capture_output=True, text=True, timeout=5,
231
+ )
232
+ except (FileNotFoundError, subprocess.TimeoutExpired):
233
+ return None
234
+ if proc.returncode != 0 or not proc.stdout.strip():
235
+ return None
236
+ try:
237
+ return json.loads(proc.stdout)
238
+ except json.JSONDecodeError:
239
+ return None
240
+
241
+ # ---- minimal one-shot RPC (subprocess CLI) -------------------------
242
+ # These let a caller ask the *same* daemon for BrowserwrightDaemon.* answers
243
+ # via its CLI subcommands without opening a ws. The interesting ones
244
+ # (subscribeFocus, uiState) require a live ws and are handled inside
245
+ # CDPSession instead.
246
+
247
+ def active_tab(self) -> Optional[dict]:
248
+ """Best-guess user-active tab via the ``browserwright-daemon active-tab``
249
+ CLI subcommand; the ws-based ``BrowserwrightDaemon.getActiveTab`` RPC is
250
+ wired into ``Session`` when a CDP connection is up."""
251
+ if not self._session_id:
252
+ return None
253
+ try:
254
+ proc = subprocess.run(
255
+ ["browserwright-daemon", "active-tab", "--json",
256
+ "--session", self._session_id],
257
+ capture_output=True, text=True, timeout=8,
258
+ )
259
+ except (FileNotFoundError, subprocess.TimeoutExpired):
260
+ return None
261
+ if proc.returncode != 0 or not proc.stdout.strip():
262
+ return None
263
+ try:
264
+ data = json.loads(proc.stdout)
265
+ except json.JSONDecodeError:
266
+ return None
267
+ return {
268
+ "targetId": data.get("targetId"),
269
+ "url": data.get("url", ""),
270
+ "title": data.get("title", ""),
271
+ "accuracy": data.get("accuracy", "unknown"),
272
+ "since_seconds": data.get("since_seconds"),
273
+ }
274
+
275
+ def attach_active(self) -> Optional[dict]:
276
+ """v0.5.4: ask the daemon's extension backend to attach the
277
+ currently-focused-window active tab — bypasses the popup click.
278
+
279
+ Returns ``{sessionId, targetId, tabId, url, title}`` on success,
280
+ ``None`` if the daemon errored or isn't reachable. The verb is unified:
281
+ on extension it adopts the focused tab into the session's group; on rdp
282
+ the daemon returns the session's current front tab. It never returns
283
+ -32601 to the agent.
284
+ """
285
+ if not self._session_id:
286
+ return None
287
+ try:
288
+ proc = subprocess.run(
289
+ ["browserwright-daemon", "attach-active", "--json",
290
+ "--session", self._session_id],
291
+ capture_output=True, text=True, timeout=20,
292
+ )
293
+ except (FileNotFoundError, subprocess.TimeoutExpired):
294
+ return None
295
+ if proc.returncode != 0 or not proc.stdout.strip():
296
+ return None
297
+ try:
298
+ return json.loads(proc.stdout)
299
+ except json.JSONDecodeError:
300
+ return None
301
+
302
+ def disconnect_upstream(self, reason: str = "skill_idle") -> bool:
303
+ """Ask the daemon to close its upstream ws (banner disappears) but
304
+ keep our socket alive. Used by REPL idle policy."""
305
+ if not self._session_id:
306
+ return False
307
+ try:
308
+ proc = subprocess.run(
309
+ ["browserwright-daemon", "disconnect",
310
+ "--reason", reason, "--session", self._session_id],
311
+ capture_output=True, text=True, timeout=5,
312
+ )
313
+ return proc.returncode == 0
314
+ except (FileNotFoundError, subprocess.TimeoutExpired):
315
+ return False
316
+
317
+ # ---- Phase B: open_background / close_tab CLI shims ---------------
318
+
319
+ def open_background(self, url: str, *, group: str = "Agent") -> Optional[dict]:
320
+ """Phase B Feature 1 — invoke ``browserwright-daemon open-background``.
321
+
322
+ Returns the parsed JSON result (``{sessionId,targetId,tabId,url,
323
+ title,groupId}``) or ``None`` if the CLI was unavailable. On
324
+ failure the captured subprocess detail is stashed on
325
+ ``self.last_cli_error`` so the caller can surface a meaningful
326
+ message instead of guessing. The daemon-side handler requires
327
+ backend=extension; on any other backend the call surfaces an
328
+ error (returncode != 0) which is recorded here verbatim.
329
+ """
330
+ self.last_cli_error = None
331
+ cmd = ["browserwright-daemon", "open-background",
332
+ "--url", url,
333
+ "--group", group]
334
+ if self._session_id:
335
+ cmd += ["--session", self._session_id]
336
+ try:
337
+ proc = subprocess.run(
338
+ cmd, capture_output=True, text=True, timeout=15,
339
+ )
340
+ except (FileNotFoundError, subprocess.TimeoutExpired) as e:
341
+ self.last_cli_error = f"subprocess failed: {e!r}"
342
+ return None
343
+ if proc.returncode != 0 or not proc.stdout.strip():
344
+ self.last_cli_error = (
345
+ f"`{' '.join(cmd)}` exit={proc.returncode}; "
346
+ f"stderr={proc.stderr.strip() or '<empty>'}; "
347
+ f"stdout={proc.stdout.strip() or '<empty>'}"
348
+ )
349
+ return None
350
+ try:
351
+ return json.loads(proc.stdout)
352
+ except json.JSONDecodeError:
353
+ self.last_cli_error = (
354
+ f"`{' '.join(cmd)}` returned non-JSON stdout: {proc.stdout!r}"
355
+ )
356
+ return None
357
+
358
+ def close_tab(
359
+ self, session_id: str | None = None, *, target_id: str | None = None,
360
+ ) -> Optional[dict]:
361
+ """Phase B Feature 2 — invoke ``browserwright-daemon close-tab``.
362
+
363
+ Pass ``target_id`` (the ``ext-tab-N`` string returned by
364
+ ``open_background``) when calling from a fresh subprocess context —
365
+ the CLI's transient ws can't see other clients' session bindings.
366
+ ``session_id`` works only from a persistent ws (e.g. inside the
367
+ Skill REPL where the same client connection issued the open).
368
+
369
+ Returns ``{"ok":True,"tabId":N}`` on success or ``None`` when the
370
+ CLI is unreachable / the daemon errored.
371
+ """
372
+ if not session_id and not target_id:
373
+ return None
374
+ self.last_cli_error = None
375
+ cmd = ["browserwright-daemon", "close-tab"]
376
+ if self._session_id:
377
+ cmd += ["--session", self._session_id]
378
+ if target_id:
379
+ cmd += ["--target-id", target_id]
380
+ if session_id:
381
+ cmd += ["--session-id", session_id]
382
+ try:
383
+ proc = subprocess.run(
384
+ cmd, capture_output=True, text=True, timeout=10,
385
+ )
386
+ except (FileNotFoundError, subprocess.TimeoutExpired) as e:
387
+ self.last_cli_error = f"subprocess failed: {e!r}"
388
+ return None
389
+ if proc.returncode != 0 or not proc.stdout.strip():
390
+ self.last_cli_error = (
391
+ f"`{' '.join(cmd)}` exit={proc.returncode}; "
392
+ f"stderr={proc.stderr.strip() or '<empty>'}; "
393
+ f"stdout={proc.stdout.strip() or '<empty>'}"
394
+ )
395
+ return None
396
+ try:
397
+ return json.loads(proc.stdout)
398
+ except json.JSONDecodeError:
399
+ self.last_cli_error = (
400
+ f"`{' '.join(cmd)}` returned non-JSON stdout: {proc.stdout!r}"
401
+ )
402
+ return None
403
+
404
+ def doctor(self) -> dict:
405
+ """Forward ``browserwright-daemon doctor --json`` over a subprocess."""
406
+ try:
407
+ proc = subprocess.run(
408
+ ["browserwright-daemon", "doctor", "--json"],
409
+ capture_output=True, text=True, timeout=10,
410
+ )
411
+ except (FileNotFoundError, subprocess.TimeoutExpired) as e:
412
+ return {"schema_version": 1, "backends": [], "error": str(e),
413
+ "skill_synthetic": True}
414
+ if proc.returncode != 0:
415
+ return {"schema_version": 1, "backends": [],
416
+ "error": (proc.stderr or proc.stdout).strip(),
417
+ "skill_synthetic": True}
418
+ try:
419
+ return json.loads(proc.stdout)
420
+ except json.JSONDecodeError:
421
+ return {"schema_version": 1, "backends": [],
422
+ "error": "doctor output was not JSON",
423
+ "skill_synthetic": True}
424
+
425
+ # ---- S6 (A2-a): daemon ↔ code version coherence --------------------
426
+ #
427
+ # A daemon that's been running across a package upgrade speaks the OLD
428
+ # protocol — newer RPC methods come back as -32601 "unknown method", which
429
+ # looks like a mysterious failure (the session-1 pothole). We detect the
430
+ # version skew up front and restart the daemon so it picks up the new code.
431
+
432
+ def running_daemon_version(self) -> Optional[str]:
433
+ """Version the *running* daemon advertises via ``status --json``.
434
+
435
+ Returns ``None`` when the daemon isn't reachable OR is too old to
436
+ advertise a version. A missing version is deliberately indistinguishable
437
+ from "no daemon" here; the coherence guard disambiguates via
438
+ :meth:`is_alive`."""
439
+ try:
440
+ proc = subprocess.run(
441
+ ["browserwright-daemon", "status", "--json"],
442
+ capture_output=True, text=True, timeout=3,
443
+ )
444
+ except (FileNotFoundError, subprocess.TimeoutExpired):
445
+ return None
446
+ if proc.returncode != 0 or not proc.stdout.strip():
447
+ return None
448
+ try:
449
+ info = json.loads(proc.stdout)
450
+ except json.JSONDecodeError:
451
+ return None
452
+ v = info.get("version")
453
+ return v if isinstance(v, str) and v else None
454
+
455
+ def installed_daemon_version(self) -> Optional[str]:
456
+ """Version of the ``browserwright-daemon`` package installed on disk, read
457
+ from ``browserwright-daemon version``. ``None`` when it can't be determined
458
+ (in which case the coherence guard declines to act — better than
459
+ thrash-restarting on a comparison we can't make)."""
460
+ try:
461
+ proc = subprocess.run(
462
+ ["browserwright-daemon", "version"],
463
+ capture_output=True, text=True, timeout=3,
464
+ )
465
+ except (FileNotFoundError, subprocess.TimeoutExpired):
466
+ return None
467
+ if proc.returncode != 0:
468
+ return None
469
+ # Output shape: "browserwright-daemon X.Y.Z" — take the last whitespace token.
470
+ text = (proc.stdout or "").strip()
471
+ if not text:
472
+ return None
473
+ return text.split()[-1] or None
474
+
475
+ def _stop_daemon(self) -> None:
476
+ """Stop the running daemon (mirrors the PID-guarded ``stop`` CLI)."""
477
+ try:
478
+ subprocess.run(
479
+ ["browserwright-daemon", "stop"],
480
+ capture_output=True, text=True, timeout=10,
481
+ )
482
+ except (FileNotFoundError, subprocess.TimeoutExpired):
483
+ pass
484
+
485
+ def _spawn_daemon(self, backend: Optional[str] = None) -> None:
486
+ """Spawn a fresh daemon. Detached so it outlives this process, mirroring
487
+ how cold-start launches ``serve``.
488
+
489
+ ``backend`` pins ``--backend`` on the respawn. The daemon refuses to
490
+ start under auto (it would silently fall back to rdp and leave the
491
+ extension relay un-bound), so a restart that drops the backend would
492
+ kill the daemon. Callers that know the backend the old daemon was
493
+ serving (see ``ensure_version_coherent``) pass it through so the
494
+ replacement keeps serving the same backend."""
495
+ cmd = ["browserwright-daemon", "serve"]
496
+ if backend:
497
+ cmd += ["--backend", backend]
498
+ try:
499
+ subprocess.Popen(
500
+ cmd,
501
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
502
+ stdin=subprocess.DEVNULL, start_new_session=True,
503
+ )
504
+ except FileNotFoundError:
505
+ pass
506
+
507
+ def ensure_version_coherent(self) -> bool:
508
+ """If the running daemon's version differs from the installed package
509
+ version (or it advertises none at all), stop + respawn it so the new
510
+ code takes effect. Returns ``True`` iff a restart was performed.
511
+
512
+ No-ops (returns ``False``) when:
513
+ - there's no daemon running at all (cold-start owns spawning), or
514
+ - the installed version can't be determined (can't compare safely).
515
+
516
+ Generic by construction: it never inspects RPC methods or specific
517
+ version strings — any future skew is handled the same way."""
518
+ installed = self.installed_daemon_version()
519
+ if installed is None:
520
+ return False
521
+ running = self.running_daemon_version()
522
+ if running is None:
523
+ # Distinguish "no daemon" (don't touch) from "daemon too old to
524
+ # report a version" (stale → restart).
525
+ if not self.is_alive():
526
+ return False
527
+ elif running == installed:
528
+ return False
529
+ # running is None-but-alive (legacy) OR running != installed → stale.
530
+ # Capture the backend the stale daemon is serving BEFORE we stop it, so
531
+ # the respawn pins the same backend. The daemon refuses to start under
532
+ # auto, so dropping the backend here would leave it dead. A daemon too
533
+ # old to answer backend-info yields None → respawn without a pin and let
534
+ # the daemon's own guard decide (BD_BACKEND/default_backend).
535
+ prior = self.get_backend_info() or {}
536
+ backend = prior.get("backend") or None
537
+ self._stop_daemon()
538
+ self._spawn_daemon(backend=backend)
539
+ self.invalidate()
540
+ return True
541
+
542
+ # ---- S6 (A2-b): rewrite -32601 "unknown method" --------------------
543
+
544
+ @staticmethod
545
+ def is_stale_method_error(error: Any) -> bool:
546
+ """True iff a JSON-RPC error object is a ``-32601`` "method not found".
547
+ Generic — keys only on the standard code, never on a method name."""
548
+ return isinstance(error, dict) and error.get("code") == -32601
549
+
550
+ @staticmethod
551
+ def explain_rpc_error(method: str, error: Any) -> str:
552
+ """Turn a JSON-RPC error object into a human-actionable message.
553
+
554
+ For ``-32601`` (unknown method) — the signature of a daemon running
555
+ older code than what's installed — we rewrite the raw envelope into a
556
+ clear "the daemon is stale, restart it" message that names the offending
557
+ method. Any other code is surfaced as its own message verbatim (those
558
+ are real protocol errors, not staleness)."""
559
+ if ModeBClient.is_stale_method_error(error):
560
+ return (
561
+ f"the running daemon doesn't have method {method!r} — it is "
562
+ f"likely stale (older than the installed code). Restart it with "
563
+ f"`browserwright-daemon stop && browserwright-daemon serve`."
564
+ )
565
+ if isinstance(error, dict) and error.get("message"):
566
+ return str(error["message"])
567
+ return f"RPC {method!r} failed: {error!r}"
568
+
569
+
570
+ # ---- factory: build a client bound to a resolved session ------------
571
+
572
+ def client_for_session(record: dict) -> ModeBClient:
573
+ """Build a Mode B client for the single global daemon (fixed socket).
574
+
575
+ The connection carries the session identity as its client label
576
+ (``skill-s<id>``) for daemon-side observability and per-session routing;
577
+ falls back to the default ``skill-repl`` when the record has no id.
578
+
579
+ Construction is lazy — ``DaemonUnavailable`` surfaces only when a primitive
580
+ first resolves the ws — but when the daemon *is* already up we restart it if
581
+ it's running stale code (S6 / A2-a), so we don't lean on newer RPCs against
582
+ an old protocol."""
583
+ client = ModeBClient()
584
+ sid = record.get("id")
585
+ if sid:
586
+ client._client_label = f"skill-s{sid}"
587
+ client._session_id = str(sid)
588
+ if client.is_alive() and client.ensure_version_coherent():
589
+ client.wait_until_alive()
590
+ return client