browserwright 0.8.2__tar.gz → 0.9.1__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 (109) hide show
  1. {browserwright-0.8.2/src/browserwright.egg-info → browserwright-0.9.1}/PKG-INFO +2 -1
  2. {browserwright-0.8.2 → browserwright-0.9.1}/pyproject.toml +27 -1
  3. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/__init__.py +2 -1
  4. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/_executor/process.py +183 -31
  5. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/_executor/protocol.py +13 -6
  6. browserwright-0.9.1/src/browserwright/_text.py +195 -0
  7. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/cli.py +164 -0
  8. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/cli.py +25 -2
  9. browserwright-0.9.1/src/browserwright/daemon/launchagent.py +529 -0
  10. browserwright-0.9.1/src/browserwright/daemon/restart_guard.py +217 -0
  11. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/server/extension_upstream.py +12 -0
  12. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/errors.py +28 -0
  13. browserwright-0.9.1/src/browserwright/repl/_md_convert.py +101 -0
  14. browserwright-0.9.1/src/browserwright/repl/_md_normalize.py +234 -0
  15. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/repl/_namespace.py +36 -3
  16. browserwright-0.9.1/src/browserwright/repl/markdown.py +266 -0
  17. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/repl/snapshot.py +43 -38
  18. browserwright-0.9.1/src/browserwright/repl/vendor/README.md +44 -0
  19. browserwright-0.9.1/src/browserwright/repl/vendor/readability.js +2812 -0
  20. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/session_runtime.py +12 -3
  21. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/skill_runtime.md +81 -6
  22. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/version.py +47 -0
  23. {browserwright-0.8.2 → browserwright-0.9.1/src/browserwright.egg-info}/PKG-INFO +2 -1
  24. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright.egg-info/SOURCES.txt +7 -0
  25. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright.egg-info/requires.txt +1 -0
  26. browserwright-0.8.2/src/browserwright/daemon/launchagent.py +0 -236
  27. {browserwright-0.8.2 → browserwright-0.9.1}/LICENSE +0 -0
  28. {browserwright-0.8.2 → browserwright-0.9.1}/README.md +0 -0
  29. {browserwright-0.8.2 → browserwright-0.9.1}/setup.cfg +0 -0
  30. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/__main__.py +0 -0
  31. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/_executor/__init__.py +0 -0
  32. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/_executor/__main__.py +0 -0
  33. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/_executor/client.py +0 -0
  34. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/cdp.py +0 -0
  35. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/__init__.py +0 -0
  36. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/_ipc.py +0 -0
  37. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/_net.py +0 -0
  38. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/_rpc.py +0 -0
  39. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/_stale.py +0 -0
  40. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/backends/__init__.py +0 -0
  41. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/backends/base.py +0 -0
  42. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/backends/cdp.py +0 -0
  43. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/backends/extension.py +0 -0
  44. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/config.py +0 -0
  45. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/doctor.py +0 -0
  46. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/errors.py +0 -0
  47. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/launch_chrome.py +0 -0
  48. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/observability.py +0 -0
  49. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/platforms.py +0 -0
  50. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/probe.py +0 -0
  51. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/relay_status.py +0 -0
  52. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/resolver.py +0 -0
  53. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/server/__init__.py +0 -0
  54. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/server/daemon.py +0 -0
  55. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/server/executor_registry.py +0 -0
  56. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/server/facade.py +0 -0
  57. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/server/facade_extension.py +0 -0
  58. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/server/listener.py +0 -0
  59. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/server/proxy.py +0 -0
  60. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/server/relay.py +0 -0
  61. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/server/state.py +0 -0
  62. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/server/status.py +0 -0
  63. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/server/upstream.py +0 -0
  64. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/server/verbs.py +0 -0
  65. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/supervise.py +0 -0
  66. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/daemon/userscripts.py +0 -0
  67. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/discovery.py +0 -0
  68. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/health.py +0 -0
  69. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/install.py +0 -0
  70. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/memory/__init__.py +0 -0
  71. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/memory/_lock.py +0 -0
  72. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/memory/_md.py +0 -0
  73. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/memory/_yaml.py +0 -0
  74. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/memory/global_mem.py +0 -0
  75. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/memory/site_mem.py +0 -0
  76. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/mode_b_client.py +0 -0
  77. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/output_schema.py +0 -0
  78. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/primitives/__init__.py +0 -0
  79. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/primitives/discovery_api.py +0 -0
  80. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/primitives/http.py +0 -0
  81. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/primitives/site.py +0 -0
  82. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/repl/__init__.py +0 -0
  83. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/repl/_smart_goto.py +0 -0
  84. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/repl/inline.py +0 -0
  85. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/repl/playwright_handle.py +0 -0
  86. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/session.py +0 -0
  87. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/session_create.py +0 -0
  88. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/session_ctx.py +0 -0
  89. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/session_registry.py +0 -0
  90. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/site_skills_starter/github.com/SKILL.md +0 -0
  91. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/site_skills_starter/github.com/memory.md +0 -0
  92. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/site_skills_starter/github.com/tasks/list_issues.py +0 -0
  93. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/site_skills_starter/google.com/SKILL.md +0 -0
  94. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/site_skills_starter/google.com/memory.md +0 -0
  95. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/site_skills_starter/google.com/tasks/search.py +0 -0
  96. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/site_skills_starter/producthunt.com/SKILL.md +0 -0
  97. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/site_skills_starter/producthunt.com/memory.md +0 -0
  98. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/site_skills_starter/producthunt.com/tasks/today.py +0 -0
  99. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/site_skills_starter/wikipedia.org/SKILL.md +0 -0
  100. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/site_skills_starter/wikipedia.org/memory.md +0 -0
  101. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/site_skills_starter/wikipedia.org/tasks/lookup.py +0 -0
  102. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/site_skills_starter/ycombinator.com/SKILL.md +0 -0
  103. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/site_skills_starter/ycombinator.com/memory.md +0 -0
  104. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/site_skills_starter/ycombinator.com/tasks/front_page.py +0 -0
  105. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/skill_doc.py +0 -0
  106. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright/task_runner.py +0 -0
  107. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright.egg-info/dependency_links.txt +0 -0
  108. {browserwright-0.8.2 → browserwright-0.9.1}/src/browserwright.egg-info/entry_points.txt +0 -0
  109. {browserwright-0.8.2 → browserwright-0.9.1}/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.8.2
3
+ Version: 0.9.1
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
@@ -10,6 +10,7 @@ Requires-Dist: pillow==12.2.0
10
10
  Requires-Dist: httpx>=0.27
11
11
  Requires-Dist: playwright>=1.60.0
12
12
  Requires-Dist: pyyaml>=6.0.3
13
+ Requires-Dist: html-to-markdown<4,>=3.10.6
13
14
  Provides-Extra: ux
14
15
  Requires-Dist: rich>=13; extra == "ux"
15
16
  Dynamic: license-file
@@ -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.8.2"
18
+ version = "0.9.1"
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"
@@ -30,6 +30,26 @@ dependencies = [
30
30
  # daemon-resolved Chrome and does not need Playwright's bundled browser.
31
31
  "playwright>=1.60.0",
32
32
  "pyyaml>=6.0.3",
33
+ # HTML -> Markdown for the `read_markdown()` content view (ADR-0006/0007).
34
+ # Rust core + PyO3, and it pulls in NOTHING transitively — which is why it
35
+ # beat `markdownify` (bs4 + soupsieve) and `trafilatura` (lxml + a 32 MB
36
+ # babel locale tree) here. It is also the only converter measured that
37
+ # carries `class="language-*"` into a fenced ```lang block and keeps
38
+ # `rowspan` cells, both of which matter on the documentation pages this
39
+ # view exists to read.
40
+ #
41
+ # We deliberately track upstream rather than freeze: the project is running
42
+ # a systematic markdown-fidelity campaign (its own "Phase Z..HH" feat
43
+ # series) that keeps improving exactly what we depend on. The safety net for
44
+ # that is `tests/skill/test_markdown_golden.py` — golden files turn an
45
+ # upstream byte-level output change into a CI failure instead of a silent
46
+ # shift in what every agent reads. ADR-0007 makes those tests a precondition
47
+ # of this `>=`, not an optional extra.
48
+ #
49
+ # The `<4` cap is not conservatism about features: 3.0.0 removed the v1
50
+ # `convert_to_markdown()` entry point outright, so a major bump can break us
51
+ # at import time. Raise it deliberately, with the golden files as the diff.
52
+ "html-to-markdown>=3.10.6,<4",
33
53
  ]
34
54
 
35
55
  [project.optional-dependencies]
@@ -58,6 +78,11 @@ browserwright = [
58
78
  "site_skills_starter/**/*.md",
59
79
  "site_skills_starter/**/*.py",
60
80
  "skill_runtime.md",
81
+ # Vendored Mozilla Readability, injected as SOURCE TEXT via page.evaluate
82
+ # (add_script_tag is blocked by page CSP; page.evaluate is not). It has to
83
+ # ship as a real file for that reason — see repl/vendor/README.md.
84
+ "repl/vendor/*.js",
85
+ "repl/vendor/*.md",
61
86
  ]
62
87
 
63
88
  [tool.setuptools.exclude-package-data]
@@ -69,6 +94,7 @@ asyncio_mode = "auto"
69
94
  testpaths = ["tests"]
70
95
  markers = [
71
96
  "real_chrome: end-to-end test that launches a real Chrome + extension (skipped unless explicitly selected)",
97
+ "launchd: loads throwaway LaunchAgents to measure real launchd behaviour; macOS-only, skipped unless explicitly selected (issue #57)",
72
98
  ]
73
99
 
74
100
  # Lint config. `select` is explicit on purpose: ruff's *default* rule set grew
@@ -42,6 +42,7 @@ from .errors import ( # noqa: F401
42
42
  NeedsUserConfirm,
43
43
  NetworkError,
44
44
  PageLoadFailed,
45
+ UnsupportedContentType,
45
46
  )
46
47
  from .primitives.discovery_api import ( # noqa: F401
47
48
  list_site_skills,
@@ -69,7 +70,7 @@ EXPORTS = [
69
70
  # errors
70
71
  "BrowserwrightError", "PageLoadFailed", "ElementNotFound", "AuthWall",
71
72
  "Captcha", "NetworkError", "DaemonUnavailable", "CDPError",
72
- "NeedsUserConfirm",
73
+ "NeedsUserConfirm", "UnsupportedContentType",
73
74
  ]
74
75
 
75
76
  __all__ = EXPORTS
@@ -40,6 +40,7 @@ from collections.abc import Iterator
40
40
  from contextlib import contextmanager, redirect_stdout
41
41
  from typing import Any
42
42
 
43
+ from .._text import spill_text, truncate_hard
43
44
  from ..errors import BrowserwrightError, serialize
44
45
  from .protocol import (
45
46
  MAX_TEXT_CHARS,
@@ -58,6 +59,92 @@ from .protocol import (
58
59
  _COLD_START_CONNECT_ATTEMPTS = 13
59
60
  _COLD_START_CONNECT_BACKOFF_S = 0.75
60
61
 
62
+ # `_bw_warn` is callable from a loop, so the warnings LIST needs a bound as much
63
+ # as each entry does. The overflow is folded into one final notice rather than
64
+ # dropped silently.
65
+ MAX_WARNINGS = 100
66
+
67
+
68
+ def _cut_notice(what: str, path: str | None) -> str:
69
+ """The out-of-band line that makes a cut recoverable rather than lossy."""
70
+ if path:
71
+ return (
72
+ f"{what} truncated to {MAX_TEXT_CHARS} chars; the full text is at "
73
+ f"{path}"
74
+ )
75
+ return (
76
+ f"{what} truncated to {MAX_TEXT_CHARS} chars; the full text could not "
77
+ f"be written to disk, so re-run producing less output"
78
+ )
79
+
80
+
81
+ def _cap_text(value: str, *, prefix: str) -> tuple[str, bool, str | None]:
82
+ """Bound one text channel. Returns (value, was_truncated, full_text_path).
83
+
84
+ Whole-line aware (``truncate_hard``): the transport must never be the layer
85
+ that severs a `[ref=eN]` a producer took care to keep intact, because the
86
+ severed token is not merely corrupt — `[ref=e2` cut from `[ref=e291]` is a
87
+ VALID ref for a different live element, so acting on it fails silently.
88
+
89
+ The bound is about what an agent can READ, so the untruncated value is
90
+ spilled to disk rather than dropped (ADR-0007 §5) and the path is reported
91
+ out-of-band by the caller. ``None`` means the spill itself failed, which
92
+ shortens the result but never fails the call.
93
+ """
94
+ if len(value) <= MAX_TEXT_CHARS:
95
+ return value, False, None
96
+ return truncate_hard(value, MAX_TEXT_CHARS), True, spill_text(
97
+ value, prefix=prefix
98
+ )
99
+
100
+
101
+ def _cap_warnings(warnings: list[str]) -> tuple[list[str], bool]:
102
+ """Bound both the number of warnings and the length of each one.
103
+
104
+ Deliberately NOT via ``_cap_text``: warnings are the out-of-band METADATA
105
+ channel, not content, so they are truncated but never spilled. Routing them
106
+ through ``_cap_text`` writes one file per over-long warning — which buries
107
+ the very spill paths these lines exist to carry, and accumulates fast
108
+ because a warning loop produces up to MAX_WARNINGS of them per call.
109
+ """
110
+ capped = [
111
+ truncate_hard(str(w), MAX_TEXT_CHARS) for w in warnings[:MAX_WARNINGS]
112
+ ]
113
+ dropped = len(warnings) - len(capped)
114
+ if dropped > 0:
115
+ capped.append(f"… {dropped} further warning(s) suppressed")
116
+ return capped, dropped > 0
117
+
118
+
119
+ def _cap_task_result_json(raw: str | None) -> tuple[str | None, bool]:
120
+ """Bound the task result JSON WITHOUT ever emitting invalid JSON.
121
+
122
+ Truncating a JSON document yields one that fails
123
+ ``ExecuteResponse.from_dict``'s own ``json.loads`` validation, which the
124
+ client reports as a malformed response and answers by REAPING the executor
125
+ — losing the session's persistent ``state`` over an oversized payload, a
126
+ strictly worse outcome than the payload itself. So an over-budget result is
127
+ replaced by a valid marker object carrying the path to the full JSON, which
128
+ a caller that actually wants it can read.
129
+ """
130
+ if raw is None or len(raw) <= MAX_TEXT_CHARS:
131
+ return raw, False
132
+ path = spill_text(raw, prefix="task-result")
133
+ marker: dict[str, Any] = {
134
+ "_browserwright_truncated": True,
135
+ "original_size": len(raw),
136
+ "full_result_path": path,
137
+ }
138
+ marker["reason"] = (
139
+ f"task result exceeded the {MAX_TEXT_CHARS}-char response bound; "
140
+ + (
141
+ f"the full JSON is at {path}"
142
+ if path
143
+ else "the full JSON could not be written to disk"
144
+ )
145
+ )
146
+ return json.dumps(marker), True
147
+
61
148
 
62
149
  @contextmanager
63
150
  def _request_environment(env: dict[str, str]) -> Iterator[None]:
@@ -83,11 +170,15 @@ def _request_environment(env: dict[str, str]) -> Iterator[None]:
83
170
 
84
171
 
85
172
  class _LivePageHolder:
86
- """Adapts a live Playwright ``page`` to the ``.page`` attribute
87
- ``snapshot.make_snapshot`` expects (it was written against the lazy
88
- ``PlaywrightHandle``). We rebind ``.page`` whenever the worker re-binds the
89
- session tab (cold-start / future ``reset()``), so ``snapshot()`` always
90
- observes the executor's current live page."""
173
+ """Adapts a live Playwright ``page`` to the ``.page`` attribute the **view**
174
+ factories expect (``snapshot.make_snapshot`` / ``markdown.make_read_markdown``
175
+ were both written against the lazy ``PlaywrightHandle``). We rebind ``.page``
176
+ whenever the worker re-binds the session tab (cold-start / target change /
177
+ future ``reset()``), so every view observes the executor's current live page.
178
+
179
+ One holder serves ALL views on purpose: a second holder is a second thing to
180
+ remember to rebind, which is exactly how ``read_markdown`` shipped bound to a
181
+ handle nobody was updating (issue #59)."""
91
182
 
92
183
  def __init__(self) -> None:
93
184
  self.page: Any = None
@@ -138,7 +229,9 @@ class _Worker:
138
229
  self._facade_death_handler: Any = None
139
230
  # Persistent per-session state, injected by reference each call (Fork 5).
140
231
  self._state: dict[str, Any] = {}
141
- self._snapshot_holder = _LivePageHolder()
232
+ # One live-page holder shared by every injected view (snapshot /
233
+ # read_markdown). Rebound wherever `self._page` is.
234
+ self._live_page_holder = _LivePageHolder()
142
235
  self._snapshot: Any = None
143
236
  # Per-call warnings/screenshots the running code can append to via the
144
237
  # injected `_bw_warn` / screenshot helpers. Reset at the start of each
@@ -351,8 +444,14 @@ class _Worker:
351
444
  self._context = ph.context_for_browser(self._browser)
352
445
  self._page = ph.bind_current_page(self._context, sess)
353
446
  self._page_target_id = sess.current_target_id
354
- self._snapshot_holder.page = self._page
355
- self._snapshot = make_snapshot(self._snapshot_holder)
447
+ self._live_page_holder.page = self._page
448
+ self._snapshot = make_snapshot(
449
+ self._live_page_holder,
450
+ # Late-bound on purpose: `_call_warnings` is REPLACED each
451
+ # call, so a bound `.append` captured here would append into
452
+ # the previous call's list and the notice would vanish.
453
+ warn=lambda m: self._call_warnings.append(m),
454
+ )
356
455
  self._arm_facade_death()
357
456
  # Issue #21: the live page must follow the session's current target
358
457
  # (``session_runtime.bind_target`` / ``open_session_tab`` /
@@ -408,8 +507,14 @@ class _Worker:
408
507
  return
409
508
  self._page = page
410
509
  self._page_target_id = sess.current_target_id
411
- self._snapshot_holder.page = page
412
- self._snapshot = make_snapshot(self._snapshot_holder)
510
+ self._live_page_holder.page = page
511
+ self._snapshot = make_snapshot(
512
+ self._live_page_holder,
513
+ # Late-bound on purpose: `_call_warnings` is REPLACED each
514
+ # call, so a bound `.append` captured here would append into
515
+ # the previous call's list and the notice would vanish.
516
+ warn=lambda m: self._call_warnings.append(m),
517
+ )
413
518
  # Same-call visibility: swap `page` inside the RUNNING heredoc's
414
519
  # globals so statements after switch_tab() see the new page.
415
520
  if self._active_globals is not None:
@@ -628,23 +733,52 @@ class _Worker:
628
733
  terminal_reason: str | None = None,
629
734
  task_result_json: str | None = None,
630
735
  ) -> ExecuteResponse:
631
- """Assemble the response: cap the console at ``MAX_TEXT_CHARS`` and
632
- attach the warnings/screenshots collected during the call."""
633
- console = buf.getvalue()
634
- truncated = False
635
- if len(console) > MAX_TEXT_CHARS:
636
- console = console[:MAX_TEXT_CHARS] + "\n… [truncated]"
637
- truncated = True
736
+ """Assemble the response with EVERY text channel bounded, and attach the
737
+ warnings/screenshots collected during the call.
738
+
739
+ All four text channels are capped here, not just ``console``. An
740
+ expression-valued last statement is an entirely ordinary thing for an
741
+ agent to write, and an uncapped ``return_value`` big enough to exceed
742
+ ``protocol._MAX_FRAME`` does not merely ship megabytes: the client reads
743
+ it as a transport error and reaps this executor, taking the session's
744
+ live ``page`` and persistent ``state`` with it. Bounding the response by
745
+ construction is what makes that unreachable.
746
+ """
747
+ console, console_cut, console_path = _cap_text(
748
+ buf.getvalue(), prefix="console"
749
+ )
750
+ rv_cut, rv_path = False, None
751
+ if return_value is not None:
752
+ return_value, rv_cut, rv_path = _cap_text(
753
+ return_value, prefix="return-value"
754
+ )
755
+ task_result_json, task_cut = _cap_task_result_json(task_result_json)
756
+
757
+ raw_warnings = (
758
+ list(self._call_warnings) if warnings is None else list(warnings)
759
+ )
760
+ # The cut is reported out-of-band (ADR-0007 §5): stdout is content,
761
+ # warnings are metadata. Without this the agent sees a short payload and
762
+ # has no way to tell it was ever longer, nor where the rest went.
763
+ if console_cut:
764
+ raw_warnings.append(_cut_notice("output", console_path))
765
+ if rv_cut:
766
+ raw_warnings.append(_cut_notice("[return value]", rv_path))
767
+ if task_cut:
768
+ raw_warnings.append(
769
+ f"task result exceeded the {MAX_TEXT_CHARS}-char response bound "
770
+ f"and was replaced by a marker; use --output json to read it"
771
+ )
772
+ capped_warnings, warn_cut = _cap_warnings(raw_warnings)
773
+
638
774
  return ExecuteResponse(
639
775
  console=console,
640
776
  return_value=return_value,
641
777
  error=error,
642
778
  exit_code=exit_code,
643
- warnings=(
644
- list(self._call_warnings) if warnings is None else list(warnings)
645
- ),
779
+ warnings=capped_warnings,
646
780
  screenshots=list(self._call_screenshots),
647
- truncated=truncated,
781
+ truncated=console_cut or rv_cut or warn_cut or task_cut,
648
782
  terminal_reason=terminal_reason,
649
783
  task_result_json=task_result_json,
650
784
  )
@@ -680,25 +814,44 @@ class _Worker:
680
814
  )
681
815
 
682
816
  def _build_globals(self) -> dict[str, Any]:
683
- """The exec namespace: the Phase C surface, but with ``page`` /
684
- ``context`` replaced by the LIVE held objects, ``state`` injected by
685
- reference (persists across calls — Fork 5), and ``snapshot`` rebound to
686
- the live page.
817
+ """The exec namespace: the Phase C surface with every lazy name replaced
818
+ by this executor's live equivalent.
687
819
 
688
820
  We reuse ``_namespace.build_globals()`` so the agent helper layer +
689
- EXPORTS stay identical to the heredoc, then OVERWRITE the lazy proxies
690
- with live objects. The handle the lazy build injected
691
- (``__bw_playwright_handle__``) is dropped — the executor owns teardown,
692
- not the per-call namespace."""
821
+ EXPORTS stay identical to the heredoc, and hand it
822
+ :meth:`_bind_live_surface` as its live-surface hook so the swap lands
823
+ *before* the agent helper layer loads (issue #59)."""
693
824
  from ..repl import _namespace
694
825
 
695
- g = _namespace.build_globals()
826
+ return _namespace.build_globals(self._bind_live_surface)
827
+
828
+ def _bind_live_surface(self, g: dict[str, Any]) -> None:
829
+ """Replace the lazily-bound namespace entries with the live ones.
830
+
831
+ Everything ``_namespace.build_globals`` binds to its lazy
832
+ ``PlaywrightHandle`` must be listed here. Resolving that handle on the
833
+ worker thread re-enters ``sync_playwright()`` inside the executor's
834
+ running asyncio loop and raises "Sync API inside the asyncio loop" —
835
+ which is what shipped for ``read_markdown`` (issue #59). The handle
836
+ itself is dropped: the executor owns teardown, not the per-call
837
+ namespace.
838
+
839
+ Called by ``build_globals`` BEFORE ``_load_agent_helpers``, so the
840
+ helper modules are seeded from the live surface too.
841
+ """
842
+ from ..repl.markdown import make_read_markdown
843
+
696
844
  g.pop("__bw_playwright_handle__", None)
697
845
  g["page"] = self._page
698
846
  g["context"] = self._context
699
847
  g["state"] = self._state # same dict object each call → persistent
700
848
  if self._snapshot is not None:
701
849
  g["snapshot"] = self._snapshot
850
+ # The other view. Bound to the shared live-page holder (so it follows a
851
+ # mid-call target change exactly like `snapshot`), and handed THIS call's
852
+ # globals so its notes find the `_bw_warn` channel injected just below —
853
+ # `make_read_markdown` reads the namespace lazily for that reason.
854
+ g["read_markdown"] = make_read_markdown(self._live_page_holder, g)
702
855
  # Task invocations inside inline code borrow the same resident surface;
703
856
  # never let the public task runner allocate a second Playwright handle.
704
857
  g["run_task"] = self._run_task_on_live_surface
@@ -710,7 +863,6 @@ class _Worker:
710
863
  # `[WARNING]` line or a screenshot path back through the response.
711
864
  g["_bw_warn"] = self._call_warnings.append
712
865
  g["_bw_record_screenshot"] = self._record_screenshot
713
- return g
714
866
 
715
867
  def _record_screenshot(self, path: str, **meta: Any) -> str:
716
868
  """Register a screenshot the heredoc captured so its path surfaces in
@@ -21,6 +21,8 @@ import struct
21
21
  from dataclasses import dataclass, field
22
22
  from typing import Any
23
23
 
24
+ from .._text import MAX_TEXT_CHARS as _MAX_TEXT_CHARS
25
+
24
26
  # Default per-call timeout (ms). Playwriter defaults to 10000ms, but real page
25
27
  # ops (cold navigation + network settle) can legitimately take longer, so we
26
28
  # pick a more generous default. It is deliberately bounded WELL UNDER any
@@ -28,11 +30,14 @@ from typing import Any
28
30
  # so a slow-but-legitimate call never trips idle reclamation mid-flight.
29
31
  DEFAULT_TIMEOUT_MS = 90000
30
32
 
31
- # Cap on the rendered text block (console + return value), mirroring
32
- # playwriter's ~10000-char truncation. Whole-line aware truncation lives in
33
- # `snapshot._truncate_lines`; here we cap the console blob so a runaway print
34
- # loop can't ship megabytes back to the agent.
35
- MAX_TEXT_CHARS = 10000
33
+ # Cap on EVERY text channel of the response — console, return value, warnings
34
+ # and the task result JSON — so a runaway print loop, or an equally ordinary
35
+ # expression-valued last statement, can't ship megabytes back to the agent.
36
+ # Re-exported from `_text` because the producers (`repl/snapshot.py`,
37
+ # `repl/markdown.py`) derive their own budgets from it and must not import the
38
+ # transport to do so; `process._finish` applies it via `_text.truncate_hard`,
39
+ # which prefers whole lines and marks the cut when it cannot keep one.
40
+ MAX_TEXT_CHARS = _MAX_TEXT_CHARS
36
41
  TERMINAL_DEADLINE_EXCEEDED = "deadline_exceeded"
37
42
  TERMINAL_RESET_REQUESTED = "reset_requested"
38
43
  TERMINAL_REASONS = frozenset(
@@ -185,7 +190,9 @@ class ExecuteResponse:
185
190
  - ``screenshots``: list of ``{"path": str, ...}`` blocks for any image the
186
191
  heredoc captured — path-based (the executor and client share a
187
192
  filesystem), so the (possibly large) bytes never ride the wire.
188
- - ``truncated``: True when the text block was capped at ``MAX_TEXT_CHARS``.
193
+ - ``truncated``: True when ANY text channel was capped at
194
+ ``MAX_TEXT_CHARS`` — console, return value, warnings or task result.
195
+ Which one it was is said in ``warnings``, the out-of-band channel.
189
196
  - ``error``: ``errors.serialize(exc)`` (or None on success), WITH a
190
197
  ``traceback`` key for generic exceptions so a shipped heredoc surfaces
191
198
  the same traceback the in-process path writes.
@@ -0,0 +1,195 @@
1
+ """Text budgets and the two truncators that enforce them.
2
+
3
+ Zero-dependency on purpose: both the executor transport (`_executor/protocol.py`,
4
+ `_executor/process.py`) and the content producers (`repl/snapshot.py`,
5
+ `repl/markdown.py`, `cli.py`) import from here, and the producers must not drag
6
+ Playwright into the transport's import graph.
7
+
8
+ Two truncators, because there are two genuinely different jobs
9
+ --------------------------------------------------------------
10
+ A producer promises **line integrity**: cutting an aria snapshot mid-line yields
11
+ `[ref=e2` where the real token was `[ref=e291]` — a ref that is not merely
12
+ corrupt but *well-formed for a different live element*, so acting on it fails
13
+ silently. Same reason a Markdown cut mid-line yields a broken
14
+ `[text](http://…` link. For a producer, overflowing a soft budget by one line is
15
+ strictly better than emitting a partial ref, so `truncate_lines` keeps the first
16
+ line whole even when it alone exceeds the budget.
17
+
18
+ The transport promises a **bound**. Its whole reason to exist is that a runaway
19
+ `print('x' * 5_000_000)` must not ship megabytes to the agent — and that payload
20
+ is ONE line with no newlines, so the producer's "keep the first line whole"
21
+ fallback would return 5,000,014 characters for a 10,000 budget. A soft
22
+ truncator cannot serve as a hard cap. `truncate_hard` therefore drops whole
23
+ lines wherever it can and cuts mid-line only when a single line still overflows,
24
+ where it says so in the marker so a severed ref is at least VISIBLE rather than
25
+ silent.
26
+
27
+ The invariant this file exists to keep (ADR-0007 §5): no layer may truncate a
28
+ payload at a raw offset once a layer above it has promised line integrity —
29
+ except a hard bound, which must, and must announce it.
30
+
31
+ And the other half of that same ADR heading — **truncation must come with the
32
+ full text**. A bound exists because an agent cannot read 50,000 characters, not
33
+ because those characters should stop existing. So every truncating layer here
34
+ spills the untruncated payload to a file and reports the path out-of-band; what
35
+ the agent sees is shortened, what it can still fetch is not.
36
+ """
37
+ from __future__ import annotations
38
+
39
+ import contextlib
40
+ import itertools
41
+ import os
42
+ from pathlib import Path
43
+
44
+ # The transport's hard bound on any single text channel of an executor response.
45
+ # Mirrors playwriter's ~10000-char truncation. This is the ceiling every other
46
+ # budget in the tree is derived from — see PRODUCER_BUDGET.
47
+ MAX_TEXT_CHARS = 10000
48
+
49
+ # What a single content producer (`snapshot()`, `read_markdown()`) may return by
50
+ # default. Deliberately UNDER MAX_TEXT_CHARS rather than equal to it: a heredoc
51
+ # normally prints other things alongside the payload, and the transport bound
52
+ # applies to the WHOLE console blob, not to one producer's output. The headroom
53
+ # is what keeps a producer's line-integrity promise from being re-cut downstream.
54
+ PRODUCER_BUDGET = 8000
55
+
56
+ TRUNC_MARKER = "… [truncated]"
57
+ # A distinct marker for the last-resort mid-line cut, so a caller (and the agent
58
+ # reading the output) can tell "you are missing whole lines" apart from "the last
59
+ # line you can see is itself incomplete, do not trust a token at its end".
60
+ MID_LINE_MARKER = "… [truncated mid-line]"
61
+
62
+
63
+ # How many spills of one kind one process keeps before dropping its oldest.
64
+ # The executor is long-lived and spills once per over-budget call, so unlike the
65
+ # markdown/screenshot spills this path would otherwise grow without limit for
66
+ # the life of the session. macOS does NOT prune /tmp — verified: files three
67
+ # days old survive there — so nothing else is going to do this for us.
68
+ SPILL_KEEP = 20
69
+
70
+ # Where spills land. A module-level name rather than a literal so the test suite
71
+ # can redirect it to a tmp dir — otherwise running the tests litters the
72
+ # developer's real /tmp, which is how the accumulation this constant limits was
73
+ # noticed in the first place. `/tmp` and not `tempfile.gettempdir()`: on macOS
74
+ # the latter is the per-user `/var/folders/…` dir, whose value comes from the
75
+ # TMPDIR of whichever session started the process, and the executor and the CLI
76
+ # reading its spill paths are two processes that need not share one.
77
+ SPILL_DIR = Path("/tmp")
78
+
79
+ # Monotonic within the process, and deliberately NOT "lowest free index": with
80
+ # pruning in play a recycled index would point an already-issued path at
81
+ # different content, so an agent holding an older path would silently read the
82
+ # wrong payload.
83
+ _spill_seq = itertools.count()
84
+
85
+
86
+ def _prune_spills(prefix: str) -> None:
87
+ """Drop this process's oldest ``prefix`` spills, keeping ``SPILL_KEEP``.
88
+
89
+ Scoped to our own pid on purpose: a session must never delete another
90
+ session's, another worktree's, or another user's files out from under them.
91
+ Entirely best-effort — a spill that cannot be pruned is not a failure.
92
+ """
93
+ mine: list[tuple[float, Path]] = []
94
+ with contextlib.suppress(OSError):
95
+ for p in SPILL_DIR.glob(f"browserwright-{prefix}-{os.getpid()}-*.txt"):
96
+ with contextlib.suppress(OSError):
97
+ mine.append((p.stat().st_mtime, p))
98
+ for _mtime, old in sorted(mine)[:-SPILL_KEEP]:
99
+ with contextlib.suppress(OSError):
100
+ old.unlink()
101
+
102
+
103
+ def spill_text(text: str, *, prefix: str) -> str | None:
104
+ """Write the UNtruncated ``text`` to /tmp; return its path, or None.
105
+
106
+ The counterpart to the truncators: a bound shortens what the agent reads, it
107
+ must not destroy what the agent could still want. The path rides back
108
+ out-of-band (warnings / stderr) and the payload rides the disk rather than
109
+ the wire — the same reason `screenshots` in the executor protocol is
110
+ path-based, and the same mechanism `repl/markdown.py` already uses for the
111
+ content view.
112
+
113
+ Returns None on any OSError: failing to spill must never turn a merely long
114
+ result into a failed call. The caller says so in its notice instead.
115
+ """
116
+ while True:
117
+ cand = (
118
+ SPILL_DIR
119
+ / f"browserwright-{prefix}-{os.getpid()}-{next(_spill_seq)}.txt"
120
+ )
121
+ if not cand.exists():
122
+ break
123
+ try:
124
+ cand.write_text(text, encoding="utf-8")
125
+ except OSError:
126
+ return None
127
+ _prune_spills(prefix)
128
+ return str(cand)
129
+
130
+
131
+ def _keep_whole_lines(text: str, budget: int) -> str | None:
132
+ """Longest whole-line prefix of ``text`` fitting ``budget``, else None.
133
+
134
+ None means not even the first line fits — the case the two callers below
135
+ resolve in opposite directions.
136
+ """
137
+ lines = text.splitlines()
138
+ kept: list[str] = []
139
+ used = 0
140
+ for ln in lines:
141
+ # +1 for the newline joining this line to the previous body.
142
+ add = len(ln) + (1 if kept else 0)
143
+ if used + add > budget:
144
+ break
145
+ kept.append(ln)
146
+ used += add
147
+ if not kept:
148
+ return None
149
+ return "\n".join(kept)
150
+
151
+
152
+ def truncate_lines(text: str, max_chars: int) -> str:
153
+ """Cap ``text`` at ``max_chars`` on a LINE boundary, never mid-line.
154
+
155
+ SOFT: if even the first line overflows the budget it is still emitted whole,
156
+ because a partial `[ref=eN]` the agent might act on is worse than
157
+ overflowing a soft cap. Producers want this. The transport must not use it —
158
+ see :func:`truncate_hard`.
159
+
160
+ Callers apply this only when ``len(text) > max_chars``; the marker is
161
+ unconditional so a truncated payload always says so.
162
+ """
163
+ marker_cost = len(TRUNC_MARKER) + 1 # + the "\n" before the marker
164
+ body = _keep_whole_lines(text, max_chars - marker_cost)
165
+ if body is None:
166
+ # Budget too small for even one line + marker: surface line one whole.
167
+ lines = text.splitlines()
168
+ body = lines[0] if lines else text
169
+ return body + "\n" + TRUNC_MARKER
170
+
171
+
172
+ def truncate_hard(text: str, budget: int) -> str:
173
+ """Cap ``text`` at ``budget`` characters — a bound that is never exceeded.
174
+
175
+ Prefers whole lines exactly like :func:`truncate_lines`, so a payload that
176
+ arrives with intact `[ref=eN]` tokens keeps them intact. Falls back to a
177
+ mid-line cut ONLY when a single line still overflows the budget, which is
178
+ the runaway-output case the bound exists for; that fallback is marked with
179
+ ``MID_LINE_MARKER`` so the incomplete tail is never mistaken for a whole one.
180
+
181
+ Guarantees ``len(result) <= budget`` for any ``budget >= 0``.
182
+ """
183
+ if len(text) <= budget:
184
+ return text
185
+ marker_cost = len(TRUNC_MARKER) + 1
186
+ if budget > marker_cost:
187
+ body = _keep_whole_lines(text, budget - marker_cost)
188
+ if body is not None:
189
+ return body + "\n" + TRUNC_MARKER
190
+ # A single line overflows the budget: cut it, and SAY that we cut it.
191
+ mid_cost = len(MID_LINE_MARKER) + 1
192
+ if budget > mid_cost:
193
+ return text[: budget - mid_cost] + "\n" + MID_LINE_MARKER
194
+ # Budget too small to even carry a marker — the bound still wins.
195
+ return text[:budget]