browserwright 0.17.0__tar.gz → 0.17.2__tar.gz

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 (111) hide show
  1. {browserwright-0.17.0/src/browserwright.egg-info → browserwright-0.17.2}/PKG-INFO +1 -1
  2. {browserwright-0.17.0 → browserwright-0.17.2}/pyproject.toml +1 -1
  3. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/config.py +43 -0
  4. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/server/facade.py +58 -9
  5. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/server/facade_extension.py +13 -2
  6. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/server/listener.py +15 -3
  7. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/server/relay.py +46 -3
  8. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon_url.py +48 -0
  9. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/errors.py +12 -2
  10. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/health.py +81 -0
  11. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/repl/playwright_handle.py +77 -3
  12. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/session.py +7 -2
  13. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/session_create.py +8 -0
  14. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/session_runtime.py +9 -1
  15. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/skill_runtime.md +33 -0
  16. {browserwright-0.17.0 → browserwright-0.17.2/src/browserwright.egg-info}/PKG-INFO +1 -1
  17. {browserwright-0.17.0 → browserwright-0.17.2}/LICENSE +0 -0
  18. {browserwright-0.17.0 → browserwright-0.17.2}/README.md +0 -0
  19. {browserwright-0.17.0 → browserwright-0.17.2}/setup.cfg +0 -0
  20. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/__init__.py +0 -0
  21. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/__main__.py +0 -0
  22. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/_executor/__init__.py +0 -0
  23. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/_executor/__main__.py +0 -0
  24. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/_executor/client.py +0 -0
  25. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/_executor/process.py +0 -0
  26. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/_executor/protocol.py +0 -0
  27. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/_text.py +0 -0
  28. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/cdp.py +0 -0
  29. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/cli.py +0 -0
  30. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/__init__.py +0 -0
  31. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/_ipc.py +0 -0
  32. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/_net.py +0 -0
  33. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/_rpc.py +0 -0
  34. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/_stale.py +0 -0
  35. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/backends/__init__.py +0 -0
  36. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/backends/base.py +0 -0
  37. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/backends/cdp.py +0 -0
  38. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/backends/extension.py +0 -0
  39. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/cli.py +0 -0
  40. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/doctor.py +0 -0
  41. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/errors.py +0 -0
  42. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/launch_chrome.py +0 -0
  43. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/launchagent.py +0 -0
  44. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/observability.py +0 -0
  45. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/platforms.py +0 -0
  46. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/probe.py +0 -0
  47. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/relay_status.py +0 -0
  48. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/resolver.py +0 -0
  49. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/restart_guard.py +0 -0
  50. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/server/__init__.py +0 -0
  51. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/server/daemon.py +0 -0
  52. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/server/exec_relay.py +0 -0
  53. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/server/executor_registry.py +0 -0
  54. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/server/extension_upstream.py +0 -0
  55. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/server/proxy.py +0 -0
  56. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/server/state.py +0 -0
  57. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/server/status.py +0 -0
  58. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/server/upstream.py +0 -0
  59. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/server/verbs.py +0 -0
  60. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/supervise.py +0 -0
  61. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/userscripts.py +0 -0
  62. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/discovery.py +0 -0
  63. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/install.py +0 -0
  64. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/memory/__init__.py +0 -0
  65. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/memory/_lock.py +0 -0
  66. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/memory/_md.py +0 -0
  67. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/memory/_yaml.py +0 -0
  68. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/memory/global_mem.py +0 -0
  69. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/memory/site_mem.py +0 -0
  70. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/mode_b_client.py +0 -0
  71. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/output_schema.py +0 -0
  72. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/primitives/__init__.py +0 -0
  73. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/primitives/discovery_api.py +0 -0
  74. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/primitives/http.py +0 -0
  75. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/primitives/site.py +0 -0
  76. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/repl/__init__.py +0 -0
  77. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/repl/_md_convert.py +0 -0
  78. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/repl/_md_normalize.py +0 -0
  79. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/repl/_namespace.py +0 -0
  80. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/repl/_smart_goto.py +0 -0
  81. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/repl/inline.py +0 -0
  82. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/repl/markdown.py +0 -0
  83. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/repl/snapshot.py +0 -0
  84. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/repl/vendor/README.md +0 -0
  85. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/repl/vendor/readability.js +0 -0
  86. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/session_ctx.py +0 -0
  87. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/session_registry.py +0 -0
  88. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/site_skills_starter/github.com/SKILL.md +0 -0
  89. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/site_skills_starter/github.com/memory.md +0 -0
  90. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/site_skills_starter/github.com/tasks/list_issues.py +0 -0
  91. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/site_skills_starter/google.com/SKILL.md +0 -0
  92. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/site_skills_starter/google.com/memory.md +0 -0
  93. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/site_skills_starter/google.com/tasks/search.py +0 -0
  94. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/site_skills_starter/producthunt.com/SKILL.md +0 -0
  95. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/site_skills_starter/producthunt.com/memory.md +0 -0
  96. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/site_skills_starter/producthunt.com/tasks/today.py +0 -0
  97. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/site_skills_starter/wikipedia.org/SKILL.md +0 -0
  98. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/site_skills_starter/wikipedia.org/memory.md +0 -0
  99. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/site_skills_starter/wikipedia.org/tasks/lookup.py +0 -0
  100. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/site_skills_starter/ycombinator.com/SKILL.md +0 -0
  101. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/site_skills_starter/ycombinator.com/memory.md +0 -0
  102. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/site_skills_starter/ycombinator.com/tasks/front_page.py +0 -0
  103. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/skill_doc.py +0 -0
  104. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/tab_surface.py +0 -0
  105. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/task_runner.py +0 -0
  106. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/version.py +0 -0
  107. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright.egg-info/SOURCES.txt +0 -0
  108. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright.egg-info/dependency_links.txt +0 -0
  109. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright.egg-info/entry_points.txt +0 -0
  110. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright.egg-info/requires.txt +0 -0
  111. {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: browserwright
3
- Version: 0.17.0
3
+ Version: 0.17.2
4
4
  Summary: Browserwright — let AI/code agents drive a real or isolated browser and author userscripts. Single package: the agent-facing REPL/site-skills/memory layer plus the bundled browser-resolving daemon (CDP proxy + extension/cloud backends).
5
5
  License-Expression: AGPL-3.0-only
6
6
  Requires-Python: >=3.11
@@ -15,7 +15,7 @@ name = "browserwright"
15
15
  # stamping regex is `^(version\s*=\s*)["'][^"']+["']\s*$` and CI aborts unless
16
16
  # it matches exactly once. `tests/skill/test_release_versioning.py` enforces all
17
17
  # of that before a tag is ever pushed. See RELEASING.md.
18
- version = "0.17.0"
18
+ version = "0.17.2"
19
19
  description = "Browserwright — let AI/code agents drive a real or isolated browser and author userscripts. Single package: the agent-facing REPL/site-skills/memory layer plus the bundled browser-resolving daemon (CDP proxy + extension/cloud backends)."
20
20
  requires-python = ">=3.11"
21
21
  license = "AGPL-3.0-only"
@@ -35,6 +35,49 @@ DEFAULT_FACADE_PORT = 19990
35
35
  # interface (e.g. `0.0.0.0` or a specific tailnet IP) for remote CDP clients.
36
36
  DEFAULT_FACADE_HOST = "127.0.0.1"
37
37
 
38
+ #: The loopback address every local client falls back to (`DEFAULT_DAEMON_URL`
39
+ #: in `daemon_url.py` is built from it).
40
+ LOOPBACK_HOST = "127.0.0.1"
41
+
42
+ #: Hosts whose bind ALREADY covers loopback, so no second listener is needed:
43
+ #: loopback itself, and the two wildcards.
44
+ _LOOPBACK_COVERING_HOSTS = frozenset({
45
+ "", "0.0.0.0", "::", "::0", "*",
46
+ })
47
+
48
+
49
+ def needs_loopback_cobind(host: str) -> bool:
50
+ """Whether binding *host* leaves 127.0.0.1 unserved.
51
+
52
+ A bind to a **specific** address listens on that address only — binding
53
+ `100.72.20.32` (the documented `--facade-host <tailnet-ip>` remote-access
54
+ setup) means `127.0.0.1:19990` is genuinely not listening, so every local
55
+ client that resolves the loopback default gets ECONNREFUSED while the
56
+ daemon is demonstrably up. The endpoint state file is supposed to bridge
57
+ that, but it is a best-effort channel (an unreadable or differently-rooted
58
+ `XDG_RUNTIME_DIR` silently falls through to the default). Remote access
59
+ must not cost local access, so the facade co-binds loopback whenever this
60
+ returns True.
61
+
62
+ Wildcards (`0.0.0.0`, `::`) and loopback itself already serve 127.0.0.1.
63
+ """
64
+ text = (host or "").strip()
65
+ if text.lower() in _LOOPBACK_COVERING_HOSTS:
66
+ return False
67
+ if text.lower() in ("localhost", "localhost."):
68
+ return False
69
+ import ipaddress
70
+
71
+ try:
72
+ addr = ipaddress.ip_address(text.strip("[]"))
73
+ except ValueError:
74
+ # A hostname we cannot classify without resolving. Co-binding loopback
75
+ # is harmless (it fails soft) and is the safer default.
76
+ return True
77
+ if addr.is_loopback or addr.is_unspecified:
78
+ return False
79
+ return True
80
+
38
81
 
39
82
  def check_name(name: str) -> str:
40
83
  """Path-traversal guard for filesystem-bound names (e.g. `--profile`)."""
@@ -64,7 +64,8 @@ import websockets
64
64
  from websockets.asyncio.server import ServerConnection, serve
65
65
 
66
66
  from .. import _ipc
67
- from ..config import DEFAULT_FACADE_PORT, Config
67
+ from ..config import (DEFAULT_FACADE_PORT, LOOPBACK_HOST, Config,
68
+ needs_loopback_cobind)
68
69
  from .. import __version__
69
70
  from ..errors import Unavailable
70
71
  from ..resolver import resolve as resolve_upstream
@@ -128,6 +129,14 @@ class PlaywrightFacade:
128
129
  self._port = port
129
130
  self._host = host
130
131
  self._server: Any = None
132
+ # A bind to a *specific* non-loopback IP (the documented remote-access
133
+ # setup, `--facade-host <tailnet-ip>`) does not listen on 127.0.0.1 at
134
+ # all, which silently breaks every LOCAL client — they resolve the
135
+ # loopback default when the endpoint state file is not visible to them
136
+ # (different XDG_RUNTIME_DIR, sandboxed /tmp, another user). Remote
137
+ # access must not cost local access, so we additionally bind loopback
138
+ # on the same port and serve both from one handler.
139
+ self._loopback_server: Any = None
131
140
  # PR2: for the extension backend the facade has no resolvable upstream
132
141
  # ws — it bridges through the daemon's shared RelayServer. The listener
133
142
  # passes a getter (the relay is created during run_serve startup, and
@@ -149,12 +158,12 @@ class PlaywrightFacade:
149
158
 
150
159
  # ---- lifecycle -------------------------------------------------------
151
160
 
152
- async def start(self) -> int:
153
- """Bind the facade ws+HTTP server. Returns the actually-bound port."""
154
- self._server = await serve(
161
+ async def _serve_on(self, host: str, port: int) -> Any:
162
+ """Bind one ws+HTTP listener for this facade on ``host:port``."""
163
+ return await serve(
155
164
  self._handle_client,
156
- self._host,
157
- self._port,
165
+ host,
166
+ port,
158
167
  process_request=self._process_request,
159
168
  compression=None,
160
169
  ping_interval=20,
@@ -164,6 +173,16 @@ class PlaywrightFacade:
164
173
  # frames — see `_MAX_WS_SIZE`.
165
174
  max_size=_MAX_WS_SIZE,
166
175
  )
176
+
177
+ async def start(self) -> int:
178
+ """Bind the facade ws+HTTP server. Returns the actually-bound port.
179
+
180
+ When ``host`` names a specific non-loopback address, loopback is bound
181
+ as a SECOND listener on the same port so local clients keep working
182
+ (see ``_loopback_server``). That co-bind is best-effort: it must never
183
+ turn a working remote bind into a fatal startup failure.
184
+ """
185
+ self._server = await self._serve_on(self._host, self._port)
167
186
  for sock in self._server.sockets:
168
187
  sa = sock.getsockname()
169
188
  if isinstance(sa, tuple) and len(sa) >= 2:
@@ -171,6 +190,32 @@ class PlaywrightFacade:
171
190
  break
172
191
  logger.info("endpoint listening on http://%s:%d (%s)",
173
192
  self._host, self._port, ", ".join(_WS_PATHS))
193
+ if needs_loopback_cobind(self._host):
194
+ try:
195
+ self._loopback_server = await self._serve_on(
196
+ LOOPBACK_HOST, self._port)
197
+ logger.info(
198
+ "endpoint also listening on http://%s:%d "
199
+ "(loopback co-bind so local clients keep working)",
200
+ LOOPBACK_HOST, self._port)
201
+ except OSError as e:
202
+ # Someone else holds loopback:port, or the ephemeral port the
203
+ # primary bind won is taken on loopback. Remote clients still
204
+ # work; local ones need an explicit endpoint. Say so loudly —
205
+ # this is exactly the failure mode that reads as "the daemon
206
+ # is running but nothing can reach it".
207
+ self._loopback_server = None
208
+ logger.warning(
209
+ "endpoint could NOT also bind %s:%d (%s) — LOCAL clients "
210
+ "that resolve the loopback default will fail to connect. "
211
+ "Point them at http://%s:%d via $BW_DAEMON_URL.",
212
+ LOOPBACK_HOST, self._port, e, self._host, self._port)
213
+ logger.warning(
214
+ "endpoint is bound to non-loopback %s — it is reachable from "
215
+ "every host that can route there, and it drives a real "
216
+ "browser with no application-layer auth (ADR-0011). Keep it "
217
+ "on a trusted tunnel/tailnet only.",
218
+ self._host)
174
219
  if self._relay_getter is not None:
175
220
  self._reaper_task = asyncio.create_task(self._auto_reaper_loop())
176
221
  return self._port
@@ -192,10 +237,14 @@ class PlaywrightFacade:
192
237
  with contextlib.suppress(asyncio.CancelledError, Exception):
193
238
  await task
194
239
  self._sessions.clear()
195
- self._server.close()
196
- with contextlib.suppress(Exception):
197
- await self._server.wait_closed()
240
+ for srv in (self._server, self._loopback_server):
241
+ if srv is None:
242
+ continue
243
+ srv.close()
244
+ with contextlib.suppress(Exception):
245
+ await srv.wait_closed()
198
246
  self._server = None
247
+ self._loopback_server = None
199
248
 
200
249
  @property
201
250
  def port(self) -> int:
@@ -94,8 +94,19 @@ _RUNTIME_REENABLE_PAUSE = 0.05
94
94
  # Instead of skipping the announce permanently we re-check for a short window
95
95
  # (the binding lands milliseconds later); a tab that never becomes visible
96
96
  # (a foreign tab) expires without an announce — the scope check still gates.
97
- _VISIBILITY_RETRY_COUNT = 5
98
- _VISIBILITY_RETRY_INTERVAL = 0.1
97
+ #
98
+ # BUG B: this window used to be 5 x 0.1s = 0.5s, which was the real ceiling on
99
+ # a cold bind. It has to outlast an extension MV3 service-worker wake-up (the
100
+ # `scoped_target_infos` round trip goes to the SW), and it must not expire
101
+ # before the CLIENT's bind budget — otherwise the daemon quietly stops trying
102
+ # while the client is still waiting, and the tab is never announced for the
103
+ # life of the bridge, which is exactly what surfaced as a 100%-reproducible
104
+ # `PageBindTimeout` with a leaked tab per attempt. 30 x 0.2s = 6s sits under
105
+ # the 10s client budget (`repl.playwright_handle._PAGE_BIND_TIMEOUT_S`) with
106
+ # room for the round trip. Cost on the happy path is zero: the loop returns on
107
+ # the first successful check, and a foreign tab is idle waiting, not work.
108
+ _VISIBILITY_RETRY_COUNT = 30
109
+ _VISIBILITY_RETRY_INTERVAL = 0.2
99
110
 
100
111
 
101
112
  # Synthetic browserContextId for synthesized page targets. The extension backend
@@ -917,7 +917,9 @@ class _UpstreamHolder:
917
917
  # fallback for an adapter that reports nothing.
918
918
  await self.state.set_connected(ext.ws_url or "ext://relay")
919
919
 
920
- async def _on_extension_hello(self) -> None:
920
+ async def _on_extension_hello(
921
+ self, *, install_id: str = "", first_seen: bool = False,
922
+ ) -> None:
921
923
  """A (auto-recovery): extension (re)connected with a fresh SW.
922
924
 
923
925
  A reloaded/updated SW reconnects with an EMPTY ``attachedTabs`` set,
@@ -957,9 +959,19 @@ class _UpstreamHolder:
957
959
  continue
958
960
  try:
959
961
  await ext.recover_session(sid)
962
+ # GH#79: say which of the two it was. This line used to
963
+ # read "after extension reconnect" unconditionally — it
964
+ # fires on EVERY hello, including the very first one from
965
+ # a brand-new browser profile, and reading it in a
966
+ # session-scoped e2e log is what made a fresh Chrome per
967
+ # test look like a service worker churning between
968
+ # commands.
960
969
  logger.info(
961
- "auto-recovered session %s after extension reconnect",
962
- sid)
970
+ "auto-recovered session %s after extension %s "
971
+ "(install_id=%s)",
972
+ sid,
973
+ "first connect" if first_seen else "reconnect",
974
+ install_id or "(unknown)")
963
975
  except Exception: # noqa: BLE001 - no group / empty group /
964
976
  # still reconnecting -- the next hello retries.
965
977
  pass
@@ -236,6 +236,7 @@ class RelayServer:
236
236
  )
237
237
  self._server: Any = None
238
238
  self._extensions: dict[str, _ExtensionConn] = {}
239
+ self._seen_install_ids: set[str] = set()
239
240
  # Monotonic connection epoch. A fresh extension hello may represent a
240
241
  # Chrome restart, where numeric tab/group ids can be recycled. Session
241
242
  # adapters use this to demote in-memory group bindings back to
@@ -260,7 +261,16 @@ class RelayServer:
260
261
  # (fresh SW after a reload/update, or a ws reconnect). The listener
261
262
  # uses it to re-attach extension sessions whose ghost table was lost
262
263
  # with the previous connection. Set by the listener.
263
- self._on_extension_hello: Callable[[], Awaitable[None]] | None = None
264
+ self._on_extension_hello: (
265
+ Callable[..., Awaitable[None]] | None) = None
266
+ # GH#79: install_ids that have said hello to THIS daemon before, so a
267
+ # reconnect can be told apart from a first connect. background.js
268
+ # persists the id in `chrome.storage.local`, so it survives service
269
+ # worker restarts and extension reloads; a genuinely new id means a new
270
+ # profile or a reinstall, not a churning SW. Reading a session-scoped
271
+ # daemon.log without that distinction is what made the e2e harness's
272
+ # one-fresh-Chrome-per-test look like an extension reconnecting with a
273
+ # new id between commands (issue #79).
264
274
 
265
275
  # ---- lifecycle -------------------------------------------------------
266
276
 
@@ -1265,11 +1275,37 @@ class RelayServer:
1265
1275
  )
1266
1276
  comparison = compare_versions(ext.browserwright_version or ext.version, __version__)
1267
1277
  ext.version_drift = comparison.drift.value
1278
+ # GH#79: one live connection per install_id. A single MV3 service
1279
+ # worker could dial the relay twice (a superseded socket's late
1280
+ # `onclose` cleared the extension's module-level `ws`, so its
1281
+ # `maintainLoop` opened a duplicate a tick later) and both sockets
1282
+ # said `hello` with the same install_id. `_extensions` is keyed by
1283
+ # install_id, so the re-key below silently evicts the older entry
1284
+ # while its TCP connection stays ESTABLISHED — a ghost we keep
1285
+ # app-pinging and can never route to. Close it explicitly instead.
1286
+ # The extension-side fix is in `chrome-extension/background.js`,
1287
+ # but it only reaches users when a new build ships to the Web
1288
+ # Store, so the invariant is enforced here too.
1289
+ superseded = [
1290
+ other for other in self._extensions.values()
1291
+ if other is not ext
1292
+ and ext.install_id
1293
+ and other.install_id == ext.install_id
1294
+ ]
1268
1295
  # Re-key the extension by install_id (so multiple extensions don't
1269
1296
  # collide on temp_key collisions).
1270
1297
  self._extensions.pop(temp_key, None)
1271
1298
  self._extensions[ext.install_id or temp_key] = ext
1272
1299
  ext.hello_received.set()
1300
+ for other in superseded:
1301
+ logger.info(
1302
+ "superseding older relay connection for install_id=%s "
1303
+ "(the extension dialled twice)",
1304
+ ext.install_id,
1305
+ )
1306
+ asyncio.create_task(self._force_close_extension(
1307
+ other, reason="superseded by a newer connection from the "
1308
+ "same install_id"))
1273
1309
  if ext.app_ping_task is None or ext.app_ping_task.done():
1274
1310
  ext.app_ping_task = asyncio.create_task(self._app_ping_loop(ext))
1275
1311
  self._first_ready.set()
@@ -1298,8 +1334,14 @@ class RelayServer:
1298
1334
  ext.browserwright_version or ext.version,
1299
1335
  __version__,
1300
1336
  )
1337
+ first_seen = bool(
1338
+ ext.install_id) and ext.install_id not in self._seen_install_ids
1339
+ if ext.install_id:
1340
+ self._seen_install_ids.add(ext.install_id)
1301
1341
  logger.info(
1302
- "extension hello: install_id=%s browser=%s version=%s protocol=%s",
1342
+ "extension hello (%s): install_id=%s browser=%s version=%s "
1343
+ "protocol=%s",
1344
+ "first connect" if first_seen else "reconnect",
1303
1345
  ext.install_id,
1304
1346
  ext.browser,
1305
1347
  ext.version,
@@ -1321,7 +1363,8 @@ class RelayServer:
1321
1363
  await self._maybe_reload_for_version_drift(ext)
1322
1364
  if self._on_extension_hello is not None:
1323
1365
  try:
1324
- await self._on_extension_hello()
1366
+ await self._on_extension_hello(
1367
+ install_id=ext.install_id, first_seen=first_seen)
1325
1368
  except Exception as e: # noqa: BLE001 - never break hello
1326
1369
  logger.warning(
1327
1370
  "extension hello callback failed: %r", e)
@@ -244,6 +244,54 @@ _SOURCE_LABEL = {
244
244
  }
245
245
 
246
246
 
247
+ def local_unreachable_fix(ep: DaemonEndpoint) -> str:
248
+ """The `fix` for "nothing answered" on a NON-explicitly-configured endpoint.
249
+
250
+ The class default — "start the single global daemon: `browserwright-daemon
251
+ serve`" — is a dead end whenever the daemon is already running, which is
252
+ the common case here: the daemon bound a *specific* non-loopback host
253
+ (`--facade-host <tailnet-ip>`) and this client resolved something else. So
254
+ name the real divergence instead of guessing.
255
+ """
256
+ published = _from_state_file()
257
+ normalized = _normalize(published) if published else None
258
+
259
+ if ep.source == "default" and normalized and normalized != ep.url:
260
+ return (
261
+ f"a daemon published {normalized} in its endpoint state file, but "
262
+ f"this client resolved the built-in default {ep.url} — they "
263
+ f"disagree. Point the client at it (`export {ENV_VAR}="
264
+ f"{normalized}`), or rebind the daemon so loopback is served too "
265
+ "(`browserwright-daemon install --facade-host 0.0.0.0` then "
266
+ "`browserwright-daemon restart`)."
267
+ )
268
+ if ep.source == "state_file":
269
+ host_note = ""
270
+ if not ep.is_loopback:
271
+ host_note = (
272
+ f" That endpoint is bound to the non-loopback host "
273
+ f"{ep.host}, so it is only reachable over that interface — if "
274
+ "it is down (VPN/tailnet off), nothing local can reach the "
275
+ "daemon."
276
+ )
277
+ return (
278
+ f"the running daemon published {ep.url} but nothing answered "
279
+ f"there.{host_note} Check it with `browserwright-daemon status` "
280
+ "and `lsof -nP -iTCP:"
281
+ f"{ep.port} -sTCP:LISTEN`, then `browserwright-daemon restart`. "
282
+ "If the daemon is up, the state file is stale."
283
+ )
284
+ return (
285
+ f"nothing is listening on the default endpoint {ep.url}. Check "
286
+ f"`browserwright-daemon status`; if a daemon IS running it is bound "
287
+ "elsewhere (see `--facade-host`) — point this client at it with "
288
+ f"${ENV_VAR}. Otherwise start one: `browserwright-daemon serve`. "
289
+ f"Note `lsof -nP -iTCP:{ep.port} -sTCP:LISTEN` shows a foreign holder "
290
+ "of the port (a proxy such as Surge can answer HTTP on it without a "
291
+ "daemon behind it)."
292
+ )
293
+
294
+
247
295
  def not_ours_to_signal_message(ep: DaemonEndpoint, action: str) -> str:
248
296
  """Why a local signal-based ``action`` is refused for a remote endpoint."""
249
297
  return (
@@ -43,9 +43,19 @@ class PageBindTimeout(BrowserwrightError):
43
43
 
44
44
  exit_code = 3
45
45
  retryable = True
46
+ # BUG B: `retryable` has to be honest. A retry only helps when the daemon
47
+ # was still going to announce the tab — a cold/reconnecting extension
48
+ # service worker is exactly that case, and the bind budget
49
+ # (`$BW_PAGE_BIND_TIMEOUT`) is the knob for it. If retries do NOT help, the
50
+ # extension side is not answering at all, and `session reset` will not
51
+ # change that — say what to check instead of looping the user.
46
52
  default_fix = (
47
- "retry the same browserwright command; if it persists, run "
48
- "`browserwright session reset <id>` and retry"
53
+ "retry the same browserwright command (a cold extension service worker "
54
+ "can miss the bind window; raise it with `export "
55
+ "BW_PAGE_BIND_TIMEOUT=30`). If EVERY retry fails, the extension is not "
56
+ "answering: check `browserwright doctor` and that the browserwright "
57
+ "extension is enabled and its Chrome window is open, then "
58
+ "`browserwright session reset <id>`"
49
59
  )
50
60
 
51
61
  def __init__(
@@ -183,6 +183,17 @@ def doctor_checks() -> dict:
183
183
  "field)",
184
184
  "update browserwright-daemon to match browserwright")
185
185
 
186
+ # 3b. endpoint_reachable — can a LOCAL client actually dial the endpoint?
187
+ # BUG A: check 3 above only *reports* the advertised address, so a
188
+ # daemon bound to a specific non-loopback host (`--facade-host
189
+ # <tailnet-ip>`) read as a clean bill of health while every local
190
+ # client failed with ECONNREFUSED on 127.0.0.1. Doctor has to dial, not
191
+ # echo — a health check that cannot observe the reported failure is the
192
+ # gap, not a passing check.
193
+ if not synthetic and info.get("alive") is not False:
194
+ for check in _endpoint_reachability_checks():
195
+ add(**check)
196
+
186
197
  # 4. schema version sanity (catches a daemon too old to speak the blob)
187
198
  sv = info.get("schema_version")
188
199
  if not synthetic:
@@ -299,3 +310,73 @@ def doctor_checks() -> dict:
299
310
  "checks": checks,
300
311
  "raw": info,
301
312
  }
313
+
314
+
315
+ def _probe_tcp(host: str, port: int, *, timeout: float = 1.5) -> str | None:
316
+ """``None`` when a TCP connect succeeds, else a short reason."""
317
+ import socket
318
+
319
+ try:
320
+ with socket.create_connection((host, port), timeout=timeout):
321
+ return None
322
+ except OSError as e:
323
+ return e.strerror or str(e)
324
+
325
+
326
+ def _endpoint_reachability_checks() -> list[dict]:
327
+ """Dial the resolved endpoint AND loopback; report the divergence.
328
+
329
+ Two distinct failures hide behind "the daemon is running":
330
+ - the endpoint the client resolves answers nothing at all;
331
+ - it answers, but loopback (what every client falls back to when the
332
+ endpoint state file is not visible) does not.
333
+ The second is BUG A, and it is invisible unless you actually connect.
334
+ """
335
+ from .daemon_url import daemon_endpoint
336
+
337
+ try:
338
+ ep = daemon_endpoint()
339
+ except Exception: # noqa: BLE001 - doctor must never raise
340
+ return []
341
+
342
+ resolved_err = _probe_tcp(ep.host, ep.port)
343
+ if resolved_err is not None:
344
+ return [{
345
+ "name": "endpoint_reachable",
346
+ "status": "fail",
347
+ "message": (f"nothing answered at {ep.host}:{ep.port} "
348
+ f"(the endpoint resolved from {ep.source})"),
349
+ "fix": ("check `browserwright-daemon status` and `lsof -nP -iTCP:"
350
+ f"{ep.port} -sTCP:LISTEN`, then `browserwright-daemon "
351
+ "restart`"),
352
+ }]
353
+
354
+ if ep.is_loopback:
355
+ return [{
356
+ "name": "endpoint_reachable",
357
+ "status": "pass",
358
+ "message": f"endpoint answers at {ep.host}:{ep.port}",
359
+ "fix": "",
360
+ }]
361
+
362
+ loopback_err = _probe_tcp("127.0.0.1", ep.port)
363
+ if loopback_err is None:
364
+ return [{
365
+ "name": "endpoint_reachable",
366
+ "status": "pass",
367
+ "message": (f"endpoint answers at {ep.host}:{ep.port} and on "
368
+ "127.0.0.1"),
369
+ "fix": "",
370
+ }]
371
+ return [{
372
+ "name": "endpoint_reachable",
373
+ "status": "fail",
374
+ "message": (f"the daemon answers at {ep.host}:{ep.port} but NOT on "
375
+ f"127.0.0.1:{ep.port} ({loopback_err}) — any local client "
376
+ "that cannot read the endpoint state file will fail to "
377
+ "connect"),
378
+ "fix": ("rebind so loopback is served too: `browserwright-daemon "
379
+ "install --facade-host 0.0.0.0` then `browserwright-daemon "
380
+ "restart`; or point clients at it with "
381
+ f"`export BW_DAEMON_URL=http://{ep.host}:{ep.port}`"),
382
+ }]
@@ -32,13 +32,45 @@ there is no loop conflict with Playwright's sync driver.
32
32
  """
33
33
  from __future__ import annotations
34
34
 
35
+ import logging
35
36
  from typing import Any
36
37
  from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
37
38
  from uuid import uuid4
38
39
 
39
40
  from ..errors import BrowserwrightError, PageBindTimeout
40
41
 
41
- _PAGE_BIND_TIMEOUT_S = 2.0
42
+ logger = logging.getLogger(__name__)
43
+
44
+
45
+ def _page_bind_timeout_s() -> float:
46
+ """How long to wait for Playwright to expose the agent-resolved tab.
47
+
48
+ BUG B: this was a hardcoded 2.0s, which is shorter than an extension MV3
49
+ service worker takes to wake up. When the SW is cold — or has just
50
+ reconnected to the relay, which happens routinely between commands — the
51
+ daemon's announce lands well after the budget, the bind fails, and the
52
+ caller is told to retry a command whose only real problem was being asked
53
+ too early. A wait measured in seconds costs nothing on the happy path (the
54
+ announce normally lands in milliseconds and the loop returns immediately),
55
+ while the old budget turned a slow wake-up into a hard failure.
56
+
57
+ Override with ``BW_PAGE_BIND_TIMEOUT`` (seconds) for a very slow machine.
58
+ """
59
+ import os
60
+
61
+ raw = (os.environ.get("BW_PAGE_BIND_TIMEOUT") or "").strip()
62
+ if raw:
63
+ try:
64
+ value = float(raw)
65
+ except ValueError:
66
+ value = 0.0
67
+ if value > 0:
68
+ return value
69
+ return _PAGE_BIND_TIMEOUT_S
70
+
71
+
72
+ #: The bind budget when nothing overrides it. Tests monkeypatch this name.
73
+ _PAGE_BIND_TIMEOUT_S = 10.0
42
74
  _PAGE_BIND_POLL_INTERVAL_S = 0.05
43
75
 
44
76
 
@@ -250,14 +282,18 @@ def bind_current_page(context: Any, sess: Any) -> Any:
250
282
  # Resolve/create + persist the session's current tab via the agent path.
251
283
  info = resolve_current_target(sess)
252
284
  target_id = info.get("targetId") if isinstance(info, dict) else None
285
+ # Did THIS call open the tab? Only step 4 of `resolve_current_target` sets
286
+ # it, so a reused tab is never mistaken for one we may discard.
287
+ we_opened_it = bool(info.get("opened")) if isinstance(info, dict) else False
253
288
 
289
+ budget = _page_bind_timeout_s()
254
290
  if target_id:
255
291
  page = _wait_for_target_page(
256
292
  context,
257
293
  sess,
258
294
  target_id,
259
295
  info.get("url"),
260
- timeout=_PAGE_BIND_TIMEOUT_S,
296
+ timeout=budget,
261
297
  )
262
298
  if page is not None:
263
299
  return patch_page_goto(page)
@@ -267,12 +303,50 @@ def bind_current_page(context: Any, sess: Any) -> Any:
267
303
  # second target merely because the facade has not exposed the first one to
268
304
  # Playwright yet: that splits the ledger and Playwright views and is the
269
305
  # source of duplicate user-visible tabs.
306
+ #
307
+ # BUG B: a tab THIS call opened and then failed to bind has no owner —
308
+ # `PageBindTimeout` is advertised as retryable, so the caller retries,
309
+ # `resolve_current_target` finds no usable tab again and opens another.
310
+ # Whether the previous one survives depends on `session end` later finding
311
+ # it in the session's tab group, which is precisely what is unreliable when
312
+ # the announce failed. No user-visible leak has been observed; this closes
313
+ # the window rather than fixing a confirmed one. Roll our own creation back
314
+ # before raising.
315
+ if we_opened_it and target_id:
316
+ _discard_unbindable_tab(sess, target_id)
270
317
  raise PageBindTimeout(
271
318
  target_id=target_id or "",
272
- timeout=_PAGE_BIND_TIMEOUT_S,
319
+ timeout=budget,
273
320
  )
274
321
 
275
322
 
323
+ def _discard_unbindable_tab(sess: Any, target_id: str) -> None:
324
+ """Close a tab we opened but could not bind, and clear its ledger binding.
325
+
326
+ Strictly best-effort: the bind already failed, and a failure to clean up
327
+ must not replace ``PageBindTimeout`` (which names the real problem) with a
328
+ teardown error. Worst case we are back to the old leak.
329
+
330
+ Clearing the durable binding matters as much as closing the tab: leaving
331
+ the ledger pointed at a tab that is gone makes the NEXT call take the
332
+ recovery path against a dead target instead of opening a clean one.
333
+ """
334
+ from ..session_runtime import close_session_tab, persist_target
335
+
336
+ try:
337
+ close_session_tab(sess, target_id=target_id)
338
+ except Exception: # noqa: BLE001 - see docstring: never mask the bind error
339
+ logger.warning("could not close unbindable tab %s", target_id,
340
+ exc_info=True)
341
+ try:
342
+ if getattr(sess, "current_target_id", None) == target_id:
343
+ sess.current_target_id = None
344
+ persist_target(None, sess=sess)
345
+ except Exception: # noqa: BLE001 - best-effort
346
+ logger.warning("could not clear binding for unbindable tab %s",
347
+ target_id, exc_info=True)
348
+
349
+
276
350
  def _wait_for_target_page(
277
351
  context: Any,
278
352
  sess: Any,
@@ -98,13 +98,18 @@ class Session:
98
98
  return self._cdp
99
99
 
100
100
  def _unreachable(self, url: str, cause: BaseException) -> DaemonUnavailable:
101
- from .daemon_url import daemon_endpoint, unreachable_message
101
+ from .daemon_url import (daemon_endpoint, local_unreachable_fix,
102
+ unreachable_message)
102
103
 
103
104
  endpoint = daemon_endpoint()
104
105
  if endpoint.explicit:
105
106
  return DaemonUnavailable(f"{unreachable_message(endpoint)} ({cause})")
107
+ # BUG A: the class default fix ("start the daemon") is actively
108
+ # misleading here — the daemon is usually running, just bound to a host
109
+ # this client did not resolve. Diagnose the divergence instead.
106
110
  return DaemonUnavailable(
107
- f"no browserwright daemon answered at {url}: {cause}")
111
+ f"no browserwright daemon answered at {url}: {cause}",
112
+ fix=local_unreachable_fix(endpoint))
108
113
 
109
114
  def _resolve_ws_url(self) -> str:
110
115
  """Ask the underlying daemon client for a CDP ws URL.
@@ -476,6 +476,14 @@ def end(record: dict) -> str:
476
476
  f"kept for retry.{hint}")
477
477
  if record.get("owner") == "create":
478
478
  msg = f"session {sid} ended; the browser it launched was closed."
479
+ elif record.get("backend") == "extension":
480
+ # BUG B: the `attach` wording below is a cdp story — it claims nothing
481
+ # in the browser was touched, while the daemon has just closed every
482
+ # tab in this session's tab group. Reporting "left untouched" for a
483
+ # teardown that closes tabs is how a leaked tab and a torn-down one
484
+ # became indistinguishable to the user.
485
+ msg = (f"session {sid} ended; its tab group was closed. Your Chrome "
486
+ f"is still running — only this session's tabs were touched.")
479
487
  else:
480
488
  msg = (f"session {sid} ended. The browser is still running — you "
481
489
  f"attached to it, so it was left untouched.")
@@ -388,9 +388,17 @@ def resolve_current_target(sess) -> dict:
388
388
  return {"targetId": tabs[0]["targetId"], "url": tabs[0]["url"],
389
389
  "title": tabs[0]["title"], "accuracy": "unknown"}
390
390
  # 4. Empty session — open a fresh working tab (NOT adopt).
391
+ #
392
+ # `opened: True` marks the tab as CREATED BY THIS CALL, which is the only
393
+ # thing that distinguishes it from the reuse paths above. Callers that can
394
+ # fail after this point (`repl.playwright_handle.bind_current_page`) need
395
+ # it to know whether a failure of theirs leaves a user-visible tab behind
396
+ # that nobody else will ever claim — steps 1-3 hand back a tab the session
397
+ # already owned, and closing one of those on a failed bind would destroy
398
+ # the agent's actual working tab.
391
399
  opened = open_session_tab(sess, "about:blank",
392
400
  skip_post_attach_commands=True)
393
- return opened | {"accuracy": "unknown"}
401
+ return opened | {"accuracy": "unknown", "opened": True}
394
402
 
395
403
 
396
404
  def eval_js(sess, expression: str, *, await_promise: bool = False) -> Any:
@@ -373,9 +373,42 @@ Reusable flows belong in site-skill tasks. A task's `run(args, ctx)` receives th
373
373
 
374
374
  ```bash
375
375
  browserwright list-tasks
376
+ browserwright list-tasks --query="search the web"
376
377
  browserwright -s "$sid" task wikipedia.org/lookup --title="Browser automation"
377
378
  ```
378
379
 
380
+ **Look before you improvise.** Run `list-tasks` before hand-writing a flow for a site. A saved task replays a known-good path instead of re-deriving it from a fresh snapshot.
381
+
382
+ **Solidify when the flow repeats.** Write a task once either of these is true, without waiting to be asked:
383
+
384
+ - You have driven the same site flow twice, or the user asks for something they will plainly ask for again — a recurring report, a standing search, a routine form.
385
+ - You just spent several snapshot/click rounds working out a path that a URL template plus two selectors can now replay directly.
386
+
387
+ Author it with the `Write` tool; the runtime is filesystem-driven and there is no scaffolding command. Put the file at `$BS_HOME/site-skills/<eTLD+1>/tasks/<name>.py` (default `~/.browserwright/site-skills/...`), or at `./site-skills/...` in the CWD when the user wants it version-controlled with the project. Reads consult project-local `./site-skills/` first, then `$BS_HOME/site-skills/`, then the bundled starter set — first hit per site name wins, so a project task shadows a personal one. `remember()` and `bootstrap_site()` write to `./site-skills/` when that directory already exists and to `$BS_HOME` otherwise.
388
+
389
+ ```python
390
+ """One-line description — this becomes the task's listed summary."""
391
+
392
+ ARGS = {
393
+ "query": {"type": "str", "required": True, "desc": "Search term"},
394
+ "lang": {"type": "str", "required": False, "default": "en", "desc": "Language code"},
395
+ }
396
+ OUTPUT = "{title: str, url: str}"
397
+ TAGS = ["search"]
398
+ REQUIRES_LOGIN = False
399
+ ESTIMATED_DURATION_SEC = 5
400
+ LAST_VERIFIED = "2026-05-25" # ISO date you last saw this actually work
401
+
402
+ def run(args, ctx=None):
403
+ # `page` / `context` / `snapshot` are injected — same surface as inline code.
404
+ page.goto(f"https://example.com/search?q={args['query']}", wait_until="load")
405
+ return {"title": page.title(), "url": page.url}
406
+ ```
407
+
408
+ Only `ARGS` and `run` are required; the rest is metadata `list-tasks` ranks and displays. The runtime imports the file, injects `page` / `context` / `snapshot` lazily (a task that never touches them opens no browser), validates the call against `ARGS`, then calls `run(args, ctx)`. Define `OUTPUT_SCHEMA` and the return shape is validated too. `ctx.memory` is the parsed frontmatter of that site's `memory.md`, so a task can read the selectors you recorded with `remember()`.
409
+
410
+ Keep the two halves in sync. The note you write with `remember()` is what makes a task cheap to repair when the site changes, and `LAST_VERIFIED` is what tells the next agent whether to trust it. Set `BROKEN_SINCE = "<ISO date>"` instead of deleting a task you found broken and could not fix.
411
+
379
412
  ## Non-browser Helpers
380
413
 
381
414
  These run without driving the browser:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: browserwright
3
- Version: 0.17.0
3
+ Version: 0.17.2
4
4
  Summary: Browserwright — let AI/code agents drive a real or isolated browser and author userscripts. Single package: the agent-facing REPL/site-skills/memory layer plus the bundled browser-resolving daemon (CDP proxy + extension/cloud backends).
5
5
  License-Expression: AGPL-3.0-only
6
6
  Requires-Python: >=3.11
File without changes
File without changes
File without changes