browserwright 0.17.4__tar.gz → 0.18.0__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 (112) hide show
  1. {browserwright-0.17.4/src/browserwright.egg-info → browserwright-0.18.0}/PKG-INFO +1 -1
  2. {browserwright-0.17.4 → browserwright-0.18.0}/pyproject.toml +1 -1
  3. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/_executor/client.py +5 -7
  4. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/_executor/process.py +94 -13
  5. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/cdp.py +2 -3
  6. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/cli.py +106 -3
  7. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/daemon/_ipc.py +330 -4
  8. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/daemon/cli.py +81 -11
  9. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/daemon/doctor.py +5 -1
  10. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/daemon/launchagent.py +160 -9
  11. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/daemon/probe.py +24 -0
  12. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/daemon/server/daemon.py +19 -0
  13. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/daemon/server/exec_relay.py +66 -3
  14. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/daemon/server/executor_registry.py +143 -16
  15. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/daemon/server/facade.py +22 -4
  16. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/daemon/server/facade_extension.py +2 -0
  17. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/daemon/server/listener.py +260 -31
  18. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/daemon/server/relay.py +35 -7
  19. browserwright-0.18.0/src/browserwright/daemon/server/session_state.py +218 -0
  20. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/daemon/server/status.py +51 -2
  21. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/daemon/server/verbs.py +204 -0
  22. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/daemon_url.py +82 -39
  23. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/errors.py +21 -17
  24. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/health.py +102 -20
  25. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/mode_b_client.py +4 -2
  26. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/session_create.py +90 -7
  27. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/skill_runtime.md +18 -28
  28. {browserwright-0.17.4 → browserwright-0.18.0/src/browserwright.egg-info}/PKG-INFO +1 -1
  29. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright.egg-info/SOURCES.txt +1 -0
  30. {browserwright-0.17.4 → browserwright-0.18.0}/LICENSE +0 -0
  31. {browserwright-0.17.4 → browserwright-0.18.0}/README.md +0 -0
  32. {browserwright-0.17.4 → browserwright-0.18.0}/setup.cfg +0 -0
  33. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/__init__.py +0 -0
  34. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/__main__.py +0 -0
  35. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/_executor/__init__.py +0 -0
  36. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/_executor/__main__.py +0 -0
  37. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/_executor/protocol.py +0 -0
  38. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/_text.py +0 -0
  39. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/daemon/__init__.py +0 -0
  40. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/daemon/_net.py +0 -0
  41. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/daemon/_rpc.py +0 -0
  42. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/daemon/_stale.py +0 -0
  43. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/daemon/backends/__init__.py +0 -0
  44. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/daemon/backends/base.py +0 -0
  45. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/daemon/backends/cdp.py +0 -0
  46. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/daemon/backends/extension.py +0 -0
  47. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/daemon/config.py +0 -0
  48. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/daemon/errors.py +0 -0
  49. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/daemon/launch_chrome.py +0 -0
  50. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/daemon/observability.py +0 -0
  51. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/daemon/platforms.py +0 -0
  52. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/daemon/relay_status.py +0 -0
  53. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/daemon/resolver.py +0 -0
  54. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/daemon/restart_guard.py +0 -0
  55. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/daemon/server/__init__.py +0 -0
  56. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/daemon/server/extension_upstream.py +0 -0
  57. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/daemon/server/proxy.py +0 -0
  58. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/daemon/server/state.py +0 -0
  59. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/daemon/server/upstream.py +0 -0
  60. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/daemon/supervise.py +0 -0
  61. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/daemon/userscripts.py +0 -0
  62. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/discovery.py +0 -0
  63. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/install.py +0 -0
  64. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/memory/__init__.py +0 -0
  65. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/memory/_lock.py +0 -0
  66. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/memory/_md.py +0 -0
  67. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/memory/_yaml.py +0 -0
  68. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/memory/global_mem.py +0 -0
  69. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/memory/site_mem.py +0 -0
  70. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/output_schema.py +0 -0
  71. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/primitives/__init__.py +0 -0
  72. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/primitives/discovery_api.py +0 -0
  73. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/primitives/http.py +0 -0
  74. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/primitives/site.py +0 -0
  75. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/repl/__init__.py +0 -0
  76. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/repl/_md_convert.py +0 -0
  77. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/repl/_md_normalize.py +0 -0
  78. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/repl/_namespace.py +0 -0
  79. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/repl/_smart_goto.py +0 -0
  80. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/repl/inline.py +0 -0
  81. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/repl/markdown.py +0 -0
  82. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/repl/playwright_handle.py +0 -0
  83. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/repl/snapshot.py +0 -0
  84. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/repl/vendor/README.md +0 -0
  85. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/repl/vendor/readability.js +0 -0
  86. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/session.py +0 -0
  87. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/session_ctx.py +0 -0
  88. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/session_registry.py +0 -0
  89. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/session_runtime.py +0 -0
  90. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/site_skills_starter/github.com/SKILL.md +0 -0
  91. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/site_skills_starter/github.com/memory.md +0 -0
  92. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/site_skills_starter/github.com/tasks/list_issues.py +0 -0
  93. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/site_skills_starter/google.com/SKILL.md +0 -0
  94. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/site_skills_starter/google.com/memory.md +0 -0
  95. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/site_skills_starter/google.com/tasks/search.py +0 -0
  96. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/site_skills_starter/producthunt.com/SKILL.md +0 -0
  97. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/site_skills_starter/producthunt.com/memory.md +0 -0
  98. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/site_skills_starter/producthunt.com/tasks/today.py +0 -0
  99. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/site_skills_starter/wikipedia.org/SKILL.md +0 -0
  100. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/site_skills_starter/wikipedia.org/memory.md +0 -0
  101. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/site_skills_starter/wikipedia.org/tasks/lookup.py +0 -0
  102. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/site_skills_starter/ycombinator.com/SKILL.md +0 -0
  103. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/site_skills_starter/ycombinator.com/memory.md +0 -0
  104. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/site_skills_starter/ycombinator.com/tasks/front_page.py +0 -0
  105. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/skill_doc.py +0 -0
  106. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/tab_surface.py +0 -0
  107. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/task_runner.py +0 -0
  108. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright/version.py +0 -0
  109. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright.egg-info/dependency_links.txt +0 -0
  110. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright.egg-info/entry_points.txt +0 -0
  111. {browserwright-0.17.4 → browserwright-0.18.0}/src/browserwright.egg-info/requires.txt +0 -0
  112. {browserwright-0.17.4 → browserwright-0.18.0}/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.4
3
+ Version: 0.18.0
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.4"
18
+ version = "0.18.0"
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"
@@ -56,11 +56,9 @@ class ExecutorUnavailable(BrowserwrightError):
56
56
  connected — actionable: the daemon must be running (it spawns the
57
57
  executor)."""
58
58
 
59
- default_fix = ("ensure the daemon is running (`browserwright-daemon status "
60
- "--json` should show `alive`); if the executor is stale, "
61
- "call `reset()` as a standalone/final inline statement, "
62
- "then retry in a new command; or run `browserwright "
63
- "session reset <id>` before retrying.")
59
+ default_fix = ("run `browserwright recover --session <id>`; it diagnoses "
60
+ "the daemon, tab, and executor in order and reports the "
61
+ "one layer that still needs human attention")
64
62
 
65
63
 
66
64
  @dataclass(frozen=True)
@@ -105,8 +103,8 @@ def _ensure_executor_lease(sess) -> ExecutorLease:
105
103
  )
106
104
  if not isinstance(executor_id, str) or not executor_id:
107
105
  raise ExecutorUnavailable(
108
- "ensureExecutor returned no executor instance identity; restart "
109
- "the daemon so timeout cleanup cannot target a newer process"
106
+ "ensureExecutor returned no executor instance identity; run "
107
+ "`browserwright recover --session <id>` before retrying"
110
108
  )
111
109
  return ExecutorLease(session_id=sid, executor_id=executor_id)
112
110
 
@@ -269,6 +269,11 @@ class _Worker:
269
269
  self._active_globals: dict[str, Any] | None = None
270
270
  # The currently-armed facade-death handler (Fork 4).
271
271
  self._facade_death_handler: Any = None
272
+ # PID of the daemon whose facade produced the live Browser. The sync
273
+ # Playwright driver does not dispatch ``disconnected`` while this
274
+ # worker is idle, so this out-of-band identity check detects a daemon
275
+ # replacement before we touch the stale context on the next call.
276
+ self._facade_daemon_pid: int | None = None
272
277
  # Persistent per-session state, injected by reference each call (Fork 5).
273
278
  self._state: dict[str, Any] = {}
274
279
  # One live-page holder shared by every injected view (snapshot /
@@ -425,7 +430,41 @@ class _Worker:
425
430
  constraint). Bind the session FIRST so ``current_session()`` /
426
431
  ``resolve_current_target()`` resolve the right ledger record."""
427
432
  if self._connected:
428
- return
433
+ try:
434
+ from ..daemon import _ipc
435
+
436
+ current_daemon_pid = _ipc.ping_status_sync().pid
437
+ except Exception: # noqa: BLE001 - the connect path reports details
438
+ current_daemon_pid = None
439
+ if (self._facade_daemon_pid is not None
440
+ and current_daemon_pid != self._facade_daemon_pid):
441
+ self._on_facade_dead(self._browser)
442
+ # A resident executor is usually parked in ``queue.get`` between
443
+ # calls. Playwright's sync driver only dispatches the browser
444
+ # ``disconnected`` event while a sync API call pumps its fiber, so
445
+ # a daemon swap can leave this flag stale until the next command.
446
+ # Pump once before trusting it; the armed callback then clears the
447
+ # old objects and this same call reconnects below. Without this,
448
+ # the first post-swap command tries to bind on the dead context,
449
+ # times out, and the client reaps the adopted executor (losing
450
+ # persistent Python state).
451
+ try:
452
+ from ..repl.playwright_handle import drain_page_events
453
+
454
+ if self._context is not None:
455
+ drain_page_events(self._context)
456
+ except Exception: # noqa: BLE001 - liveness pump is best-effort
457
+ pass
458
+ if self._connected:
459
+ is_connected = getattr(self._browser, "is_connected", None)
460
+ try:
461
+ alive = bool(is_connected()) if callable(is_connected) else True
462
+ except Exception: # noqa: BLE001 - a failed probe is disconnected
463
+ alive = False
464
+ if not alive:
465
+ self._on_facade_dead()
466
+ if self._connected:
467
+ return
429
468
  from ..session import Session, set_session
430
469
  from ..session_ctx import resolve_session
431
470
 
@@ -494,6 +533,12 @@ class _Worker:
494
533
  # the previous call's list and the notice would vanish.
495
534
  warn=lambda m: self._call_warnings.append(m),
496
535
  )
536
+ try:
537
+ from ..daemon import _ipc
538
+
539
+ self._facade_daemon_pid = _ipc.ping_status_sync().pid
540
+ except Exception: # noqa: BLE001 - browser connection itself is live
541
+ self._facade_daemon_pid = None
497
542
  self._arm_facade_death()
498
543
  # Issue #21: the live page must follow the session's current target
499
544
  # (``session_runtime.bind_target`` / ``open_session_tab`` /
@@ -692,8 +737,14 @@ class _Worker:
692
737
 
693
738
  The handler reference is retained for testability and lifecycle
694
739
  introspection."""
740
+ # Capture the exact connection this callback belongs to. A
741
+ # disconnected event from the old daemon can be delivered late while
742
+ # the worker is already connecting a replacement Browser; that stale
743
+ # callback must not clear the newly assigned objects.
744
+ armed_browser = self._browser
745
+
695
746
  def handler(*_):
696
- return self._on_facade_dead()
747
+ return self._on_facade_dead(armed_browser)
697
748
 
698
749
  try:
699
750
  self._browser.on("disconnected", handler)
@@ -701,14 +752,46 @@ class _Worker:
701
752
  except Exception: # noqa: BLE001 - never let arming break cold-start
702
753
  self._facade_death_handler = None
703
754
 
704
- def _on_facade_dead(self) -> None:
755
+ def _on_facade_dead(self, browser: Any | None = None) -> None:
756
+ """ADR-0013 rule 1: survive the daemon, reconnect lazily.
757
+
758
+ This used to ``os._exit(0)`` ("Fork 4"), which made every daemon
759
+ restart destroy every session's ``state``. Now the executor only
760
+ forgets its Playwright objects; the next call finds ``_connected``
761
+ False and re-runs :meth:`_connect_and_bind` on the same live driver —
762
+ the exact path a failed cold-start already retries. The replacement
763
+ daemon adopts this process from its discovery record, so the agent's
764
+ variables survive the swap. If no daemon comes back, the reconnect
765
+ fails with an actionable error and the flag stays False for the next
766
+ attempt; the daemon's idle reaper eventually retires us.
767
+
768
+ Fires on Playwright's driver thread; only flags and references are
769
+ touched here, never the (thread-affine) Playwright objects."""
770
+ if browser is not None and self._browser is not browser:
771
+ return
705
772
  sys.stderr.write(
706
773
  f"executor {self._session_id}: facade transport dropped "
707
- "(daemon restart?); self-exiting for cold restart\n")
708
- # os._exit so we don't run atexit/finalizers that might re-enter the
709
- # (now dead) Playwright driver and hang. The daemon reaps us + cleans
710
- # the discovery file; the next heredoc cold-starts a fresh executor.
711
- os._exit(0)
774
+ "(daemon restart?); will reconnect on the next call\n")
775
+ self._connected = False
776
+ self._facade_death_handler = None
777
+ self._facade_daemon_pid = None
778
+ self._browser = None
779
+ self._context = None
780
+ self._page = None
781
+ self._live_page_holder.page = None
782
+ # The session's control-plane socket to the old daemon is dead too;
783
+ # drop it so the rebind dials the replacement instead of a corpse.
784
+ try:
785
+ from ..session import current_session
786
+ sess = current_session()
787
+ close = getattr(sess, "close", None)
788
+ if callable(close):
789
+ close()
790
+ invalidate = getattr(getattr(sess, "daemon", None), "invalidate", None)
791
+ if callable(invalidate):
792
+ invalidate()
793
+ except Exception: # noqa: BLE001 - best-effort; the rebind re-dials
794
+ pass
712
795
 
713
796
  def _execute(self, req: ExecuteRequest) -> ExecuteResponse:
714
797
  """Run one code blob in the persistent namespace, capturing stdout +
@@ -871,11 +954,9 @@ class _Worker:
871
954
  "the session's tab binding is gone (extension "
872
955
  "reloaded/updated, daemon restarted, or the tab was closed) "
873
956
  f"and re-binding a fresh tab failed ({failure}). The "
874
- "executor recycled itself; the NEXT command re-attaches the "
875
- "session automatically. If the tab is gone for good, run "
876
- f"`browserwright session reset -s {sid}` first, or "
877
- f"`browserwright session attach-active -s {sid}` to adopt the "
878
- "tab you are looking at, then retry."
957
+ "executor recycled itself. Run "
958
+ f"`browserwright recover --session {sid}`; it re-attaches or "
959
+ "opens the session tab and returns healthy before you retry."
879
960
  )
880
961
  return self._finish(
881
962
  buf, error=error, exit_code=exit_code,
@@ -40,9 +40,8 @@ def _rpc_error_fix(method: str, err: object) -> str:
40
40
  # breaks the deadlock; the generic -32601 hint does not apply.
41
41
  return (
42
42
  "an executor orphaned by a daemon crash may still hold this "
43
- "target: run `browserwright session reset <session-id>` (or "
44
- "`browserwright session end --session=<session-id>`) to reap it, "
45
- "then retry"
43
+ "target: run `browserwright recover --session <session-id>`; it "
44
+ "adopts or replaces that executor without changing other sessions"
46
45
  )
47
46
  return ""
48
47
 
@@ -30,7 +30,8 @@ Usage:
30
30
  browserwright -s <session-id> [--env NAME ...] -f script.py
31
31
  browserwright -s <session-id> [--env NAME ...] --code-stdin < script.py
32
32
 
33
- browserwright session new --backend=<extension|cdp|env> --name=SESSION_LABEL [--create | --attach=PORT]
33
+ browserwright session new --backend=<extension|cdp> --name=SESSION_LABEL [--reuse] [--create | --attach=PORT]
34
+ browserwright recover --session=<id> (the one recovery verb: exit 0 healthy, 4 needs-human)
34
35
  browserwright session reset <id>
35
36
  browserwright session end --session=ID
36
37
  browserwright session attach-active [--session=ID | -s ID] [--json]
@@ -726,6 +727,101 @@ def _cmd_memory(args: list[str]) -> int:
726
727
  return 1
727
728
 
728
729
 
730
+ def _cmd_recover(args: list[str], *, session_id: Optional[str] = None) -> int:
731
+ """`browserwright recover --session <id>` — ADR-0013 rule 2, the ONE
732
+ thing an agent does when a call failed.
733
+
734
+ Client side first: a dead daemon cannot answer an RPC, so the daemon
735
+ self-check (three criteria, two agreeing probes) runs here. "Gone" or
736
+ "stale version" is repaired through the default endpoint's on-demand
737
+ start (the sanctioned restart path); "something else answers on the
738
+ port" and "the probes disagree" are `needs-human` because recovery never
739
+ signals or replaces an unidentified process. Then the daemon runs
740
+ the per-session ladder and reports the state reached. Exit 0 = healthy,
741
+ 4 = needs-human, 2 = usage."""
742
+ kw = _parse_kv_args(args)
743
+ sid = kw.get("session") or session_id
744
+ if not sid:
745
+ print("usage: browserwright recover --session=<id>", file=sys.stderr)
746
+ return 2
747
+ sid = str(sid)
748
+ from . import session_create
749
+ from .daemon.launchagent import daemon_self_check
750
+ from .daemon_url import daemon_endpoint
751
+
752
+ steps: list[str] = []
753
+ ep = daemon_endpoint()
754
+ verdict = daemon_self_check(None)
755
+ if not verdict["healthy"]:
756
+ crit = verdict["criterion"]
757
+ if crit in ("gone", "version") and not ep.explicit:
758
+ steps.append(f"daemon: {verdict['detail']}; starting the installed one")
759
+ from .daemon import _ipc
760
+ _ipc.log_lifecycle(
761
+ "automatic-recovery", criterion=crit,
762
+ probes=",".join(verdict.get("probes") or []),
763
+ reason=verdict["detail"], session=sid)
764
+ try:
765
+ session_create._ensure_daemon_running()
766
+ except Exception as e: # noqa: BLE001
767
+ return _recover_report(sid, "needs-human", steps,
768
+ f"daemon could not be started: {e}")
769
+ from .mode_b_client import ModeBClient
770
+ if not ModeBClient().wait_until_alive(timeout=20.0):
771
+ return _recover_report(sid, "needs-human", steps,
772
+ "no daemon answered within 20s after the "
773
+ "start; `browserwright-daemon logs` has "
774
+ "its last words")
775
+ steps.append("daemon: up")
776
+ else:
777
+ return _recover_report(sid, "needs-human", steps, verdict["detail"])
778
+ else:
779
+ steps.append(f"daemon: {verdict['detail']}")
780
+
781
+ import json as _json
782
+ import subprocess
783
+ try:
784
+ proc = subprocess.run(
785
+ ["browserwright-daemon", "recover", "--session", sid],
786
+ capture_output=True, text=True, timeout=120.0,
787
+ env=session_create._daemon_child_env())
788
+ except (OSError, subprocess.SubprocessError) as e:
789
+ return _recover_report(sid, "needs-human", steps,
790
+ f"could not ask the daemon to recover: {e}")
791
+ try:
792
+ result = _json.loads(proc.stdout.strip().splitlines()[-1])
793
+ if not isinstance(result, dict):
794
+ raise ValueError("recover result is not an object")
795
+ except (ValueError, IndexError):
796
+ detail = ((proc.stderr or proc.stdout).strip()[:400]
797
+ if proc.returncode != 0 else
798
+ f"unreadable recover result: {proc.stdout[:200]!r}")
799
+ return _recover_report(
800
+ sid, "needs-human", steps,
801
+ detail or f"daemon recover exited {proc.returncode}")
802
+ if proc.returncode not in (0, 4):
803
+ return _recover_report(sid, "needs-human", steps,
804
+ (proc.stderr or f"daemon recover exited "
805
+ f"{proc.returncode}").strip()[:400])
806
+ for st in result.get("steps") or []:
807
+ steps.append(f"{st.get('rung')}: {st.get('detail')}")
808
+ return _recover_report(sid, result.get("state") or "needs-human", steps,
809
+ result.get("reason") or "")
810
+
811
+
812
+ def _recover_report(sid: str, state: str, steps: list[str], reason: str) -> int:
813
+ for line in steps:
814
+ print(f" - {line}", file=sys.stderr)
815
+ if state == "healthy":
816
+ print(f"session {sid}: healthy — retry your call")
817
+ return 0
818
+ print(f"session {sid}: {state}" + (f" — {reason}" if reason else ""))
819
+ if state == "needs-human":
820
+ print(" a retry will not change this; report it with the lines above "
821
+ "(`browserwright doctor` has the full picture)", file=sys.stderr)
822
+ return 4
823
+
824
+
729
825
  def _cmd_session(args: list[str], *, session_id: Optional[str] = None) -> int:
730
826
  """``browserwright session {new|attach-active|reset|end|list|prune} ...`` (P2)."""
731
827
  from . import session_create
@@ -756,7 +852,7 @@ def _cmd_session(args: list[str], *, session_id: Optional[str] = None) -> int:
756
852
  file=sys.stderr)
757
853
  return 1
758
854
  print("usage: browserwright session new --backend=<extension|cdp> "
759
- "--name=SESSION_LABEL [--create | --attach=<port|url>]",
855
+ "--name=SESSION_LABEL [--reuse] [--create | --attach=<port|url>]",
760
856
  file=sys.stderr)
761
857
  print("--name is a short task-specific session label. Extension sessions "
762
858
  "use it as the Chrome tab group title; cdp sessions use it only to "
@@ -769,11 +865,16 @@ def _cmd_session(args: list[str], *, session_id: Optional[str] = None) -> int:
769
865
  sid = session_create.new(
770
866
  backend=backend, create=bool(kw.get("create")),
771
867
  attach=kw.get("attach"), name=kw.get("name"),
868
+ reuse=bool(kw.get("reuse")),
772
869
  )
773
870
  except ValueError as e:
774
871
  print(str(e), file=sys.stderr)
775
872
  return 1
776
- print(f"OK: session {sid} created", file=sys.stderr)
873
+ if session_create.last_new_reused == sid:
874
+ print(f"OK: reusing session {sid} (--reuse matched an existing "
875
+ f"{backend} session named {kw.get('name')!r})", file=sys.stderr)
876
+ else:
877
+ print(f"OK: session {sid} created", file=sys.stderr)
777
878
  print(sid) # token-frugal: bare id
778
879
  return 0
779
880
 
@@ -1110,6 +1211,8 @@ def main(argv: Optional[list[str]] = None) -> None:
1110
1211
  sys.exit(_cmd_session(rest, session_id=global_session))
1111
1212
  if cmd == "whoami":
1112
1213
  sys.exit(_cmd_whoami(rest, session_id=global_session))
1214
+ if cmd == "recover":
1215
+ sys.exit(_cmd_recover(rest, session_id=global_session))
1113
1216
  if cmd == "userscript":
1114
1217
  sys.exit(_cmd_userscript(rest, session_id=global_session))
1115
1218