browserwright 0.6.8__tar.gz → 0.6.11__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.8 → browserwright-0.6.11}/PKG-INFO +1 -1
  2. {browserwright-0.6.8 → browserwright-0.6.11}/README.md +36 -54
  3. {browserwright-0.6.8 → browserwright-0.6.11}/pyproject.toml +1 -1
  4. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/_executor/client.py +7 -0
  5. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/config.py +13 -0
  6. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/server/extension_upstream.py +7 -1
  7. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/server/facade_extension.py +2 -1
  8. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/server/listener.py +91 -6
  9. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/server/proxy.py +3 -1
  10. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/server/relay.py +191 -5
  11. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/primitives/page.py +11 -3
  12. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/session_create.py +9 -2
  13. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/session_registry.py +14 -0
  14. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright.egg-info/PKG-INFO +1 -1
  15. {browserwright-0.6.8 → browserwright-0.6.11}/setup.cfg +0 -0
  16. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/__init__.py +0 -0
  17. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/__main__.py +0 -0
  18. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/_executor/__init__.py +0 -0
  19. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/_executor/__main__.py +0 -0
  20. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/_executor/process.py +0 -0
  21. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/_executor/protocol.py +0 -0
  22. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/api.py +0 -0
  23. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/cdp.py +0 -0
  24. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/cli.py +0 -0
  25. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/__init__.py +0 -0
  26. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/_ipc.py +0 -0
  27. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/active_tab.py +0 -0
  28. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/auth.py +0 -0
  29. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/backends/__init__.py +0 -0
  30. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/backends/base.py +0 -0
  31. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/backends/cloud.py +0 -0
  32. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/backends/env.py +0 -0
  33. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/backends/extension.py +0 -0
  34. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/backends/rdp.py +0 -0
  35. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/cli.py +0 -0
  36. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/doctor.py +0 -0
  37. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/errors.py +0 -0
  38. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/launch_chrome.py +0 -0
  39. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/observability.py +0 -0
  40. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/platforms.py +0 -0
  41. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/resolver.py +0 -0
  42. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/server/__init__.py +0 -0
  43. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/server/daemon.py +0 -0
  44. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/server/executor_registry.py +0 -0
  45. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/server/facade.py +0 -0
  46. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/server/state.py +0 -0
  47. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/server/upstream.py +0 -0
  48. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/userscripts.py +0 -0
  49. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/discovery.py +0 -0
  50. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/errors.py +0 -0
  51. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/health.py +0 -0
  52. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/install.py +0 -0
  53. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/memory/__init__.py +0 -0
  54. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/memory/_md.py +0 -0
  55. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/memory/_yaml.py +0 -0
  56. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/memory/global_mem.py +0 -0
  57. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/memory/repl_mem.py +0 -0
  58. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/memory/session_decisions.py +0 -0
  59. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/memory/site_mem.py +0 -0
  60. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/mode_b_client.py +0 -0
  61. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/multitask.py +0 -0
  62. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/output_schema.py +0 -0
  63. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/primitives/__init__.py +0 -0
  64. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/primitives/discovery_api.py +0 -0
  65. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/primitives/http.py +0 -0
  66. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/primitives/inspect.py +0 -0
  67. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/primitives/interact.py +0 -0
  68. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/primitives/site.py +0 -0
  69. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/release_install.py +0 -0
  70. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/repl/__init__.py +0 -0
  71. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/repl/_namespace.py +0 -0
  72. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/repl/_smart_goto.py +0 -0
  73. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/repl/inline.py +0 -0
  74. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/repl/playwright_handle.py +0 -0
  75. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/repl/snapshot.py +0 -0
  76. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/session.py +0 -0
  77. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/session_ctx.py +0 -0
  78. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/session_runtime.py +0 -0
  79. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/site_skills_starter/github.com/SKILL.md +0 -0
  80. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/site_skills_starter/github.com/memory.md +0 -0
  81. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/site_skills_starter/github.com/tasks/list_issues.py +0 -0
  82. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/site_skills_starter/google.com/SKILL.md +0 -0
  83. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/site_skills_starter/google.com/memory.md +0 -0
  84. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/site_skills_starter/google.com/tasks/search.py +0 -0
  85. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/site_skills_starter/producthunt.com/SKILL.md +0 -0
  86. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/site_skills_starter/producthunt.com/memory.md +0 -0
  87. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/site_skills_starter/producthunt.com/tasks/today.py +0 -0
  88. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/site_skills_starter/wikipedia.org/SKILL.md +0 -0
  89. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/site_skills_starter/wikipedia.org/memory.md +0 -0
  90. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/site_skills_starter/wikipedia.org/tasks/lookup.py +0 -0
  91. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/site_skills_starter/ycombinator.com/SKILL.md +0 -0
  92. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/site_skills_starter/ycombinator.com/memory.md +0 -0
  93. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/site_skills_starter/ycombinator.com/tasks/front_page.py +0 -0
  94. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/skill_doc.py +0 -0
  95. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/skill_runtime.md +0 -0
  96. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/subscriptions.py +0 -0
  97. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/task_runner.py +0 -0
  98. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/version.py +0 -0
  99. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright.egg-info/SOURCES.txt +0 -0
  100. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright.egg-info/dependency_links.txt +0 -0
  101. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright.egg-info/entry_points.txt +0 -0
  102. {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright.egg-info/requires.txt +0 -0
  103. {browserwright-0.6.8 → browserwright-0.6.11}/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.8
3
+ Version: 0.6.11
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.8"
7
+ version = "0.6.11"
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}"))
@@ -78,6 +78,9 @@ DEFAULT_RELAY_PORT = 19989
78
78
  # with the playwriter / OpenCLI experience.
79
79
  ATTACH_RETRY_LIMIT = 3
80
80
  ATTACH_RETRY_BACKOFF = (0.1, 0.3, 0.8) # seconds; len must equal ATTACH_RETRY_LIMIT
81
+ APP_PING_INTERVAL = 5.0
82
+ STALE_FRAME_AFTER = 30.0
83
+ RECONNECT_WAIT_TIMEOUT = 35.0
81
84
 
82
85
 
83
86
  @dataclass
@@ -110,6 +113,8 @@ class _ExtensionConn:
110
113
  hello_received: asyncio.Event = field(default_factory=asyncio.Event)
111
114
  pending: dict[int, asyncio.Future] = field(default_factory=dict)
112
115
  tabs: dict[int, GhostTarget] = field(default_factory=dict)
116
+ last_frame_ts: float = field(default_factory=time.monotonic)
117
+ app_ping_task: asyncio.Task | None = None
113
118
 
114
119
 
115
120
  class RelayServer:
@@ -273,6 +278,9 @@ class RelayServer:
273
278
  heuristic-recent-activate table.
274
279
  """
275
280
  ext = self._pick_active_extension()
281
+ if ext is None:
282
+ return None
283
+ ext = await self._ensure_extension_fresh(ext)
276
284
  if ext is None:
277
285
  return None
278
286
  return await self._request(ext, {"type": "queryActiveTab"},
@@ -280,7 +288,7 @@ class RelayServer:
280
288
 
281
289
  async def query_group_tabs(self, group_name: str | None = None, *,
282
290
  group_id: int | None = None,
283
- timeout: float = 5.0) -> dict | None:
291
+ timeout: float = 15.0) -> dict | None:
284
292
  """Live membership query: ask the extension for the tabs of the
285
293
  session's tab group. ``group_id`` is the durable primary key (the
286
294
  numeric Chrome groupId); ``group_name`` is accepted for older callers
@@ -291,6 +299,9 @@ class RelayServer:
291
299
  Returns None when no extension is connected (mirrors
292
300
  query_active_tab's caller-falls-back contract)."""
293
301
  ext = self._pick_active_extension()
302
+ if ext is None:
303
+ return None
304
+ ext = await self._ensure_extension_fresh(ext)
294
305
  if ext is None:
295
306
  return None
296
307
  body: dict = {"type": "queryGroup"}
@@ -322,6 +333,7 @@ class RelayServer:
322
333
  ext = self._pick_active_extension()
323
334
  if ext is None:
324
335
  raise RuntimeError("no extension connected")
336
+ ext = await self._ensure_extension_fresh_or_raise(ext)
325
337
  last_err: Exception | None = None
326
338
  body: dict = {"type": "attachActive"}
327
339
  if group_name:
@@ -367,6 +379,7 @@ class RelayServer:
367
379
  ext = self._pick_active_extension()
368
380
  if ext is None:
369
381
  raise RuntimeError("no extension connected")
382
+ ext = await self._ensure_extension_fresh_or_raise(ext)
370
383
  # Idempotency: extension may already hold chrome.debugger.attach on
371
384
  # this tab (popup click, prior daemon lifecycle — the SW survives
372
385
  # daemon restarts and re-announces attached tabs on reconnect, so
@@ -403,6 +416,9 @@ class RelayServer:
403
416
  async def detach_tab(self, tab_id: int, *,
404
417
  timeout: float = 5.0) -> None:
405
418
  ext = self._extension_for_tab(tab_id)
419
+ if ext is None:
420
+ return
421
+ ext = await self._ensure_extension_fresh(ext)
406
422
  if ext is None:
407
423
  return
408
424
  try:
@@ -419,6 +435,7 @@ class RelayServer:
419
435
  group_name: str | None = "Agent",
420
436
  group_id: int | None = None,
421
437
  background: bool = True,
438
+ skip_post_attach_commands: bool = False,
422
439
  timeout: float = 10.0,
423
440
  ) -> GhostTarget:
424
441
  """Spec Phase B Feature 1: open a tab in the background (active=false)
@@ -440,6 +457,7 @@ class RelayServer:
440
457
  ext = self._pick_active_extension()
441
458
  if ext is None:
442
459
  raise RuntimeError("no extension connected")
460
+ ext = await self._ensure_extension_fresh_or_raise(ext)
443
461
  body: dict = {"type": "createTab", "url": url}
444
462
  if group_name:
445
463
  body["groupName"] = group_name
@@ -450,6 +468,8 @@ class RelayServer:
450
468
  # is requested so existing extensions default to background.
451
469
  if not background:
452
470
  body["background"] = False
471
+ if skip_post_attach_commands:
472
+ body["skipPostAttachCommands"] = True
453
473
  result = await self._request(ext, body, timeout=timeout) or {}
454
474
  tab_id = int(result.get("tabId", -1))
455
475
  if tab_id < 0:
@@ -489,6 +509,7 @@ class RelayServer:
489
509
  ext = self._extension_for_tab(tab_id)
490
510
  if ext is None:
491
511
  raise RuntimeError(f"no extension knows tab {tab_id}")
512
+ ext = await self._ensure_extension_fresh_or_raise(ext)
492
513
  try:
493
514
  await self._request(
494
515
  ext, {"type": "closeTab", "tabId": tab_id}, timeout=timeout)
@@ -504,6 +525,7 @@ class RelayServer:
504
525
  ext = self._extension_for_tab(tab_id)
505
526
  if ext is None:
506
527
  raise RuntimeError(f"no extension owns tab {tab_id}")
528
+ ext = await self._ensure_extension_fresh_or_raise(ext)
507
529
  return await self._request(ext, {
508
530
  "type": "command",
509
531
  "tabId": tab_id,
@@ -522,6 +544,7 @@ class RelayServer:
522
544
  ext = self._pick_active_extension()
523
545
  if ext is None:
524
546
  raise RuntimeError("no extension connected")
547
+ ext = await self._ensure_extension_fresh_or_raise(ext)
525
548
  return await self._request(
526
549
  ext, {"type": f"userscript.{verb}", **payload}, timeout=timeout)
527
550
 
@@ -546,6 +569,106 @@ class RelayServer:
546
569
  self._next_cmd_id += 1
547
570
  return v
548
571
 
572
+ def _extension_is_stale(self, ext: _ExtensionConn) -> bool:
573
+ if not ext.hello_received.is_set():
574
+ return False
575
+ return (time.monotonic() - ext.last_frame_ts) > STALE_FRAME_AFTER
576
+
577
+ async def _ensure_extension_fresh(
578
+ self, ext: _ExtensionConn,
579
+ ) -> _ExtensionConn | None:
580
+ """Return a live extension connection, force-closing ghost sockets.
581
+
582
+ MV3 can suspend the SW while Chrome's network process keeps the TCP
583
+ websocket ESTABLISHED. Protocol pings still succeed there, but no app
584
+ frames arrive. The daemon treats missing app frames as authoritative
585
+ and tears down the ghost before sending a user command.
586
+ """
587
+ if not self._extension_is_stale(ext):
588
+ return ext
589
+ await self._force_close_extension(ext, reason="stale app-level heartbeat")
590
+ return await self._wait_for_replacement(ext, timeout=RECONNECT_WAIT_TIMEOUT)
591
+
592
+ async def _ensure_extension_fresh_or_raise(
593
+ self, ext: _ExtensionConn,
594
+ ) -> _ExtensionConn:
595
+ fresh = await self._ensure_extension_fresh(ext)
596
+ if fresh is None:
597
+ raise RuntimeError(
598
+ "extension relay connection appears stale and did not reconnect "
599
+ f"within {RECONNECT_WAIT_TIMEOUT:.0f}s")
600
+ return fresh
601
+
602
+ async def _force_close_extension(self, ext: _ExtensionConn, *, reason: str) -> None:
603
+ logger.warning(
604
+ "force-closing stale extension relay connection: install_id=%s reason=%s",
605
+ ext.install_id or "(pending)",
606
+ reason,
607
+ )
608
+ for fut in list(ext.pending.values()):
609
+ if not fut.done():
610
+ fut.set_exception(ConnectionError(f"extension relay closed: {reason}"))
611
+ if fut.cancelled():
612
+ continue
613
+ with contextlib.suppress(BaseException):
614
+ fut.exception()
615
+ with contextlib.suppress(Exception):
616
+ await asyncio.wait_for(
617
+ ext.conn.close(code=1011, reason=reason),
618
+ timeout=1.0,
619
+ )
620
+
621
+ async def _wait_for_replacement(
622
+ self, old_ext: _ExtensionConn, *, timeout: float,
623
+ allow_any_install: bool = False,
624
+ ) -> _ExtensionConn | None:
625
+ deadline = time.monotonic() + max(0.0, timeout)
626
+ install_id = old_ext.install_id
627
+ while time.monotonic() < deadline:
628
+ candidates = [
629
+ ext for ext in self._extensions.values()
630
+ if ext is not old_ext and ext.hello_received.is_set()
631
+ ]
632
+ if install_id:
633
+ for ext in candidates:
634
+ if ext.install_id == install_id:
635
+ return ext
636
+ if allow_any_install and candidates:
637
+ return candidates[0]
638
+ await asyncio.sleep(0.1)
639
+ if install_id:
640
+ return None
641
+ if candidates := [
642
+ ext for ext in self._extensions.values()
643
+ if ext is not old_ext and ext.hello_received.is_set()
644
+ ]:
645
+ return candidates[0]
646
+ return None
647
+
648
+ async def _retry_request_on_replacement(
649
+ self,
650
+ ext: _ExtensionConn,
651
+ body: dict,
652
+ *,
653
+ timeout: float,
654
+ loop: asyncio.AbstractEventLoop,
655
+ ) -> dict | None:
656
+ await self._force_close_extension(ext, reason=f"{body.get('type')} request failed")
657
+ replacement = await self._wait_for_replacement(
658
+ ext, timeout=RECONNECT_WAIT_TIMEOUT)
659
+ if replacement is None:
660
+ raise ConnectionError(
661
+ "extension relay did not reconnect after request failure")
662
+ retry_id = self._alloc_id()
663
+ retry_body = {**{k: v for k, v in body.items() if k != "id"}, "id": retry_id}
664
+ retry_fut: asyncio.Future = loop.create_future()
665
+ replacement.pending[retry_id] = retry_fut
666
+ try:
667
+ await replacement.conn.send(json.dumps(retry_body))
668
+ return await asyncio.wait_for(retry_fut, timeout=timeout)
669
+ finally:
670
+ replacement.pending.pop(retry_id, None)
671
+
549
672
  async def _request(self, ext: _ExtensionConn, body: dict, *,
550
673
  timeout: float) -> dict | None:
551
674
  cmd_id = self._alloc_id()
@@ -556,7 +679,18 @@ class RelayServer:
556
679
  try:
557
680
  await ext.conn.send(json.dumps(body))
558
681
  return await asyncio.wait_for(fut, timeout=timeout)
682
+ except asyncio.TimeoutError:
683
+ if not self._extension_is_stale(ext):
684
+ raise
685
+ return await self._retry_request_on_replacement(
686
+ ext, body, timeout=timeout, loop=loop)
687
+ except (ConnectionError, websockets.exceptions.ConnectionClosed):
688
+ return await self._retry_request_on_replacement(
689
+ ext, body, timeout=timeout, loop=loop)
559
690
  finally:
691
+ if not fut.cancelled():
692
+ with contextlib.suppress(BaseException):
693
+ fut.exception()
560
694
  ext.pending.pop(cmd_id, None)
561
695
 
562
696
  # ---- ws handlers -----------------------------------------------------
@@ -633,6 +767,7 @@ class RelayServer:
633
767
  self._extensions[temp_key] = ext
634
768
  try:
635
769
  async for raw in conn:
770
+ ext.last_frame_ts = time.monotonic()
636
771
  if not isinstance(raw, (str, bytes)):
637
772
  continue
638
773
  text = raw if isinstance(raw, str) else raw.decode("utf-8", errors="replace")
@@ -650,8 +785,12 @@ class RelayServer:
650
785
  logger.warning("extension handler crashed: %r", e)
651
786
  finally:
652
787
  key = ext.install_id or temp_key
653
- self._extensions.pop(key, None)
654
- self._extensions.pop(temp_key, None)
788
+ if self._extensions.get(key) is ext:
789
+ self._extensions.pop(key, None)
790
+ if self._extensions.get(temp_key) is ext:
791
+ self._extensions.pop(temp_key, None)
792
+ if ext.app_ping_task is not None:
793
+ ext.app_ping_task.cancel()
655
794
  for fut in list(ext.pending.values()):
656
795
  if not fut.done():
657
796
  fut.set_exception(ConnectionError("extension disconnected"))
@@ -673,6 +812,8 @@ class RelayServer:
673
812
  self._extensions.pop(temp_key, None)
674
813
  self._extensions[ext.install_id or temp_key] = ext
675
814
  ext.hello_received.set()
815
+ if ext.app_ping_task is None or ext.app_ping_task.done():
816
+ ext.app_ping_task = asyncio.create_task(self._app_ping_loop(ext))
676
817
  self._first_ready.set()
677
818
  if (
678
819
  ext.extension_protocol_version
@@ -709,6 +850,9 @@ class RelayServer:
709
850
  pass
710
851
  return
711
852
 
853
+ if kind == "pong":
854
+ return
855
+
712
856
  if kind == "attached":
713
857
  tab_id = int(msg.get("tabId", -1))
714
858
  if tab_id < 0:
@@ -725,7 +869,7 @@ class RelayServer:
725
869
  # lifecycle so they can synthesize Target.targetCreated /
726
870
  # attachedToTarget for a live `connect_over_cdp` client. The agent
727
871
  # path ignores these (its `_on_event` only handles `event`).
728
- await self._fanout_listeners(msg)
872
+ self._schedule_fanout_listeners(msg)
729
873
  return
730
874
 
731
875
  if kind == "detached":
@@ -733,7 +877,7 @@ class RelayServer:
733
877
  if tab_id < 0:
734
878
  return
735
879
  ext.tabs.pop(tab_id, None)
736
- await self._fanout_listeners(msg)
880
+ self._schedule_fanout_listeners(msg)
737
881
  return
738
882
 
739
883
  if kind == "response":
@@ -762,6 +906,26 @@ class RelayServer:
762
906
 
763
907
  logger.debug("extension sent unknown type %r: %s", kind, str(msg)[:100])
764
908
 
909
+ async def _app_ping_loop(self, ext: _ExtensionConn) -> None:
910
+ try:
911
+ while True:
912
+ await asyncio.sleep(APP_PING_INTERVAL)
913
+ if not ext.hello_received.is_set():
914
+ continue
915
+ if self._extension_is_stale(ext):
916
+ await self._force_close_extension(
917
+ ext, reason="missing app-level frames")
918
+ return
919
+ try:
920
+ await ext.conn.send(json.dumps({
921
+ "type": "ping",
922
+ "ts": int(time.time() * 1000),
923
+ }))
924
+ except Exception:
925
+ return
926
+ except asyncio.CancelledError:
927
+ raise
928
+
765
929
  async def _fanout_listeners(self, msg: dict) -> None:
766
930
  """Call every additional fan-out observer with the raw extension
767
931
  message (PR2). Isolated from the primary `_on_event` so one observer
@@ -772,6 +936,28 @@ class RelayServer:
772
936
  except Exception as e: # noqa: BLE001
773
937
  logger.warning("relay fan-out listener raised: %r", e)
774
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
+
775
961
 
776
962
  class _CommandError(Exception):
777
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.8
3
+ Version: 0.6.11
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