browserwright 0.6.9__tar.gz → 0.6.12__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 (103) hide show
  1. {browserwright-0.6.9 → browserwright-0.6.12}/PKG-INFO +1 -1
  2. {browserwright-0.6.9 → browserwright-0.6.12}/README.md +36 -54
  3. {browserwright-0.6.9 → browserwright-0.6.12}/pyproject.toml +1 -1
  4. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/_executor/client.py +7 -0
  5. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/daemon/config.py +13 -0
  6. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/daemon/server/extension_upstream.py +7 -1
  7. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/daemon/server/facade_extension.py +2 -1
  8. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/daemon/server/listener.py +91 -6
  9. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/daemon/server/proxy.py +3 -1
  10. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/daemon/server/relay.py +27 -2
  11. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/primitives/page.py +11 -3
  12. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/session_create.py +9 -2
  13. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/session_registry.py +14 -0
  14. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright.egg-info/PKG-INFO +1 -1
  15. {browserwright-0.6.9 → browserwright-0.6.12}/setup.cfg +0 -0
  16. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/__init__.py +0 -0
  17. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/__main__.py +0 -0
  18. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/_executor/__init__.py +0 -0
  19. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/_executor/__main__.py +0 -0
  20. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/_executor/process.py +0 -0
  21. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/_executor/protocol.py +0 -0
  22. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/api.py +0 -0
  23. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/cdp.py +0 -0
  24. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/cli.py +0 -0
  25. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/daemon/__init__.py +0 -0
  26. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/daemon/_ipc.py +0 -0
  27. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/daemon/active_tab.py +0 -0
  28. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/daemon/auth.py +0 -0
  29. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/daemon/backends/__init__.py +0 -0
  30. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/daemon/backends/base.py +0 -0
  31. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/daemon/backends/cloud.py +0 -0
  32. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/daemon/backends/env.py +0 -0
  33. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/daemon/backends/extension.py +0 -0
  34. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/daemon/backends/rdp.py +0 -0
  35. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/daemon/cli.py +0 -0
  36. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/daemon/doctor.py +0 -0
  37. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/daemon/errors.py +0 -0
  38. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/daemon/launch_chrome.py +0 -0
  39. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/daemon/observability.py +0 -0
  40. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/daemon/platforms.py +0 -0
  41. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/daemon/resolver.py +0 -0
  42. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/daemon/server/__init__.py +0 -0
  43. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/daemon/server/daemon.py +0 -0
  44. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/daemon/server/executor_registry.py +0 -0
  45. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/daemon/server/facade.py +0 -0
  46. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/daemon/server/state.py +0 -0
  47. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/daemon/server/upstream.py +0 -0
  48. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/daemon/userscripts.py +0 -0
  49. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/discovery.py +0 -0
  50. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/errors.py +0 -0
  51. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/health.py +0 -0
  52. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/install.py +0 -0
  53. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/memory/__init__.py +0 -0
  54. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/memory/_md.py +0 -0
  55. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/memory/_yaml.py +0 -0
  56. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/memory/global_mem.py +0 -0
  57. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/memory/repl_mem.py +0 -0
  58. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/memory/session_decisions.py +0 -0
  59. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/memory/site_mem.py +0 -0
  60. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/mode_b_client.py +0 -0
  61. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/multitask.py +0 -0
  62. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/output_schema.py +0 -0
  63. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/primitives/__init__.py +0 -0
  64. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/primitives/discovery_api.py +0 -0
  65. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/primitives/http.py +0 -0
  66. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/primitives/inspect.py +0 -0
  67. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/primitives/interact.py +0 -0
  68. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/primitives/site.py +0 -0
  69. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/release_install.py +0 -0
  70. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/repl/__init__.py +0 -0
  71. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/repl/_namespace.py +0 -0
  72. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/repl/_smart_goto.py +0 -0
  73. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/repl/inline.py +0 -0
  74. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/repl/playwright_handle.py +0 -0
  75. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/repl/snapshot.py +0 -0
  76. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/session.py +0 -0
  77. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/session_ctx.py +0 -0
  78. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/session_runtime.py +0 -0
  79. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/site_skills_starter/github.com/SKILL.md +0 -0
  80. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/site_skills_starter/github.com/memory.md +0 -0
  81. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/site_skills_starter/github.com/tasks/list_issues.py +0 -0
  82. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/site_skills_starter/google.com/SKILL.md +0 -0
  83. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/site_skills_starter/google.com/memory.md +0 -0
  84. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/site_skills_starter/google.com/tasks/search.py +0 -0
  85. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/site_skills_starter/producthunt.com/SKILL.md +0 -0
  86. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/site_skills_starter/producthunt.com/memory.md +0 -0
  87. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/site_skills_starter/producthunt.com/tasks/today.py +0 -0
  88. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/site_skills_starter/wikipedia.org/SKILL.md +0 -0
  89. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/site_skills_starter/wikipedia.org/memory.md +0 -0
  90. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/site_skills_starter/wikipedia.org/tasks/lookup.py +0 -0
  91. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/site_skills_starter/ycombinator.com/SKILL.md +0 -0
  92. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/site_skills_starter/ycombinator.com/memory.md +0 -0
  93. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/site_skills_starter/ycombinator.com/tasks/front_page.py +0 -0
  94. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/skill_doc.py +0 -0
  95. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/skill_runtime.md +0 -0
  96. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/subscriptions.py +0 -0
  97. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/task_runner.py +0 -0
  98. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright/version.py +0 -0
  99. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright.egg-info/SOURCES.txt +0 -0
  100. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright.egg-info/dependency_links.txt +0 -0
  101. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright.egg-info/entry_points.txt +0 -0
  102. {browserwright-0.6.9 → browserwright-0.6.12}/src/browserwright.egg-info/requires.txt +0 -0
  103. {browserwright-0.6.9 → browserwright-0.6.12}/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.6.9
3
+ Version: 0.6.12
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
  Requires-Python: >=3.11
6
6
  Requires-Dist: cdp-use==1.4.5
@@ -61,13 +61,12 @@ from your own systemd-user unit or process supervisor.
61
61
 
62
62
  The browser extension is intentionally not part of the PyPI packaging contract.
63
63
  The long-term user path is to publish/install it through the Chrome Web Store.
64
- Until then, local development and release installs can still use the repo's
65
- `chrome-extension/` directory or the copied local release extension described
64
+ Until then, local development can use the repo's `chrome-extension/` directory,
65
+ and global installs should use the GitHub Release extension artifact described
66
66
  below.
67
67
 
68
- The agent skill bundle remains a thin shell around the installed CLI. For
69
- local release installs, the release installer symlinks that shell into Claude
70
- Code, Codex, and Pi. For PyPI installs, the intended stable contract is:
68
+ The agent skill bundle remains a thin shell around the installed CLI. For PyPI
69
+ installs, the intended stable contract is:
71
70
 
72
71
  ```bash
73
72
  browserwright --print-skill
@@ -115,60 +114,39 @@ git push origin v0.6.3
115
114
  The GitHub Action only runs for `v*` tags. A tag like `v0.6.3` publishes package
116
115
  version `0.6.3`.
117
116
 
118
- ### Local immutable release install
117
+ Each `v*` GitHub Release also includes a
118
+ `browserwright-extension-<version>.zip` asset. Use that zip as the official
119
+ extension download path when installing outside PyPI or before the Chrome Web
120
+ Store path is available.
119
121
 
120
- From the repo root. If you have [`mise`](https://mise.jdx.dev/), install an immutable local release:
122
+ ### Local development link
121
123
 
122
- ```bash
123
- mise run install-release
124
- ```
125
-
126
- This builds a wheel, installs it into:
127
-
128
- ```bash
129
- ~/.local/share/browserwright/releases/<version>/
130
- ```
131
-
132
- and points global entry points at that release copy:
124
+ For development only, `mise run dev-link` links the current checkout into
125
+ global PATH and skill dirs. That is convenient for debugging but can break
126
+ global agents if the checkout is broken.
133
127
 
134
128
  ```bash
135
- ~/.local/bin/browserwright -> releases/<version>/.venv/bin/browserwright
136
- ~/.local/bin/browserwright-daemon -> releases/<version>/.venv/bin/browserwright-daemon
137
- ~/.claude/skills/browserwright -> releases/<version>/skill
138
- ~/.codex/skills/browserwright -> releases/<version>/skill
139
- ~/.pi/agent/skills/browserwright -> releases/<version>/skill
129
+ mise run dev-link
140
130
  ```
141
131
 
142
- The global install does not point at the development checkout, so broken in-progress edits do not break agents already using browserwright.
143
-
144
- Useful release commands:
145
-
146
- ```bash
147
- browserwright release status
148
- browserwright release list
149
- browserwright release activate <version>
150
- mise run version-check
151
- ```
152
-
153
- The skill bundle is intentionally a thin shell. The authoritative agent instructions come from `browserwright --print-skill`, so installed agents read docs generated by the installed CLI in the active release.
154
-
155
- For development only, `mise run dev-link` links the current checkout into global PATH and skill dirs. That is convenient for debugging but can break global agents if the checkout is broken.
132
+ The skill bundle is intentionally a thin shell. The authoritative agent
133
+ instructions come from `browserwright --print-skill`, so installed agents read
134
+ docs generated by the installed CLI.
156
135
 
157
136
  ## Update the local global install
158
137
 
159
- Use this flow when you want the global Code Agent install on this machine to pick up the current repo state:
138
+ Use this flow when you want the global Code Agent install on this machine to
139
+ pick up the latest published release:
160
140
 
161
141
  ```bash
162
- # 1. Build a non-editable release and atomically update global symlinks.
163
- mise run install-release
142
+ # 1. Install/upgrade browserwright from PyPI.
143
+ # 2. Download the matching GitHub Release extension artifact.
144
+ # 3. Restart the daemon and verify versions.
145
+ mise run upgrade-global
164
146
 
165
- # 2. Verify the active release, CLI, daemon, generated skill, and extension metadata.
166
- browserwright release status
147
+ # 4. Optional explicit verification.
167
148
  browserwright version check
168
149
  browserwright-daemon version check
169
-
170
- # 3. Restart the daemon if release status says the running daemon is stale.
171
- browserwright-daemon restart
172
150
  ```
173
151
 
174
152
  If the daemon is running manually instead of as the macOS LaunchAgent, restart it manually:
@@ -178,23 +156,25 @@ browserwright-daemon stop
178
156
  browserwright-daemon serve
179
157
  ```
180
158
 
181
- The release installer also copies the unpacked Chrome extension into the stable local load path:
159
+ `upgrade-global` unpacks the GitHub Release asset into the stable local load
160
+ path:
182
161
 
183
162
  ```bash
184
163
  /Users/metajs/Library/Mobile Documents/com~apple~CloudDocs/etc/chrome-extension/browserwright
185
164
  ```
186
165
 
187
- If `install-release` reports `reload_chrome_extension: true`, open `chrome://extensions/` and reload the `browserwright` unpacked extension from that stable path. The path does not change across releases; the installer overwrites its contents.
166
+ If the task reports that the extension changed, open `chrome://extensions/` and
167
+ reload the `browserwright` unpacked extension from that stable path. The path
168
+ does not change across releases; the task overwrites its contents.
188
169
  Reload any existing tab that already shows a duplicated `👀` attach marker so
189
170
  the extension can normalize the title marker after the upgrade.
190
171
 
191
- ## Local release discipline
172
+ ## Release discipline
192
173
 
193
- For local immutable release installs, `pyproject.toml` is the semver source used
194
- by the local build. For PyPI releases, the git tag is the source of truth and
195
- the publish workflow writes that version into `pyproject.toml` before building.
196
- The Python runtime, daemon runtime, generated skill document, and extension
197
- checks all read or compare against the built package version.
174
+ For PyPI releases, the git tag is the source of truth and the publish workflow
175
+ writes that version into `pyproject.toml` before building. The release workflow
176
+ also writes the same tag version into `chrome-extension/manifest.json` before
177
+ uploading `browserwright-extension-<version>.zip` to the GitHub Release.
198
178
 
199
179
  After installing a release:
200
180
 
@@ -204,7 +184,9 @@ browserwright-daemon version check
204
184
  browserwright-daemon restart # when installed as the macOS LaunchAgent
205
185
  ```
206
186
 
207
- If `browserwright release install-local` reports that `chrome_extension` changed, reload the unpacked extension in Chrome from the active release's `chrome-extension/` path. Chrome does not allow this repo to refresh the extension automatically.
187
+ Chrome does not allow this repo to refresh an unpacked extension automatically.
188
+ If `mise run upgrade-global` reports that the extension changed, reload it in
189
+ Chrome.
208
190
 
209
191
  ## Smoke test
210
192
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "browserwright"
7
- version = "0.6.9"
7
+ version = "0.6.12"
8
8
  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)."
9
9
  requires-python = ">=3.11"
10
10
  dependencies = [
@@ -17,6 +17,7 @@ import socket
17
17
  import time
18
18
 
19
19
  from ..errors import BrowserwrightError
20
+ from .. import session_registry as reg
20
21
  from .protocol import (
21
22
  DEFAULT_TIMEOUT_MS,
22
23
  ExecuteRequest,
@@ -81,6 +82,12 @@ def run_on_executor(sess, code: str, *,
81
82
  connect_over_cdp + bind (moved off the control plane), which can add up to
82
83
  ~35s. The executor itself bounds the worker per-call timeout; this slack
83
84
  only prevents the CLIENT recv from giving up before the executor replies."""
85
+ sid = _session_id(sess)
86
+ # Session idle is "time since the last user/agent instruction", not
87
+ # executor process liveness. Touch before contacting the executor so a
88
+ # wedged or long-running executor cannot prevent the durable idle clock
89
+ # from reflecting that a new instruction arrived.
90
+ reg.touch(sid)
84
91
  sock_path = ensure_executor(sess)
85
92
  recv_timeout = max(timeout_ms, 1) / 1000.0 + _COLD_START_RECV_SLACK_S
86
93
  conn = _connect(sock_path, timeout=recv_timeout)
@@ -131,6 +131,7 @@ class Config:
131
131
  cdp_url: str | None = None # BD_CDP_URL / BU_CDP_URL — env backend uses this
132
132
  chrome_binary: str | None = None # BD_CHROME_BINARY — launch-chrome
133
133
  idle_close_after: float | None = None # seconds; None = never (default)
134
+ session_idle_prune: float | None = 24 * 3600 # ledger prune threshold
134
135
  # Playwright-facing CDP facade. `serve` binds an additional TCP ws+HTTP
135
136
  # endpoint that a real Playwright client can `connect_over_cdp` to.
136
137
  #
@@ -246,6 +247,9 @@ def load(
246
247
  cfg.backends.cloud.auth = dict(auth_subtables[cfg.backends.cloud.auth_kind])
247
248
  if "idle_close_after" in toml and isinstance(toml["idle_close_after"], (int, float)):
248
249
  cfg.idle_close_after = float(toml["idle_close_after"])
250
+ if "session_idle_prune" in toml and isinstance(toml["session_idle_prune"], (int, float)):
251
+ v = float(toml["session_idle_prune"])
252
+ cfg.session_idle_prune = v if v > 0 else None
249
253
  # Playwright facade port (phase A1). toml key `facade_port`.
250
254
  if "facade_port" in toml and isinstance(toml["facade_port"], int):
251
255
  cfg.facade_port = toml["facade_port"]
@@ -268,6 +272,15 @@ def load(
268
272
  from .errors import UserError
269
273
  raise UserError(
270
274
  f"BD_IDLE_CLOSE_AFTER must be a number, got {e['BD_IDLE_CLOSE_AFTER']!r}")
275
+ if "BD_SESSION_IDLE_PRUNE" in e:
276
+ try:
277
+ v = float(e["BD_SESSION_IDLE_PRUNE"])
278
+ cfg.session_idle_prune = v if v > 0 else None
279
+ except ValueError:
280
+ from .errors import UserError
281
+ raise UserError(
282
+ f"BD_SESSION_IDLE_PRUNE must be a number, got "
283
+ f"{e['BD_SESSION_IDLE_PRUNE']!r}")
271
284
  if "BD_CDP_WS" in e:
272
285
  cfg.cdp_ws = e["BD_CDP_WS"]; cfg.cdp_ws_source = "BD_CDP_WS"
273
286
  elif "BU_CDP_WS" in e:
@@ -500,6 +500,7 @@ class ExtensionUpstream:
500
500
  group_name: str | None = "Agent",
501
501
  session_id: str | None = None,
502
502
  background: bool = True,
503
+ skip_post_attach_commands: bool = False,
503
504
  ) -> dict:
504
505
  """Open a background tab in the session's tab group via the relay,
505
506
  fabricate a sessionId, and return
@@ -514,7 +515,12 @@ class ExtensionUpstream:
514
515
  gid = self._relay.session_group(session_id)
515
516
  self.reset_session_announce(session_id)
516
517
  gt = await self._relay.create_background_tab(
517
- url, group_name=group_name, group_id=gid, background=background)
518
+ url,
519
+ group_name=group_name,
520
+ group_id=gid,
521
+ background=background,
522
+ skip_post_attach_commands=skip_post_attach_commands,
523
+ )
518
524
  group_id = getattr(gt, "group_id", -1)
519
525
  group_id = int(group_id) if isinstance(group_id, int) else -1
520
526
  if self._group_required(
@@ -511,7 +511,8 @@ class ExtensionFacadeBridge:
511
511
  gt = await self._ext.open_background_tab(
512
512
  url, group_name=group_name,
513
513
  session_id=self._session_id,
514
- background=True)
514
+ background=True,
515
+ skip_post_attach_commands=True)
515
516
  tab_id = int(gt["tabId"])
516
517
  created_group = gt.get("groupId")
517
518
  if isinstance(created_group, int) and created_group >= 0:
@@ -46,6 +46,8 @@ from .facade import PlaywrightFacade
46
46
 
47
47
  logger = logging.getLogger(__name__)
48
48
 
49
+ _SESSION_PRUNE_INTERVAL_S = 3600.0
50
+
49
51
 
50
52
  # ---- per-upstream context factory ------------------------------------------
51
53
 
@@ -224,8 +226,10 @@ async def run_serve(cfg: Config) -> int:
224
226
  # (Fork 4 self-exit / segfault) so the registry never accumulates corpses.
225
227
  # Upstream idle-close + executor idle-reap are gated on cfg.idle_close_after
226
228
  # inside the loop.
229
+ await _auto_prune_sessions(daemon, reason="startup")
227
230
  idle_task: asyncio.Task | None = asyncio.create_task(
228
- _idle_watchdog(daemon, cfg.idle_close_after))
231
+ _idle_watchdog(daemon, cfg.idle_close_after,
232
+ session_idle_prune=cfg.session_idle_prune))
229
233
  try:
230
234
  await stop.wait()
231
235
  logger.info("browserwright-daemon shutdown requested")
@@ -979,7 +983,76 @@ class _UpstreamHolder:
979
983
  # ---- graceful shutdown -----------------------------------------------------
980
984
 
981
985
 
982
- async def _idle_watchdog(daemon: "Daemon", idle_after: float | None) -> None:
986
+ async def _auto_prune_sessions(daemon: "Daemon", *, reason: str) -> list[dict]:
987
+ """Best-effort durable ledger prune.
988
+
989
+ The session idle clock is `ledger.last_seen`, updated when a new
990
+ user/agent instruction arrives. Executor liveness is deliberately ignored:
991
+ a stuck executor can stay alive forever and must not keep a session from
992
+ being cleaned after the instruction-idle threshold.
993
+ """
994
+ idle_seconds = daemon.cfg.session_idle_prune
995
+ if not idle_seconds:
996
+ return []
997
+ try:
998
+ from ... import session_registry
999
+ stale = session_registry.stale(idle_seconds=idle_seconds)
1000
+ except Exception as e: # noqa: BLE001 - cleanup must not kill the daemon
1001
+ logger.warning("auto session-prune failed (%s): %r", reason, e)
1002
+ return []
1003
+ pruned: list[dict] = []
1004
+ for rec in stale:
1005
+ sid = str(rec.get("id") or "")
1006
+ if not sid:
1007
+ continue
1008
+ try:
1009
+ daemon.executors.kill(sid)
1010
+ except Exception as e: # noqa: BLE001
1011
+ logger.warning("auto session-prune executor kill failed "
1012
+ "(session=%s): %r", sid, e)
1013
+ if rec.get("backend") == "rdp" and rec.get("owner") == "create":
1014
+ try:
1015
+ await daemon.teardown_rdp_context(sid)
1016
+ except Exception as e: # noqa: BLE001
1017
+ logger.warning("auto session-prune rdp teardown failed "
1018
+ "(session=%s): %r", sid, e)
1019
+ elif rec.get("backend") == "extension":
1020
+ try:
1021
+ runtime = rec.get("runtime") or {}
1022
+ group_id = runtime.get("group_id")
1023
+ group_id = (
1024
+ group_id
1025
+ if isinstance(group_id, int) and group_id >= 0
1026
+ else None
1027
+ )
1028
+ holder = daemon.shared_context.holder
1029
+ upstream = holder.upstream
1030
+ if hasattr(upstream, "end_session"):
1031
+ await upstream.end_session(sid, group_id) # type: ignore[attr-defined]
1032
+ except Exception as e: # noqa: BLE001
1033
+ logger.warning("auto session-prune extension teardown failed "
1034
+ "(session=%s): %r", sid, e)
1035
+ try:
1036
+ removed = session_registry.remove(sid)
1037
+ except Exception as e: # noqa: BLE001
1038
+ logger.warning("auto session-prune ledger remove failed "
1039
+ "(session=%s): %r", sid, e)
1040
+ removed = None
1041
+ if removed is not None:
1042
+ pruned.append(removed)
1043
+ if pruned:
1044
+ logger.info("auto-pruned %d idle session(s) on %s: %s",
1045
+ len(pruned), reason,
1046
+ [str(rec.get("id")) for rec in pruned])
1047
+ return pruned
1048
+
1049
+
1050
+ async def _idle_watchdog(
1051
+ daemon: "Daemon",
1052
+ idle_after: float | None,
1053
+ *,
1054
+ session_idle_prune: float | None = None,
1055
+ ) -> None:
983
1056
  """Spec §6.5/§6.6: when configured, close each upstream after `idle_after`
984
1057
  seconds with no activity. The next client command lazy-opens it again.
985
1058
 
@@ -995,13 +1068,21 @@ async def _idle_watchdog(daemon: "Daemon", idle_after: float | None) -> None:
995
1068
  leak a subprocess.
996
1069
 
997
1070
  Runs unconditionally; idle-close + idle-reap are no-ops when `idle_after`
998
- is None. We poll at half the idle threshold (or every 5s when idle is off,
999
- just for crash-reap granularity).
1071
+ is None. Durable ledger prune is controlled independently by
1072
+ `session_idle_prune`. We poll at the smallest active supervision cadence,
1073
+ or every 5s when only crash-reap is active.
1000
1074
  """
1001
- poll = 5.0 if not idle_after else max(1.0, idle_after / 2.0)
1075
+ cadences = [5.0]
1076
+ if idle_after:
1077
+ cadences.append(max(1.0, idle_after / 2.0))
1078
+ if session_idle_prune:
1079
+ cadences.append(_SESSION_PRUNE_INTERVAL_S)
1080
+ poll = min(cadences)
1081
+ last_session_prune_at = time.time()
1002
1082
  try:
1003
1083
  while True:
1004
1084
  await asyncio.sleep(poll)
1085
+ now = time.time()
1005
1086
  # --- executor supervision (Phase B PR2) ---
1006
1087
  try:
1007
1088
  daemon.executors.reap_dead()
@@ -1010,12 +1091,16 @@ async def _idle_watchdog(daemon: "Daemon", idle_after: float | None) -> None:
1010
1091
  except Exception as e: # noqa: BLE001 - never let reap break the loop
1011
1092
  logger.warning("executor reap failed: %r", e)
1012
1093
  # --- upstream idle-close (gated) ---
1094
+ if session_idle_prune and (
1095
+ now - last_session_prune_at >= _SESSION_PRUNE_INTERVAL_S):
1096
+ await _auto_prune_sessions(daemon, reason="watchdog")
1097
+ last_session_prune_at = now
1013
1098
  if not idle_after:
1014
1099
  continue
1015
1100
  for ctx in daemon.all_contexts():
1016
1101
  if ctx.state.upstream_phase != UpstreamPhase.CONNECTED:
1017
1102
  continue
1018
- idle_for = time.time() - ctx.state.last_activity_at
1103
+ idle_for = now - ctx.state.last_activity_at
1019
1104
  if idle_for >= idle_after:
1020
1105
  logger.info("idle-watchdog: closing %s upstream after %.1fs",
1021
1106
  ctx.backend, idle_for)
@@ -1171,10 +1171,12 @@ class Router:
1171
1171
  # extension backend; background=False opens the tab in the foreground.
1172
1172
  background = params.get("background")
1173
1173
  background = background if isinstance(background, bool) else True
1174
+ skip_post_attach_commands = params.get("skipPostAttachCommands") is True
1174
1175
  try:
1175
1176
  result = await self._open_background_tab(
1176
1177
  url, group_name=group_name, session_id=session,
1177
- background=background)
1178
+ background=background,
1179
+ skip_post_attach_commands=skip_post_attach_commands)
1178
1180
  except Exception as e:
1179
1181
  await self._send_to_client(client.client_id, _error_response(
1180
1182
  req_id, -32603, f"openBackgroundTab failed: {e!r}"))
@@ -435,6 +435,7 @@ class RelayServer:
435
435
  group_name: str | None = "Agent",
436
436
  group_id: int | None = None,
437
437
  background: bool = True,
438
+ skip_post_attach_commands: bool = False,
438
439
  timeout: float = 10.0,
439
440
  ) -> GhostTarget:
440
441
  """Spec Phase B Feature 1: open a tab in the background (active=false)
@@ -467,6 +468,8 @@ class RelayServer:
467
468
  # is requested so existing extensions default to background.
468
469
  if not background:
469
470
  body["background"] = False
471
+ if skip_post_attach_commands:
472
+ body["skipPostAttachCommands"] = True
470
473
  result = await self._request(ext, body, timeout=timeout) or {}
471
474
  tab_id = int(result.get("tabId", -1))
472
475
  if tab_id < 0:
@@ -866,7 +869,7 @@ class RelayServer:
866
869
  # lifecycle so they can synthesize Target.targetCreated /
867
870
  # attachedToTarget for a live `connect_over_cdp` client. The agent
868
871
  # path ignores these (its `_on_event` only handles `event`).
869
- await self._fanout_listeners(msg)
872
+ self._schedule_fanout_listeners(msg)
870
873
  return
871
874
 
872
875
  if kind == "detached":
@@ -874,7 +877,7 @@ class RelayServer:
874
877
  if tab_id < 0:
875
878
  return
876
879
  ext.tabs.pop(tab_id, None)
877
- await self._fanout_listeners(msg)
880
+ self._schedule_fanout_listeners(msg)
878
881
  return
879
882
 
880
883
  if kind == "response":
@@ -933,6 +936,28 @@ class RelayServer:
933
936
  except Exception as e: # noqa: BLE001
934
937
  logger.warning("relay fan-out listener raised: %r", e)
935
938
 
939
+ def _schedule_fanout_listeners(self, msg: dict) -> None:
940
+ """Notify secondary observers without blocking the relay reader.
941
+
942
+ An extension ``attached`` frame is often followed immediately by the
943
+ response to the create/attach request. Facade listeners may issue their
944
+ own relay requests for scoped visibility checks, so awaiting them inline
945
+ can deadlock the single websocket reader before it consumes the pending
946
+ response. Schedule the fan-out instead and keep draining extension
947
+ frames.
948
+ """
949
+ if not self._event_listeners:
950
+ return
951
+ task = asyncio.create_task(self._fanout_listeners(dict(msg)))
952
+
953
+ def _done(t: asyncio.Task) -> None:
954
+ try:
955
+ t.result()
956
+ except Exception as e: # noqa: BLE001
957
+ logger.warning("relay fan-out task raised: %r", e)
958
+
959
+ task.add_done_callback(_done)
960
+
936
961
 
937
962
  class _CommandError(Exception):
938
963
  """Wrapped extension-side CDP error. Surfaced to the caller in
@@ -224,7 +224,12 @@ def switch_tab(target) -> dict:
224
224
  return {"targetId": target_id}
225
225
 
226
226
 
227
- def open(url: str = "about:blank", *, background: bool = True) -> dict:
227
+ def open(
228
+ url: str = "about:blank",
229
+ *,
230
+ background: bool = True,
231
+ skip_post_attach_commands: bool = False,
232
+ ) -> dict:
228
233
  """Open a new working tab in this session's browser, attach, bind as
229
234
  current. The unified tab-opening primitive (docs §Tier B) — replaces
230
235
  both ``new_tab`` and ``open_background``.
@@ -246,7 +251,10 @@ def open(url: str = "about:blank", *, background: bool = True) -> dict:
246
251
  try:
247
252
  payload = sess.cdp.send(
248
253
  "BrowserwrightDaemon.openBackgroundTab",
249
- url=url, bsSession=sid, background=background,
254
+ url=url,
255
+ bsSession=sid,
256
+ background=background,
257
+ skipPostAttachCommands=skip_post_attach_commands,
250
258
  )
251
259
  except CDPError as e:
252
260
  raise CDPError(
@@ -400,7 +408,7 @@ def current_page() -> dict:
400
408
  return {"targetId": tabs[0]["targetId"], "url": tabs[0]["url"],
401
409
  "title": tabs[0]["title"], "accuracy": "unknown"}
402
410
  # 4. Empty session — open a fresh working tab (NOT adopt).
403
- return open("about:blank") | {"accuracy": "unknown"}
411
+ return open("about:blank", skip_post_attach_commands=True) | {"accuracy": "unknown"}
404
412
 
405
413
 
406
414
  def wait(seconds: float = 1.0) -> None:
@@ -125,10 +125,17 @@ def reset_executor(record: dict) -> str:
125
125
  def reap(*, idle_seconds: float) -> list[dict]:
126
126
  """Prune idle sessions; for create-owned ones, also tear down the browser
127
127
  the daemon launched. Returns the pruned records."""
128
- pruned = reg.prune(idle_seconds=idle_seconds)
129
- for rec in pruned:
128
+ stale = reg.stale(idle_seconds=idle_seconds)
129
+ pruned: list[dict] = []
130
+ for rec in stale:
131
+ _reap_executor(rec)
132
+ if rec.get("backend") == "extension":
133
+ _end_extension_workspace(rec)
130
134
  if rec.get("owner") == "create":
131
135
  _close_browser(rec)
136
+ removed = reg.remove(str(rec.get("id")))
137
+ if removed is not None:
138
+ pruned.append(removed)
132
139
  return pruned
133
140
 
134
141
 
@@ -124,6 +124,20 @@ def list_all() -> list[dict]:
124
124
  return [sessions[k] for k in sorted(sessions, key=int)]
125
125
 
126
126
 
127
+ def stale(*, idle_seconds: float) -> list[dict]:
128
+ """Sessions idle longer than ``idle_seconds`` without removing them."""
129
+ now = time.time()
130
+ p = _ledger_path()
131
+ if not p.exists():
132
+ return []
133
+ sessions = json.loads(p.read_text())["sessions"]
134
+ records = [
135
+ e for e in sessions.values()
136
+ if now - e.get("last_seen", 0.0) >= idle_seconds
137
+ ]
138
+ return sorted(records, key=lambda e: int(e.get("id", 0)))
139
+
140
+
127
141
  def prune(*, idle_seconds: float) -> list[dict]:
128
142
  """Remove sessions idle longer than ``idle_seconds``; return removed records."""
129
143
  now = time.time()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: browserwright
3
- Version: 0.6.9
3
+ Version: 0.6.12
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
  Requires-Python: >=3.11
6
6
  Requires-Dist: cdp-use==1.4.5
File without changes