effect-browser 0.2.0-beta.9 → 0.3.0-beta.10

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 (204) hide show
  1. package/README.md +849 -1161
  2. package/dist/Browser-S4kvpmqa.mjs +605 -0
  3. package/dist/Browser-S4kvpmqa.mjs.map +1 -0
  4. package/dist/Browser.d.mts +127 -3
  5. package/dist/Browser.mjs +2 -22
  6. package/dist/BrowserError-Coy7EW1N.d.mts +140 -0
  7. package/dist/BrowserError.d.mts +2 -0
  8. package/dist/BrowserError.mjs +211 -0
  9. package/dist/BrowserError.mjs.map +1 -0
  10. package/dist/BrowserEvent-BRu2juEz.d.mts +279 -0
  11. package/dist/BrowserEvent.d.mts +2 -0
  12. package/dist/BrowserEvent.mjs +297 -0
  13. package/dist/BrowserEvent.mjs.map +1 -0
  14. package/dist/Cdp.d.mts +32 -0
  15. package/dist/Cdp.mjs +61 -0
  16. package/dist/Cdp.mjs.map +1 -0
  17. package/dist/Change-DNvztNLu.mjs +138 -0
  18. package/dist/Change-DNvztNLu.mjs.map +1 -0
  19. package/dist/Change-iMlcuzVD.d.mts +71 -0
  20. package/dist/Change.d.mts +2 -0
  21. package/dist/Change.mjs +2 -0
  22. package/dist/Chromium.d.mts +29 -81
  23. package/dist/Chromium.mjs +57 -392
  24. package/dist/Chromium.mjs.map +1 -1
  25. package/dist/Expect.d.mts +62 -0
  26. package/dist/Expect.mjs +114 -0
  27. package/dist/Expect.mjs.map +1 -0
  28. package/dist/Frame-BERmf_gM.d.mts +117 -0
  29. package/dist/Frame.d.mts +2 -0
  30. package/dist/Frame.mjs +123 -0
  31. package/dist/Frame.mjs.map +1 -0
  32. package/dist/Moment.d.mts +2 -0
  33. package/dist/Moment.mjs +288 -0
  34. package/dist/Moment.mjs.map +1 -0
  35. package/dist/Motion.d.mts +40 -0
  36. package/dist/Motion.mjs +122 -0
  37. package/dist/Motion.mjs.map +1 -0
  38. package/dist/Page-B5cjaUp5.mjs +618 -0
  39. package/dist/Page-B5cjaUp5.mjs.map +1 -0
  40. package/dist/Page-BlPjfibv.d.mts +595 -0
  41. package/dist/Page.d.mts +2 -0
  42. package/dist/Page.mjs +2 -0
  43. package/dist/Plan-BI8XGvDu.d.mts +209 -0
  44. package/dist/Plan-D1wrJVs8.mjs +508 -0
  45. package/dist/Plan-D1wrJVs8.mjs.map +1 -0
  46. package/dist/Plan.d.mts +2 -3
  47. package/dist/Plan.mjs +2 -2
  48. package/dist/Policy.d.mts +76 -0
  49. package/dist/Policy.mjs +237 -0
  50. package/dist/Policy.mjs.map +1 -0
  51. package/dist/Presentation.d.mts +48 -0
  52. package/dist/Presentation.mjs +117 -0
  53. package/dist/Presentation.mjs.map +1 -0
  54. package/dist/Snapshot-B34hZ4Jl.d.mts +68 -0
  55. package/dist/Snapshot.d.mts +2 -0
  56. package/dist/Snapshot.mjs +79 -0
  57. package/dist/Snapshot.mjs.map +1 -0
  58. package/dist/Stage.d.mts +64 -0
  59. package/dist/Stage.mjs +237 -0
  60. package/dist/Stage.mjs.map +1 -0
  61. package/dist/Supervisor-CYWFK3vM.d.mts +280 -0
  62. package/dist/Supervisor-TNKNOVzu.mjs +599 -0
  63. package/dist/Supervisor-TNKNOVzu.mjs.map +1 -0
  64. package/dist/Supervisor.d.mts +2 -0
  65. package/dist/Supervisor.mjs +2 -0
  66. package/dist/context-B6xr8nVi.mjs +261 -0
  67. package/dist/context-B6xr8nVi.mjs.map +1 -0
  68. package/dist/index.d.mts +17 -14
  69. package/dist/index.mjs +18 -13
  70. package/dist/match.inpage-xOe9LNO0.mjs +48 -0
  71. package/dist/match.inpage-xOe9LNO0.mjs.map +1 -0
  72. package/dist/page-B8tUQELg.mjs +5073 -0
  73. package/dist/page-B8tUQELg.mjs.map +1 -0
  74. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  75. package/dist/url-Cd04Okid.mjs +264 -0
  76. package/dist/url-Cd04Okid.mjs.map +1 -0
  77. package/package.json +96 -61
  78. package/src/Browser.ts +666 -0
  79. package/src/BrowserError.ts +234 -0
  80. package/src/BrowserEvent.ts +296 -0
  81. package/src/Cdp.ts +101 -0
  82. package/src/Change.ts +88 -0
  83. package/src/Chromium.ts +102 -0
  84. package/src/Expect.ts +180 -0
  85. package/src/Frame.ts +130 -0
  86. package/src/Moment.ts +479 -0
  87. package/src/Motion.ts +166 -0
  88. package/src/Page.ts +522 -0
  89. package/src/Plan.ts +663 -0
  90. package/src/Policy.ts +325 -0
  91. package/src/Presentation.ts +195 -0
  92. package/src/Snapshot.ts +89 -0
  93. package/src/Stage.ts +467 -0
  94. package/src/Supervisor.ts +599 -0
  95. package/src/index.ts +20 -0
  96. package/src/internal/input/actions.ts +610 -0
  97. package/src/internal/input/dispatch.ts +161 -0
  98. package/src/internal/input/edit.inpage.ts +162 -0
  99. package/src/internal/input/evidence.inpage.ts +164 -0
  100. package/src/internal/input/gate.ts +224 -0
  101. package/src/internal/input/guard.inpage.ts +605 -0
  102. package/src/internal/input/guard.ts +184 -0
  103. package/src/internal/input/keyboard.ts +112 -0
  104. package/src/internal/input/keys.ts +127 -0
  105. package/src/internal/input/perform.ts +376 -0
  106. package/src/internal/input/pointer.ts +153 -0
  107. package/src/internal/input/replies.ts +195 -0
  108. package/src/internal/input/settle.ts +50 -0
  109. package/src/internal/input/style.ts +186 -0
  110. package/src/internal/input/targets.inpage.ts +293 -0
  111. package/src/internal/input/targets.ts +148 -0
  112. package/src/internal/page/bridge.ts +386 -0
  113. package/src/internal/page/context.ts +312 -0
  114. package/src/internal/page/lane.ts +311 -0
  115. package/src/internal/page/navigation.ts +183 -0
  116. package/src/internal/page/openings.ts +87 -0
  117. package/src/internal/page/page.ts +165 -0
  118. package/src/internal/page/url.ts +100 -0
  119. package/src/internal/page/viewport.ts +43 -0
  120. package/src/internal/pictures/capture.ts +776 -0
  121. package/src/internal/pictures/clock.ts +141 -0
  122. package/src/internal/pictures/counts.ts +92 -0
  123. package/src/internal/pictures/pictures.ts +520 -0
  124. package/src/internal/pictures/transport.ts +242 -0
  125. package/src/internal/reading/choose.ts +100 -0
  126. package/src/internal/reading/context.inpage.ts +201 -0
  127. package/src/internal/reading/controls.inpage.ts +158 -0
  128. package/src/internal/reading/expect.inpage.ts +70 -0
  129. package/src/internal/reading/match.inpage.ts +92 -0
  130. package/src/internal/reading/names.inpage.ts +306 -0
  131. package/src/internal/reading/outline.inpage.ts +260 -0
  132. package/src/internal/reading/reading.ts +223 -0
  133. package/src/internal/reading/ready.inpage.ts +164 -0
  134. package/src/internal/reading/ready.ts +111 -0
  135. package/src/internal/reading/subjects.inpage.ts +188 -0
  136. package/src/internal/reading/text.inpage.ts +135 -0
  137. package/src/internal/reading/walk.inpage.ts +220 -0
  138. package/src/internal/supervisor/lifecycle.ts +416 -0
  139. package/src/internal/timeline/changes.inpage.ts +216 -0
  140. package/src/internal/timeline/changes.ts +100 -0
  141. package/src/internal/timeline/events.ts +94 -0
  142. package/src/internal/timeline/fold.inpage.ts +123 -0
  143. package/src/internal/timeline/history.inpage.ts +229 -0
  144. package/src/internal/timeline/marks.inpage.ts +207 -0
  145. package/src/internal/timeline/record.inpage.ts +133 -0
  146. package/src/internal/timeline/sight.inpage.ts +122 -0
  147. package/src/internal/timeline/window.ts +96 -0
  148. package/src/internal/timeline/words.ts +65 -0
  149. package/dist/Bootstrap-B9UUXsvF.d.mts +0 -183
  150. package/dist/Bootstrap-CeQOWBy6.mjs +0 -189
  151. package/dist/Bootstrap-CeQOWBy6.mjs.map +0 -1
  152. package/dist/Bootstrap.d.mts +0 -3
  153. package/dist/Bootstrap.mjs +0 -2
  154. package/dist/Browser-BF9_sORP.d.mts +0 -388
  155. package/dist/Browser.mjs.map +0 -1
  156. package/dist/BrowserData-CPIIIHol.d.mts +0 -585
  157. package/dist/BrowserData.d.mts +0 -3
  158. package/dist/BrowserData.mjs +0 -695
  159. package/dist/BrowserData.mjs.map +0 -1
  160. package/dist/BrowserRuntime-B6nT1G4X.d.mts +0 -148
  161. package/dist/BrowserRuntime-C1r2aSRK.mjs +0 -6820
  162. package/dist/BrowserRuntime-C1r2aSRK.mjs.map +0 -1
  163. package/dist/BrowserRuntime.d.mts +0 -3
  164. package/dist/BrowserRuntime.mjs +0 -3
  165. package/dist/Capture.d.mts +0 -56
  166. package/dist/Capture.mjs +0 -678
  167. package/dist/Capture.mjs.map +0 -1
  168. package/dist/CaptureData-CYriZHX1.mjs +0 -201
  169. package/dist/CaptureData-CYriZHX1.mjs.map +0 -1
  170. package/dist/CaptureData.d.mts +0 -219
  171. package/dist/CaptureData.mjs +0 -2
  172. package/dist/Errors.d.mts +0 -158
  173. package/dist/Errors.mjs +0 -207
  174. package/dist/Errors.mjs.map +0 -1
  175. package/dist/Images-yCG9BmRU.mjs +0 -76
  176. package/dist/Images-yCG9BmRU.mjs.map +0 -1
  177. package/dist/OperationOptions-DAeRS6O9.mjs +0 -55
  178. package/dist/OperationOptions-DAeRS6O9.mjs.map +0 -1
  179. package/dist/PageControl.d.mts +0 -15
  180. package/dist/PageControl.mjs +0 -34
  181. package/dist/PageControl.mjs.map +0 -1
  182. package/dist/PageRegistry-B3q9DS3g.mjs +0 -164
  183. package/dist/PageRegistry-B3q9DS3g.mjs.map +0 -1
  184. package/dist/Plan-Dyxu2q8C.mjs +0 -543
  185. package/dist/Plan-Dyxu2q8C.mjs.map +0 -1
  186. package/dist/PlanData-B668RxbQ.d.mts +0 -4831
  187. package/dist/PlanData.d.mts +0 -3
  188. package/dist/PlanData.mjs +0 -268
  189. package/dist/PlanData.mjs.map +0 -1
  190. package/dist/PlanInput-BJcPPncs.mjs +0 -75
  191. package/dist/PlanInput-BJcPPncs.mjs.map +0 -1
  192. package/dist/PublicSession-DCSMMqOj.mjs +0 -5602
  193. package/dist/PublicSession-DCSMMqOj.mjs.map +0 -1
  194. package/dist/Testing.d.mts +0 -479
  195. package/dist/Testing.mjs +0 -2147
  196. package/dist/Testing.mjs.map +0 -1
  197. package/dist/Timeline-fV22Xk-S.d.mts +0 -29
  198. package/dist/Timeline.d.mts +0 -4
  199. package/dist/Timeline.mjs +0 -20
  200. package/dist/Timeline.mjs.map +0 -1
  201. package/dist/TimelineData-BsOn-YGZ.d.mts +0 -3818
  202. package/dist/TimelineData.d.mts +0 -3
  203. package/dist/TimelineData.mjs +0 -482
  204. package/dist/TimelineData.mjs.map +0 -1
package/README.md CHANGED
@@ -1,1221 +1,909 @@
1
- # Browser automation for Effect
1
+ # effect-browser
2
2
 
3
- `effect-browser` owns one scoped browser runtime: bounded actions, exact observations, typed page-to-host bindings, live JPEG capture and explicit page holds. `effect-browser/chromium` launches or attaches to self-managed Chromium. [Browserbase](../browserbase/README.md) supplies the same runtime through its own hosted acquisition and cleanup.
3
+ Browser automation for [Effect](https://effect.website) agents, over Playwright: page control, a
4
+ compact page outline for models, screencast frames, a record of what visibly changed on a page,
5
+ windows over a page's events, changes and frames, and moments, an account in pictures and words of
6
+ what a page showed and what changed on it. An agent drives its pages through
7
+ [`effect-agent-browser`](../agent-browser), with Yielded Agent's browser tools.
4
8
 
5
- This package has no Browserbase or Effect Agent dependency. Its common entry points do not load Chromium process-management code. Playwright is an optional peer loaded when a browser connects; install the pinned `playwright-core@1.63.0` to use that capability. The implementation targets trusted Node and Bun hosts. Data contracts being provider-independent do not imply browser-client or edge-runtime support.
6
-
7
- ## One owner and two ways to supply it
8
-
9
- A `BrowserSession<E>` is the live, host-only capability returned by the supplying implementation. It preserves callback failures and diagnostics of type `E`, one action budget, one native connection and one set of capture/page-control reservations. `implementation` identifies the control implementation. `closeChecked` performs the owner's cleanup and fails if its required cleanup was not confirmed. On concrete Chromium and Browserbase sessions it returns that same frozen cleanup receipt on success; the generic session permits discarding that value. Helpers that only use operations take `AnySession`, an alias for `BrowserSession<unknown>`. Helpers that supervise callback failures must stay generic in `E` or the concrete session so they retain those failures.
10
-
11
- All browser operations use issued `Page` and `Frame` objects on the original connection. `Capture.start`, `Capture.stream` and `PageControl` authenticate the exact Page privately; spreading or decoding an object cannot copy authority. An absent registration fails with reason `UnregisteredSession` and outcome `undispatched`. A copy, fabricated value or separately loaded runtime can cause that refusal; it does not establish which occurred. A registered Page with page control disabled still fails `Unsupported`. Tools and adapters bind the issued Page together with its original session for callback supervision and checked owner cleanup. Neither opens another browser.
12
-
13
- Mutations on one Page share its permit with its Frames; healthy Pages can proceed independently. Registry operations remain serialized. An observed node remains usable only until an invalidating event; a replaced node is never searched for again. A timed-out or interrupted mutation with an unacknowledged native command has an `unknown` outcome and is never automatically replayed. Its exact page is revoked before bounded closure. Positive closure reports `containment: PageClosed` and preserves healthy pages; unconfirmed closure fences the session and reports `SessionFenced`. A page that its own popup/dialog policy paused is kept open for the operator instead, and its unknown work reports `PagePaused`. Containment does not make the original action known. `performed` means dispatched input was acknowledged before a later step failed; it also must not be replayed blindly. `undispatched`, `rejected`, `performed` and `unknown` remain distinct outcomes.
14
-
15
- ## Self-managed Chromium
16
-
17
- `effect-browser/chromium` provides `Chromium`. It uses the same modeled browser owner, native driver, exact-node observations, bootstrap bindings, capture and page control as a hosted session. It requires no Browserbase client, project, key, allocation response or provider endpoint. `BrowserSession<E>` is their shared modeled capability; `BrowserbaseSession<E>` retains the separate provider reference, artifacts, Live View, handoff and release contracts.
18
-
19
- ```ts
20
- import { NodeServices } from "@effect/platform-node";
21
- import { Effect, Layer } from "effect";
22
- import * as Browser from "effect-browser/browser";
23
- import { BrowserPolicy } from "effect-browser/browser-data";
24
- import { Chromium } from "effect-browser/chromium";
25
-
26
- const program = Browser.scoped(Chromium.launch(BrowserPolicy.unrestricted()), (browser) =>
27
- Effect.gen(function* () {
28
- yield* browser.initialPage.navigate({ url: "https://example.com" });
29
- return yield* browser.initialPage.observe({ scope: "viewport" });
30
- }),
31
- ).pipe(
32
- Effect.provide(
33
- Chromium.layer({
34
- viewport: { width: 1280, height: 720 },
35
- pageControl: true,
36
- launch: { headless: true, chromiumSandbox: true },
37
- }).pipe(Layer.provide(NodeServices.layer)),
38
- ),
39
- );
40
- ```
41
-
42
- The Layer requires Effect's `Crypto`, which the host platform supplies: `NodeServices.layer` here, `BunServices.layer` on Bun, or `NodeCrypto.layer` alone. Process references, connection ids, native binding names and handoff tokens are drawn from it. The Layer captures it once, so `launch`, `acquire` and `attach` never require it. Layer construction validates configuration and starts nothing. The optional `playwright-core` peer is loaded only when needed. `Chromium.acquire(policy, { bootstrap })` starts one owned Chromium process and registers cleanup before waiting for a connection; its cached `connect` yields one `ChromiumSession<E>`. `Chromium.launch` combines those steps. These static operations access the configured service, as do `Chromium.attach` and the corresponding `BrowserbaseBrowser` operations. Bootstrap consumer errors and services remain in the acquisition signatures.
43
-
44
- `Browser.scoped(open, use)` is the common workflow supervisor for both sources, including borrowed attachment. It runs the acquisition once in its own scope, retains the concrete browser type in `use`, and races workflow completion against typed fail-session callbacks. The workflow has its own child scope: fibers and finalizers finish before `closeChecked` runs, so callback cleanup may still use a healthy browser. Checked closure runs on success, failure, thrown defects and cancellation. A body failure and a cleanup failure both remain in this workflow's own final Effect cause; neither is overwritten. An outer race or timeout can select another result and discard that losing cause even though cleanup ran. Configure the provider's `onCleanup` to record its receipt in a host-owned sink outside the raced workflow when cleanup evidence must survive that composition. A body that completes concurrently with a fail-session callback can still win the race; supervision does not establish failure priority. The ownership finalizer still runs if acquisition itself fails. No failed acquisition or action is replayed.
45
-
46
- The same combinator supports `open.pipe(Browser.scoped(use))`, including a reusable `const use = Browser.scoped((browser) => browser.initialPage.observe())` stored before choosing the provider. That unannotated callback sees common browser operations; use an annotated callback or the data-first form for provider-specific members or typed binding diagnostics. The returned function infers the supplying session's callback error independently of its acquisition error. Explicit generic applications must follow the curried overload's revised parameter lists: four outer parameters (`S, A, E2, R2`) and three returned parameters (`E, AE, AR`). It removes the scopes it owns while preserving other required services and error types. Returning a browser, stream or other live capability from `use` does not extend that resource's lifetime. The old provider-specific `withBrowser` methods and `BrowserRuntime.withBrowser` are replaced by this one public operation.
47
-
48
- ### A long-lived session in a Layer
49
-
50
- An application can share one acquired session through a Layer for its finite application scope:
51
-
52
- ```ts
53
- import { NodeServices } from "@effect/platform-node";
54
- import { Context, Effect, Layer } from "effect";
55
- import { Chromium, type ChromiumSession } from "effect-browser/chromium";
56
- import { BrowserPolicy } from "effect-browser/browser-data";
57
-
58
- class SharedBrowser extends Context.Service<SharedBrowser, ChromiumSession>()(
59
- "app/SharedBrowser",
60
- ) {}
61
-
62
- const shared = Layer.effect(
63
- SharedBrowser,
64
- Chromium.launch(BrowserPolicy.unrestricted({ maxElapsedMillis: 300_000 })),
65
- ).pipe(
66
- Layer.provide(Chromium.layer({ onCleanup: recordReceipt })),
67
- Layer.provide(NodeServices.layer),
68
- );
69
-
70
- const program = Effect.gen(function* () {
71
- const browser = yield* SharedBrowser;
72
- return yield* browser.initialPage.observe({ scope: "viewport" });
73
- }).pipe(Effect.provide(shared));
9
+ ```sh
10
+ npm install effect-browser@beta effect playwright-core
11
+ npx playwright-core install chromium
74
12
  ```
75
13
 
76
- Here `recordReceipt` is the host's bounded receipt sink. Consumers of the same Layer build share
77
- selection, budgets, native admission and lifetime; the Layer does not reset an expired session or
78
- give same-Page operations independent permits. Its acquisition finalizer performs unchecked release and
79
- stores the receipt. It does not supervise `browser.failure`, and does not turn incomplete cleanup
80
- into a checked-close error for every consumer. The application must choose that supervision and
81
- explicit checked closure, or use `Browser.scoped` for a bounded workflow that owns those decisions.
82
- Calling `Browser.scoped` on the shared live session would close it for every consumer, so it is not
83
- a per-request wrapper for this pattern.
84
-
85
- ### Receipts outside a race
86
-
87
- An outer timeout may return `None` while discarding the losing workflow's checked-close error.
88
- Place the receipt sink outside that race when those facts must remain observable:
14
+ `Chromium.layer` launches that Chromium without its sandbox, as Playwright does, so an exploit in a
15
+ page's renderer runs with your user's privileges. `Chromium.layer({ sandbox: true })` runs it
16
+ sandboxed. Hosts that don't allow unprivileged user namespaces, such as many containers and Ubuntu
17
+ 23.10 or later by default, can't start the sandbox; opening then fails and says so.
18
+
19
+ `Chromium.layer` leaves signals to your program: Playwright's own handlers, which close every
20
+ browser and, on SIGINT, exit before any finalizer runs, are off. Run the program with
21
+ `NodeRuntime.runMain` from `@effect/platform-node`, which interrupts it on SIGINT and SIGTERM, so
22
+ the browser closes with its scope. A signal that ends the process outright still takes Chromium
23
+ with it, since its pipe closes, but leaves Playwright's temporary profile behind.
24
+
25
+ | Module | What it holds |
26
+ | -------------- | --------------------------------------------------------------------------------- |
27
+ | `Browser` | The `Browser` service: tabs, recent events and the Playwright context |
28
+ | `Chromium` | A local Chromium as a `Browser` layer |
29
+ | `Cdp` | Any DevTools endpoint as a `Browser` layer |
30
+ | `Page` | One tab: navigation, reads, screenshots, input, waits and the screencast |
31
+ | `Snapshot` | The model-readable outline of a page, with refs for its controls |
32
+ | `Frame` | A screencast frame |
33
+ | `BrowserEvent` | Tabs, documents, loads, actions, dialogs, pointer motion and the browser's end |
34
+ | `Motion` | A bounded pointer planner, as a value, and the tuned sigma-lognormal one |
35
+ | `Presentation` | Input performed for viewers: a presenter, and its views of pages |
36
+ | `Stage` | The source of a live output, switched between pages and stamped |
37
+ | `BrowserError` | Typed failures, whether input reached the page first, and what each leaves |
38
+ | `Policy` | Judges that read what an input means, and a guard that acts on them unattended |
39
+ | `Change` | What visibly changed on a page over a window, element by element |
40
+ | `Moment` | A page's events, changes and frames over a window; a moment, laid out as a prompt |
41
+ | `Plan` | A walk recorded from a page's events, replayed on a fresh page by subject |
42
+ | `Supervisor` | A browser kept open across losses and session ends, as generations |
43
+
44
+ A snapshot outlines the viewport, or the whole page, with a ref such as `e12` on each control, and
45
+ lists those controls as values, `Snapshot.Control`: each one's ref, kind, name and what it holds,
46
+ a select's options, and whether it is disabled, checked or takes typed text. `within` reads only
47
+ inside the elements a CSS selector matches, and `query` keeps the lines that hold some text.
48
+ `Page.waitFor` waits, in one call the page answers as it comes, until what a selector matches
49
+ shows, hides or is enabled, with some text if asked. `press` takes `on`, a ref it focuses before the
50
+ first key, such as a link only the keyboard reaches. `Change.describe` tells a change in the words
51
+ a moment uses. The actions a call performs record a caller's id as their `correlation` when it runs
52
+ under `Page.correlate(id)`, such as a model's tool call.
53
+
54
+ `Page.zoom` captures a region in viewport CSS pixels as a `Zoom` schema value with `region` and `image`;
55
+ crop pixel coordinates need the region's origin added before using them as click coordinates.
56
+
57
+ `Page.click` returns a `ResolvedTarget` captured before input: the requested point, element label,
58
+ role, accessible name, context, cursor and link target. Pixel targeting resolves through the page
59
+ script and keeps the original point; the receipt names the control even when a nested child
60
+ received the hit. Refs inside same-origin frames are measured, scrolled and checked for cover in the
61
+ top viewport. A ref that no longer names an element of the current documents, including one in a
62
+ frame that has since navigated, fails with `StaleRef` naming that ref, with or without a guard.
63
+
64
+ A subject names an element durably: its role, accessible name and tag, and its context, the words
65
+ around it that say which one it is. In a table, `row` is the row's header, or else its first cell
66
+ with letters, so a rank or a price never names it, and `column` is the header over it, spanned cells
67
+ counted; elsewhere, `label` is the words just before it in its row, item, group or block. `heading`
68
+ is the nearest heading above it, left out for what is pinned to the viewport. The page binds each to
69
+ the element, so a value is never read under another column's header.
70
+
71
+ `Page.find` reads structure without the outline, in one call to the page. It returns every element
72
+ that matches a query, in tree order, each a `Found`: a ref the actions take, its `subject`, its box,
73
+ whether it is in the viewport, and its state (disabled, focused, and checked, expanded, selected,
74
+ pressed or a heading's level where they apply). A `role` matches ignoring case. A `name` matches
75
+ when it reads the same once spaces are collapsed and case folded, or when a `RegExp` finds itself
76
+ in it. `text` matches the smallest element showing it, and a control or heading for the words
77
+ inside it; `near` matches whole words of the context, as replay's choice does. With no rule,
78
+ `find` returns every element in scope that has a role or is a control. There is no ordinal: a
79
+ caller tells equal elements apart by their context, and finding none is an empty result, not a
80
+ failure. `scope: "document"` reads the whole page; the default is the viewport.
81
+
82
+ `Page.text` reads what the viewport shows, or one element whole with `scope` set to its ref, in one
83
+ call to the page: a line per block, table cells apart by tabs, cut at a line after `maxChars`
84
+ (12,000 by default). Like the outline, it leaves out what the page hides from assistive technology,
85
+ such as icon glyphs. What a field holds reads `••••` unless `unmask` is set, and a secret field's
86
+ always does: a password, a one-time code or a card field, one whose style shows dots for what it
87
+ holds, as a PIN field's may, and one that was secret when the library saw it, such as a password
88
+ its page now reveals. The outline shows what fields hold, as the agent needs, but masks a secret
89
+ field the same way.
90
+
91
+ Reading the viewport, the outline, `find` and `text` skip a subtree whose box lies outside it before
92
+ styling anything in it, so a long table costs only its rows in view. A box says nothing of what is
93
+ positioned out of it, so a subtree is kept when its box is empty; when it holds what is painted at
94
+ the viewport's edges, corners or middle, beneath any transparent layer too, or in its top layer; or
95
+ when it holds what its style attribute pins in view. Something a stylesheet pins inside a subtree
96
+ out of view, away from those points or ignoring the pointer, is missed. The outline's `above` and
97
+ `below` count the parts skipped, each an element out of view with all it holds.
98
+
99
+ `Page.ready` waits until a page is ready to be shown, in one call that checks in the page until it
100
+ is, or until the time is up: its document is parsed and has painted since, nothing that ends is
101
+ animating in view, its fonts and the images in view have loaded, nothing in view is marked busy
102
+ (`aria-busy`), and the viewport shows something: text, a canvas drawn on, a picture or drawing
103
+ larger than an icon, a video or a frame. A canvas mounted blank, as chart libraries mount theirs,
104
+ or a spinner alone is still loading. A screen that only says "Loading…" in words reads as ready,
105
+ and a WebGL canvas drawn once, without keeping its drawing, reads as blank. A hidden tab, which
106
+ paints nothing, is never ready.
107
+
108
+ With `quietMillis`, the screen must then also stay still that long: no screencast frame comes,
109
+ counted from the first frame of a capture the wait starts itself, since a hosted browser sends that
110
+ frame over half a second late. A capture's silence alone proves little: a stalled connection holds
111
+ frames on their way, and Chromium sends frames only while few acknowledgements are unanswered. So
112
+ the spell counts only while every acknowledgement is answered, and it ends with one more call to the
113
+ page, whose answer arrives behind every frame sent before it; a frame that comes first starts the
114
+ spell again. That call costs the wait one round trip. A capture on a connection of its own, as on
115
+ Browserbase, gets one round trip there too, beside the call, since frames there do not wait behind
116
+ the page's calls: a 4 fps canvas whose capture connection stalled for a frame read as still in 2
117
+ of 2 waits before, and in none since. Where the page's changes are recorded, the
118
+ page's own check also waits until nothing in view has changed for the spell, so frames the browser
119
+ holds back, as an encoder's backlog does, cannot pass for a still page. Stillness is a heuristic
120
+ on a canvas: a canvas that keeps drawing, such as a live chart, is never still, and one that pauses
121
+ longer than `quietMillis` between phases reads as still. On the slot machine fixture, with the
122
+ connection stalled for 450 to 900 ms toward either end while the reels spun, none of 132 waits
123
+ ended early, where all 36 did before.
124
+
125
+ `Page.ready({ still: "content", quietMillis })` instead observes a full interval without visible
126
+ recorded DOM changes. It starts the recorder itself and starts the interval again in each new
127
+ document; navigation retries share the original timeout. Canvas animation can continue while DOM
128
+ content is ready. This does not prove pixel or screencast stillness and inherits the change
129
+ recorder's limits for canvas, SVG, frames, shadow content and values changed only by scripts.
130
+
131
+ `Page.changes({ since, until })` says what visibly changed on a page over a window, in one call to
132
+ the page, element by element, as a `Change`: text that changed, appeared, disappeared or came and
133
+ went (`brief`), a field's value, or the title. Each says what it showed at the window's start and
134
+ end, how often that changed, the lowest and highest of a number that changed more than once, when
135
+ it last changed before the window, so news stands apart from what keeps changing, and its
136
+ `subject` with the context that binds it: a price under its row and column. A field's value reads
137
+ `••••` unless `unmask` is set, and a secret field's always does. A change names its `cause`, the
138
+ trusted input it followed, only where it was that input's doing. It must not have been in flux: in
139
+ the 10 s before the input it had not changed, and nothing had come into or left what it sits in, so
140
+ a ticker's next tick or a feed's next line names no click. Then it changed within 3 s inside what
141
+ the input acted on or its row, form, dialog or controlled element, so "Order placed" a server's
142
+ reply later keeps its click; or elsewhere within 500 ms, on a page where nothing had changed in the
143
+ 2 s before the input, so a menu a portal puts at the end of the page keeps its click, and a chat
144
+ line beside a ticking board does not. What holds the input's target, as a menu holds its item, does
145
+ not count against it. Against inert clicks on boards whose 2 to 20 cells tick every 0.25 to 6 s,
146
+ the rule credited none of 401 ticks and none of 96 chat lines, where a second's quiet on the element
147
+ and 500 ms anywhere had credited about half the ticks of the slower boards and every chat line.
148
+ What a busy page costs is the credit for an effect out of a click's reach, which a moment then
149
+ tells as a step; on a still page, an unrelated line 250 ms after a click is still credited.
150
+
151
+ A page records once something reads its changes: the first read starts it, and finds nothing yet.
152
+ From then the page's own session starts the recorder at the start of each later document, once it
153
+ is parsed, until nobody has read it for two minutes; a page nobody reads records nothing and costs
154
+ nothing. The first read also registers the recorder, in the same round trip, and every later read
155
+ is one call. A browser whose clock no capture has mapped yet maps it on its first read of changes,
156
+ once, in four round trips more. While it runs, a MutationObserver marks what changed: a text-only
157
+ element, as most prices are, is read at once, and anything else, with whether it was in view, as
158
+ the page renders it, through an IntersectionObserver that forces no layout. A change counts as seen
159
+ when it was in the viewport as the page rendered it, its style showing it, so one scrolled away
160
+ later is still told, and something removed or hidden only if it was in view before. On a
161
+ 2,000-cell table rewritten every 50 ms, locally, the page's busy time went from about 100 ms per
162
+ 3 s to about 230 recording, where an empty observer costs about 180.
163
+
164
+ The record keeps 256 elements with their last 32 changes, for a minute. On a busier page, an
165
+ element that keeps changing gives way first, then one never seen in view, then one that has gone,
166
+ so news stays; whatever gives way, or finds no room, is counted in `dropped`, and `from` moves
167
+ past it: the record never claims to be whole where it is not. A window can start at a previous
168
+ read's `Changes`, continuing exactly where it ended on the page's own clock, or at a frame's
169
+ paint, and end at a frame's paint, so a narrator airing a frame late is told nothing that frame
170
+ does not show. Times are host milliseconds through the browser's one clock mapping, as frames'
171
+ are. The record does not see pictures, a canvas, frames, shadow roots or SVG, a value a script
172
+ sets, or a class that reveals an element it has not seen before, such as a toast already on the
173
+ page, though its later changes it does.
174
+
175
+ A page's timeline has three tracks on the browser's host clock: its events, what changed on it,
176
+ and its frames. `page.window({ since, until })` reads them over a window, a `Moment.Window`: the
177
+ events and frames the page keeps, at no call, and its changes, in one call, the first starting its
178
+ record as any read of changes does. `since` is a previous window, to go on exactly where it ended, a
179
+ frame, a host time, or a `Duration` back from `until`; `until` is a frame or a host time, now by
180
+ default. A consumer that airs a frame seconds after it was painted reads the window that ends at
181
+ that frame, so that a line it writes now tells what its viewers will see. A part that cannot be
182
+ read is in `missing`, with why: a window fails only for bounds that are not finite, or a negative
183
+ reach back.
184
+ `Moment.account(window)` says what was so at the window's end. Its `changing` are the changes still
185
+ under way: what kept changing, more than once in the window or already before it, and changed
186
+ again within twice its own pace before the end, as a ticking price does. Its `settled` are the
187
+ rest, each showing what it settled at, as a price that ticked and stopped does. Its `stillFor` is
188
+ how long the page had been still, by the last change in view its record shows or the last paint
189
+ among its screencast frames; a screenshot shows the page, not when it changed. Frames show paint
190
+ only while a capture runs, and the record does not see a canvas, so a canvas that draws with no
191
+ capture running reads as still. A wait for a still screen is `Page.ready({ quietMillis })`, which
192
+ also holds through a stalled connection.
193
+
194
+ `Moment.capture` is a window that ends at a picture of the page now. It takes the picture first,
195
+ then the window up to its paint, from where a previous window or moment ended, so the changes hold
196
+ nothing the picture does not show. A picture, a read of changes or an outline that cannot be had is
197
+ missing from the moment, with why, and the moment is still made. `Moment.toPrompt` leads with what
198
+ changed: news and what settled first, then what was still changing, by the moment's account, with
199
+ the cells of a column that changed together on one line; a change that kept changing says whether
200
+ it still was at the moment or since when it had settled, so a narrator need not assert a value that
201
+ is still moving. It names an action as what a change followed, or as a step: where changes followed
202
+ it but none names it, as when its effect landed out of its reach on a busy page; where its effect is
203
+ drawn, such as a click on a canvas, which only the screenshots show; or where it came before the
204
+ record began. Hovers, scrolls and attempts that nothing followed are left out, and a failed action is told
205
+ only as failed: its error is advice to the caller that acted, and may name a ref. A moment whose
206
+ record saw none of its window, as a page's first, whose read starts the record, says that what
207
+ changed was not recorded and lists every step instead; one whose record began within the window
208
+ says nothing changed only after that. What it could not read, it names, with why, and a last frame
209
+ that is not the picture is not shown as the moment.
210
+
211
+ `Plan` rehearses a walk once and replays it later, near live, with no model call.
212
+ `Plan.fromEvents(page.recentEvents)` keeps one page's completed actions with their subjects and
213
+ the options they were given, its navigations with the address asked for and the one reached, and
214
+ what was typed as an input slot named for its field, never the text itself. A navigation to an
215
+ address with a credential withheld is an input slot too, `address`, so replay goes where the caller
216
+ says, never to the address without its credential. `Plan.replay(page,
217
+ plan, { inputs })` takes the steps in turn: before each that acts, it waits for `Page.ready`
218
+ (`settle` sets how, or `false` not to wait), finds the one element the step's subject names with
219
+ `Plan.locate`, and acts on it. An element that does not repeat every part of the recorded context
220
+ is another subject: its row read whole, so a row named "Wrapped BTC" is not the row "BTC" (laid out
221
+ again without rows, as cards, one of its fields must start with the row's words), its column where
222
+ it is under one, and its label and heading read the same or found as words in order. So the one
223
+ element left once the recorded one has gone, such as Alice's "Remove" after Bob's, drifts rather
224
+ than stands in. The rest rank by how much they read the same; a tie is `Ambiguous`, since there is
225
+ no ordinal. Text typed into whatever had focus went into the focused field, which the action
226
+ records as its subject, so replay inserts into that field wherever focus has gone, retaining its
227
+ existing text. Caret positions are not recorded: insertion uses the field's caret when focused.
228
+ A press recorded on a subject resolves and focuses it before sending keys. Text first
229
+ typed into a secret field is typed only into one. An element step never falls back to coordinates.
230
+ A step recorded at a point presses the same place within the element found, brought into view
231
+ first, and only when a press there reaches that element, not something over it. Replay stops at
232
+ the first step it cannot take, with a `ReplayError` naming the step and why: `Missing`,
233
+ `Ambiguous`, `Drifted` (nothing with the subject's role and name repeats its context, or the walk
234
+ did not arrive at the recorded site), the step's `BrowserError`, or `Uncertain`. Arrival waits
235
+ are bounded by `arrival` (five seconds by default), including redirects after an action. A zero
236
+ wait makes one read without polling; that read retains the page operation's own timeout.
237
+ Each `Action` now records its ending address, so a bounded event history can still record the site
238
+ of later actions after its earlier navigation has expired.
239
+
240
+ `Plan.take(page, plan, index)` takes one step and returns a `Receipt`: the step index, host-clock
241
+ `startedAt` and `at`, and `taken`, `verified` or `skipped`. `Plan.replay` returns those receipts;
242
+ a `ReplayError` includes completed receipts and a serializable `stopped` value with the next step
243
+ and whether its input is `unsent` or `unknown`. Resume with `{ from: error.stopped }`. A numeric
244
+ index means fresh input, so never convert an unresolved stopping point into a number to retry it.
245
+ An interrupted call remains interrupted. Persist that a step has begun **before** calling it;
246
+ a missing receipt, including after a process restart, is `Stopped({ step, input: "unknown" })`.
247
+ The consumer owns that durable journal and the decision to reconcile a consequential operation.
248
+
249
+ An optional `Step.expect` checks `Checked`, `Appeared`, `Gone` or `Navigated` before input and
250
+ waits for it afterward. `verified` means the specified observation held; it is not a transaction
251
+ receipt from the target service. Keep the receipt with its plan to interpret that evidence.
252
+ `Checked` can converge a checkbox click to a desired boolean.
253
+ Unknown non-repeatable input is never automatically sent again: only a positive check explicitly
254
+ marked `durable: true` permits continuation. This is the author's assertion of a lasting,
255
+ uniquely scoped read-back, such as a saved order receipt. A toast or an intermediate URL cannot
256
+ justify it. A negative or failed durable check still leaves the operation uncertain.
257
+ Navigation, hover, scroll, selection and replacement typing without submission can be repeated;
258
+ back, reload, insertion, presses, drags and ordinary clicks cannot. `Plan.repeatable(step)` says
259
+ which applies. `Plan.restore(page, plan, { upTo, inputs })` reconstructs earlier steps on a fresh
260
+ page using those same rules and refuses unresolved commitments. `upTo` is exclusive.
261
+
262
+ `Plan.Plan.new(steps)` selects the stored version: recordings without expected checks remain
263
+ version 1; checks require version 2. Both decode, with version and contents validated together.
264
+ Old 0.2 plans and unknown versions do not decode.
265
+
266
+ `Browser.Options.guard` is the input policy. Its `InputRequest` schema contains the action, the
267
+ resolved element, the page's URL and title, and the `facts` the page's structure establishes:
268
+ `form-submit`, `cross-origin`, `download`, `upload`, `secret` (typing with `type` into a
269
+ password, one-time code or card field, or submitting a form that holds a filled one), `scripted` (the activated
270
+ control has no effect of the browser's own, so only page script decides what happens) and
271
+ `opaque` (nothing names what receives the input, such as a canvas or an unlabelled icon). More
272
+ than one may apply. They describe what the input activates, found as the browser finds it: the
273
+ submit button around a painted label, an SVG link around a shape, an image-map area. Hovering and
274
+ scrolling activate nothing and carry none. `point` is present for literal pixel targets; a ref's
275
+ coordinates are resolved after approval so preparation never scrolls.
276
+
277
+ Facts never come from what an element's text says: words change meaning with context and
278
+ language, so a payment, a deletion or a consent is for a judge to recognise. The request carries
279
+ the evidence one needs: the target's name and description, its dialog, the heading before it, the
280
+ text beside it in its row or form, and the form's fields with their types and autocomplete tokens.
281
+ Fields carry whether they are filled, never their values. All of it is page text the page
282
+ controls, to read as evidence and never as instructions. Text typed into a `secret` field reaches
283
+ the guard and the recorded `Action` as `redacted`, and its key events as `Unidentified` keys;
284
+ `press` sends and records the keys it is given, so type secrets with `type`.
285
+ It covers clicks, drags, typing, keys, selection, hover, scroll and navigation, including a new
286
+ tab's destination.
287
+
288
+ The policy's effect succeeds to allow, fails with `PolicyDenied` to refuse, or waits for a signal
289
+ the consumer owns to hold. For example:
89
290
 
90
291
  ```ts
91
- let receipt: ChromiumCleanupResult | undefined;
92
- const result =
93
- yield *
94
- Browser.scoped(Chromium.launch(policy), (browser) => browser.initialPage.navigate({ url })).pipe(
95
- Effect.timeoutOption("30 seconds"),
96
- Effect.provide(
97
- Chromium.layer({
98
- onCleanup: (value) =>
99
- Effect.sync(() => {
100
- receipt = value;
101
- }),
102
- }),
103
- ),
104
- );
105
- // result can be None; receipt still records the completed cleanup attempt's actual facts.
106
- ```
107
-
108
- The sink above retains one host-only receipt; it is not durable storage. A direct path can return
109
- `yield* browser.closeChecked` from the callback to obtain the concrete receipt, with typed failure
110
- when the owner's required cleanup was unconfirmed. Repeated checked closure and scope finalization
111
- reuse the same cleanup attempt. Body failure and direct or explicitly awaited interruption retain
112
- checked-close errors in that workflow's own cause; a racing parent chooses which result survives.
113
- The [complete compiled example](examples/shared-session.ts) shows all three compositions.
114
-
115
- Cleanup notification is attempted once after canonical evidence has been stored. Callback
116
- construction throws, defects, self-interruption and a two-second cooperative timeout are contained.
117
- Browserbase's built-in diagnostic reporter and the optional `onCleanup` sink are contained
118
- independently, so a broken reporter cannot suppress the sink. A failed sink does not confirm
119
- cleanup, alter the stored receipt, or retry teardown. `cleanupResult` remains the canonical local
120
- record. Notification timeout relies on cooperative interruption and cannot preempt synchronous
121
- host code that never yields. Receipt storage, Context-writer settlement and `closeChecked` validation
122
- are outside this optional-notification boundary.
123
-
124
- Owned launch currently supports POSIX hosts (Linux and macOS). It uses a fresh temporary profile, an ephemeral loopback debugger port and one maintained CDP connection. The launcher owns those arguments; callers cannot replace them through `args`. `executablePath` selects an installed Chromium explicitly; otherwise the pinned Playwright executable is used. Headless mode and Chromium sandboxing default to enabled. `chromiumSandbox: false` is an explicit host exception, never inferred from `CI`, root execution or a connection failure. Setting the option alone does not prove the operating system's sandbox configuration. Startup waiting is bounded by `startupTimeoutMillis` (15 seconds by default, at most 60 seconds) and the owner's remaining lifetime. Acquisition does not retry a failed launch.
125
-
126
- For an externally owned Chromium, use `browser.attach(endpoint, { policy, target?, bootstrap? })`. `endpoint` is a `Redacted<string>` containing the exact `ws://127.0.0.1:PORT/devtools/browser/ID` or IPv6-loopback equivalent advertised by that browser. HTTP discovery URLs, non-loopback hosts, credentials, queries and fragments are refused. This validates the control endpoint's shape; it does not authenticate the host running it. A supplied `target.targetId` must identify an existing page. Without one, several candidate pages are an explicit ambiguity error. Attachment preserves the existing viewport and does not replay the layer's launch arguments or create a replacement browser. Coordinate any independent controllers yourself, especially when enabling page control.
127
-
128
- Chromium identity is `{ provider: "chromium", id }`, identifying this ownership lifetime. It is not a Browserbase session reference, a PID or an attachment credential. `close` and `cleanupResult` retain `connection` and `process` facts separately. Owned cleanup fences operations, stops capture, disposes initialization, disconnects and terminates its process group; only observed termination permits removing the temporary profile. Failure or timeout remains in `issues` and leaves `process: "unknown"` when exit was not established. Borrowed cleanup disconnects its own client and reports `process: "not-owned"`; it never terminates the external process. Repeated close calls share one result. No local result has a provider `remote: "confirmed"` field. `onCleanup` receives these bounded, host-only facts even when connection setup fails after launch.
129
-
130
- `launch.proxy: { server, bypass? }` forwards an existing host-operated proxy to Chromium. With a proxy, the default bypass value is `<-loopback>` so Chromium does not silently exclude loopback destinations; a different bypass is an explicit host choice. Additional reviewed native flags, such as disabling QUIC and non-proxied WebRTC UDP, can be supplied through `launch.args`. This module does not implement a proxy or qualify its transport/DNS coverage. Browser policy remains `Unrestricted`; a local endpoint, URL admission or successful local test never establishes whole-browser egress containment. Preserve and test the selected enforcing proxy independently. The Browserbase integration validates its own provider-issued endpoints separately.
131
-
132
- Owned Chromium also watches a parent-owned debugging pipe for disconnection. The pipe carries
133
- no protocol commands; Playwright continues to use the loopback endpoint. If the host exits or is
134
- killed, including before `connect`, the operating system closes the pipe and Chromium exits.
135
- Normal scoped cleanup still checks process-group termination and removes the profile. A killed
136
- host cannot produce a cleanup receipt or remove its temporary profile. Borrowed attachments have
137
- no such pipe and leave their externally owned browser running.
138
-
139
- ## Passive status and native diagnostics
140
-
141
- ```ts
142
- const status = yield * session.status;
143
- const diagnostics = yield * session.diagnostics;
144
- // status: { phase, reason, generation, busy, unresolvedDispatch, actions: { used, maximum } }
145
- // diagnostics: { records, total, dropped, truncated }
146
- ```
147
-
148
- Both reads copy host memory, acquire no browser permit, charge no action and remain available
149
- while busy, faulted or closed. A snapshot is not an admission token: the next operation still
150
- checks its ticket. `phase` describes admission/lifecycle, not remote cleanup confirmation. Its
151
- closed vocabulary includes `faulted` for a known terminal trigger, alongside `acquiring`, `open`,
152
- `paused`, `detached`, `uncertain`, `closing` and `closed`; exhaustive host matches must include the
153
- new case. `reason` retains the original terminal trigger through later cleanup. `busy` reports
154
- active admission or pending policy cleanup. Action-count or host-read exhaustion leaves the owner
155
- open and is not a terminal failure.
156
-
157
- `actions` is the model-reachable allowance as the owner counts it: `maximum` is the policy's
158
- `maxActions` and `used` the operations admitted against it, whether they then succeeded or not.
159
- Every step of `fillForm` and its submit is an action, and so is each reading an agent Tool takes
160
- after its action; a form's verification, `checkpoint`, `controlFacts` and `status` itself are not.
161
- A refused operation is not counted, so `used` never passes `maximum`, and at `maximum` the next
162
- action fails `Limit { dimension: "actions" }` undispatched while the owner stays open. A host that
163
- runs one long session reads `used` instead of counting actions itself. `BrowserPolicy.maxActions`
164
- accepts 1–1,000,000 (100 by default, like the host-read allowance's bound); a long session raises it
165
- together with `maxElapsedMillis`.
166
-
167
- `unresolvedDispatch` is separate from the trigger. Idle expiry records `expired` without inventing
168
- an unknown dispatch. Expiry or a fail-session callback overlapping native work retains both the
169
- known trigger and unresolved control. A fence or borrowed disconnection is not acknowledgement
170
- that the browser stopped. Positive owned termination evidence can retire that control indication;
171
- it does not reconcile earlier business effects or change their `unknown` outcomes. The concrete
172
- cleanup receipt remains the authority for process termination or provider release confirmation.
173
-
174
- Diagnostics retain the latest 32 records, oldest first, with a closed reason, disposition,
175
- connection generation and host monotonic timestamp. Counters saturate at `Number.MAX_SAFE_INTEGER`;
176
- `truncated` and `dropped` disclose eviction. Records contain no URL, title, message, native object
177
- or consumer cause. Typed callback errors remain in `failure` and `bindingDiagnostics`.
178
-
179
- Under popup-close policy, a popup beyond `maxPages` is quarantined and closed once. Its originating
180
- click can finish, while new browser work is refused until cleanup is confirmed. Confirmed closure
181
- preserves the original page's usability. The native cleanup pool is bounded at 32 operations;
182
- the two-second acknowledgement deadline does not free an unresolved native promise's slot.
183
- Lost acknowledgement fences control without replay; a late acknowledgement cannot reopen it.
184
- Capacity refusal before dispatch is a known block rather than fabricated uncertainty. Dialog-cap
185
- overflow follows the same bounded dismissal rules. An acknowledged before-unload dismissal can
186
- retire only the exact navigation captured when its dialog arrived and subsequently rejected.
187
- An attributed popup/dialog pause quarantines its exact Page and stops its capture and bindings;
188
- healthy peers keep working. The original Page reports `paused` and permits explicit close, while
189
- input remains refused. Its page timeline ends `stale`, as an all-page pause ends every page's, so a
190
- run waiting to start on it fails preparation at once. An action the pause interrupts on that page, such as the click that opened
191
- the dialog, is never closed away: its unknown outcome reports `containment: PagePaused`, and the
192
- quarantined page's native work does not hold up a handoff's drain. Recovery requires a drained
193
- session handoff and explicit operator release, then newly acquired Page/Frame authority. Unattributed policy or shared connection failures fence
194
- the session conservatively.
195
-
196
- ## Browser operations
197
-
198
- ### Page and Frame authority
199
-
200
- `session.initialPage` is the Page acquired on the first connection. `session.listPages()` returns
201
- bounded metadata; `session.page(info)` authenticates it and issues the canonical Page for that
202
- native page and generation. A Page provides navigation, input, observation, exact-node actions,
203
- checkpoint, readiness, viewport control and close. `page.listFrames()` and `page.frame(info)`
204
- issue a Frame with the same document operations for that exact frame. None changes display
205
- selection or opens a connection. An action's result `url` is its frame's http(s) address; a
206
- document without one (`about:srcdoc`, `about:blank`, `data:`) reports its nearest ancestor
207
- frame's, which is also its base URL. That address is resolved before dispatch, so an action never
208
- fails over it after input landed: a target with no http(s) address anywhere in its frame tree, or
209
- whose own http(s) address cannot be reported (longer than 8,192 characters, or carrying
210
- credentials; an ancestor's address never stands in for it), is refused `Unsupported` and
211
- `undispatched`, and an action that removes its own frame or closes its
212
- page reports the address read before dispatch.
213
-
214
- Pass an issued Page or Frame as the object you were given. Capture, page control, provider
215
- transfers, agent tools and `Browser.checkPage` authenticate it by identity, so a spread copy
216
- (`{ ...page }`), a wrapper or a Proxy is refused `UnregisteredSession`, even though methods copied
217
- onto it still run. To instrument work, read `session.timeline` and `page.timeline`, which record
218
- every operation's phases, and plan run receipts, instead of wrapping Page methods.
219
-
220
- ```ts
221
- const stage = session.initialPage;
222
- const scout = yield * session.createPage();
223
- yield * scout.navigate({ url: scoutUrl });
224
- const stageObservation = yield * stage.observe();
225
-
226
- yield * scout.observe();
227
- yield * scout.screenshot({ fullPage: false });
228
- yield *
229
- stage.clickElement({
230
- observationId: stageObservation.observationId,
231
- elementId: stageObservation.controls[0]!.elementId,
232
- });
292
+ import { Effect } from "effect";
293
+ import { PolicyDenied } from "effect-browser/BrowserError";
294
+ import * as Chromium from "effect-browser/Chromium";
295
+
296
+ const browser = Chromium.layer({
297
+ guard: (request) =>
298
+ request.facts.includes("secret") && request.facts.includes("cross-origin")
299
+ ? Effect.fail(new PolicyDenied({ detail: "Secrets are never sent to another site." }))
300
+ : Effect.void,
301
+ });
233
302
  ```
234
303
 
235
- Use a control whose inspected role and label match your intent. Each frame has its own current
236
- observation: reading or acting on the scout preserves the stage's exact references. Another
237
- observation of the same frame retires its predecessor. A reference passed through the wrong
238
- Page or Frame fails `Stale/undispatched` before input. Mutation invalidation remains conservative
239
- for the affected page. A detached frame, closed page or old connection generation cannot acquire
240
- fresh authority. A refusal names the session's own reason first: while the session is closed,
241
- expired, fenced or paused, work through any issued Page fails `Closed`, `Expired` or `Busy`, not as
242
- a stale capability. Otherwise a closed Page fails `Closed`, and a Page from an earlier generation,
243
- a closing page or a detached frame fails `Stale`, all `undispatched`. `page.status` is passive
244
- host state, including terminal containment facts, and
245
- remains readable after closure. Reconnect and handoff resume return a bounded `Inventory` with
246
- the current generation and fresh Page metadata. Acquire a Page from that inventory and call
247
- `page.observe()` explicitly; old capabilities stay stale. Native title and URL reads are
248
- non-atomic. The separate `session.pages` lifecycle stream attaches its cached registry snapshot
249
- and journal cursor together.
250
-
251
- Observation storage is finite, including snapshots awaiting native disposal. Optional
252
- `automation.observationLimits` sets all six bounds together: defaults are 16 snapshots,
253
- 8,192 handles and 64 MiB per page, and 64 snapshots, 32,768 handles and 256 MiB per session.
254
- Reads reserve capacity before native allocation and return `Limit/undispatched` when it is
255
- unavailable. Timeout or caller cancellation does not return a reservation until native work
256
- and disposal settle, or exact positive page/connection retirement proves it unusable.
257
- The snapshot bounds include temporary reads such as facts, screenshots and checkpoints. Leave
258
- one slot beyond each retained frame observation for an action's fresh read; a one-slot bound
259
- supports passive reads alone but cannot read facts or act while an observation occupies it.
260
- Extraction reserves 4 KiB plus the requested text bytes and 512 KiB per requested control;
261
- a picture checkpoint also reserves its picture allowance. A standalone screenshot reserves
262
- 4 KiB plus its returned-byte allowance. A plan step's targets reserve, per frame, one
263
- descriptor read at a time (4 KiB plus 512 KiB) and then what each resolved control actually
264
- retains, so a large form refuses only when its retained facts exceed the bound. Released native
265
- resources reduce the retained reservation.
266
-
267
- `page.ready({ timeoutMillis })`, `page.describe({ timeoutMillis })`,
268
- `page.listFrames({ timeoutMillis })`, `page.close({ timeoutMillis })` and
269
- `session.listPages({ timeoutMillis })` narrow the owner's deadline. `Capture.start(page, options)`
270
- and `Capture.stream(page, options)` use the same owner and capture budget. With page control
271
- enabled, `PageControl.state(page)`, `PageControl.suspend(page)` and
272
- `PageControl.resume(page, receipt)` authenticate that exact Page and suspension receipt.
273
-
274
- ### Actions, plans and recording
275
-
276
- Issued Pages and Frames expose `run(plan, options)` and scoped `start(plan, options)` on their original owner. `effect-browser/plan-data` supplies one tagged action schema for navigation, exact target input, selection, forms, scrolling and bounded waits. A live plan can name an observed Ref or a Descriptor; durable version-1 targets are descriptor-only, with authored literal values or named inputs. Recording replaces input literals with named slots. `Plan.make` validates live intent, `Plan.decode` validates stored intent, and `Plan.encode` serializes normalized defaults. Ingress checks finite data cost before semantic decoding: at most 128 distinct steps and 1 MiB of encoded data.
304
+ `effect-browser/Policy` builds guards for a browser with nobody watching. A judge reads what an
305
+ input means. `Policy.reviewer()` asks the `LanguageModel` in context for a structured review, and
306
+ `Policy.decider` asks a `DecisionModel`, such as Jev through `@effect/ai-typesafe`. Both give a
307
+ `Judgement`: for each `Risk` (`financial`, `account`, `access`, `deletion`, `communication` and
308
+ `secret`), the probability that the input does it, and the probability that the user's task asks
309
+ for it. `Policy.escalate(first, second)` asks `second` only when `first` is unsure.
310
+ `Policy.make({ judge })` denies an input with a likely risk the task does not ask for, saying which
311
+ and why. `origins` keeps input to some origins, and `deny` refuses facts outright:
277
312
 
278
313
  ```ts
279
- import * as Plan from "effect-browser/plan";
280
-
281
- // page is an issued Page on the browser's active connection.
282
- const ran =
283
- yield *
284
- page.run(
285
- {
286
- version: 1,
287
- steps: [
288
- { id: "open", action: { _tag: "Navigate", url: "https://example.com" } },
289
- {
290
- id: "search",
291
- action: {
292
- _tag: "Fill",
293
- target: { _tag: "Descriptor", descriptor: { kind: "input", label: "Search" } },
294
- value: { _tag: "Input", name: "query" },
295
- },
296
- },
297
- {
298
- id: "quiet",
299
- action: { _tag: "Wait", mode: { _tag: "Settled", quietMillis: 100, withinMillis: 2000 } },
300
- },
301
- ],
302
- },
303
- { inputs: { query: "Effect" }, within: "20 seconds", style: "plain" },
304
- );
305
- const stored = yield * Plan.recorded(ran);
306
- const encoded = yield * Plan.encode(stored);
307
- ```
308
-
309
- Every step has its own admission and action accounting. Form fields, verification and submit reuse the existing exact-node form implementation; a walk never holds a permit across its steps. `within` captures one absolute run bound, and each step captures its action and queue deadlines once. Resolution, form phases, conditions and optional checkpoints cannot renew those bounds. `through: stepId` returns a successful prefix and the next step's index and id; it does not automatically resume or replay that prefix. Missing input bindings or an invalid `through` fail preparation before any input.
310
-
311
- Descriptors compare the full exact kind and label, optional destination and stable field metadata, checked ordinal/cardinality and a bounded main-anchored frame path. Scope normalizes to `document`; a recorded viewport descriptor uses document-wide uniqueness by default. Explicit `ViewportContext` resolution requires checked preconditions. Missing, ambiguous, incomplete or exhausted evidence refuses before input. Descriptor steps resolve private exact nodes within the step's admission without replacing the public observation. A live Ref always keeps its original identity and is never substituted.
312
-
313
- `yield* page.resolve(descriptor, { guard })` performs an explicit admitted read and returns a fresh ordinary Ref. It retires that Frame's previous public observation on success. A Page cannot mint a child Frame's Ref: acquire the checked child Frame and resolve there. A plan on a Page can route a main-anchored child descriptor privately to that exact frame without changing selection.
314
-
315
- `const operation = yield* page.start(plan, options)` returns a scoped handle before its worker is scheduled. Its `completed` Effect joins one result; repeated joins submit no new input. `operation.cancel` interrupts that worker. `operation.attempts` retains bounded host evidence after cancellation, scope exit or an outer race, including original causes and every native dispatch/acknowledgement phase. Run registrations are bounded to 32 per Page and 128 per session, with at most 1,024 phase rows per step. Evidence overflow prevents complete recording. `StepFailed` distinguishes preparation from an attempted step and retains the completed prefix, current attempt and original `BrowserError`. Its `message` names only the stage, step and reason; the retained steps can hold authored literal values, so log the message rather than the whole error. An acknowledgement survives later verification or postcondition failure; an unknown command stops the walk and keeps the original owner's containment outcome.
316
-
317
- `Plan.recorded(ran)` projects only completely captured successful intent. Literal fill/type values become deterministic named input slots; existing input names and source conditions remain unchanged. `Plan.inputSlots(plan)` lists every named input a plan reads, with its step, action path and whether it is typed `text` or a replacement `value`, so a host binds `inputs` to replay a recording without the literal ever entering the stored plan. Failed or incomplete whole forms and unknown attempts are not reusable plans. After a later failure, `Plan.recorded(yield* operation.attempts, { through: completedStepId })` can explicitly retain an acknowledged completed prefix. Capture is based on checked native facts, and missing or omitted identity facts produce `Incomplete` rather than executable descriptor intent.
318
-
319
- `Plan.recordedNavigation(operation, { id })` records a navigation started with `startNavigation`, such as the operation an agent-browser `Navigation` receipt holds, as a plan of one `Navigate` step named `id` (`"navigate"` by default). It joins `completed`, which stops nothing. Only a navigation that reached DOMContentLoaded is recorded; one that failed, was stopped or abandoned, or whose outcome is unknown fails `RecordingIncomplete` with `Unacknowledged`, as an unacknowledged run step does. The step holds `operation.request`, the address asked for, not the `NavigationResult.url` a redirect reached, so a replay repeats the request, as a recorded run's `Navigate` step does.
320
-
321
- `yield* page.settled({ quiet: "100 millis", within: "2 seconds" })` uses one owned wait in the pinned document. It reports quiet DOM mutation, scroll, root geometry and viewport signals. It does not establish network, descendant animation or business completion. Native timers work on hidden pages, but browser throttling can still cause an honest timeout. Cancellation retains wait capacity until native observer disposal is confirmed. A page-initiated navigation that replaces the observed document ends the wait `Stale` and confirms that disposal, so the next wait can start. Plain plans use the same browser inputs as ordinary operations; presentation and recording composition stay with the host.
322
-
323
- ### Replayable session evidence
324
-
325
- `session.timeline` publishes one bounded metadata journal for the active connection. An issued
326
- `page.timeline` filters that same journal for its original Page. Snapshot reads and independent
327
- subscribers acquire no browser permit, send no input and cannot stop native capture.
328
-
329
- ```ts
330
- import * as Timeline from "effect-browser/timeline";
331
-
332
- const history = yield * session.timeline.snapshot();
333
- const tail = session.timeline.events(history.resumeAfter);
334
- const appendTime = yield * session.timeline.now;
335
- const fromTime = session.timeline.events({ at: appendTime });
336
- const portable = yield * Timeline.encodeSnapshot(history);
337
- ```
314
+ import { Effect, Layer } from "effect";
315
+ import * as Chromium from "effect-browser/Chromium";
316
+ import * as Policy from "effect-browser/Policy";
338
317
 
339
- `events(cursor)` resumes exclusively after that sequence; `events({ at })` includes events
340
- appended at or after the domain-qualified time. `events()` follows from the current tail when
341
- the stream executes. Snapshots carry immutable events, global available bounds, eviction facts,
342
- terminal state and `resumeAfter`. A quiet Page's filtered snapshot still uses the global
343
- watermark, including when it has no matching events. Evicting every retained event does not
344
- reset that watermark. Requested evicted history and lapped subscribers fail `TimelineGap` with
345
- requested and available cursors. Foreign store/clock identities and future cursors fail
346
- `TimelineCursorError`; consumers choose how to recover rather than silently losing evidence.
347
- An event the journal refuses as malformed or oversized leaves a `MetadataOmitted` marker with
348
- its original target, correlation and tag, so a Page view sees its own gap. An omission whose
349
- attribution was itself refused is unattributed and appears in every Page view.
350
-
351
- `session.pages` is a lifecycle stream: it atomically attaches a copied Inventory baseline from
352
- the canonical bounded native registry and the journal watermark, then follows lifecycle events.
353
- Inventory metadata is explicitly cached: unread or omitted title/address fields are `null`.
354
- It grants no live Page authority. `session.listPages()` remains the admitted fresh native
355
- metadata read; its URL/title round trips are non-atomic. Restarting `session.pages` obtains a
356
- fresh baseline after a gap. `Timeline.projectPages` and `Timeline.encodePages` remove cached
357
- addresses, titles and native target IDs from the client projection. Native navigation and capture
358
- boundaries retain bounded host addresses observed at their original callbacks; `Timeline.project`
359
- and `Timeline.encodeClient` strip those nested fields too. `Timeline.encode` and `encodeSnapshot`
360
- encode host evidence and preserve those addresses.
361
-
362
- The session facade chooses the current journal when an Effect or Stream executes. Each active
363
- subscription stays on that journal. Reconnect creates a new store identity; old subscribers
364
- drain their available tail and terminate, or report a gap if lapped. A time selector earlier
365
- than the current journal's start fails `TimelineGap`: that history belongs to the previous
366
- journal, which an issued Page's timeline from that connection still reads. Issued Page timelines stay
367
- on their original domain. Page/session terminal delivery does not bypass retention, and
368
- `page.status` retains original containment facts after the corresponding events have evicted.
369
-
370
- Events include plan correlation, original native phases, genuine input intervals, navigation,
371
- settled/containment outcomes and capture references. A dispatch means handoff to checked native
372
- implementation; acknowledgement means native completion. A performed preparatory or burst reply
373
- carries `acknowledgement: { subphase, logicalComplete }`; only an acknowledgement without that
374
- fact, or with `logicalComplete: true`, completed logical input. Later follow-up failure remains
375
- separate. Key events contain counts and their units, never text. Individual pointer commands
376
- are `Pointer` events; `Glide` is reserved for an actual bounded intended schedule. Capture IDs,
377
- interval-local frame sequences and boundary attribution refer to the original capture; no
378
- frame bytes enter the journal. `FirstFrame` means first accepted received frame after a
379
- boundary, without proving new-document pixels. Picture native-call intervals remain separate
380
- from DOM/text intervals and screencast receipt times.
381
-
382
- Phase `nativeOrdinal` counts original dispatch phases within its `operationId`, rather than
383
- individual CDP commands. `DisplayChanged` carries the selection and qualified running, held or
384
- unknown display state from the same native callback.
385
-
386
- Terminal retirement waits for original logical ticket/run outcomes and bounded capture stop
387
- cleanup to publish their evidence. An unconfirmed native stop remains unconfirmed; retirement
388
- does not wait indefinitely for raw native promises. Later native callbacks stay pinned to the
389
- old journal and can be refused once it has retired. Capture `latePhase` qualifies a Watching or
390
- Started callback after the logical interval ended. Original capture snapshots and quarantine
391
- retain their independent native stop and loss facts.
392
-
393
- Append timestamps come from the owner's captured host Clock and order with global sequences.
394
- Receipt/source times are payload facts and never backdate append order. `Stamp.offsetNanos`
395
- is relative to that clock domain's origin; its `clockId` is required. Explicit `*Json` schemas
396
- and Timeline encoding helpers represent bigint offsets/sequences as decimal strings. Native
397
- presentation Unix milliseconds remain a separately qualified source clock.
398
-
399
- `timelineLimits` on Chromium/Browserbase options, or `automation.timelineLimits` on the runtime,
400
- bounds retained metadata and subscriptions. Defaults are 60 seconds, 4,096 events, 4 MiB,
401
- 32 subscribers and 64 KiB per event. Configurable maxima are six hours, 65,536 events, 64 MiB,
402
- 256 subscribers and 1 MiB per event. Metadata-byte and per-event limits have a 2 KiB minimum so
403
- canonical terminal and omission evidence fits. Count/time/byte eviction runs on append and read. Subscribers
404
- retain bounded cursor/wakeup state rather than event queues; a slow consumer cannot backpressure
405
- an admitted native action. Returned values are ordinary immutable snapshots, without an archive
406
- registry retaining old journals.
407
-
408
- ### Admission and deadlines
409
-
410
- Ordinary operations on one Page share its permit, including operations on its Frames. Work on
411
- other Pages can proceed independently: a held scout read or typing response does not block
412
- stage input, a stage checkpoint or another Page's capture. Creation, inventory and global
413
- lifecycle operations use the registry permit. Inventory is bounded and non-atomic. So does issuing:
414
- `session.page(info)` and `page.frame(info)` authenticate and register, so they never wait for, or
415
- fail `Busy` on, work already running on that page. `page.describe()`, `page.listFrames()` and
416
- `Capture.start` read the page and share its permit like other reads; pass `admission.queue` to
417
- wait behind that work.
418
-
419
- Every admitted operation accepts trailing host-only `OperationOptions`:
318
+ const browser = Layer.unwrap(
319
+ Effect.gen(function* () {
320
+ const judge = yield* Policy.reviewer();
420
321
 
421
- ```ts
422
- yield * scout.click({ selector: "#advance" }, { admission: { queue: "1 second" } });
423
- yield * stage.checkpoint({}, { timeoutMillis: 2000, admission: { queue: "500 millis" } });
322
+ return Chromium.layer({ guard: Policy.make({ judge, origins: ["https://shop.example"] }) });
323
+ }),
324
+ );
424
325
  ```
425
326
 
426
- Omitting `admission.queue`, or setting it to zero, refuses conflicting work immediately with
427
- `Busy/undispatched`. A positive finite Effect duration allows FIFO waiting for that permit.
428
- The queue defaults to 32 pending callers per Page or registry and 128 across the session.
429
- `automation.admissionLimits.pendingPerPage` and `pendingPerSession` can set either bound to an
430
- integer from 1 through 1,024. `Chromium.layer` and `BrowserbaseBrowser.layer` accept those fields
431
- under their top-level `admissionLimits` option. `QueueFull` reports the refusing scope, maximum and observed count;
432
- `QueueExpired` means the admission wait expired. Cancellation and either refusal dispatch
433
- nothing and consume no action allowance. A granted waiter owns the permit before waking, so a
434
- new arrival cannot overtake it.
435
-
436
- The operation deadline and queue deadline start when the Effect executes. Waiting counts toward
437
- `timeoutMillis`, the policy deadline and the browser lifetime; admission never renews them.
438
- The queue deadline bounds only waiting, so admitted work can finish after that deadline. An
439
- operation deadline that ends first reports `Timeout`. These options do not queue away a stale
440
- target, a Page hold, a navigation reservation or a native-wait conflict. The issued Page/frame
441
- authority remains fixed while waiting; display selection cannot redirect queued input.
442
- `Capture.start` and `Capture.stream` accept admission through their capture options. Exact-node
443
- methods keep the host's `ElementAdmission` callback separate from trailing operation options.
444
-
445
- `page.status` includes passive `admission` state: the active operation, pending count and bound,
446
- oldest wait age, retained native operations, native wait and stop setup. `session.admission` adds
447
- aggregate counts and registry state. Native work that dispatched input keeps its Page occupied
448
- after caller cancellation until the actual work settles or exact positive Page/connection
449
- retirement proves it unusable. A read, or a mutation cancelled before dispatch, can no longer
450
- change the page: it stays counted in `nativePending` but leaves the Page usable at once.
451
- These host snapshots grant no priority and expose no native handles.
452
-
453
- Navigation stopping and Page closure have reserved, bounded cleanup admission, so a full ordinary
454
- queue cannot prevent recovery. Page closure uses fail-fast reserved admission and its operation
455
- deadline when joining an existing close; `admission.queue` does not control those waits. Session closure
456
- preempts work. Handoff, resume and reconnect refuse
457
- an existing ordinary permit holder before installing their exclusive lifecycle barrier; handoff
458
- then drains retained native work that can still change a page within its bound before granting
459
- operator control.
460
-
461
- ### Exact Pages and Frames
462
-
463
- The session owns lifecycle, display selection and the page registry. `initialPage` is the Page
464
- issued on the first connection; it remains that original lifetime fact after reconnect. Browser
465
- input and reads belong to issued Pages and Frames. Constructing or running an operation on one
466
- never follows display selection:
327
+ The task comes from `Policy.Task`, which `effect-agent-browser`'s tools provide to their inputs
328
+ from the run's `task`; elsewhere, provide it yourself, or risky input is denied for want of one. A judge sees the task, the action, the typed
329
+ text and the facts as trusted, and the page's text only as evidence. It never sees the agent's own
330
+ words. A judgement only adds to structure: a `secret` fact counts whatever the judge reads. A judge
331
+ that fails or exceeds `timeout` (30 seconds) leaves the input unjudged. `make` then denies the input
332
+ if it has any fact, with the judge's failure as the `PolicyDenied` cause, and lets an input without
333
+ facts, such as a same-origin link, go ahead. Through `@effect/ai-openrouter` 4.0.0, structured output needs
334
+ `strictJsonSchema: true` in the model's config, or OpenRouter drops the response format and every
335
+ review fails to decode.
336
+
337
+ Holds use `policyTimeout`, a finite positive duration defaulting to five minutes, separately from
338
+ the action timeout. They do not keep the page locked. After approval, the library verifies the same
339
+ document, target and relevant facts before sending input: a control's name is bound but other page
340
+ text, such as a live price, is not; the URL is bound without a fragment that only marks a place on
341
+ the page, as scroll-spy and feed pages rewrite while scrolling (a `#/` or `#!` hash route stays
342
+ bound). Changed targets fail undispatched; the library never retries the action or the policy
343
+ automatically. A pointer press is checked again once the pointer has arrived and the page has had a
344
+ frame to react: the approved control must still receive the press point, so a control that appears
345
+ under the pointer, such as a hover menu, stops the action before the button goes down. A link,
346
+ button or other control nested inside the target between it and the press point stops it too, since
347
+ the approval inspected the target, not that control. Each
348
+ further press of a double or triple click is checked the same way after the earlier clicks' handlers
349
+ have run. Typing approves its field once. Plain text then goes into it in one insertion, which no
350
+ key handler can split, so the page sees one `input` event and no keys, and a paste's time does not
351
+ grow with its length: 2,000 characters took half a second at a 70 ms round trip. Typing key by key,
352
+ as a presenter's view does, checks that the approved control still has focus once the keys before
353
+ are answered, before each space, which could press a focused button, and after its last key, so a
354
+ key handler that moves focus stops the typing before its next space; each check is about two
355
+ protocol round trips, and the typing deadline allows 250 ms for it. A policy timeout is a typed `PolicyTimeout`, and tools surface both timeout and denial as ordinary failed
356
+ receipts. Without a guard, actions are allowed and nothing is revalidated. Canvas and opaque frames
357
+ expose their outer element's metadata.
358
+
359
+ A presenter's view reaches an off-screen ref target with visible wheel input before the pointer
360
+ moves to it. Scroll attempts are bounded and may use one instant fallback. A denied or held action
361
+ does not scroll. After scrolling, a guarded action checks the original target again; a page handler
362
+ that changes its meaning can therefore stop it after its wheel input but before a click, and the
363
+ failure is undispatched: travel toward a press is not the action's input. Drag endpoints are
364
+ resolved together in the final viewport, and checked under the pointer, before the button is
365
+ pressed.
366
+
367
+ A view's pointer follows the planner its presenter was given, `Motion.lognormal` unless another.
368
+ That tuned two-stroke sigma-lognormal model is evaluated every 16.7 ms, but a move goes only when
369
+ the pointer reaches a new pixel; the exact destination lands at the model's end time, so only that
370
+ final move can repeat the position before it. A custom `plan(from, to)` returns a complete
371
+ schedule with finite coordinates and nondecreasing absolute `afterMillis` offsets, at most 2,048
372
+ samples and 5,000 milliseconds, ending at the exact destination. The browser decodes it with
373
+ `Motion.Plan` into its own copy, then checks the endpoint; invalid plans fail with
374
+ `InvalidRequest` before their track or input is sent. Equal-time samples are retained. Plain input
375
+ never plans a glide: its pointer jumps, or moves in eight short steps while dragging.
376
+
377
+ For recorded human strokes, install the optional `effect-browser-human-strokes` package and give
378
+ its planner to the presenter:
467
379
 
468
380
  ```ts
469
- const stage = session.initialPage;
470
- const scout = yield * session.createPage();
471
- const navigateStage = stage.navigate({ url: stageUrl });
381
+ import * as HumanStrokes from "effect-browser-human-strokes";
382
+ import * as Presentation from "effect-browser/Presentation";
472
383
 
473
- yield * session.selectPage(scout);
474
- yield * navigateStage; // still navigates the original stage
475
- const seen = yield * scout.observe({ scope: "viewport" });
384
+ const presenter = yield * Presentation.make({ motion: yield * HumanStrokes.motion });
476
385
  ```
477
386
 
478
- `session.page(pageInfo)` checks both local and native identity under owner admission.
479
- `createPage()` returns the Page issued for the new page without selecting it. `selectPage(page)`
480
- takes a Page this session issued and chooses the displayed page, which is also the page `detach`
481
- records and `reconnect` resumes on; it never retargets a Page or Frame operation, and a closed or
482
- stale Page is refused unsent. `Browser.checkPage(session, page)` checks that a Page or Frame
483
- is one this session issued, by identity, and that its authority is still current; adapters call it
484
- once when they bind, so a foreign, copied or retired target fails before any work. A child frame
485
- is reached through `page.frame(frameInfo)`, never by selection. Use `page.describe()`, `page.listFrames()`,
486
- `page.frame(frameInfo)`, `page.resizeViewport(viewport)` and `page.close()` for exact page work.
487
- The selected-session actions, `retain`, `pinPage`, `pinFrame` and their target-view types are
488
- removed. The shared operation contract is `PageOperations`.
489
-
490
- | Previous API | Current API |
491
- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------ |
492
- | Session actions, `observe`, `checkpoint`, `ready`, `target` | Issued `Page`/`Frame` methods and immutable `identity` |
493
- | `retain`, `pinPage`, `pinFrame`, `selectFrame` | `initialPage`, checked `session.page(info)` and `page.frame(info)` |
494
- | Session `describePage`, `framesOf`, `closePage`, `resizeViewport` | `page.describe`, `page.listFrames`, `page.close`, `page.resizeViewport` |
495
- | Session capture and PageInfo capture target | `Capture.start(page)` / `Capture.stream(page)` |
496
- | Session PageControl adapters | `PageControl.state(page)`, `suspend(page)`, `resume(page, receipt)` |
497
- | `createPage()` returning `PageInfo` | `createPage()` returns the issued `Page`; `page.describe()` reads its metadata |
498
- | `selectPage(pageInfo)` | `selectPage(page)` with an issued `Page` |
499
-
500
- Failed metadata acquisition after creation keeps its original dispatch evidence and never
501
- creates a second page. Page operations preserve typed failures and scoped ownership; a terminal
502
- Page's retained status and timeline remain readable, while new work refuses undispatched.
503
-
504
- Each page `createPage` opens is a window of its own, not a tab. Chromium paints only the front
505
- tab of a window, so a screenshot of a tab behind another waits seconds for a compositor frame, or
506
- never gets one, and the page that was in front goes behind each new tab. A window of its own is
507
- painted whether or not another page is in front, so any page can be pictured and read at speed
508
- and the selected page keeps its capture. With a fixed viewport, each new window's contents are
509
- set to it, as the first page's are; a provider-managed or preserved viewport is left to the
510
- browser, which sizes a new window like the last one it showed. A browser that answers the
511
- request for a window with a protocol error, which means it created nothing, gets tabs from then
512
- on. `createPage()` serializes creation and adoption under the registry permit and fails immediately
513
- when it is occupied. Pass `{ admission: { queue: "1 second" } }` to wait FIFO within the same
514
- operation deadline. It does not wait behind another Page's ordinary operation. A refused or
515
- expired caller opens nothing.
516
-
517
- Inventory authenticates each native Page before adopting it into the owner's registry,
518
- including popups. `page.describe()` reads its current address and title without reading every
519
- other page. `session.listPages()` reads every page's title concurrently under bounded admission.
520
- Metadata does not itself grant live input authority.
387
+ That package bundles 32,130 attributed CC BY 4.0 strokes, preserving their original sample times.
388
+ The core package includes no stroke data. Every glide reserves its full bounded schedule before
389
+ its published clock starts, so delayed replies cannot stretch a dense stroke through backpressure.
390
+ At most 2,112 input commands and reservations are owned at once; ordinary input retains its
391
+ 64-command admission limit. Actions still await their replies before succeeding, and interruption
392
+ stops the unsent suffix and releases held input.
393
+
394
+ Typing sends key pairs for printable US characters, plainly and in a view; other text uses
395
+ Unicode insertion. A typed space or letter can press a focused button, toggle a box, follow a
396
+ link or change a select, so `type` refuses before any input when its `into` ref is not a text field
397
+ or, without `into`, when focus is on such a control; `press` sends keys to those. With `secret`,
398
+ `type` also refuses a field the page does not mark secret, so a password goes only where the page
399
+ hides it, as a replayed password does. A view types at its pacing's words a minute, 70 by
400
+ default, including slower word starts, with key holds around 110 ms that can overlap. The ordered schedule releases a repeated
401
+ physical key before pressing it again. Keys follow that schedule without waiting for each network
402
+ reply. Pending replies are bounded and drained before an action succeeds; interruption stops new
403
+ input and releases every submitted held key. Shortcut chords retain Playwright’s platform-specific
404
+ editing behavior. Before each key, typing and repeated presses check that the page is still in the
405
+ document the action began in, and stop with a dispatched `NotActionable` once it has moved on. The
406
+ browser reports a new document as it commits, so a key sent within about one protocol round trip of
407
+ that commit can still reach it. Under a guard, typing's submit Enter and each repeated Enter or
408
+ Space are first checked against the approved element.
409
+
410
+ After a click, a key, a submit or a scroll, the page settles before the action returns: one call
411
+ waits a task and a frame in the page, and Chromium answers it only once a navigation the input
412
+ asked for, by a link, a form or a handler's timer, has committed. A document that committed is then
413
+ waited for until it is parsed, within 5 seconds. `pushState` and a 204 answer wait for nothing. A
414
+ navigation a handler starts once a fetch answers comes too late for any wait; the next look sees
415
+ it, and a picture never shows a document the page has left. A tab the input opens is discovered and
416
+ registered before the action returns, within its action timeout. An unresolved opening fails with
417
+ dispatched input, and closing the page or losing its browser wakes the wait with `Closed`.
418
+
419
+ ## Presenting pages
420
+
421
+ `Presentation` performs input for viewers of a page, and `Stage` decides which page they see.
521
422
 
522
423
  ```ts
523
- const stageInfo = (yield * session.listPages()).find((info) => info.title === "Stage")!;
524
- const stage = yield * session.page(stageInfo);
525
- const childInfo = (yield * stage.listFrames()).find((info) => info.parentFrameId !== null)!;
526
- const child = yield * stage.frame(childInfo);
527
-
528
- yield * session.selectPage(scout);
529
- yield * stage.click({ selector: "#advance" });
530
- const childText = yield * child.readText({ selector: "#status" });
531
- ```
424
+ import { Effect, Stream } from "effect";
425
+ import { Browser } from "effect-browser/Browser";
426
+ import type { Frame } from "effect-browser/Frame";
427
+ import * as Presentation from "effect-browser/Presentation";
428
+ import * as Stage from "effect-browser/Stage";
532
429
 
533
- Issuance changes no display selection and opens no second connection. Each operation keeps its
534
- exact connection-local page/frame authority. Closing its Page, detaching its Frame or reconnecting
535
- makes it stale. Navigation preserves the Page/Frame authority and retires that document's node
536
- references. Holds and in-flight navigation reservations are checked on the issued target.
537
-
538
- IDs are opaque and namespaced per connection. After reconnect, read fresh `listPages()`, match
539
- exactly one saved native `targetId`, issue `session.page(freshInfo)`, and reacquire its Frames with
540
- `page.listFrames()` / `page.frame(info)`. Zero matches means gone and multiple matches mean
541
- ambiguous. Never fall back to ordering, local serial, URL or title. A surviving native target
542
- cannot make an old Page or copied metadata current again.
543
-
544
- Dispatched input on the observation's own page still retires its references, including `hover`,
545
- `pointerMove`, `wheel` and `scroll`. Hover then click therefore deliberately needs a new inspection.
546
- Page scripts can replace nodes or change relevant state in response to any input; no verb is
547
- assumed harmless. Navigation or closure on that page and reconnect also retire its observation,
548
- even while another page is selected. IDs are opaque and connection-specific; consume the actual
549
- inspection result rather than predicting serials. Attachment, identity and fresh-state checks at
550
- dispatch remain necessary even when the last input targeted a different page.
551
-
552
- ### Performed plans and absolute starts
553
-
554
- `page.run(plan, { style: {} })` uses the named default performed profile; omitted style or
555
- `style: "plain"` keeps plain input. `Plan.checkRunOptions(value)` checks and normalizes host
556
- options, preserving public `within`, queue admission and performed defaults. Profiles have finite
557
- pointer duration/inset/curvature, key interval/hold and scroll duration/interval bounds. `seed` is
558
- an optional safe integer; the actual chosen seed is retained in run and attempt receipts.
559
- Step-local planning depends on that seed and stable step ID, independently of other Pages or
560
- ambient Random use. It reproduces planned paths for the same geometry, not website behavior or
561
- remote delivery timing. Slips default to probability zero and require explicit opt-in.
562
-
563
- The pointer timing uses a chosen bounded Shannon-form policy for measured targets, and explicit
564
- coordinates use a chosen bounded distance policy. The fifth-order progress polynomial gives zero
565
- endpoint velocity/acceleration; applying it to a curved policy path does not claim globally minimum
566
- Cartesian jerk. Constants are library policy, not calibrated human guarantees. Scheduled glides are
567
- output-only compositor metadata: they do not generate hover along the drawn path. Exact native hit
568
- checks and selected node identity remain required immediately before real input. A missing actual
569
- click position stays null even when an intended aim is retained.
570
-
571
- A schedule has at most 128 geometry samples per path or scroll, and performed text at most 256
572
- Unicode code points, which expand to at most 768 strokes (a slip adds a wrong key and its
573
- Backspace), independently of action and pending-native capacity. Longer text fails `Limit` with
574
- dimension `code-points` and its measured count before any input. Performed Fill
575
- may focus/select/replace inside its action; Type still requires existing focus. Each performed
576
- logical action costs the same action budget as its plain counterpart. FillForm retains its
577
- per-field admission, verification, partial results and guarded submit.
578
-
579
- `startAt` is an absolute bigint on this owner's host monotonic clock, available through
580
- `session.monotonicTimeNanos` and, for a host that holds only an issued Page or Frame, the same
581
- clock's `page.monotonicTimeNanos`. Future starts wait before admission, reserve no Page permit and
582
- never begin before that instant on the owner's clock, so `latenessNanos` is never negative.
583
- A waiting start holds only its run registration, never the timeline: when its Page closes or its
584
- owner pauses, detaches or ends, the run fails preparation at once instead of at its start.
585
- `within` starts at the intended boundary and bounds queueing, preparation, pacing, input and
586
- postconditions together, capped by owner lifetime and each action deadline. Missed preparation
587
- reports `ScheduleMissed` without inventing an attempt; an insufficient pacing budget reports
588
- `TimingBudgetExceeded` before new input. Performed strokes start at their absolute schedule
589
- offsets, so a slow reply delays one stroke rather than every later one. Before each stroke's
590
- first key, the rest of the schedule must still fit the deadline at the round trips the browser
591
- has actually taken; a stroke that could not finish is refused whole, and strokes already
592
- acknowledged stay `performed`. A performed click is held to the same rule. Playwright's click
593
- checks the node again before its first input event, and a deadline inside those checks would
594
- report `unknown` for input never sent, so a click the deadline cannot fit at the press's
595
- measured round trip is refused `TimingBudgetExceeded`, undispatched, and its Page stays open.
596
- Receipts retain requested/intended/actual start, deadline
597
- and lateness. Cancellation stops future submissions and keeps readable attempt history with the
598
- original Effect Cause. Known preparatory work followed by refusal is rejected with its subphase
599
- receipts; pending input remains unknown, and acknowledged logical input stays performed when a
600
- later check fails.
601
-
602
- ### An unknown outcome on an exact Page
603
-
604
- A mutation whose outcome becomes unknown after dispatch, because it timed out, failed or was
605
- interrupted, never runs again automatically. When the owner can attribute it to an exact Page,
606
- it revokes that Page's admission and asks the browser to close it within two seconds. This
607
- includes issued Page/Frame operations, forms, file transfers,
608
- navigation, initialization and callback retirement. Concurrent close requests join the same
609
- owned closure attempt. Once the browser confirms closure, healthy Pages, their observations and
610
- captures can continue. The original operation still fails with its own reason and `unknown`
611
- outcome, carrying `PageClosed` containment; its Page and Frame authority remains terminal.
612
- `session.diagnostics` records `page-contained`.
613
-
614
- Closing the page does not undo what the operation already did. A request it made, a cookie or
615
- storage it changed, a download or a popup it started can all outlast the page, exactly as they
616
- can after a known outcome. Unconfirmed closure or a failure without trustworthy Page attribution
617
- fences the session conservatively and reports `SessionFenced`. A late response never reopens
618
- fenced control. Containment reports owner safety independently of the original action outcome.
619
-
620
- The complete [multi-page example](examples/multi-page.ts) keeps an issued presentation Page while
621
- the selected scout supplies observations. It reads and captures the presentation page without
622
- switching selection and returns only data after the shared browser scope closes.
623
-
624
- ### Typed host failures
625
-
626
- `BrowserError` keeps its operation and a required `outcome` (`undispatched`, `rejected`, `performed`, or
627
- `unknown`), while `reason` is a tagged union. Recover by reason without parsing strings:
430
+ declare const encode: (frame: Frame) => Effect.Effect<void>; // the application's own output
628
431
 
629
- ```ts
630
- const read = session.initialPage
631
- .readText({})
632
- .pipe(Effect.catchReason("BrowserError", "Busy", () => Effect.succeed({ text: "" })));
432
+ const program = Effect.gen(function* () {
433
+ const stage = yield* Stage.make({ quality: 80 });
434
+ const presenter = yield* Presentation.make();
435
+ const page = yield* (yield* Browser).newPage("https://example.com");
436
+ yield* stage.frames.pipe(Stream.runForEach(encode), Effect.forkScoped);
437
+ const shown = yield* stage.present(page); // Presented { page, at, latency }
438
+ yield* presenter.view(page).click("e12"); // glides, then clicks, as a person would
439
+ });
633
440
  ```
634
441
 
635
- `Limit` includes measured `dimension`, `maximum` and `observed` fields. For example, an action
636
- allowance exhausted at two reports `maximum: 2, observed: 2`; it does not count a third call that
637
- was never admitted. `Configuration` and `Malformed` may name a declared schema-field prefix,
638
- never a supplied value or unknown property name. `Provider`/`Transport` may carry `status` and
639
- `RateLimited` may carry `retryAfterMillis`; those fields no longer sit on the outer error. A
640
- `Provider` failure the native engine raised carries `detail`: the first line of what the engine
641
- said, such as `elementHandle.click: Target page, context or browser has been closed`, without
642
- terminal escape codes and at most 512 characters, with every address cut to its origin
643
- (`https://shop.test/…`) and Playwright's call log dropped, since a path, query or DevTools endpoint
644
- can carry a credential. It is a host diagnostic, and the agent tools never project it.
645
- `InitializationError` and the provider's separate resource-error families retain their own
646
- contracts. The Agent tools project host errors to their compact model-facing vocabulary and
647
- retain the originals in the bounded, host-only `ToolHost.toolFailures` snapshot.
648
-
649
- ### A navigation you can watch while it loads
650
-
651
- `navigate` holds nothing open that you can see into: it returns when the document reaches DOMContentLoaded. `startNavigation` is the same single dispatch, left in flight, so a recorder can look at a page while it is still arriving, hold it, and let it finish:
652
-
653
- Both accept optional `timeoutMillis` from 1 to 600000, capped by the remaining session lifetime.
654
- Omitting it uses the configured action timeout. Page and Frame calls share the
655
- same navigator; the maintained model `browser_navigate` tool still accepts only its upstream URL
656
- request. This field is the loading deadline, not a promise of rollback on timeout. On a main-frame
657
- timeout from the pinned engine, up to 3000 ms of recovery may follow that deadline. One absolute
658
- recovery deadline includes owner-permit waiting, native setup and acknowledgement, and is capped
659
- by the remaining session lifetime. It is never renewed and cannot extend that lifetime.
442
+ A presenter owns one drawn pointer. `presenter.view(page)` is the page with its actions performed:
443
+ each waits first as a person reacts, by `Presentation.human`'s medians, 280 ms after an expected
444
+ change, 600 ms after a new document and 1 second on another page; the pointer glides from where
445
+ viewers last saw it, on whichever page; a field is clicked before typing; and the wheel turns in
446
+ 100 px notches, in bursts of up to nine. One view acts at a time. The time a view spends showing an
447
+ action, its glides, holds and typing, has a budget of its own outside `actionTimeout`. `page`
448
+ itself stays plain, and plain input on any page never waits for a view. `view.aim(target)` starts
449
+ the glide as soon as a target is known, as when it appears in a model's streamed tool call; the
450
+ view's next action completes it if it acts on that target, and stops it where it is otherwise. An
451
+ aim records no action, and does nothing under an input guard.
452
+
453
+ A stage is one per live output. `stage.cue(page)` starts capture now; `cue.ready` waits for a
454
+ current frame, and `cue.commit({ at })` switches on the host time of an event that actually
455
+ happened. Preparing belongs to the caller's scope; once committed, capture belongs to the stage.
456
+ Closing an abandoned cue stops its capture. `present(page, { at })` is the convenience for cueing
457
+ and committing in one call.
660
458
 
661
459
  ```ts
662
- const operation =
663
- yield * handle.startNavigation(StartNavigationRequest.make({ url, timeoutMillis: 30_000 }));
664
-
665
- const early = yield * page.checkpoint({ picture: true }); // what has loaded so far
666
- const receipt = yield * PageControl.suspend(page); // timers, CSS and parsing stop
667
- yield * PageControl.resume(page, receipt);
668
- const { url: loaded } = yield * operation.completed;
460
+ // Inside a scoped program: lineSent yields Some(hostTime) when the line goes out, or None if dropped.
461
+ const cue = yield * stage.cue(page);
462
+ yield * cue.ready;
463
+ const sent = yield * lineSent;
464
+ if (Option.isSome(sent)) yield * cue.commit({ at: sent.value });
669
465
  ```
670
466
 
671
- The owner's permit is released as soon as the navigation is dispatched. While it loads, reads, checkpoints, holds and every other page proceed, and anything that would change _this_ page fails `busy` and `undispatched`. `navigate` runs on the same machinery, so there is one navigator.
672
-
673
- - `request` is what was asked for, checked before dispatch. `Plan.recordedNavigation` records it once `completed` succeeds.
674
- - `completed` belongs to that one navigation: a successor reaching the same URL fails it instead of completing it, and its URL is the page that navigated, not whichever page is selected by then. **Interrupting a waiter stops nothing.** The browser keeps loading and nothing is dispatched again.
675
- - `stop` asks the browser to stop loading. Its acknowledgement is a known outcome: `completed` then fails `interrupted`, the page holds whatever had loaded, and the session stays usable. It does not undo anything the page already did. Concurrent callers share their active stop attempt. A busy refusal or cancellation before dispatch allows a later request only after native setup and its port retire; pending or unconfirmed retirement keeps setup capacity occupied. Once a stop is dispatched, every later caller receives that attempt's recorded result, including failure or interruption, without sending it again. A completed navigation cannot stop its successor.
676
- - A main-frame loading timeout asks the same stop coordinator to retire that exact navigation. Acknowledged stop completes it with `Timeout/unknown` and leaves the owner open: partial content remains inspectable and deliberate subsequent input is allowed. An explicit stop racing recovery shares its attempt; a completed predecessor cannot stop a successor. The `unknown` outcome still says nothing about page effects before cancellation.
677
- - A failed or unacknowledged recovery, replacement navigation, detached frame or other unknown native outcome closes that exact Page, with a session fence if closure is unconfirmed. Pinned child-frame timeout also uses Page containment: `Page.stopLoading` acts on the whole page, so automatic frame-local cancellation is not claimed. Explicit stop retains its page-wide meaning.
678
- - Leaving the operation's scope unsettled retains an unknown outcome and closes its Page; healthy Pages survive confirmed closure. Neither navigation nor a dispatched stop is replayed, and no timeout or acknowledgement promises rollback, an unchanged DOM, or termination of every page timer or worker.
679
-
680
- A read can inspect a loading Page. If a native read fails because its document was replaced or navigation is in flight, the error is `target-changed` and `undispatched`, which permits another read. A read is never a mutation. A held page is not read at all; take the checkpoint before the hold and keep it.
681
-
682
- Locally, a hold during an incremental response stops parsing as well as timers: a chunk the server sends meanwhile is not parsed until resume. That is an observation of the pinned Chromium, not a guarantee about hosted sessions.
683
-
684
- ### What is on screen, and what a host may know about it
685
-
686
- `page.observe()` reads the whole document. `page.observe({ scope: "viewport" })` keeps only text and controls that are on screen and reachable, and says what it left out:
467
+ Commits serialize in call order. A switch waits until `at` and for admitted old-page input and
468
+ its native replies, keeping new old-page input queued through the cut. Reads and capture continue.
469
+ The consumer can stop performance between `Plan.take` steps for a clean cut. Interruption before
470
+ the switch changes nothing and leaves the cue usable. A committed switch keeps ownership through
471
+ outgoing capture cleanup, even if its caller is interrupted then.
472
+
473
+ `Presented.at` records when the switch took effect, no earlier than the event, the incoming frame
474
+ or the last frame shown; `latency` is its delay after the requested `at`. `firstFrame` bounds a
475
+ wait for current paint (5 seconds by default). Old-page input admission, drain and the final frame
476
+ wait are also bounded by that browser's `navigationTimeout`. Failure before the cut leaves the
477
+ old output in place. An old document's frame cannot satisfy a new document's readiness; paint-time
478
+ uncertainty must place it after the document commit. A cue joining an existing quiet capture can
479
+ use that capture's current frame without waiting for invented new paint.
480
+
481
+ `stage.shown({ hold: "1 second" })` describes the output without inventing new frames:
482
+
483
+ - `Painted` carries a frame when it is published to the output.
484
+ - `Still` follows a quiet interval only after a fresh page/capture round trip, with no pending
485
+ capture acknowledgement, the same active capture and current document, and no newer frame.
486
+ - `Stalled` says silence could not be confirmed. A timeout is uncertainty, not stillness.
487
+ - `Gone` says the page or browser is lost. It is emitted once for that feed, with nothing more
488
+ until another page is committed.
489
+
490
+ `hold` must be finite and positive. Continuous paint restarts the silence interval. A failed
491
+ capture can restart while its page/browser still stands, but old paint cannot pass for a new
492
+ capture's first frame. These guarantees depend on Chromium reporting its paint; they do not
493
+ prove that every canvas update was delivered. `frames` remains the paint-only stream with bounded
494
+ reader queues, so subscribe before presenting to receive the first image. The consumer owns
495
+ encoding, redaction, slates and any repetition of a confirmed held image.
496
+
497
+ Local Chromium verifies preparation, ownership, input barriers and evidence. Earlier hosted
498
+ capture measurements do not establish these cued-presentation contracts: the H1 overlap/load
499
+ probe remains unrun until included Browserbase allowance is verified.
500
+
501
+ `Browser.now`, event stamps, frame `receivedAt` and a window's bounds share host monotonic milliseconds
502
+ from the clock captured when the browser is made. They remain ordered across wall-clock corrections.
503
+ Page operations also pace input and measure their deadlines on that clock, so a caller running
504
+ under another `Clock`, such as a `TestClock`, cannot stall an action or a page's turns.
505
+ Compare these stamps only within that clock: they are not epoch dates or comparable across hosts.
506
+ `Frame.timing` distinguishes `BrowserPaint` from `Screenshot`. Native frames retain browser epoch
507
+ milliseconds in `timestamp` and map them to `hostTime`, with an explicit clock uncertainty.
508
+ Screenshot fallbacks have only a host capture interval; their `timestamp` getter is undefined.
509
+ Windows and a moment's frame captions use `hostTime`, so delayed delivery cannot make old paint current.
510
+ A picture states how old it may be. `Page.frame({ maxAge, after })` serves the newest screencast
511
+ frame when it has the viewport's size and was painted at most `maxAge` ago, at the earliest its
512
+ timing allows: 250 ms by default, while 0 always takes a new screenshot. With `after: "input"` the
513
+ frame must also follow the page's latest submitted input, including input of an interrupted action,
514
+ and the read waits for the action in flight: what a caller that has just acted needs.
515
+ `Page.screenshot` always applies that rule, so a caller that acts and then looks sees what its
516
+ action did; a deck or a narrator that wants a picture of a stated age reads `frame`, which a frame
517
+ that qualifies serves at once, even while an action runs. A screencast sends only changes and can
518
+ miss a final paint, so a frame is only ever as current as its age. No read reuses a frame painted
519
+ before the page's current document began, when the page's own session saw its main frame commit
520
+ it: a page that keeps painting while the next document loads would otherwise leave its own frames
521
+ the newest when the navigation returns. Otherwise a new screenshot is
522
+ taken, which `frame` returns as a `Screenshot`-timed frame. A screenshot, which the agent's
523
+ observations take, and a `Moment`'s picture use `after: "input"`, so a stopped capture, a lost
524
+ final paint or later input never presents older paint as the page an action left.
525
+
526
+ A new picture goes on the page's own protocol session. Where a device pixel is a CSS pixel and
527
+ nothing is cropped, it is one `Page.captureScreenshot`; a crop, or another device pixel ratio, adds
528
+ the page's layout metrics for Playwright's clip formula. Where Playwright knows no viewport, as in
529
+ the default context over CDP, the page's first picture also learns it. Chromium takes a clipped
530
+ picture through the device emulation of the session that asks for it, then restores the view's size
531
+ and what that session emulated, so a page's pictures go one at a time. Where Playwright emulates the
532
+ viewport, as `Chromium.layer` does, a crop or a scaled picture is Playwright's own screenshot, on its
533
+ own session, which sends Playwright's 335 KB injected script with a document's first one, as it
534
+ would over CDP once a caller gives Playwright a viewport. Elsewhere, as on Browserbase, whose own
535
+ session emulates the screen, the page's own session holds a copy of the screen the page reads from
536
+ the page's first clipped picture on, at two calls once, so a crop leaves the screen as it was, and
537
+ `device-width` media queries with it. The copy is the screen as the page read it then, and every
538
+ later picture restores it, so a session that changes the screen afterwards sees its change undone.
539
+ When the library's session leaves the page, as when a kept session's process ends, Chromium clears
540
+ the emulation, as a crop used to.
541
+
542
+ Reads go to the page script in an isolated world. The page's own session registers the script at the
543
+ library's first read of the page, so every later document runs it from its start: a document's
544
+ first read takes two round trips and later reads one. Tabs the library only tracks are left alone,
545
+ and the clock probe runs in a world of its own.
546
+ Every operation is recorded as an `Action`, also when its caller interrupts it. An element or point
547
+ action records its `subject`, and a drag where it ended (`to`): the role, accessible name, tag and
548
+ context of what it found, read when its target was resolved, before the pointer moved or any input
549
+ was sent, so a click that removes its element still names it: the same subject `Page.find` gives. A ref is
550
+ reused by later outlines, so read `subject` rather than resolving `target` against a later snapshot.
551
+
552
+ Every renderer reads the same host wall clock, so the browser keeps one clock mapping for all of its
553
+ pages. Opening a browser measures nothing: the browser's first capture measures the mapping, with
554
+ three read-only probes in a world of their own, and waits for that one estimate. A busy page answers
555
+ each probe late, behind its own work, so the probes it answered within two seconds serve: the
556
+ estimate is as uncertain as the page made the wait, and the capture fails, undispatched, only if
557
+ the page answered none. A later capture starts with the estimate there is. While frames flow, a
558
+ capture measures again once the estimate is ten seconds old, and every frame is timed by the
559
+ browser's newest estimate, so a wide first estimate narrows once a probe finds the page idle, and
560
+ hours on air leave frames as certain as a fresh capture's. An estimate says less about the offset
561
+ as it ages, by up to 100 parts per million of its age, and a frame carries its estimate's
562
+ uncertainty at its paint. A new measurement
563
+ replaces the estimate only if it says more about the offset than the aged one, or if its interval
564
+ cannot contain the current offset (the clocks moved); a probe slowed by one busy tab cannot skew
565
+ every tab's stamps. Transport asymmetry remains in the reported uncertainty.
566
+
567
+ Once a capture has mapped the clock, mouse input and raw text-key events carry the epoch time each
568
+ was meant for; until then Chromium stamps them as it receives them, and input never waits for a
569
+ measurement. Shortcut chords retain Playwright's platform behavior, and Unicode insertion has no
570
+ timestamp field.
571
+
572
+ `Page.captureStats()` counts, over the page's life or with `{ window }` over up to the latest
573
+ minute: received and accepted frames, missing timestamps, `late` frames (Chromium encodes several at
574
+ once and finished these after a newer one; they are dropped, never reordered, and a busy machine
575
+ makes more of them), frames dropped for their size (`foreignSize`) or for coming during one of the
576
+ library's own clipped pictures (`duringPictures`), frames a reader `lost` by falling behind, and
577
+ paint-time gap totals/minimum/maximum/last. `ackBacklog` is the acknowledgements still unanswered:
578
+ Chromium sends a frame only while few are, so a backlog over a slow connection means fewer frames.
579
+ Chromium draws a clipped or scaled screenshot of the page, taken
580
+ from any session, into its running screencast. Readers never receive a frame whose picture has
581
+ another shape than its device. Frames of another device size wait, in order: the page's own size
582
+ returning drops them, while the page reporting a viewport of their size, or their lasting a second,
583
+ delivers them as a real resize, so a page that cannot answer, or that measures in other units as
584
+ under browser zoom, is never stalled. `foreignSize` therefore also counts some of the page's own
585
+ frames: those of a new size still unconfirmed when another size replaced it, when the capture
586
+ stopped, or beyond the 16 held. The library's own clipped pictures are left out while they are
587
+ taken: from the picture's call until 50 ms after its reply, by when a frame arrived or by its paint
588
+ time widened by the clock's uncertainty either side. A crop on the page's own session, as over CDP
589
+ and on Browserbase, is drawn at the crop's own size, so there only frames of another size are left
590
+ out, and the page's own keep reaching readers. Where Playwright emulates the viewport, as
591
+ `Chromium.layer` does, a crop keeps the device's size, so every frame in that window is left out,
592
+ the page's own included: the capture pauses for the picture's round trip, plus 50 ms, plus twice
593
+ the clock's uncertainty, about 100 ms a zoom locally, and a crop with the viewport's own
594
+ proportions never reaches readers either. A clip someone else takes passes where Chromium keeps
595
+ the device's size during it and the clip has the viewport's proportions, and on a page that paints
596
+ nothing after it, a clip of any shape lasts long enough to pass for a resize.
597
+ Each page's own session holds focus emulation, so a tab behind another keeps painting and its
598
+ capture keeps delivering, whatever else is attached; a capture starts once the browser has
599
+ confirmed it, and fails with `Timeout` at the action timeout if a renderer stuck in a script never
600
+ does.
601
+ A provider that reaches its browser over a network can run captures on a connection of their own,
602
+ a `Browser.CaptureSource` given as `Cdp.Options.capture`, as `effect-browserbase` does: there a
603
+ frame never waits behind a large message on the connection that drives the page. Frames then carry
604
+ the document their own connection saw commit, numbered as `Navigated` numbers them, and a failure
605
+ of that connection ends the capture with `Failed` and leaves the page as it was.
606
+ Concurrent readers share one native screencast and its quality and size: a reader without options
607
+ joins whatever is running, and one whose explicit options differ fails with `InvalidRequest` rather
608
+ than silently receiving other frames. Each reader has a bounded 16-frame queue; a slow reader's
609
+ observed sequence gaps add to `lost`. Late subscribers do not count earlier history.
610
+ ACKs are independent of reader speed and bounded to 32 unresolved replies; exhaustion ends capture
611
+ with a typed error. `recentFrames` keeps the frames painted within `frameHistory` of the newest,
612
+ 5 seconds by default, a moment's default window. Capture stops when its last
613
+ reader leaves; a stop whose reply is late is never resent, and the next capture waits for it (up
614
+ to 2 s per attempt) rather than disabling capture for the page.
615
+
616
+ A page's `id` is its CDP target id. A new connection to the same browser, as after a dropped one,
617
+ finds each page under the id it had, with `browser.page(id)`; a stored id the browser no longer
618
+ finds is a page that closed. `browser.pages` lists the open pages in the order the browser began
619
+ tracking them, and `browser.firstPage` gives the first, opening one when there is none.
620
+
621
+ A page's life is told on the browser's timeline. `PageOpened`; `Navigated` for every move of its
622
+ main frame, `sameDocument` for one within the document, such as `pushState`, with `document`
623
+ counting the page's documents from 0, the one it had when the browser began tracking it;
624
+ `PageLoaded` as a document finishes parsing and loading, as the page's own session hears it, so
625
+ after the `Navigated` that began the document; `DialogShown`; and `PageClosed`, by the
626
+ page or because it crashed. A document count belongs to the page within one `Browser`, and starts
627
+ again on a new connection. Each frame carries the document it followed and the page's address. A
628
+ tab the site opened that the library could not track is `PageUntracked`. A tab a page opened names
629
+ it as its `opener`, and the opener's events hold its opening. Chromium announces a title change
630
+ only with the next change of address, so there is no title event: read `page.title` when
631
+ `Navigated` or `PageLoaded` says the page moved. The browser answers it, in one call that a busy
632
+ page cannot hold up, and an untitled page's is empty.
633
+
634
+ The browser's own end is one `Disconnected`, and `browser.disconnected` completes with its cause:
635
+ `connection`, `session`, at or after the provider's `expiresAt`, which `SessionEnding` announces,
636
+ or `released`, by the owner's scope. A browser lost so publishes no `PageClosed` for its pages,
637
+ and calls in flight on them fail at once rather than at their deadline. A screencast's readers are
638
+ told as the page's scope closes, once that cause is known: a capture that fails first, as one on a
639
+ connection of its own can, waits up to two seconds for it before it fails as itself. A crashed page
640
+ is closed: Playwright drives it no more, and its calls would never return.
641
+
642
+ A failure on a page that is gone is `Closed` with its cause: `page`, `crashed`, or the browser's.
643
+ `BrowserError.consequence(error)` says what any failure leaves: what was `lost` (`nothing`, the
644
+ `page`, or the `session` with its pages), and whether to `repeat` the call: `safe`, as nothing
645
+ reached the browser; `check` its effect first, as it may have taken place; `pointless` as it is,
646
+ such as with a stale ref; or `resume` reading events from a newer cursor. `message` is a sentence
647
+ for operators, and the browser tools tell a model what to do about the failure.
648
+
649
+ `Expect` is that check, for an action whose effect the page shows. An attempt names what the action
650
+ is for, `appeared(query)`, `gone(query)`, `changed(query)` or `navigated(url?)`, with `Page.find`'s
651
+ queries over the whole document, and takes its baseline as it begins. Each `run` checks first and
652
+ sends nothing where the expectation holds already, `AlreadyDone`; otherwise it acts and waits for
653
+ it, `Done`, or `NotDone` after `wait`, 5 seconds by default. An action that fails after its input
654
+ went is checked too: `Done` if its effect came, else its own failure, so the next `run` of the same
655
+ attempt, on the same page or the one a reconnect finds by its id, acts only if nothing came.
687
656
 
688
657
  ```ts
689
- const seen = yield * page.observe({ scope: "viewport" });
690
- seen.viewport; // { clippedText, coveredText, uncertainText, unreachableControls, exhausted, ... }
658
+ const placing = yield * Expect.attempt(page, Expect.appeared({ text: "Order placed" }));
659
+ const outcome = yield * placing.run(page, (page) => page.click(ref));
691
660
  ```
692
661
 
693
- `viewport.documentScroll`, when present, reports `{ x, y, scrollWidth, scrollHeight, clientWidth, clientHeight }` in CSS pixels. The same reading samples its own Page's or Frame's `window.scrollX/Y` and root `document.scrollingElement` dimensions, so a blank viewport can still report measured progress. Offsets are finite and signed (including negative RTL offsets); dimensions are nonnegative integers, all bounded by `Number.MAX_SAFE_INTEGER`. Client dimensions can differ from `viewport.width/height` because of scrollbars. The field is absent when the document has no scrolling element and in older readings. It reports document layout without exposing offscreen text: nested scrolling containers have their own positions, and these measurements alone do not establish a scroll direction or that the document has ended. Checkpoints carry the same measured geometry without issuing references.
694
-
695
- Visibility here is geometry and hit-testing, never a pixel comparison, and the counts say which was which. Text is kept when its line boxes intersect the viewport and the browser finds its own element at a sampled point. A text node that crosses the viewport edge contributes only its lines on screen (`clippedText`). Text behind another element is left out (`coveredText`). Something that takes no pointer events is invisible to that hit test, so for a point beneath one the browser is asked, on the existing connection, which box is on top with pointer events ignored; that also finds boxes inside closed shadow roots. A box that paints at the point, with a fill, border, shadow, filter, image, canvas, SVG, text or a generated `::before`/`::after`, covers the text (`coveredText`); an empty transparent container over the whole page hides nothing. Text the browser could not settle, for example beneath a second such box, in a child frame, or past 256 such points in one reading, is left out as `uncertainText` rather than called visible. `exhausted` means the traversal budget ran out first and the reading is known to be incomplete. Canvas pixels and compositing effects are not interpreted.
696
-
697
- `match` narrows a reading, in either scope, to what contains a piece of text, ignoring case: text lines, and controls whose label contains it. A select is kept when its own label or any of its option labels matches, with its options beside it. The filter runs inside the page before `maxControls` and `maxTextBytes` are spent, so twenty header links cannot crowd out the one control asked for:
662
+ A check is one call to the page, and so is a wait, which the page answers as the effect comes;
663
+ `navigated` reads what the page already knows, at no call. `changed` compares what the query found
664
+ at the start, its elements' text, field values and states but focus, by a mark the page keeps, so
665
+ none of it leaves the page. On a page a reconnect found, `navigated` without an address tells only
666
+ a move to another address, as the new connection counts documents afresh. A presenter's view is
667
+ checked as its page is; a page a `Browser` did not make is refused.
668
+
669
+ Every address the library reports, in events, frames, reads, errors, a guard's request and the
670
+ outline's links, keeps its identity and loses its userinfo and its credentials' values, which read
671
+ `Page.redacted`. A credential is a query, fragment or path parameter named for a token, secret,
672
+ password, signature, assertion, session id or one-time code, or for a code or key that signs
673
+ someone in, such as `verification_code` or `api_key`. `code`, `key`, `session`, `sid` and `ticket`
674
+ as often name what a page shows, so under them only a value that looks generated, of at least 16
675
+ characters with letters and digits, is one. A chart's `?ticker=ETH` and a quote's `?code=BTC`
676
+ stay; a short one-time code under a bare `code` stays too.
677
+
678
+ `initScripts` take `{ match, source }`. The source runs before each new document's own scripts, in
679
+ every frame, or only where `match`, a `RegExp`, finds the document's address, as a block. Playwright
680
+ registers them as it sets each page up, so a popup's first document runs them too, at no call per
681
+ document.
682
+
683
+ `Browser.events()` streams `RecordedEvent` values: `{ sequence, event }`. The sequence orders
684
+ all browser events and is the replay cursor. Call `browser.events({ after: lastSequence })` to
685
+ resume, or use `after: 0` for everything since the browser opened if it is still retained. With
686
+ no cursor, streaming begins at subscription. `eventHistory` keeps a positive bounded number of
687
+ events (4,096 by default), also exposed without envelopes by `recentEvents`. A reader whose next
688
+ event has expired fails with `EventHistoryExpired`; it never skips events silently. Consume the
689
+ stream promptly and size retention for the consumer’s delay. Closing the browser scope wakes and
690
+ ends idle readers.
691
+
692
+ The presentation track uses the same channel. `TrackPlanned` publishes a whole glide before
693
+ input, with `from` and samples whose `afterMillis` are offsets from `at`. Its sequence identifies
694
+ the plan. `TrackPerformed` ends that plan, names it in `plan`, and records the number of samples
695
+ actually submitted plus the last submitted point. The consumer must trim the planned suffix to
696
+ that count after interruption or failure. `complete` means all samples were submitted; it does
697
+ not claim that the browser acknowledged or painted them.
698
+
699
+ `PointerPressed`, `PointerReleased`, `WheelScrolled`, `KeyChanged`, `TextInserted` and
700
+ `CursorChanged` describe the remaining input. Button, key and text events are published at
701
+ submission, including cleanup releases. Cursor shape comes from resolved target metadata.
702
+ `TrackEvent` is the schema union for these presentation events; a window leaves them out of its
703
+ events. Compositing remains the consumer’s job.
704
+
705
+ A page waits only for itself. Its operations take turns in one lane of its own: an action, which
706
+ sends input or navigates, has the page to itself, in the order actions were asked; reads (the
707
+ outline, `find`, `text`, `changes`, pictures, zooms, `title` and the viewport) share it, after the action in
708
+ flight and every action asked before them, so a read describes the page an action left, never one
709
+ an action is still changing. `ready` follows the action in flight too, and holds none back while it
710
+ watches. An action then waits for its page's unresolved input replies. Its timeout bounds those
711
+ waits before a full timeout bounds the action itself: a turn not given in time fails `Busy`, with
712
+ how long it waited and how many operations were ahead, and replies not answered in time fail
713
+ `Timeout`, as for a page that does not answer. `Page.failFast(effect)` runs operations that fail
714
+ `Busy` at once instead of waiting. A policy hold leaves the page's turn free.
715
+
716
+ `browser.suspend({ drain })` withholds library input on every tab for the current scope. It first
717
+ drains operations already admitted, through their `Action` records and native acknowledgements.
718
+ A canceled call may already have sent input: its unanswered reply still prevents a successful
719
+ drain. New and queued writes fail `Suspended`, with `dispatched: false`, and record that refusal.
720
+ Already-held authority refuses input before invoking an input guard; a guard already waiting is
721
+ checked again at admission. Reads, captures and events continue.
698
722
 
699
723
  ```ts
700
- const found = yield * page.observe({ scope: "document", match: "create account" });
701
- found.match; // "create account"
724
+ yield *
725
+ Effect.gen(function* () {
726
+ const paused = yield* browser.suspend();
727
+ // An event reader can resume from paused.since; paused.at is host monotonic time.
728
+ const page = yield* browser.firstPage;
729
+ yield* page.screenshot();
730
+ yield* operatorDone;
731
+ }).pipe(Effect.scoped);
732
+ // Input resumes after this scope, provided no other suspension still holds it.
702
733
  ```
703
734
 
704
- It filters what is read and never searches for, re-finds or substitutes a node: references still come from the reading itself, with every exact-node check. What a matched reading leaves out is not evidence of absence, which is why it names its `match`.
705
-
706
- An `Observation` is safe to show a model, and the adapter's `browser_inspect` Tool fits it to its model-result bounds. It carries no destination, form target or field value, in either scope. What a host needs to decide whether a control may be acted on is a separate, host-only read from the exact node:
707
-
708
- Controls also carry optional `checked`, `selected`, `inputType` and `required`. Native checkbox
709
- and radio state comes from the element; applicable ARIA state accepts only explicit true/false.
710
- Mixed, unknown or invalid values are omitted, not converted to false. Selection describes an
711
- option or selectable control, never an invented state for an entire `<select>`. Required state
712
- is emitted only for applicable native inputs or reviewed ARIA roles. State that can change an
713
- input decision participates in the exact-node fresh-facts check; field values and destinations
714
- remain excluded from the model projection.
735
+ Suspensions nest, including scopes closed out of order. `drain` defaults to `navigationTimeout`
736
+ (30 seconds); zero acquires only when input is already idle. Waiters that join a drain share its
737
+ first deadline. Expiry fails all those waiters with `Busy` and restores admission; cancellation
738
+ abandons only that caller's claim. Browser loss fails acquisition with `Closed`. A failed
739
+ acquisition leaves no suspension behind. The gate controls library actions and navigation;
740
+ input from the provider's own viewer or direct native calls remains under the operator's control.
741
+ The consumer owns the operator's decisions and any durable workflow journal.
742
+
743
+ Reads keep their work. An identical read asked while one is in flight on the page, with no action
744
+ asked between them, joins it, and costs no call of its own. Each read runs in the page's scope under
745
+ the action timeout whoever gives up, so a caller's own `Effect.timeout` loses nothing: a read that
746
+ ends after all its callers gave up serves the next caller to ask the same within an action timeout,
747
+ unless an action or a new document came first. An action stops the reads nobody awaits rather than
748
+ wait for them. Pictures and `changes` only join: a picture's caller says how old it may be, and a
749
+ window of changes ends as it is read.
750
+
751
+ `page.state` is what the library already knows of a page, at no call and with no wait for its
752
+ turn: where its main frame is, its document and when it was committed, how far that document has
753
+ loaded, its newest screencast frame, and the viewport's text and title as last read in that
754
+ document, each with when it was learned, so a caller reads their ages. A caller that must never
755
+ wait, such as one reading the screen on air, reads it rather than polling the page. Chromium
756
+ announces no title change, so the title is as a read of the text last found it.
757
+
758
+ `Browser.Options.maxPages` bounds the pages a browser keeps open, those a site opened included,
759
+ which are never refused: at the limit, `newPage` waits within the action timeout for one to close,
760
+ then fails `Limit`, or fails at once under `Page.failFast`.
761
+
762
+ Each page has its own pointer, where its last move left it, which is also where Chromium has it:
763
+ its first glide starts mid-viewport, and each later one where the one before ended, whatever
764
+ input on another tab does meanwhile. Every sent move updates the position, including a partially
765
+ cancelled glide. A later viewport clamps the starting point to its bounds if necessary.
766
+
767
+ ## Supervising browser generations
768
+
769
+ `Supervisor.make` keeps a browser open across losses and session ends. Its `open` builds each
770
+ generation in a scope of its own and can return `{ browser, value }`: the application's pages,
771
+ tools or other resources belong to that generation. A caller that stops waiting never interrupts
772
+ the shared acquisition. Browser-only providers can continue returning `{ browser }`, with a
773
+ `void` value. Pages and values do not carry over when a new generation opens.
715
774
 
716
775
  ```ts
717
- const facts = yield * page.controlFacts(reference);
718
- // kind, label, disabled, editable, inputType, autocomplete, formMethod, box, placement, hitTest
719
- // destination: the resolved link target, or where this control submits its form
720
- ```
721
-
722
- `destination` is resolved by the browser against the document's base URL, and honours a `formaction` override. It can carry a token, which is why it is not in an `Observation`. An over-long destination is left out, never cut. No value and no markup is ever included.
723
-
724
- A reference is to the control that was inspected, not merely to a node. If the same attached node now has a different destination, input type, autocomplete category, form method, label or disabled state, acting on it fails `stale` and `undispatched` before input, exactly as it does for a replaced or detached node. A performed action also rechecks after delayed preparation; an acknowledged preparatory scroll or focus retains its `rejected` outcome if that later check fails. An existing reference is never substituted by a selector or label lookup. A text-entry control's label never comes from what was entered into it: a textarea, an editable element or a `textbox`, `searchbox`, `combobox` or `spinbutton` role is named only by `aria-label`, `placeholder`, its `<label>` or `aria-placeholder`, so typing neither renames it nor puts the typed text into a recorded descriptor.
776
+ import { Effect } from "effect";
777
+ import * as Chromium from "effect-browser/Chromium";
778
+ import * as Supervisor from "effect-browser/Supervisor";
725
779
 
726
- When a decision has to be current at dispatch, pass a policy. It is evaluated on facts read from that exact node immediately before the input:
780
+ const program = Effect.gen(function* () {
781
+ const browsers = yield* Supervisor.make({
782
+ open: Effect.gen(function* () {
783
+ const browser = yield* Chromium.open();
784
+ const page = yield* browser.firstPage;
727
785
 
728
- ```ts
729
- yield *
730
- page.fillElement(reference, value, {
731
- admit: (facts) => facts.inputType !== "password" && facts.autocomplete !== "cc-number",
786
+ return { browser, value: page };
787
+ }),
732
788
  });
789
+ return yield* browsers.use(({ value: page }) => page.snapshot());
790
+ });
733
791
  ```
734
792
 
735
- Anything but `true`, or a policy that throws, sends no further input and fails `denied`. Performed pointer delays and fill preparation re-sample the original exact node and invoke the same policy again before further input. The policy is a plain synchronous function on purpose: it runs while the Page's permit is held, where waiting on a model or a network call would stall other ordinary operations on that Page. It is not an atomic check-and-input transaction, because page script can still run before the native input lands.
736
-
737
- `fillElement` also refuses, undispatched, what the maintained engine would otherwise refuse only after dispatch, where an unknown outcome would close that Page or fence the session if closure is unconfirmed: a hidden control (`not-visible`), a disabled one (`disabled`), one that is not an editable input, textarea or content-editable element, and text that a `number`, `date`, `time`, `range` or other value-typed input would not keep (`unsupported`). The value is checked on a detached copy with the same constraints; the page's own control is not touched until the fill is sent.
738
-
739
- ### Exact native option selection
740
-
741
- `selectOption(reference, options, admission?)` selects once on the native `<select>` named by
742
- an `ObservedElement`. The options are a nonempty array of at most 64 unique option `elementId`s
743
- from the same observation. No label, value or selector is a substitute for an issued ID.
744
-
745
- An observed native select has `multiple` and `optionsTruncated` metadata. Its retained option
746
- controls carry `selectElementId`, `selected`, `disabled` and a bounded label. Choices of a visible
747
- select are available in viewport observations even when its menu is collapsed; they consume the
748
- same `maxControls` allowance as other controls. Truncation is explicit, and an option outside the
749
- retained set cannot be selected by guessing its position or value.
793
+ `use(work, { wait })` atomically borrows the serving generation and runs `work` in a scope of its
794
+ own. The lease gives its `generation`, `browser`, `value`, an `ending` notice and `retain`.
795
+ `wait` defaults to `waitTimeout` (two minutes); zero either borrows immediately or fails
796
+ `Unavailable`. Cancelling the caller closes its work scope and lets its hold go. Ordinary work
797
+ errors stay ordinary work errors. `browser` still returns an unheld browser: use it only when the
798
+ caller can tolerate that generation being released underneath it.
750
799
 
751
- The private value comparison is bounded to 65,536 UTF-16 units per option. A longer value is not
752
- issued for selection: its legacy control state may remain visible, but `selectElementId` is
753
- absent and the parent reports `optionsTruncated`. After a page hold, revalidate the select and
754
- each requested option through `revalidateElement`; revalidating only the select does not approve
755
- its option nodes.
756
-
757
- The owner validates the same select and option nodes, document, current membership, multiple and
758
- enabled state before dispatch. It also compares the submitted value privately without returning
759
- that value in the observation or action result. Duplicate labels remain distinguishable through
760
- their IDs. Changed or replaced controls are refused before input; unknown outcomes after dispatch
761
- are never replayed. Selection emits ordinary native select input/change events through the
762
- maintained engine, returns `ActionResult` and retires the observation on that page. It does not
763
- claim the website finished work triggered by those events. The same synchronous host `admission`
764
- used for exact-node input applies to the selected control.
765
-
766
- ### Filling a form in one operation
767
-
768
- `fillForm(request, admission?, options?)` sets several controls of one observation, in order, then optionally clicks one submit control:
800
+ A retained scope transfers a hold beyond the `use` call. Acquire anything that must travel with
801
+ that hold in its scope, and close it when the receiver is finished. Within a scoped program with
802
+ `browsers` as above and `Exit` and `Scope` imported from `effect`:
769
803
 
770
804
  ```ts
771
- const result =
805
+ const prepared =
772
806
  yield *
773
- page.fillForm({
774
- observationId: seen.observationId,
775
- fields: [
776
- { elementId: email, value: "ada@example.test" },
777
- { elementId: terms, checked: true },
778
- { elementId: plan, options: [pro] },
779
- ],
780
- submit: create,
781
- });
782
- // Dispatched toggle fields and the optional submit also carry bounded input receipts.
783
- ```
784
-
785
- Each field gives exactly one of `value`, text that replaces the contents of an input, textarea or content-editable element; `checked`, the state a checkbox, radio or switch should end in, clicked only when it differs, and a native radio is never asked to clear itself; or `options`, issued option IDs of a native select exactly as for `selectOption`. A form has at most 32 fields, each control at most once, and its submit control is not also one of its fields.
786
-
787
- Every step is its own admitted and charged action on the exact observed node, after the same fresh checks as `fillElement`, `selectOption` and `clickElement`, including the host's `admission` policy. Each step, the verification read included, captures its own action deadline and queue window when it is requested, so `timeoutMillis` and `admission.queue` bound every step rather than the whole form; inside a plan, the step's captured bounds still cap them all. One difference is deliberate: a control may have become enabled since it was observed, such as a submit button a form enables once it is complete, but it must be enabled when its step runs. The observation stays usable for this form's own steps only. Navigation, a page hold or any other caller's action still retires it, and the form retires it when it ends.
788
-
789
- After each dispatched step, the page's own handlers get at least two rendered frames and `settleMillis` (50 by default, at most 5000, 0 to skip). A text field is then left, as a person moving on would leave it, so formatting a page applies on blur belongs to that step. Before submit, `verify` (true by default) reads every field again and stops the form when one no longer holds what its own step left there: a re-render, an asynchronous reset, anything that changed a field after it was set. What was read is compared on the host and never returned.
790
-
791
- The first refused or uncertain step ends the form, and nothing after it is sent. A form is not a transaction: fields set before the stop stay set, and `stopped` says where it ended (`field`, `verify` or `submit`) and why, as a `BrowserError` whose outcome says whether that step itself was dispatched. A toggle the page would not change stops with `failed` and `rejected`. `submitted` is true only when the submit click completed. The operation fails outright only when its first step does, exactly as that single action would.
792
-
793
- ### Bounded waits that leave room for recording
794
-
795
- `waitFor({ selector, state })` still supports the host's bounded selector conditions: `visible`,
796
- `hidden`, `attached` and `detached`. It captures the issued page, frame and document when admitted.
797
- `waitForElement({ reference, state, timeoutMillis? })` instead waits on an exact node issued by the
798
- current observation, with `visible`, `hidden`, `enabled` or `disabled`. It never searches for a node
799
- that replaced the reference. Hidden includes detachment of the original node; for the other
800
- conditions detachment fails `Stale/undispatched`. Replacing the document or frame fails even a
801
- hidden wait. A node's changed enabled/visible state is what the wait observes, not fresh input
802
- authorization: inspect again before acting on changed control state.
803
-
804
- Wait admission briefly holds the existing permit, checks readiness and page holds, and charges
805
- one model-reachable action. The pending wait then owns its own cancellation signal, connection
806
- generation and absolute deadline. An exact-node request may shorten the deadline to 1–60,000 ms;
807
- the configured action timeout and remaining lifetime still cap it, including setup. Selection
808
- can move elsewhere without retargeting the wait. Its condition is a sample, not a reservation
809
- that the page will remain unchanged.
810
-
811
- While a wait is pending, `checkpoint`, `listPages` and ordinary bounded reads can use the normal
812
- permit. Mutations, another navigation and page holds on the waited page are refused before
813
- dispatch. Input and observation on another page may proceed; replacing the waited frame's observation is refused until the logical wait ends. Closing a page or session remains available and cancels affected waits.
814
- No general read-under-write bypass has been added.
815
-
816
- One native wait may be outstanding per exact Page, with a finite session bound of 32. Cancellation and timeout release the
817
- logical barrier without inventing an uncertain mutation, but retain native capacity until the
818
- actual native promise and any required handle disposal settle, or that exact connection retires.
819
- Consequently another wait can still receive `Busy/undispatched` after its predecessor's caller
820
- has stopped waiting. Status `busy` includes that retained native capacity; it does not mean every
821
- ordinary operation is blocked. A cancelled exact-node wait keeps only its leased node alive when
822
- the observation is replaced, and its late cleanup cannot dispose a successor's references.
823
-
824
- ### Passive checkpoints for a recorder
825
-
826
- `page.observe()` replaces that frame's current observation, so a recorder calling it on the same frame would retire the references an agent is about to use. `page.checkpoint()` is the passive path: viewport text, control facts and, when asked, a PNG of the viewport.
827
-
828
- ```ts
829
- const checkpoint = yield * page.checkpoint({ picture: true });
830
- ```
831
-
832
- It issues no references, is not a mutation, and leaves the action observation and the selection exactly as they were, so inspect, checkpoint, then act on the inspected node all compose. It is host-only, because it carries control facts. Text and picture are read one after the other, never atomically: the interval is on the host monotonic clock, and `documentChanged` says the document was replaced in between. A held page is refused `busy` rather than woken to be read: take the checkpoint before the hold and keep it.
833
-
834
- `checkpoint` and `controlFacts` each consume one separate host-read allowance, configured by
835
- `Chromium.layer({ maxHostReads })` or the corresponding provider Layer. The default is 10,000,
836
- with an explicit integer bound of 1–1,000,000; invalid values are refused rather than clamped.
837
- Exhaustion reports `Limit { dimension: "host-reads", maximum, observed } / undispatched` while the
838
- owner stays open. These operations still obey the same lifetime, bytes, deadline and admission
839
- concurrency bounds: a checkpoint can execute page script and is not unlimited free work.
840
- `observe`, `readText`, `screenshot` and `waitFor` continue consuming the model-reachable action
841
- allowance, which `status.actions` reports. Neither budget is a tool parameter, and `maxActions`
842
- has not become mutations-only.
843
-
844
- ### Real pointer and wheel input
845
-
846
- `pointerMove`, `hover` and `wheel` send the input a person's hardware would, so pages see trusted events, `:hover` applies, and the browser itself decides what is under the pointer. `scroll` stays what it was: script in the page, instantaneous, raising no wheel event. That difference is how a recording tells one from the other.
847
-
848
- ```ts
849
- const handle = session.initialPage; // exact original Page authority
850
-
851
- yield * handle.pointerMove(PointerMoveRequest.make({ to: { x: 140, y: 100 } }));
852
- yield * handle.hover(HoverRequest.make({ selector: "#menu" }));
853
- // A nested scroll container under the pointer scrolls, not the page.
854
- const receipt =
855
- yield * handle.wheel(WheelRequest.make({ deltaX: 0, deltaY: 240, at: { x: 420, y: 120 } }));
856
- ```
857
-
858
- Coordinates are CSS pixels in the main frame's viewport. Each logical input is one action on its issued Page or Frame. Performed plans add bounded pacing and output-only glide evidence; viewers draw cursor artwork from projected evidence without injecting presentation DOM into the website.
859
-
860
- `hover` places the pointer on one exact element where it is, by selector or by the node an observation named (`page.hoverElement`). It never scrolls to reach it, because that would hide a scripted scroll inside a native-input operation. If the pointer cannot be placed on the element (it is outside the viewport, has no area, or something covers it) the call fails `not-visible` and `undispatched`. A performed hover aims inside the part of the element its viewport and overflow ancestors leave visible, so it reaches a partly clipped element a plain hover reaches, and refuses only one with no visible part.
861
-
862
- For a child-frame element, hover checks the commanded point through each ancestor
863
- frame and then the exact node, including cross-origin documents. The receipt
864
- still uses main-viewport CSS coordinates. An overlay or clipping ancestor refuses
865
- input. Frame traversal is bounded at 32 levels, with bounded shadow/DOM ancestry
866
- checks. Axis-aligned translation and positive scaling are supported; rotation,
867
- perspective and other unsupported frame mappings are refused rather than guessed.
868
- These reads and native input are separate operations in the browser: page script
869
- can still change geometry between validation and dispatch.
870
-
871
- An `InputReceipt` carries the target it was sent to, the position this owner commanded, and an interval on the same host monotonic clock that stamps `CapturedFrame.receivedMonotonicNanos`. Pointer moves, hovers and positioned wheels report their known point. A Playwright-managed click reports `position: null`: Playwright does not expose its chosen click point through the supported API, so the receipt does not guess. The click still uses Playwright's exact-node checks and native hit testing. Its unknown point invalidates the owner's remembered pointer position, so later key receipts also report `null` until a known pointer command places it again. The click interval brackets the supported Playwright call and may include actionability or navigation waits; Playwright does not expose the exact native dispatch instant. Input and pixels share one timeline, so a compositor can place known points on frames that show them. A wheel or click receipt does not claim the page finished work triggered by the input or that any frame shows it.
872
-
873
- ### Real key input
874
-
875
- `fill` sets a field's value in one step: the page gets an `input` event and no `keydown`, `keypress` or `keyup`, so anything that reacts to keys behaves differently under a recorder than it does for a person. `press` and `type` send the strokes a keyboard would. Handlers see trusted key events, and the browser does what it does for a person: Tab moves focus and selects the field it lands in, Enter submits a form that has a submit button, and Backspace edits.
876
-
877
- ```ts
878
- const handle = session.initialPage; // exact original Page authority
879
-
880
- // Focus is the page's business. A real click gives it, and keys then follow it.
881
- yield * handle.click(ClickRequest.make({ selector: "#from" }));
882
- yield * handle.type(TypeRequest.make({ text: "Vienna" }));
883
- yield * handle.press(PressRequest.make({ key: "Backspace" }));
884
- yield * handle.press(PressRequest.make({ key: "k", modifiers: ["Control"] }));
885
- // Sent only if `#from` still has focus; otherwise nothing is sent at all.
886
- yield * handle.press(PressRequest.make({ key: "Enter", into: "#from" }));
887
- ```
888
-
889
- Keys go to whatever has focus in the issued page, in whichever frame that is, because that is where the browser sends them. `into` narrows that to one exact element, which must already have focus or hold the element that does, through any open shadow root. If it does not, the call fails `not-focused` and `undispatched`. It never focuses the element for you, for the reason `hover` never scrolls: that would hide a scripted focus inside a native-input operation. `page.pressElement` and `page.typeElement` apply the same rule to the node an observation named and take the same host admission as `fillElement`, so switching from `fill` to real typing gives up neither exactness nor the check on fresh control facts.
890
-
891
- A key is spelled as the `KeyboardEvent.key` the page will see, and the vocabulary is closed: `Enter`, `Tab`, `Backspace`, `Delete`, `Escape`, the four arrows, `Home`, `End`, `PageUp`, `PageDown`, or one printable ASCII character (a space is `" "`), with `Shift`, `Control`, `Alt` and `Meta` as modifiers. The native engine parses a key string, chords included, and begins holding the modifiers before it has validated the key, so nothing reaches it that was not reviewed here. A modifier other than Shift makes a chord rather than a character, and nothing is typed.
892
-
893
- `type` sends up to 256 characters as one charged action under a single action timeout. It submits ordered native input in windows of at most 16 code points and 32 commands, without waiting for every individual reply. Each normal stroke ends at key-up, and the window drains before another begins. The owner is checked before every command, including key-up: a fence stops unsent input, and no interrupted run is replayed or repaired. Failure while dispatched input is still unacknowledged leaves an `unknown` outcome; the owner closes that exact page, or fences the session when closure is unconfirmed. Failure after all earlier input was acknowledged reports `performed`. Neither result replays the run. A canceled caller does not release native typing capacity; unresolved attachment, replies or failed port cleanup retain it until confirmed retirement. A run whose replies all succeeded leaves its private port attached for the next run on the same Page. Each Page owns its port, with at most 32 retained ports across the connection; switching Pages does not detach another Page's idle port. Positive Page closure or connection retirement releases its capacity.
894
-
895
- With `into`, the original node and document must still have focus before each subsequent window. Focus is never repaired. Commands already submitted in a window can land after focus moves. Once that window has successfully drained, a later `not-focused` refusal reports `performed`: earlier input was acknowledged, and the refused window sends nothing.
896
-
897
- Performed typing checks focus once before each stroke, never between a key and its release: a
898
- key whose own default action moves focus (Tab, an Enter that submits, an auto-advancing field)
899
- still completes its stroke. A fully acknowledged stroke, including its key and modifier releases,
900
- retains `performed` evidence if a later stroke is refused. An acknowledged text insertion, the
901
- Backspace that erases a performed Fill's old value, a filled form field and an acknowledged
902
- scroll sample do the same. Only focus, selection and scroll-into-view preparation remain partial
903
- and report `rejected` when nothing else landed; unresolved key input still reports `unknown` and
904
- receives the original containment policy. A later failure never weakens what was acknowledged.
905
-
906
- A character the US layout cannot produce is committed as text, the way an input method commits it: the field changes and no key event says so. Plain `type` sends a shifted character as its own key with `shiftKey` false. When a page reads the modifier, send that stroke through `press` with `Shift` held, spelling the key as the page will see it: `{ key: "A", modifiers: ["Shift"] }`. Spelled `"a"`, the engine sends `a` with Shift down, which is what Shift produces with Caps Lock on. Control characters are refused in text because the engine presses Enter for a line break; a named key is always its own `press`. Performed plans hold Shift for uppercase and shifted punctuation and pace complete balanced strokes under one logical action. A stroke remains unresolved until its key and modifiers are released; quiet intervals do not renew the absolute deadline or retain unresolved native replies.
907
-
908
- Like a plain window, a performed stroke submits its modifiers, its key and their releases in order
909
- without waiting for each reply, so its key-up is submitted its planned hold after its key-down
910
- rather than a round trip later, and a held Shift adds no round trip of its own. Delivery jitter
911
- on the way to a remote browser is not controlled. The owner is still checked before every
912
- command, nothing more is sent once a reply has failed, and the stroke drains all of its at most ten
913
- replies before it resolves. Each stroke therefore still costs its releases' round trip, and its
914
- focus check's when it names an element: against a remote browser, a stroke whose planned interval
915
- is shorter than those starts late. A slow renderer still handles a stroke's events one at a time,
916
- so the deadline check charges each stroke what strokes with as many commands have taken, scaled up
917
- from smaller strokes until one of its size has drained.
918
-
919
- A press waits for native input acknowledgement, without waiting for resulting navigation. If Enter submits a form, wait for what the next document shows with `waitFor`. A receipt carries the same target, pointer position and interval as any other input, and never says which key was pressed or what was typed. Typing a secret is still more observable than one `fill`, because the page sees every stroke; prefer `fill` for one unless the page requires keys. Both operations are available in the model-facing toolkit in `effect-agent-browser`.
920
-
921
- The connection endpoint is read through the exact allocated session, so a provider reply that names a different session is refused before any CDP attachment.
922
-
923
- Persistent Browserbase contexts require a live writer permit from `ContextCoordination.withWriter` when writes are persisted. Detach/reconnect is opt-in with `keepAlive`. Detach first lets admitted work finish within a bounded interval, such as a page close admitted behind its barrier or a started navigation that is still loading, rather than disconnecting beneath it; work that cannot finish fails the detach as `Timeout/undispatched` and leaves the session open. Reconnect creates a new handle generation, verifies the selected target, obtains fresh state, and never replays pending input or treats serialized agent state as a live browser.
924
-
925
- Human handoff blocks new admission and drains native work that can still change a page within a
926
- bounded interval before pausing automation and returning host-only Live View material. Pure waits
927
- and reads do not hold it up; the pause refuses them as `Stale`. Work that cannot drain in time,
928
- such as a navigation still loading, fails the request with `Timeout/undispatched`: no handoff is
929
- granted, the barrier lifts and automation continues. Resume requires an explicit operator-release signal
930
- and returns fresh bounded Page inventory under the lifecycle permit; observing a newly acquired
931
- Page is explicit. Every Page and Frame issued before the handoff, `initialPage` included, stays retired after resume, though the connection is the same: its operations fail `Stale` and `undispatched`, so issue Pages again from `inventory.pages` with `session.page(info)`. Once the pause is installed, a failed Live View authorization does not silently resume automation. Live View URLs are temporary bearer material; iframe styling is not an authorization boundary. Live View is also where browser-window presentation already exists for watching a session as it runs, and it is the provider's: beside each full-screen URL Browserbase issues a bordered one (`debuggerUrl`, "mimic a real browser with borders"), and a navbar that `navbar=false` hides. This package decodes and returns only the full-screen URL. The bordered one carries the same control authority and would be issued under the same rules, so surfacing it is a small host-only addition whenever something needs it; nothing here does yet, so it is not exported.
932
-
933
- ## Registrations, capabilities and document readiness
934
-
935
- `browser.launch(policy, { bootstrap })`, `browser.acquire(policy, { bootstrap })` and `browser.attach(endpoint, { policy, bootstrap })` take a trusted registration plan at acquisition. The browser Layer fixes launch configuration; consumer callback dependencies are captured at acquisition. A plan is built from `Bootstrap.binding`, `Bootstrap.init`, `Bootstrap.permissions` and `Bootstrap.combine`. Combination preserves the error and service unions of different handlers; the callable bridge precedes all dependent init steps in one native script registration. Script content, handler implementations and origin grants are host configuration, never model output or page input.
936
-
937
- ```ts
938
- import * as Bootstrap from "effect-browser/bootstrap";
939
- import * as Browser from "effect-browser/browser";
940
- import { Chromium } from "effect-browser/chromium";
941
- import { BrowserPolicy } from "effect-browser/browser-data";
942
- import { Context, Effect, Schema } from "effect";
943
-
944
- class ShowSettings extends Context.Service<ShowSettings, { readonly title: string }>()(
945
- "ShowSettings",
946
- ) {}
947
-
948
- const bootstrap = Bootstrap.combine(
949
- Bootstrap.permissions({ origin: "https://portal.example.com", permissions: ["clipboard-read"] }),
950
- Bootstrap.binding({
951
- name: "getShowSettings",
952
- origins: ["https://portal.example.com"],
953
- input: Schema.Struct({ version: Schema.Literal(3) }),
954
- output: Schema.Struct({ title: Schema.String }),
955
- maxConcurrent: 2,
956
- maxInputBytes: 256,
957
- maxOutputBytes: 4096,
958
- timeoutMillis: 2000,
959
- failureMode: "fail-session",
960
- handle: () => Effect.map(ShowSettings, (settings) => ({ title: settings.title })),
961
- }),
962
- Bootstrap.init({
963
- id: "show-settings-v3",
964
- origins: ["https://portal.example.com"],
965
- content: "globalThis.__ready = globalThis.getShowSettings({ version: 3 }).then(() => true);",
966
- readiness: {
967
- expression: "globalThis.__ready",
968
- timeoutMillis: 5_000,
969
- existingDocuments: "RequireFreshNavigation",
970
- },
971
- }),
972
- );
973
-
974
- const run = Browser.scoped(
975
- Chromium.launch(BrowserPolicy.unrestricted(), { bootstrap }),
976
- (session) =>
807
+ browsers.use((lease) =>
977
808
  Effect.gen(function* () {
978
- yield* session.initialPage.navigate({ url: "https://portal.example.com" });
979
- yield* session.initialPage.ready();
980
- return yield* session.initialPage.observe();
981
- }),
982
- );
983
- // run requires Chromium and ShowSettings. Browser.scoped discharges both the
984
- // callback's and the use function's Scope, but neither one's other services or errors.
985
- ```
986
-
987
- Registration is installed before this connection creates any document, and permissions precede the bundle. It is still not readiness: an asynchronous step cannot pause a website's own scripts, so a document is ready only when its expression resolves to exactly `true`. Readiness is keyed by frame and document epoch and evaluated once per document, so a completed wait can never ready the document that replaced the one it observed.
988
-
989
- Each binding accepts exactly one JSON-compatible argument and returns the output codec's **encoded** JSON value. A transforming codec such as `Schema.FiniteFromString` therefore exposes a string to the page while its host handler works with a number. The native callback validates the actual caller's allowed origin and document before input decoding, immediately before invoking the handler, and before replying. This uses Chromium's execution-context identity and `uniqueContextId` on child CDP sessions belonging to the existing connection: a frame URL, a page-supplied origin, and a reused numeric context id are not authorization. The pinned Playwright `exposeBinding` callback supplies a frame but not the calling document's identity; it is deliberately not used as a weaker substitute. No raw protocol or second browser owner is exposed.
990
-
991
- Plans admit at most 16 uniquely named bindings. Omitted binding options default to `maxConcurrent: 1`, `maxInputBytes: 65536`, `maxOutputBytes: 65536`, `timeoutMillis: 10000` and `failureMode: "reject-call"`. Name, exact origins, codecs and handler remain required. Explicit bounds/mode pass the same validated registration path; zero, null or excessive values are rejected, never clamped. `combine` selects the most conservative `existingDocuments` policy across its scripts, even when their origin sets differ. Admission reserves capacity before native validation, codec work or a callback fiber starts; a timed-out native operation retains that reservation until it actually settles, including across reconnect. The page wrapper additionally rejects cyclic, sparse, accessor-bearing, non-plain, non-finite and non-JSON input rather than silently changing it through `JSON.stringify`. Its traversal admits at most 64 levels and 65,536 nodes; the configured byte limit still applies. Native target and default-document registries are finite, and closed native targets retire their authority immediately.
992
-
993
- `reject-call` rejects only the affected invocation and permits subsequent healthy calls. `fail-session` completes `session.failure` with the original typed consumer cause and fences the owner. A call from a document outside the binding's origins is refused before admission under either mode: it takes no capacity, never reaches the codecs or the handler, and cannot fail the session. It is counted as rejected and recorded as a `reject-call` failure with reason `origin`. Pages receive only `BrowserBindingError: Browser binding call rejected`, with no host stack, consumer error payload, credentials or SDK cause. `session.bindingDiagnostics` is a bounded **host-only** snapshot containing per-binding accounting and the latest 32 typed causes; do not serialize it into a Tool response. Callback service reads run independently of a browser mutation. Reentrant browser work on that same Page follows ordinary admission: it fails `busy` and `undispatched` by default, or waits under an explicit finite queue. Other healthy Pages can proceed independently.
809
+ const hold = yield* lease.retain;
810
+ const page = yield* Effect.acquireRelease(lease.browser.newPage(), (page) =>
811
+ page.close.pipe(Effect.ignore),
812
+ ).pipe(Scope.provide(hold));
994
813
 
995
- `Browser.scoped` supervises fail-session errors and requires the owner's checked cleanup. Explicit `acquire`/`launch` retain the typed failure signal and detailed cleanup receipt for callers that need to manage that decision themselves. Teardown synchronously closes callback admission, interrupts managed callback fibers, removes this connection's registrations, disconnects locally, and still invokes the supplying lifetime’s release when a prior cleanup step fails. Reconnect installs fresh callable registrations, never replays an old invocation or a consumer init script into an already-running document, and cannot reuse quarantined callback capacity.
996
-
997
- Operations that depend on an initialized document wait for the current one. Navigation, selection and page management do not, so initialization cannot deadlock the navigation that produces the document it is waiting for. A document that was already running when the bundle was registered — the page you attach to, or the one a reconnect finds — never ran it: `RequireFreshNavigation` reports `RequiresNavigation` and refuses dependent work, while `AcceptAlreadyRunning` verifies the requirement against that document instead of assuming it. Neither reloads a page whose work may be uncertain; that stays your decision. `session.initialPage.ready()` reports the current document without charging an action, and an origin outside the plan is reported as `NotApplicable` rather than waited on.
998
-
999
- The reviewed permission subset is exercised against real Chromium. Provider extensions, persistent contexts and provider reconnect evidence belong to the supplying integration; see the [Browserbase guide](../browserbase/README.md).
1000
-
1001
- ## Live capture and presentation
1002
-
1003
- For a frame-processing pipeline, use `Capture.stream(page, options)`. It starts only when consumed and owns one interval per subscription. `Stream.take`, consumer failure and interruption all finish that interval before the stream completes, while the enclosing browser stays open. A new subscription creates a new interval; concurrent subscriptions on the same page still fail `busy` under the existing reservation. Capture bounds and native-stop quarantine rules are unchanged.
1004
-
1005
- ```ts
1006
- import { Stream } from "effect";
1007
- import * as Capture from "effect-browser/capture";
1008
-
1009
- const firstFrame = Capture.stream(page, { lifetime: "page" }).pipe(
1010
- Stream.take(1),
1011
- Stream.runCollect,
1012
- );
1013
- ```
1014
-
1015
- Use `Capture.start` instead when the host needs passive snapshots, an explicit stop acknowledgement or the final loss summary. Stream completion runs cleanup but does not itself assert that Chromium acknowledged the stop; unconfirmed cleanup keeps the page quarantined. Both APIs use the same bounded frame stream and owner.
1016
-
1017
- Live capture frames carry owned JPEG bytes, captured target identity, sequence number, source presentation time, host monotonic receipt time, geometry and explicit drop accounting. Buffers are bounded by frame count and bytes; slow consumers drop old frames instead of creating an unbounded fiber/callback backlog. Buffer dropping is not page-clock backpressure and does not reduce what the browser produced upstream. Holding a capture callback is not a promise that page timers or animations stop.
1018
-
1019
- `sourceTimeMillis` is the browser's wall clock when it took the frame for the screencast, stamped before the frame is encoded. Chromium encodes up to three frames at once and emits each when its encode completes, so two frames stamped close together can arrive in either order. A frame that arrives behind a newer one can no longer be presented in order: it is discarded and counted in `late`. It is never sorted back in or given another time, so delivered source times strictly increase and a gap in `sequence` marks the omission. Concurrent encoding can put at most two late frames in a row. A longer run means source time itself went backwards, and the interval ends with `Timestamp` error evidence.
1020
-
1021
- The migration equation is **`discarded = overflow + late + duplicates + rejected`**. The four
1022
- components are mutually exclusive: `overflow` is buffer eviction, `late` is out-of-order input,
1023
- `duplicates` is repeated source time, and `rejected` contains other refused or undelivered frames.
1024
- The old `dropped` field is removed, not redefined as overflow. A late frame was omitted from
1025
- delivery; that fact does not prove network loss. The same counters appear in live snapshots,
1026
- and `upstreamDrops` remains `"unknown"`. The default frame capacity remains four.
1027
-
1028
- ### Watching live or with a delay
1029
-
1030
- A viewer can watch as it happens, or a few seconds behind so that text or other media made about a moment is ready when that moment is shown. Both are the same interval. For a delay, the consumer waits until each frame's `receivedMonotonicNanos` plus the delay before passing it on. While it waits, frames stay in the interval's own buffer, so `maxFrames` (at most 1024) and `maxBufferedBytes` are the delay's memory bound, and anything beyond them is dropped oldest-first and counted as `overflow`. Size them for the delay: five seconds of a busy page at up to 60 frames a second is 300 frames, and a 1280×720 JPEG of dense text is about 200 KB. The same clock stamps `documentBoundaries`, so an address bar drawn beside the frames changes when the frame after a boundary is shown. The record keeps the latest 64 boundaries, so a long stream can still name what it is showing. An interval may last up to six hours (`maxDurationMillis`, 60 seconds by default).
1031
-
1032
- ```ts
1033
- import { Clock, Effect, Stream } from "effect";
1034
-
1035
- const interval =
1036
- yield *
1037
- Capture.start(page, {
1038
- lifetime: "page",
1039
- size: { width: 1280, height: 720 },
1040
- maxFrames: 400,
1041
- maxBufferedBytes: 64 * 1024 * 1024,
1042
- maxDurationMillis: 30 * 60_000,
1043
- });
1044
- const delayed = interval.frames.pipe(
1045
- Stream.mapEffect((frame) =>
1046
- Effect.flatMap(Clock.monotonicTimeNanos, (now) => {
1047
- const wait = Number(frame.receivedMonotonicNanos + 5_000_000_000n - now) / 1e6;
1048
- return Effect.as(wait > 0 ? Effect.sleep(wait) : Effect.void, frame);
814
+ return { page, hold, ending: lease.ending };
1049
815
  }),
1050
- ),
1051
- );
816
+ );
817
+ // Hand `prepared` to its next owner; that owner eventually lets it go.
818
+ yield * Scope.close(prepared.hold, Exit.void);
1052
819
  ```
1053
820
 
1054
- `Capture.multipart(frames)` turns frames into one `multipart/x-mixed-replace` response for an `<img>`, the motion JPEG that WHATWG HTML defines for images. It closes each frame with the next part's delimiter and headers at once, because a browser shows a part only when it has read the headers of the one after it: without that, a viewer runs one frame behind and never shows the last picture of a page that has gone still. It writes no metadata, so nothing but pictures reaches a viewer, and it draws a new boundary from `Crypto` for each response. Call it once per viewer, over one fan-out of the interval, for example a sliding `PubSub` with `replay: 1` so that a slow viewer skips frames without slowing anyone else and a new one is shown the current picture. End that fan-out before the HTTP server stops: a server waits for its open responses.
1055
-
1056
- Closing, navigating, detaching a relevant frame, or resizing the captured page ends its interval explicitly without ending a sibling page's capture. `Capture.start(page, { lifetime: "page" })` instead follows a page's main frame across documents: start it before a navigation and it covers the loading in between. Same-document URL changes leave document-lifetime capture and observations active. A page-lifetime interval records those changes with `sameDocument: true` and keeps the current document number; only a cross-document commit advances it. The native screencast is never restarted for a navigation, so a boundary is not a gap this package introduced. Each frame carries the `document` it was received during (0, then one more per new document), and the summary's bounded `documentBoundaries` give the last sequence before each URL change and the address it reached, with `initialUrl` for document 0. That is attribution by receipt order, not proof of whose pixels a frame shows: one received just after a navigation can still show the document before it. Selecting another page or frame does not invalidate an unrelated interval. Handoff pause, connection loss, an uncertain owner and session closure still invalidate all child intervals. A confirmed native stop releases only its own reservation; a failed stop on a live page keeps that target quarantined until a late stop confirms or the page definitively closes. Stopping a child capture does not close its browser. The frame seam has **no website-audio source**, so this package does not synthesize silent samples or infer audio support from a video container. Caller encoding is demonstrated in [the caller encoder example](../browserbase/examples/record-video.ts); the example decodes every generated frame with the caller's FFmpeg and checks presentation timestamps and pixel checksums. Native acceptance requires changing pixels and source-time agreement rather than accepting container headers as video evidence. Filming across a navigation with one page-lifetime interval, resampled onto a constant-rate reel with the address of each document reported, is demonstrated in [the footage example](../browserbase/examples/realistic-footage/README.md).
1057
-
1058
- Same-document detection uses the pinned Playwright 1.63.0 client `Frame` event `navigated`: `newDocument` is absent for URL-only changes, and the client emits this event synchronously before `Page` emits `framenavigated`. A runtime missing that private signal fails explicitly rather than treating URL changes as document commits.
1059
-
1060
- ### Read metadata while capture is running
821
+ Rotation publishes `Draining` for the outgoing generation. It stays alive until its last hold
822
+ closes or `handover` expires, 30 seconds by default. `ending` is the first notice that it stopped
823
+ serving, with a `Superseded` reason and monotonic `revokeAt`; loss or retirement can revoke sooner.
824
+ At revocation, `use` interrupts work still running, waits for its finalizers and fails
825
+ `Superseded`. New `retain` calls refuse a revoked or releasing generation, and a retained scope
826
+ cannot extend the deadline. Retained scopes and work scopes close before the provider's
827
+ `release`; the generation's own scope closes last. Cleanup can therefore extend the time taken to
828
+ finish revocation. `Superseded` does not say whether an input reached the site: use the action's
829
+ receipt and plan stopping point to resolve that uncertainty.
830
+
831
+ Nonexclusive `rotate`, including `rotateBefore` timed rotation, opens the next generation while
832
+ old holds drain. Handover does not delay later rotations; multiple old generations can remain
833
+ owned, so the consumer chooses its rotation and overlap budget. Calls share one acquisition in
834
+ progress. Under `exclusive`, the current generation drains and finishes its provider release
835
+ before acquisition starts. Call `rotate` outside `use` for a clean cut: an exclusive rotation
836
+ inside `use` waits for its own hold to end or be revoked.
837
+
838
+ A loss publishes `Lost` with the browser's `disconnected` cause and revokes its holds immediately.
839
+ It reopens on `reopen`, which also retries an open failure until the schedule ends. A provider's
840
+ `definite` failure goes `Down` at once, with the cause also available through `Unavailable`.
841
+ `retire` revokes immediately, stops acquisition and waits for every generation to release.
842
+ Closing the supervisor's scope does the same unless `keep` is set: then only the serving
843
+ generation skips provider release, `Kept`; its application scopes still close. Other generations
844
+ are released normally.
845
+
846
+ `states` streams `Opening`, `Reopening`, `Open`, `Draining`, `Lost`, `Down`, `Closed` and `Kept`.
847
+ `Closed.released` preserves `Settled` or `Unconfirmed`; the latter does not establish that a
848
+ provider stopped the browser or its billing. `Generation.at` is wall-clock epoch milliseconds;
849
+ `hostTime` and `revokeAt` are host monotonic milliseconds from the supervisor's Effect `Clock`.
850
+ They can be compared with browser event and frame times when those use that same clock and host,
851
+ not across processes or against a provider's wall-clock timestamps. A generation event is
852
+ lifetime evidence, not proof that a frame was shown. `Browserbase.supervise` supplies hosted
853
+ generations and, with `keep`, can adopt one it previously kept.
854
+
855
+ Every module is also an entry point, such as `effect-browser/Page`. The
856
+ [repository README](https://github.com/mannyc2/effect-agent-browserbase#readme) has examples.
857
+
858
+ ## Tracing
859
+
860
+ Operations are spans in Effect's tracer, so an application that installs an exporter sees them in
861
+ its own traces; the library installs none. For the agent in the repository's README, with
862
+ `effect/observability` provided outermost so that opening the browser is traced too:
1061
863
 
1062
864
  ```ts
1063
- const interval = yield * Capture.start(page, { lifetime: "page" });
1064
- const current = yield * interval.snapshot;
1065
- // current.phase is "capturing"; initialUrl and recorded documentBoundaries are available now.
1066
- ```
1067
-
1068
- `snapshot` copies the bounded metadata already recorded in host memory. It does
1069
- no browser work, consumes no frames, charges no action, and works while the page
1070
- is held. Repeated reads neither stop nor restart capture. `observedMonotonicNanos`
1071
- stamps the read; each boundary retains its own commit observation time. The
1072
- record keeps the latest 64 boundaries; `documentBoundariesTruncated` reports that earlier
1073
- ones were let go, and `currentDocument` continues counting. Addresses longer than the existing
1074
- bound remain `null`. The model should not receive these host-only addresses by
1075
- accident merely because it can inspect the page.
1076
-
1077
- Every summary and snapshot includes `qualification`: this exact target's authority phase,
1078
- containment facts, and the owner's phase and generation. A revoked Page may still be awaiting
1079
- closure; `PageClosed` appears only after positive native closure, independently of `nativeStop`.
1080
- Later `snapshot` and `completed` reads can retain that confirmation even when capture ended
1081
- earlier. The original action outcome and capture end reason remain unchanged. A terminal Page
1082
- requires explicitly fresh authority and intent before another capture can start.
1083
-
1084
- While capturing, `reason` and `nativeStop` are `null`. `phase: "stopping"` means
1085
- capture has stopped accepting frames but native cleanup is pending. `"stopped"`
1086
- means that cleanup attempt settled; only `nativeStop: "confirmed"` confirms the
1087
- native stop. All snapshots are observations of accounting so far: buffered frames
1088
- can still be drained after stop. For final accounting, stop or await capture
1089
- termination, finish draining `frames`, then read `completed` to obtain the terminal
1090
- summary with final delivery counts. Preserve the terminal error and loss counters
1091
- when reconciling earlier snapshots. `late` is one disjoint component of `discarded`, and
1092
- `upstreamDrops` remains `"unknown"`; live metadata adds no stronger pixel or loss
1093
- guarantee.
1094
-
1095
- ### What a compositor is given, and what it owns
1096
-
1097
- Footage from `capture` is the page surface. It has no pointer, no tab strip and no address bar, so on its own it reads as the inside of a tab rather than as a browser. Drawing those is the application's, exactly as cursor artwork, easing and encoding are: this package has no window-compositing API and will not grow one. What it owes a compositor is the evidence only it can see, on one timeline:
1098
-
1099
- - each frame's `receivedMonotonicNanos`, and the `document` it was received during;
1100
- - an `InputReceipt` for each exposed native pointer, click and key operation, with a known position when the owner has one and an interval on that same clock. Internal download and file-chooser clicks clear the remembered pointer position but do not expose a receipt;
1101
- - an address for every document a frame can name: `initialUrl` for document 0, read in the same turn the watch is installed, and a `url` on each cross-document boundary, read inside the navigation event that committed it. Page-lifetime captures separately mark same-document URL changes without advancing the document number. An application that samples `observe()` between actions can learn an address, but never when it became the address. One longer than 8192 characters is `null`, never cut into an address the page did not show.
1102
-
1103
- A cross-document boundary is the commit, and it is the only moment of a navigation an application cannot see for itself. A same-document boundary records a URL change without claiming that a new document committed. When a cross-document navigation started and when its document finished loading are yours to stamp around the operation that caused it, because you made the call: `startNavigation` returns at dispatch and its `completed` resolves at DOMContentLoaded. Frames and receipts use the session's captured Effect `Clock`; a later caller Clock override does not change their domain. Capture `yield* Clock.Clock` when opening the session if you need raw host stamps on that same clock. `session.timeline.now` supplies the qualified offsets used by journal intervals. A title is page state that changes whenever the page likes, not part of a transition: read it with `page.describe()` when you need one, and stamp that read yourself.
1104
-
1105
- The two clocks are never related for you. `sourceTimeMillis` is the browser's wall clock; everything above is the host's monotonic clock; on a hosted session they differ by an offset this package cannot observe. Every frame it receives is already late by the very capture latency it would be trying to measure, and nothing else it is handed carries the browser's time. Relating them takes a round trip into the page, which costs either a charged action or a registered binding, on a schedule only the application can choose, and it fails on a held page. So place input on frames by receipt time, which is always available and late by the capture latency, or measure the offset yourself with a four-timestamp exchange over a typed binding, as NTP does: the page stamps when it called and when the reply arrived, the host stamps when it received and when it replied, and half the best round trip is the error bound to report beside the offset.
1106
-
1107
- ## Explicit stage-page holds (opt-in)
1108
-
1109
- Set `pageControl: true` on `Chromium.layer` or `BrowserbaseBrowser.layer` to use the host-only `page-control` module. The default remains off. `PageControl.suspend(page)` returns a live `PageSuspension`; `PageControl.resume(page, receipt)` consumes that exact receipt. Use an issued Page from `session.page(info)` or `initialPage`. Selection can move to the scout without invalidating the receipt, but connection loss, external target invalidation, completed resume, or another session does invalidate it. Holding or resuming a page may run its `freeze` and `resume` handlers, so nothing observed on _that_ page may be acted on unchecked afterwards: a reference fails `stale` until `page.revalidateElement(reference)` confirms it is still attached and still the control that was inspected. That check sends nothing, never searches for a substitute, and refuses a replaced, detached or changed node. An observation of another page is untouched, so an agent keeps driving the scout while the stage is held. `PageControl.state` reports the last acknowledged local state, not proof about a lost remote connection.
1110
-
1111
- This opt-in uses maintained CDP attachment with `noDefaults: true` and owner-controlled per-page focus emulation. It intentionally does not support `keepAlive`, reattachment, human handoff, or popup/dialog `pause` policies. These combinations fail before acquisition; use the existing `retain`/`close` popup policies and `dismiss` dialog policy. Resume explicitly activates the native page without changing SDK selection. Do not enable it where another native client owns focus. Modeled input, DOM reads, waits and viewport changes on held/unknown pages fail before dispatch; the scout remains operable. Page close and session close remain available. Capture does not thaw a held page; frame consumption and acknowledgements never suspend/resume it implicitly.
1112
-
1113
- The native tests cover page timers, RAF and CSS animation, scout progress with both pages captured, an already-paused animation, and restoration of a non-default rate. Resume waits for one bounded real RAF in an isolated world at rate zero before restoring that rate, avoiding Blink's stale pre-hold animation clock. In-flight callbacks are not undone. Date/wall time, network, media/audio, workers/service workers, and external/provider actions are not promised frozen. This is presentation control, not a security boundary or browser virtual time.
1114
-
1115
- Unknown partial native failures close the exact Page, preserving healthy peers after confirmed closure; unconfirmed closure fences the session. There is no success receipt, automatic rollback or retry. Scope cleanup never sends a hidden resume: it closes the owned session/connection. Chromium may reset animation state on CDP detachment, so a previous hold acknowledgement does not guarantee remote clocks remain held after connection loss. Hosted-provider equivalence has not been tested.
1116
-
1117
- ## Network policy
1118
-
1119
- Only explicit trusted-host `Unrestricted` network policy is implemented. `ExactHosts` and `PublicWeb` fail before acquisition. A local debugger endpoint, an input-admission callback or a filtered initial navigation URL does not establish containment for redirects, subresources, popups, workers or DNS. Applications requiring egress restrictions must operate and independently qualify that enforcing boundary. Chromium can select a host-operated proxy at launch; provider routing is configured by its own integration. These choices never silently substitute a browser provider.
1120
-
1121
- ## Testing without a browser
1122
-
1123
- `effect-browser/testing` opens the real owner over a scripted native engine. `Testing.open(script, options)` returns a `ScriptedSession<E>`: the same `BrowserSession<E>` that Chromium and Browserbase return, and admission, budgets, staleness, dispatch evidence, capture accounting, page holds and typed callbacks are the production code paths. Only the pages and the native outcomes are scripted, so an application, a Toolkit composition or a Layer runs on the pinned Node and Bun with no Chromium process, no Playwright installation and no credentials. The defaults are the ones a Chromium launch would use: an unrestricted policy, a 1280×720 viewport and a ten-second action timeout.
1124
-
1125
- A script lists documents by address, each with its text and the controls it offers. A control's `id` becomes its issued `elementId`, and observations are numbered `observation-1`, `observation-2`, …, so a scripted model turn or a stored expectation can name a node statically. A `link` or a submit control carries a `destination`, which is both its host-only destination fact and where activating it leads; a plain button navigates through `activates` and reports no destination, as a real `onclick` handler has none, and the schema refuses a destination where a real document would report none. Navigating to an unlisted address reaches an empty document at that address. A checkbox or radio `input` without `checked` starts unchecked, as a real one does. `fillForm` and matched readings take the same steps over the script: a text step records its value in `document.values` and leaves the control, a toggle is clicked only when it differs, options are the observation's issued option IDs, and `next("fill-form", …)` arms the next step, verification read or submit.
865
+ import { Effect, Layer } from "effect";
866
+ import { FetchHttpClient } from "effect/http";
867
+ import { Otlp, OtlpSerialization } from "effect/observability";
1126
868
 
1127
- An optional document `documentScroll` supplies an explicit geometry snapshot with the same six fields as a reading. `observe`, `checkpoint` and `document.current` copy those facts; `document.update` supplies a subsequent snapshot. Scripted scroll and resize operations do not infer layout, clamping or RTL origins from those numbers. Omit the field when geometry is unknown. Authored snapshots establish the owner's behavior over those facts, not native layout.
869
+ // Exports to OTEL_EXPORTER_OTLP_ENDPOINT when OTEL_TRACES_EXPORTER=otlp is set.
870
+ const Observability = Otlp.layerFromConfig({ resource: { serviceName: "my-agent" } }).pipe(
871
+ Layer.provide(OtlpSerialization.layerJson),
872
+ Layer.provide(FetchHttpClient.layer),
873
+ );
1128
874
 
1129
- ```ts
1130
- import { expect, it } from "@effect/vitest";
1131
- import { Effect } from "effect";
1132
- import * as Browser from "effect-browser/browser";
1133
- import { BrowserPolicy } from "effect-browser/browser-data";
1134
- import { Reasons } from "effect-browser/errors";
1135
- import * as Testing from "effect-browser/testing";
1136
-
1137
- const shop: Testing.Script = {
1138
- documents: [
1139
- {
1140
- url: "https://shop.test/",
1141
- text: "We use cookies.",
1142
- controls: [
1143
- {
1144
- id: "accept",
1145
- kind: "button",
1146
- label: "Accept all",
1147
- activates: "https://shop.test/?consent=1",
1148
- },
1149
- ],
1150
- },
1151
- { url: "https://shop.test/?consent=1", text: "Welcome back." },
1152
- ],
1153
- };
1154
-
1155
- it.effect("an unknown click is never replayed", () =>
1156
- Browser.scoped(
1157
- Testing.open(shop, { policy: BrowserPolicy.unrestricted({ maxActions: 10 }) }),
1158
- (browser) =>
1159
- Effect.gen(function* () {
1160
- yield* browser.control.next("click", {
1161
- _tag: "Fail",
1162
- reason: Reasons.Timeout.make({}),
1163
- outcome: "unknown",
1164
- });
1165
- const observation = yield* browser.initialPage.observe();
1166
- const accept = { observationId: observation.observationId, elementId: "accept" };
1167
-
1168
- const first = yield* browser.initialPage.clickElement(accept).pipe(Effect.flip);
1169
-
1170
- expect(first).toMatchObject({ reason: { _tag: "Timeout" }, outcome: "unknown" });
1171
- const retry = yield* browser.initialPage.clickElement(accept).pipe(Effect.flip);
1172
-
1173
- expect(retry).toMatchObject({ reason: { _tag: "Closed" }, outcome: "undispatched" });
1174
- const clicks = (yield* browser.control.calls).filter((c) => c.operation === "click");
1175
-
1176
- expect(clicks).toHaveLength(1);
1177
- expect(clicks[0]).toMatchObject({ dispatched: true, settled: "failed" });
1178
- }),
1179
- ),
875
+ program.pipe(
876
+ Effect.provide([Chromium.layer(), OpenRouter]),
877
+ Effect.provide(Observability),
878
+ Effect.runPromise,
1180
879
  );
1181
880
  ```
1182
881
 
1183
- `browser.control` is the test's side of the scripted browser. The browser outlives any one connection, as a keep-alive browser does: a reconnection to the same address finds the pages it left, and the control keeps working while no connection is open, so a test can change a page while its owner is detached. `connections` lists every connection made to that browser and how each ended so far (`open`, `closed`, `dropped`, `close-failed`, or `refused` by the script's `connections: ["refuse", …]`), which makes a connection an owner left open visible. `next("disconnect", …)` scripts the owner's native teardown: `Fail` makes it fail, so the receipt reports `connection: "failed"` with a `disconnect` issue, and `Hold` parks it at a gate however long cleanup waits. A navigation stop is recorded in `calls` as `navigate-stop`, and its arms apply before or after it is sent. Closing the exact affected page after an unknown outcome is recorded as `close-page`; arming `close-page` with `Fail` makes that close fail, so the owner fences. The rest of `browser.control`: `next(operation, outcome)` arms what the next admitted call of one operation does. `Fail` with `outcome: "unknown"` dispatches and then fails: acknowledged exact-page closure preserves healthy peers, while failed closure fences the session and refuses every later mutation `Closed` and `undispatched`; `Fail` with `undispatched` or `rejected` never dispatches. `Hold` parks the call at a `Gate` before or after dispatch, so a test can interrupt or time out a call at a known point and then check what the owner made of it. `Disconnect` drops the connection inside the call. `calls` is the recorder: every admitted call with its operation, page, node, `dispatched` and `settled`, and never a filled value, typed text or address; `document.values` and `document.files` hold those separately. `document.replace` swaps the page's document as a navigation would, so retained nodes and pending waits go stale and a capture learns of a new document; `document.update` changes the same document in place, so controls that keep their `id` keep their identity and waits re-evaluate. `capture.emit` hands a frame to a running interval, `invoke` calls a registered binding as a page would, and `disconnect` drops every open connection outside any call. `closeChecked` returns a `ScriptedCleanupResult`, and `onCleanup` receives it, as the concrete owners do.
1184
-
1185
- Time is the caller's clock. In-flight navigations, waits, holds and callback deadlines run under the context the session was opened in, so under `it.effect` from `@effect/vitest` a `TestClock.adjust` advances them and nothing real elapses, while `it.live` runs them in real time. `Testing.binding(script)` is the same engine as an opaque `BrowserBinding` for code that constructs a runtime itself; its `browsers` has one control handle for each address connected to, in first-connection order; `Testing.jpeg()` is a small valid JPEG for frames.
1186
-
1187
- Randomness is scripted too. The owner's ids and handoff tokens come from `Testing.sequentialCrypto`, a `Crypto` Layer whose bytes count up from one, so `Testing.open` requires no platform service and its values repeat from run to run. A provider Layer built over `Testing.binding` still requires a `Crypto`; provide `Testing.sequentialCrypto` to keep that test free of a platform package. It is not random and computes no digest, so never give it to a real browser.
1188
-
1189
- A scripted pass establishes the owner's behaviour over any engine, not what real Chromium reports for a page. `test/native/scripted-parity.test.ts` therefore runs one case list against both `Testing.open` and `Chromium.launch` on a loopback page, and every case must end in the same reason and outcome under both. Nothing scripted establishes anything about a hosted provider.
1190
-
1191
- ## Integration construction
1192
-
1193
- `effect-browser/browser-runtime` is the supported host integration boundary. `make` validates an immutable runtime configuration and returns `acquire(policy, source, request)`. It requires Effect's `Crypto` and captures it for connection ids and handoff tokens, so an integration's Layer requires `Crypto` once and `acquire` never does. A source acquires one concrete lifetime, registers its release in the supplied Scope before returning, resolves its authorized endpoint, and supplies detailed release evidence plus `closeChecked`. Browserbase owns remote allocation/status and writer settlement; Chromium owns process termination/profile removal. The runtime supplies connection cleanup, captures bootstrap services, and creates the one registered session.
1194
-
1195
- The connection exposes that exact session and a bounded set of modeled integration operations. An integration can add its own resource identity and authorized file-selection or handoff methods to the same object. It must authorize stored file paths before using integration file selection; ordinary browser sessions do not expose a server-path interface. Hosted handoff pauses under the existing owner before a provider Live View is issued, and resume/reconnect remain owned state transitions.
1196
-
1197
- The native driver, action permits, mutable capture leases and registry lookup are private. No constructor returns a raw CDP driver or permits a consumer to recover native authority from a session. `playwright` issues opaque engine configuration; any endpoint resolver is trusted host configuration and must retain the supplying integration's endpoint validation. `Chromium` and `BrowserbaseBrowser` both use this same construction path.
1198
-
1199
- ## Public entry points
1200
-
1201
- | Entry point | Responsibility |
1202
- | ------------------------ | ------------------------------------------------------------------------------------- |
1203
- | `browser` | Common session, bound target, navigation operation and host admission types |
1204
- | `browser-data`, `errors` | Credential-free schemas and expected browser/initialization errors |
1205
- | `plan` | Live plan construction, durable encoding, recording and typed run failures |
1206
- | `plan-data` | Bounded actions, descriptors, conditions and durable plan schemas |
1207
- | `timeline` | Live evidence capability, explicit JSON codecs and sanitized lifecycle projections |
1208
- | `timeline-data` | Bounded evidence, cursors, clock offsets, snapshots and typed gap/resource errors |
1209
- | `bootstrap` | Typed bindings, init/permission plans, readiness and host diagnostics |
1210
- | `capture` | Bounded live frame intervals, snapshots and final accounting; reexports frame schemas |
1211
- | `capture-data` | Capture options, binary frame and result schemas without session operations |
1212
- | `page-control` | Explicit host-owned page holds and receipt-based resume |
1213
- | `chromium` | Self-managed Chromium launch, borrowed loopback attachment and process cleanup |
1214
- | `browser-runtime` | Supported construction for integrations supplying browser lifetimes |
1215
- | `testing` | The real owner over a scripted engine: scripts, armed outcomes, a call recorder |
1216
-
1217
- The root intentionally excludes the Chromium namespace. Import its entry point explicitly.
1218
-
1219
- ## Validation
1220
-
1221
- Use the pinned workspace and Vite+ commands described in [Contributing](../../CONTRIBUTING.md). Unit tests exercise ownership, callback errors, native-call classification and bounded capture; `test/testing.test.ts` exercises the public scripted engine through the same owner. Native Chromium tests use real loopback pages, capture and page holds, verify that borrowed closure leaves the external browser running, and run the scripted-parity case list against both engines. The `browser` installed consumer has no Browserbase or Effect Agent package; the `agent` consumer adds only the common adapter and framework. Provider-backed native integration remains separately exercised by Browserbase consumers. None of these local checks establishes hosted-provider equivalence.
882
+ The agent's turns, model calls and tool calls are Yielded's spans, with OpenTelemetry's GenAI
883
+ attributes. Every page operation is a
884
+ `Page.<name>` span, such as `Page.click`, `Page.type` or `Page.navigate`, with what its `Action`
885
+ records except text: the target, the subject's role, name and tag, whether input was dispatched,
886
+ `queuedMillis` spent waiting for its turn and the page's replies, and a failure's reason as
887
+ `error.type`. A read that shared another's work says so in `shared`: `joined` or `kept`.
888
+ `Page.prepare`, the policy's preparation, and `Page.guard`, which lasts as long as a hold and holds
889
+ a judge's model call, are its children, as are pointer travel (`Page.move`) and the settle after input (`Page.settle`).
890
+ `Page.snapshot`, `Page.find`, `Page.text`, `Page.title`, `Page.viewport`,
891
+ `Page.screenshot` and `Page.frame` (with
892
+ `source`: a reused screencast `frame` or a new `screenshot`), `Page.zoom`, `Page.ready` and the
893
+ other waits are spans, as are `Plan.replay` and `Plan.locate`, and so is each round trip to the
894
+ page's script (`Page.evaluate`) and its registration (`Page.register`). Each page span reports its cost on the page's own protocol session: `calls`,
895
+ `bytesOut` and `bytesIn` (their parameters and results as JSON) and `waitedMillis`, how long at
896
+ least one call awaited its reply. An operation inside another counts toward both. Calls Playwright
897
+ makes on its own sessions, such as navigation, are not counted. `Chromium.launch`, `Cdp.connect` and
898
+ `Browser.newPage` cover opening a browser; `Browserbase.open` records its session's id and region,
899
+ `Browserbase.release` a release and its outcome, and `Browserbase.holdContext` the wait for a
900
+ stored context another session writes to. `Page.calibrateClock` maps the browser's clock onto the
901
+ host's with three probes, each a `Page.evaluate` of `clock`, and records the fastest answered
902
+ probe's `roundTripMillis`: the round trip to the browser, plus any wait behind the page's own work.
903
+ One that runs alongside a capture shows its failure only there.
904
+
905
+ Page spans never carry typed text, and a script round trip is named by its function alone. A tool
906
+ call's parameters, which hold what a model typed, are Yielded's to record, under its own telemetry
907
+ and redaction. `Page.evaluate` spans are at `Trace` level and the phases
908
+ inside an action at `Debug`: set `Tracer.MinimumTraceLevel` to `"Info"` to keep only the coarser
909
+ spans.