effect-browser 0.2.0-beta.6 → 0.2.0-beta.8

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 (68) hide show
  1. package/README.md +507 -130
  2. package/dist/Browser-n-bKgBNN.d.mts +372 -0
  3. package/dist/Browser.d.mts +3 -189
  4. package/dist/Browser.mjs +9 -1
  5. package/dist/Browser.mjs.map +1 -1
  6. package/dist/{BrowserData-DPh3Oaof.d.mts → BrowserData-CPIIIHol.d.mts} +84 -10
  7. package/dist/BrowserData.d.mts +2 -2
  8. package/dist/BrowserData.mjs +106 -7
  9. package/dist/BrowserData.mjs.map +1 -1
  10. package/dist/{BrowserRuntime-BBAjpVFa.d.mts → BrowserRuntime-CFe9uUTv.d.mts} +16 -11
  11. package/dist/{BrowserRuntime-DImn_Oe_.mjs → BrowserRuntime-HJ1yYlFS.mjs} +4977 -4134
  12. package/dist/BrowserRuntime-HJ1yYlFS.mjs.map +1 -0
  13. package/dist/BrowserRuntime.d.mts +2 -2
  14. package/dist/BrowserRuntime.mjs +2 -1
  15. package/dist/Capture.d.mts +9 -7
  16. package/dist/Capture.mjs +157 -32
  17. package/dist/Capture.mjs.map +1 -1
  18. package/dist/{CaptureData-Dys0Ko9d.mjs → CaptureData-CYriZHX1.mjs} +25 -6
  19. package/dist/CaptureData-CYriZHX1.mjs.map +1 -0
  20. package/dist/CaptureData.d.mts +62 -3
  21. package/dist/CaptureData.mjs +2 -2
  22. package/dist/Chromium.d.mts +2 -2
  23. package/dist/Chromium.mjs +19 -10
  24. package/dist/Chromium.mjs.map +1 -1
  25. package/dist/Errors.d.mts +69 -17
  26. package/dist/Errors.mjs +57 -6
  27. package/dist/Errors.mjs.map +1 -1
  28. package/dist/Images-CaaPNeed.mjs +76 -0
  29. package/dist/Images-CaaPNeed.mjs.map +1 -0
  30. package/dist/OperationOptions-DAeRS6O9.mjs +55 -0
  31. package/dist/OperationOptions-DAeRS6O9.mjs.map +1 -0
  32. package/dist/PageControl.d.mts +6 -6
  33. package/dist/PageControl.mjs +16 -10
  34. package/dist/PageControl.mjs.map +1 -1
  35. package/dist/PageRegistry-B3q9DS3g.mjs +164 -0
  36. package/dist/PageRegistry-B3q9DS3g.mjs.map +1 -0
  37. package/dist/Plan-D8qQ48Ic.mjs +512 -0
  38. package/dist/Plan-D8qQ48Ic.mjs.map +1 -0
  39. package/dist/Plan.d.mts +3 -0
  40. package/dist/Plan.mjs +2 -0
  41. package/dist/PlanData-B668RxbQ.d.mts +4831 -0
  42. package/dist/PlanData.d.mts +3 -0
  43. package/dist/PlanData.mjs +268 -0
  44. package/dist/PlanData.mjs.map +1 -0
  45. package/dist/PlanInput-BJcPPncs.mjs +75 -0
  46. package/dist/PlanInput-BJcPPncs.mjs.map +1 -0
  47. package/dist/PublicSession-Bs61Eal8.mjs +5601 -0
  48. package/dist/PublicSession-Bs61Eal8.mjs.map +1 -0
  49. package/dist/Testing.d.mts +48 -6
  50. package/dist/Testing.mjs +675 -123
  51. package/dist/Testing.mjs.map +1 -1
  52. package/dist/Timeline-fV22Xk-S.d.mts +29 -0
  53. package/dist/Timeline.d.mts +4 -0
  54. package/dist/Timeline.mjs +20 -0
  55. package/dist/Timeline.mjs.map +1 -0
  56. package/dist/TimelineData-BsOn-YGZ.d.mts +3818 -0
  57. package/dist/TimelineData.d.mts +3 -0
  58. package/dist/TimelineData.mjs +482 -0
  59. package/dist/TimelineData.mjs.map +1 -0
  60. package/dist/index.d.mts +5 -1
  61. package/dist/index.mjs +5 -1
  62. package/package.json +17 -1
  63. package/dist/Association-BsSfBYVx.mjs +0 -138
  64. package/dist/Association-BsSfBYVx.mjs.map +0 -1
  65. package/dist/BrowserRuntime-DImn_Oe_.mjs.map +0 -1
  66. package/dist/CaptureData-Dys0Ko9d.mjs.map +0 -1
  67. package/dist/PageControlAssociation-DabEg5AR.mjs +0 -10
  68. package/dist/PageControlAssociation-DabEg5AR.mjs.map +0 -1
package/README.md CHANGED
@@ -8,9 +8,9 @@ This package has no Browserbase or Effect Agent dependency. Its common entry poi
8
8
 
9
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
10
 
11
- All callers use this exact session. `Capture.start`, `Capture.stream` and `PageControl` authenticate its identity privately; spreading or decoding an object cannot copy authority. A session or binding absent from the receiving runtime's private registry 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 session with page control disabled still fails `Unsupported`. `Tools.run` from `effect-agent-browser/tools` uses the original session directly; `yield* Adapter.fromSession(session, { selection: "current" | "retained" })` adapts it to the framework's handle with an explicit target policy. Neither opens another browser.
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
12
 
13
- Mutations are 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 after dispatch has an unknown outcome and is never automatically replayed. Unresolved control fences the owner; a main-frame loading timeout can instead retire control through one acknowledged stop, as described below. `undispatched`, `rejected` and `unknown` remain distinct expected outcomes.
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
14
 
15
15
  ## Self-managed Chromium
16
16
 
@@ -25,8 +25,8 @@ import { Chromium } from "effect-browser/chromium";
25
25
 
26
26
  const program = Browser.scoped(Chromium.launch(BrowserPolicy.unrestricted()), (browser) =>
27
27
  Effect.gen(function* () {
28
- yield* browser.navigate({ url: "https://example.com" });
29
- return yield* browser.observe({ scope: "viewport" });
28
+ yield* browser.initialPage.navigate({ url: "https://example.com" });
29
+ return yield* browser.initialPage.observe({ scope: "viewport" });
30
30
  }),
31
31
  ).pipe(
32
32
  Effect.provide(
@@ -43,7 +43,7 @@ The Layer requires Effect's `Crypto`, which the host platform supplies: `NodeSer
43
43
 
44
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
45
 
46
- The same combinator supports `open.pipe(Browser.scoped(use))`, including a reusable `const use = Browser.scoped((browser) => browser.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.
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
47
 
48
48
  ### A long-lived session in a Layer
49
49
 
@@ -69,13 +69,13 @@ const shared = Layer.effect(
69
69
 
70
70
  const program = Effect.gen(function* () {
71
71
  const browser = yield* SharedBrowser;
72
- return yield* browser.observe({ scope: "viewport" });
72
+ return yield* browser.initialPage.observe({ scope: "viewport" });
73
73
  }).pipe(Effect.provide(shared));
74
74
  ```
75
75
 
76
76
  Here `recordReceipt` is the host's bounded receipt sink. Consumers of the same Layer build share
77
77
  selection, budgets, native admission and lifetime; the Layer does not reset an expired session or
78
- make concurrent operations independent. Its acquisition finalizer performs unchecked release and
78
+ give same-Page operations independent permits. Its acquisition finalizer performs unchecked release and
79
79
  stores the receipt. It does not supervise `browser.failure`, and does not turn incomplete cleanup
80
80
  into a checked-close error for every consumer. The application must choose that supervision and
81
81
  explicit checked closure, or use `Browser.scoped` for a bounded workflow that owns those decisions.
@@ -91,7 +91,7 @@ Place the receipt sink outside that race when those facts must remain observable
91
91
  let receipt: ChromiumCleanupResult | undefined;
92
92
  const result =
93
93
  yield *
94
- Browser.scoped(Chromium.launch(policy), (browser) => browser.navigate({ url })).pipe(
94
+ Browser.scoped(Chromium.launch(policy), (browser) => browser.initialPage.navigate({ url })).pipe(
95
95
  Effect.timeoutOption("30 seconds"),
96
96
  Effect.provide(
97
97
  Chromium.layer({
@@ -184,85 +184,360 @@ Lost acknowledgement fences control without replay; a late acknowledgement canno
184
184
  Capacity refusal before dispatch is a known block rather than fabricated uncertainty. Dialog-cap
185
185
  overflow follows the same bounded dismissal rules. An acknowledged before-unload dismissal can
186
186
  retire only the exact navigation captured when its dialog arrived and subsequently rejected.
187
- Existing popup/dialog pause policies still require their explicit host recovery path.
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.
188
195
 
189
196
  ## Browser operations
190
197
 
191
- ### Selected, retained and pinned targets
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.
192
219
 
193
- The session itself is the convenient selected-target API. Its `navigate`, `readText`, `click`,
194
- `fill`, pointer/key input and screenshot Effects resolve the selected page/frame when the Effect
195
- actually executes. Constructing an Effect does not freeze the current selection:
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
+ });
233
+ ```
234
+
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.
196
277
 
197
278
  ```ts
198
- const navigateScout = session.navigate({ url: scoutUrl });
199
- yield * session.selectPage(scoutInfo);
200
- yield * navigateScout; // navigates the scout selected above
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);
201
307
  ```
202
308
 
203
- `yield* session.retain` is the deliberate retained-selection form. Acquisition resolves and
204
- validates selection under the owner's permit, then remembers its generation and selection revision.
205
- Selecting another page or frame, including moving away and back, makes that handle fail
206
- `Stale/undispatched`. The shared operation interface is `TargetOperations`; the session, retained
207
- view and pinned view choose their target at different times. The old `bind()` and `currentTarget`
208
- members have been removed; `session.target` remains a checked metadata read.
209
-
210
- | Previous API | Current API |
211
- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
212
- | `bind()` / `currentTarget`, `BoundTarget` | `retain`, `RetainedTarget` and shared `TargetOperations`; direct operations remain on the session |
213
- | `selectPage(id)` / `closePage(id)`, string returned by `createPage` | Checked `PageInfo` in and out; `selectPage` and `selectFrame` return `void` |
214
- | String `BrowserError.reason`, optional outcome | Tagged reason, `catchReason`/`catchReasons`, and required dispatch outcome |
215
- | Concrete `closeChecked` returns `undefined` | The canonical frozen receipt after the existing ownership check passes |
216
- | Capture `dropped` | `discarded` and disjoint `overflow`, `late`, `duplicates`, `rejected` |
217
- | Default-never `BrowserSession` on a non-supervising helper | `AnySession`; supervisors preserve their actual `E` or concrete session |
218
-
219
- The [root migration table](../../README.md#api-migration) also covers the adapter's required
220
- selection option, compact tool reasons/host diagnostics, binding defaults, common navigation
221
- deadline, optional control state and the explicit C1 generic-arity edge.
222
-
223
- `createPage` returns `PageInfo` for the exact created entry and does not select it. Both
224
- `selectPage(pageInfo)` and `closePage(pageInfo)` verify its local and native identities under
225
- owner admission. Page/frame selection returns `void`; request `retain` separately when a retained
226
- sequence is intended. Failed metadata acquisition after creation keeps the creation's dispatch
227
- evidence and never creates a second page.
228
-
229
- For work that must stay on a page while selection moves elsewhere, pin it explicitly:
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
+ `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.
320
+
321
+ ### Replayable session evidence
322
+
323
+ `session.timeline` publishes one bounded metadata journal for the active connection. An issued
324
+ `page.timeline` filters that same journal for its original Page. Snapshot reads and independent
325
+ subscribers acquire no browser permit, send no input and cannot stop native capture.
230
326
 
231
327
  ```ts
232
- const stageInfo = (yield * session.pages).find((page) => page.title === "Stage")!;
233
- const stage = yield * session.pinPage(stageInfo);
234
- const childInfo = (yield * session.framesOf(stageInfo)).find(
235
- (frame) => frame.parentFrameId !== null,
236
- )!;
237
- const child = yield * session.pinFrame(stageInfo, childInfo);
238
-
239
- yield * session.selectPage(scoutInfo);
328
+ import * as Timeline from "effect-browser/timeline";
329
+
330
+ const history = yield * session.timeline.snapshot();
331
+ const tail = session.timeline.events(history.resumeAfter);
332
+ const appendTime = yield * session.timeline.now;
333
+ const fromTime = session.timeline.events({ at: appendTime });
334
+ const portable = yield * Timeline.encodeSnapshot(history);
335
+ ```
336
+
337
+ `events(cursor)` resumes exclusively after that sequence; `events({ at })` includes events
338
+ appended at or after the domain-qualified time. `events()` follows from the current tail when
339
+ the stream executes. Snapshots carry immutable events, global available bounds, eviction facts,
340
+ terminal state and `resumeAfter`. A quiet Page's filtered snapshot still uses the global
341
+ watermark, including when it has no matching events. Evicting every retained event does not
342
+ reset that watermark. Requested evicted history and lapped subscribers fail `TimelineGap` with
343
+ requested and available cursors. Foreign store/clock identities and future cursors fail
344
+ `TimelineCursorError`; consumers choose how to recover rather than silently losing evidence.
345
+ An event the journal refuses as malformed or oversized leaves a `MetadataOmitted` marker with
346
+ its original target, correlation and tag, so a Page view sees its own gap. An omission whose
347
+ attribution was itself refused is unattributed and appears in every Page view.
348
+
349
+ `session.pages` is a lifecycle stream: it atomically attaches a copied Inventory baseline from
350
+ the canonical bounded native registry and the journal watermark, then follows lifecycle events.
351
+ Inventory metadata is explicitly cached: unread or omitted title/address fields are `null`.
352
+ It grants no live Page authority. `session.listPages()` remains the admitted fresh native
353
+ metadata read; its URL/title round trips are non-atomic. Restarting `session.pages` obtains a
354
+ fresh baseline after a gap. `Timeline.projectPages` and `Timeline.encodePages` remove cached
355
+ addresses, titles and native target IDs from the client projection. Native navigation and capture
356
+ boundaries retain bounded host addresses observed at their original callbacks; `Timeline.project`
357
+ and `Timeline.encodeClient` strip those nested fields too. `Timeline.encode` and `encodeSnapshot`
358
+ encode host evidence and preserve those addresses.
359
+
360
+ The session facade chooses the current journal when an Effect or Stream executes. Each active
361
+ subscription stays on that journal. Reconnect creates a new store identity; old subscribers
362
+ drain their available tail and terminate, or report a gap if lapped. A time selector earlier
363
+ than the current journal's start fails `TimelineGap`: that history belongs to the previous
364
+ journal, which an issued Page's timeline from that connection still reads. Issued Page timelines stay
365
+ on their original domain. Page/session terminal delivery does not bypass retention, and
366
+ `page.status` retains original containment facts after the corresponding events have evicted.
367
+
368
+ Events include plan correlation, original native phases, genuine input intervals, navigation,
369
+ settled/containment outcomes and capture references. A dispatch means handoff to checked native
370
+ implementation; acknowledgement means native completion. A performed preparatory or burst reply
371
+ carries `acknowledgement: { subphase, logicalComplete }`; only an acknowledgement without that
372
+ fact, or with `logicalComplete: true`, completed logical input. Later follow-up failure remains
373
+ separate. Key events contain counts and their units, never text. Individual pointer commands
374
+ are `Pointer` events; `Glide` is reserved for an actual bounded intended schedule. Capture IDs,
375
+ interval-local frame sequences and boundary attribution refer to the original capture; no
376
+ frame bytes enter the journal. `FirstFrame` means first accepted received frame after a
377
+ boundary, without proving new-document pixels. Picture native-call intervals remain separate
378
+ from DOM/text intervals and screencast receipt times.
379
+
380
+ Phase `nativeOrdinal` counts original dispatch phases within its `operationId`, rather than
381
+ individual CDP commands. `DisplayChanged` carries the selection and qualified running, held or
382
+ unknown display state from the same native callback.
383
+
384
+ Terminal retirement waits for original logical ticket/run outcomes and bounded capture stop
385
+ cleanup to publish their evidence. An unconfirmed native stop remains unconfirmed; retirement
386
+ does not wait indefinitely for raw native promises. Later native callbacks stay pinned to the
387
+ old journal and can be refused once it has retired. Capture `latePhase` qualifies a Watching or
388
+ Started callback after the logical interval ended. Original capture snapshots and quarantine
389
+ retain their independent native stop and loss facts.
390
+
391
+ Append timestamps come from the owner's captured host Clock and order with global sequences.
392
+ Receipt/source times are payload facts and never backdate append order. `Stamp.offsetNanos`
393
+ is relative to that clock domain's origin; its `clockId` is required. Explicit `*Json` schemas
394
+ and Timeline encoding helpers represent bigint offsets/sequences as decimal strings. Native
395
+ presentation Unix milliseconds remain a separately qualified source clock.
396
+
397
+ `timelineLimits` on Chromium/Browserbase options, or `automation.timelineLimits` on the runtime,
398
+ bounds retained metadata and subscriptions. Defaults are 60 seconds, 4,096 events, 4 MiB,
399
+ 32 subscribers and 64 KiB per event. Configurable maxima are six hours, 65,536 events, 64 MiB,
400
+ 256 subscribers and 1 MiB per event. Metadata-byte and per-event limits have a 2 KiB minimum so
401
+ canonical terminal and omission evidence fits. Count/time/byte eviction runs on append and read. Subscribers
402
+ retain bounded cursor/wakeup state rather than event queues; a slow consumer cannot backpressure
403
+ an admitted native action. Returned values are ordinary immutable snapshots, without an archive
404
+ registry retaining old journals.
405
+
406
+ ### Admission and deadlines
407
+
408
+ Ordinary operations on one Page share its permit, including operations on its Frames. Work on
409
+ other Pages can proceed independently: a held scout read or typing response does not block
410
+ stage input, a stage checkpoint or another Page's capture. Creation, inventory and global
411
+ lifecycle operations use the registry permit. Inventory is bounded and non-atomic. So does issuing:
412
+ `session.page(info)` and `page.frame(info)` authenticate and register, so they never wait for, or
413
+ fail `Busy` on, work already running on that page. `page.describe()`, `page.listFrames()` and
414
+ `Capture.start` read the page and share its permit like other reads; pass `admission.queue` to
415
+ wait behind that work.
416
+
417
+ Every admitted operation accepts trailing host-only `OperationOptions`:
418
+
419
+ ```ts
420
+ yield * scout.click({ selector: "#advance" }, { admission: { queue: "1 second" } });
421
+ yield * stage.checkpoint({}, { timeoutMillis: 2000, admission: { queue: "500 millis" } });
422
+ ```
423
+
424
+ Omitting `admission.queue`, or setting it to zero, refuses conflicting work immediately with
425
+ `Busy/undispatched`. A positive finite Effect duration allows FIFO waiting for that permit.
426
+ The queue defaults to 32 pending callers per Page or registry and 128 across the session.
427
+ `automation.admissionLimits.pendingPerPage` and `pendingPerSession` can set either bound to an
428
+ integer from 1 through 1,024. `Chromium.layer` and `BrowserbaseBrowser.layer` accept those fields
429
+ under their top-level `admissionLimits` option. `QueueFull` reports the refusing scope, maximum and observed count;
430
+ `QueueExpired` means the admission wait expired. Cancellation and either refusal dispatch
431
+ nothing and consume no action allowance. A granted waiter owns the permit before waking, so a
432
+ new arrival cannot overtake it.
433
+
434
+ The operation deadline and queue deadline start when the Effect executes. Waiting counts toward
435
+ `timeoutMillis`, the policy deadline and the browser lifetime; admission never renews them.
436
+ The queue deadline bounds only waiting, so admitted work can finish after that deadline. An
437
+ operation deadline that ends first reports `Timeout`. These options do not queue away a stale
438
+ target, a Page hold, a navigation reservation or a native-wait conflict. The issued Page/frame
439
+ authority remains fixed while waiting; display selection cannot redirect queued input.
440
+ `Capture.start` and `Capture.stream` accept admission through their capture options. Exact-node
441
+ methods keep the host's `ElementAdmission` callback separate from trailing operation options.
442
+
443
+ `page.status` includes passive `admission` state: the active operation, pending count and bound,
444
+ oldest wait age, retained native operations, native wait and stop setup. `session.admission` adds
445
+ aggregate counts and registry state. Native work that dispatched input keeps its Page occupied
446
+ after caller cancellation until the actual work settles or exact positive Page/connection
447
+ retirement proves it unusable. A read, or a mutation cancelled before dispatch, can no longer
448
+ change the page: it stays counted in `nativePending` but leaves the Page usable at once.
449
+ These host snapshots grant no priority and expose no native handles.
450
+
451
+ Navigation stopping and Page closure have reserved, bounded cleanup admission, so a full ordinary
452
+ queue cannot prevent recovery. Page closure uses fail-fast reserved admission and its operation
453
+ deadline when joining an existing close; `admission.queue` does not control those waits. Session closure
454
+ preempts work. Handoff, resume and reconnect refuse
455
+ an existing ordinary permit holder before installing their exclusive lifecycle barrier; handoff
456
+ then drains retained native work that can still change a page within its bound before granting
457
+ operator control.
458
+
459
+ ### Exact Pages and Frames
460
+
461
+ The session owns lifecycle, display selection and the page registry. `initialPage` is the Page
462
+ issued on the first connection; it remains that original lifetime fact after reconnect. Browser
463
+ input and reads belong to issued Pages and Frames. Constructing or running an operation on one
464
+ never follows display selection:
465
+
466
+ ```ts
467
+ const stage = session.initialPage;
468
+ const scout = yield * session.createPage();
469
+ const navigateStage = stage.navigate({ url: stageUrl });
470
+
471
+ yield * session.selectPage(scout);
472
+ yield * navigateStage; // still navigates the original stage
473
+ const seen = yield * scout.observe({ scope: "viewport" });
474
+ ```
475
+
476
+ `session.page(pageInfo)` checks both local and native identity under owner admission.
477
+ `createPage()` returns the Page issued for the new page without selecting it. `selectPage(page)`
478
+ takes a Page this session issued and chooses the displayed page, which is also the page `detach`
479
+ records and `reconnect` resumes on; it never retargets a Page or Frame operation, and a closed or
480
+ stale Page is refused unsent. `Browser.checkPage(session, page)` checks that a Page or Frame
481
+ is one this session issued, by identity, and that its authority is still current; adapters call it
482
+ once when they bind, so a foreign, copied or retired target fails before any work. A child frame
483
+ is reached through `page.frame(frameInfo)`, never by selection. Use `page.describe()`, `page.listFrames()`,
484
+ `page.frame(frameInfo)`, `page.resizeViewport(viewport)` and `page.close()` for exact page work.
485
+ The selected-session actions, `retain`, `pinPage`, `pinFrame` and their target-view types are
486
+ removed. The shared operation contract is `PageOperations`.
487
+
488
+ | Previous API | Current API |
489
+ | ----------------------------------------------------------------- | ------------------------------------------------------------------------------ |
490
+ | Session actions, `observe`, `checkpoint`, `ready`, `target` | Issued `Page`/`Frame` methods and immutable `identity` |
491
+ | `retain`, `pinPage`, `pinFrame`, `selectFrame` | `initialPage`, checked `session.page(info)` and `page.frame(info)` |
492
+ | Session `describePage`, `framesOf`, `closePage`, `resizeViewport` | `page.describe`, `page.listFrames`, `page.close`, `page.resizeViewport` |
493
+ | Session capture and PageInfo capture target | `Capture.start(page)` / `Capture.stream(page)` |
494
+ | Session PageControl adapters | `PageControl.state(page)`, `suspend(page)`, `resume(page, receipt)` |
495
+ | `createPage()` returning `PageInfo` | `createPage()` returns the issued `Page`; `page.describe()` reads its metadata |
496
+ | `selectPage(pageInfo)` | `selectPage(page)` with an issued `Page` |
497
+
498
+ Failed metadata acquisition after creation keeps its original dispatch evidence and never
499
+ creates a second page. Page operations preserve typed failures and scoped ownership; a terminal
500
+ Page's retained status and timeline remain readable, while new work refuses undispatched.
501
+
502
+ Each page `createPage` opens is a window of its own, not a tab. Chromium paints only the front
503
+ tab of a window, so a screenshot of a tab behind another waits seconds for a compositor frame, or
504
+ never gets one, and the page that was in front goes behind each new tab. A window of its own is
505
+ painted whether or not another page is in front, so any page can be pictured and read at speed
506
+ and the selected page keeps its capture. With a fixed viewport, each new window's contents are
507
+ set to it, as the first page's are; a provider-managed or preserved viewport is left to the
508
+ browser, which sizes a new window like the last one it showed. A browser that answers the
509
+ request for a window with a protocol error, which means it created nothing, gets tabs from then
510
+ on. `createPage()` serializes creation and adoption under the registry permit and fails immediately
511
+ when it is occupied. Pass `{ admission: { queue: "1 second" } }` to wait FIFO within the same
512
+ operation deadline. It does not wait behind another Page's ordinary operation. A refused or
513
+ expired caller opens nothing.
514
+
515
+ Inventory authenticates each native Page before adopting it into the owner's registry,
516
+ including popups. `page.describe()` reads its current address and title without reading every
517
+ other page. `session.listPages()` reads every page's title concurrently under bounded admission.
518
+ Metadata does not itself grant live input authority.
519
+
520
+ ```ts
521
+ const stageInfo = (yield * session.listPages()).find((info) => info.title === "Stage")!;
522
+ const stage = yield * session.page(stageInfo);
523
+ const childInfo = (yield * stage.listFrames()).find((info) => info.parentFrameId !== null)!;
524
+ const child = yield * stage.frame(childInfo);
525
+
526
+ yield * session.selectPage(scout);
240
527
  yield * stage.click({ selector: "#advance" });
241
528
  const childText = yield * child.readText({ selector: "#status" });
242
529
  ```
243
530
 
244
- Pinning never changes global selection and opens no second browser connection. A pinned handle
245
- contains only modeled operations plus its immutable `Target`; every operation re-resolves the
246
- connection-local page/frame identity through the same owner. Reconnect makes the old generation
247
- stale, closing its page or detaching its frame makes it unusable, and an in-flight navigation
248
- reservation or page hold is checked on the pinned page rather than whichever page happens to be
249
- selected.
250
-
251
- Page and frame IDs are opaque and namespaced per connection. Old `PageInfo`, IDs and live handles
252
- are invalid after reconnect. In the same known browser lifetime, read fresh `pages`, match exactly
253
- one saved `targetId`, and use that fresh page record; zero matches means gone and multiple matches
254
- mean ambiguous. Never fall back to order, local serial, URL or title. Then reacquire frames with
255
- `framesOf(freshPage)`. A surviving native target can identify the same page, but cannot make old
256
- local metadata or handles current again.
257
-
258
- Pinned operations are selector-based. They intentionally do not create another retained
259
- `Observation` or an exact-node namespace. There is still one observation: a new `observe` replaces
260
- it. A pinned read or mutation on another page leaves that observation intact, as does selecting
261
- away and back without observing another document. Exact-node work refuses `Stale/undispatched`
262
- while another page or frame is selected; it never redirects the reference to that selection.
263
- Returning to the original page/frame can use the original reference only if its document and
264
- exact control are still valid. This does not revive a retained-selection handle: `retain` still
265
- becomes stale after every selection change, including away and back.
531
+ Issuance changes no display selection and opens no second connection. Each operation keeps its
532
+ exact connection-local page/frame authority. Closing its Page, detaching its Frame or reconnecting
533
+ makes it stale. Navigation preserves the Page/Frame authority and retires that document's node
534
+ references. Holds and in-flight navigation reservations are checked on the issued target.
535
+
536
+ IDs are opaque and namespaced per connection. After reconnect, read fresh `listPages()`, match
537
+ exactly one saved native `targetId`, issue `session.page(freshInfo)`, and reacquire its Frames with
538
+ `page.listFrames()` / `page.frame(info)`. Zero matches means gone and multiple matches mean
539
+ ambiguous. Never fall back to ordering, local serial, URL or title. A surviving native target
540
+ cannot make an old Page or copied metadata current again.
266
541
 
267
542
  Dispatched input on the observation's own page still retires its references, including `hover`,
268
543
  `pointerMove`, `wheel` and `scroll`. Hover then click therefore deliberately needs a new inspection.
@@ -272,17 +547,81 @@ even while another page is selected. IDs are opaque and connection-specific; con
272
547
  inspection result rather than predicting serials. Attachment, identity and fresh-state checks at
273
548
  dispatch remain necessary even when the last input targeted a different page.
274
549
 
275
- The complete [multi-page example](examples/multi-page.ts) keeps a presentation page pinned while
550
+ ### Performed plans and absolute starts
551
+
552
+ `page.run(plan, { style: {} })` uses the named default performed profile; omitted style or
553
+ `style: "plain"` keeps plain input. `Plan.checkRunOptions(value)` checks and normalizes host
554
+ options, preserving public `within`, queue admission and performed defaults. Profiles have finite
555
+ pointer duration/inset/curvature, key interval/hold and scroll duration/interval bounds. `seed` is
556
+ an optional safe integer; the actual chosen seed is retained in run and attempt receipts.
557
+ Step-local planning depends on that seed and stable step ID, independently of other Pages or
558
+ ambient Random use. It reproduces planned paths for the same geometry, not website behavior or
559
+ remote delivery timing. Slips default to probability zero and require explicit opt-in.
560
+
561
+ The pointer timing uses a chosen bounded Shannon-form policy for measured targets, and explicit
562
+ coordinates use a chosen bounded distance policy. The fifth-order progress polynomial gives zero
563
+ endpoint velocity/acceleration; applying it to a curved policy path does not claim globally minimum
564
+ Cartesian jerk. Constants are library policy, not calibrated human guarantees. Scheduled glides are
565
+ output-only compositor metadata: they do not generate hover along the drawn path. Exact native hit
566
+ checks and selected node identity remain required immediately before real input. A missing actual
567
+ click position stays null even when an intended aim is retained.
568
+
569
+ A schedule has at most 128 geometry samples per path or scroll, and performed text at most 256
570
+ Unicode code points, which expand to at most 768 strokes (a slip adds a wrong key and its
571
+ Backspace), independently of action and pending-native capacity. Longer text fails `Limit` with
572
+ dimension `code-points` and its measured count before any input. Performed Fill
573
+ may focus/select/replace inside its action; Type still requires existing focus. Each performed
574
+ logical action costs the same action budget as its plain counterpart. FillForm retains its
575
+ per-field admission, verification, partial results and guarded submit.
576
+
577
+ `startAt` is an absolute bigint on this owner's host monotonic clock, available through
578
+ `session.monotonicTimeNanos` and, for a host that holds only an issued Page or Frame, the same
579
+ clock's `page.monotonicTimeNanos`. Future starts wait before admission, reserve no Page permit and
580
+ never begin before that instant on the owner's clock, so `latenessNanos` is never negative.
581
+ A waiting start holds only its run registration, never the timeline: when its Page closes or its
582
+ owner pauses, detaches or ends, the run fails preparation at once instead of at its start.
583
+ `within` starts at the intended boundary and bounds queueing, preparation, pacing, input and
584
+ postconditions together, capped by owner lifetime and each action deadline. Missed preparation
585
+ reports `ScheduleMissed` without inventing an attempt; an insufficient pacing budget reports
586
+ `TimingBudgetExceeded` before new input. Performed strokes start at their absolute schedule
587
+ offsets, so a slow reply delays one stroke rather than every later one. Before each stroke's
588
+ first key, the rest of the schedule must still fit the deadline at the round trips the browser
589
+ has actually taken; a stroke that could not finish is refused whole, and strokes already
590
+ acknowledged stay `performed`. Receipts retain requested/intended/actual start, deadline
591
+ and lateness. Cancellation stops future submissions and keeps readable attempt history with the
592
+ original Effect Cause. Known preparatory work followed by refusal is rejected with its subphase
593
+ receipts; pending input remains unknown, and acknowledged logical input stays performed when a
594
+ later check fails.
595
+
596
+ ### An unknown outcome on an exact Page
597
+
598
+ A mutation whose outcome becomes unknown after dispatch, because it timed out, failed or was
599
+ interrupted, never runs again automatically. When the owner can attribute it to an exact Page,
600
+ it revokes that Page's admission and asks the browser to close it within two seconds. This
601
+ includes issued Page/Frame operations, forms, file transfers,
602
+ navigation, initialization and callback retirement. Concurrent close requests join the same
603
+ owned closure attempt. Once the browser confirms closure, healthy Pages, their observations and
604
+ captures can continue. The original operation still fails with its own reason and `unknown`
605
+ outcome, carrying `PageClosed` containment; its Page and Frame authority remains terminal.
606
+ `session.diagnostics` records `page-contained`.
607
+
608
+ Closing the page does not undo what the operation already did. A request it made, a cookie or
609
+ storage it changed, a download or a popup it started can all outlast the page, exactly as they
610
+ can after a known outcome. Unconfirmed closure or a failure without trustworthy Page attribution
611
+ fences the session conservatively and reports `SessionFenced`. A late response never reopens
612
+ fenced control. Containment reports owner safety independently of the original action outcome.
613
+
614
+ The complete [multi-page example](examples/multi-page.ts) keeps an issued presentation Page while
276
615
  the selected scout supplies observations. It reads and captures the presentation page without
277
616
  switching selection and returns only data after the shared browser scope closes.
278
617
 
279
618
  ### Typed host failures
280
619
 
281
- `BrowserError` keeps its operation and a required `outcome` (`undispatched`, `rejected`, or
620
+ `BrowserError` keeps its operation and a required `outcome` (`undispatched`, `rejected`, `performed`, or
282
621
  `unknown`), while `reason` is a tagged union. Recover by reason without parsing strings:
283
622
 
284
623
  ```ts
285
- const read = session
624
+ const read = session.initialPage
286
625
  .readText({})
287
626
  .pipe(Effect.catchReason("BrowserError", "Busy", () => Effect.succeed({ text: "" })));
288
627
  ```
@@ -291,9 +630,14 @@ const read = session
291
630
  allowance exhausted at two reports `maximum: 2, observed: 2`; it does not count a third call that
292
631
  was never admitted. `Configuration` and `Malformed` may name a declared schema-field prefix,
293
632
  never a supplied value or unknown property name. `Provider`/`Transport` may carry `status` and
294
- `RateLimited` may carry `retryAfterMillis`; those fields no longer sit on the outer error.
633
+ `RateLimited` may carry `retryAfterMillis`; those fields no longer sit on the outer error. A
634
+ `Provider` failure the native engine raised carries `detail`: the first line of what the engine
635
+ said, such as `elementHandle.click: Target page, context or browser has been closed`, without
636
+ terminal escape codes and at most 512 characters, with every address cut to its origin
637
+ (`https://shop.test/…`) and Playwright's call log dropped, since a path, query or DevTools endpoint
638
+ can carry a credential. It is a host diagnostic, and the agent tools never project it.
295
639
  `InitializationError` and the provider's separate resource-error families retain their own
296
- contracts. The Agent tools project host errors to their compact eleven-reason vocabulary and
640
+ contracts. The Agent tools project host errors to their compact model-facing vocabulary and
297
641
  retain the originals in the bounded, host-only `ToolHost.toolFailures` snapshot.
298
642
 
299
643
  ### A navigation you can watch while it loads
@@ -301,7 +645,7 @@ retain the originals in the bounded, host-only `ToolHost.toolFailures` snapshot.
301
645
  `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:
302
646
 
303
647
  Both accept optional `timeoutMillis` from 1 to 600000, capped by the remaining session lifetime.
304
- Omitting it uses the configured action timeout. Direct, retained and pinned calls share the
648
+ Omitting it uses the configured action timeout. Page and Frame calls share the
305
649
  same navigator; the maintained model `browser_navigate` tool still accepts only its upstream URL
306
650
  request. This field is the loading deadline, not a promise of rollback on timeout. On a main-frame
307
651
  timeout from the pinned engine, up to 3000 ms of recovery may follow that deadline. One absolute
@@ -312,9 +656,9 @@ by the remaining session lifetime. It is never renewed and cannot extend that li
312
656
  const operation =
313
657
  yield * handle.startNavigation(StartNavigationRequest.make({ url, timeoutMillis: 30_000 }));
314
658
 
315
- const early = yield * session.checkpoint({ picture: true }); // what has loaded so far
316
- const receipt = yield * PageControl.suspend(session, page); // timers, CSS and parsing stop
317
- yield * PageControl.resume(session, receipt);
659
+ const early = yield * page.checkpoint({ picture: true }); // what has loaded so far
660
+ const receipt = yield * PageControl.suspend(page); // timers, CSS and parsing stop
661
+ yield * PageControl.resume(page, receipt);
318
662
  const { url: loaded } = yield * operation.completed;
319
663
  ```
320
664
 
@@ -323,34 +667,36 @@ The owner's permit is released as soon as the navigation is dispatched. While it
323
667
  - `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.
324
668
  - `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.
325
669
  - 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.
326
- - A failed or unacknowledged recovery, replacement navigation, detached frame or other native rejection keeps the conservative fence. Pinned child-frame timeout also fences: `Page.stopLoading` acts on the whole page, so automatic frame-local cancellation is not claimed. Explicit stop retains its page-wide meaning.
327
- - Leaving the operation's scope unsettled fences it as unknown. 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.
670
+ - 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.
671
+ - 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.
328
672
 
329
- A read is ordered against the document being replaced by failing: if the document it was reading was replaced underneath it, or a navigation is still in flight on its page, the error is `target-changed` and `undispatched`, which means read again. A read is never a mutation, so that is always safe. A held page is not read at all; take the checkpoint before the hold and keep it.
673
+ 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.
330
674
 
331
675
  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.
332
676
 
333
677
  ### What is on screen, and what a host may know about it
334
678
 
335
- `session.observe()` reads the whole document. `session.observe({ scope: "viewport" })` keeps only text and controls that are on screen and reachable, and says what it left out:
679
+ `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:
336
680
 
337
681
  ```ts
338
- const seen = yield * session.observe({ scope: "viewport" });
682
+ const seen = yield * page.observe({ scope: "viewport" });
339
683
  seen.viewport; // { clippedText, coveredText, uncertainText, unreachableControls, exhausted, ... }
340
684
  ```
341
685
 
686
+ `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.
687
+
342
688
  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.
343
689
 
344
690
  `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:
345
691
 
346
692
  ```ts
347
- const found = yield * session.observe({ scope: "document", match: "create account" });
693
+ const found = yield * page.observe({ scope: "document", match: "create account" });
348
694
  found.match; // "create account"
349
695
  ```
350
696
 
351
697
  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`.
352
698
 
353
- An `Observation` is safe to show a model, and the adapter's `browser_inspect` Tool returns it as is. It therefore 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:
699
+ 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:
354
700
 
355
701
  Controls also carry optional `checked`, `selected`, `inputType` and `required`. Native checkbox
356
702
  and radio state comes from the element; applicable ARIA state accepts only explicit true/false.
@@ -361,27 +707,27 @@ input decision participates in the exact-node fresh-facts check; field values an
361
707
  remain excluded from the model projection.
362
708
 
363
709
  ```ts
364
- const facts = yield * session.controlFacts(reference);
710
+ const facts = yield * page.controlFacts(reference);
365
711
  // kind, label, disabled, editable, inputType, autocomplete, formMethod, box, placement, hitTest
366
712
  // destination: the resolved link target, or where this control submits its form
367
713
  ```
368
714
 
369
715
  `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.
370
716
 
371
- 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`, exactly as it does for a replaced or detached node. Nothing is ever re-found by selector or label.
717
+ 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.
372
718
 
373
719
  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:
374
720
 
375
721
  ```ts
376
722
  yield *
377
- session.fillElement(reference, value, {
723
+ page.fillElement(reference, value, {
378
724
  admit: (facts) => facts.inputType !== "password" && facts.autocomplete !== "cc-number",
379
725
  });
380
726
  ```
381
727
 
382
- Anything but `true`, or a policy that throws, sends nothing and fails `denied`. The policy is a plain synchronous function on purpose: it runs while the owner's permit is held, where waiting on a model or a network call would stall every other operation. It is not an atomic check-and-input transaction, because page script can still run before the native input lands.
728
+ 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.
383
729
 
384
- `fillElement` also refuses, undispatched, what the maintained engine would otherwise refuse only after dispatch, where the unknown outcome would fence the owner: 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.
730
+ `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.
385
731
 
386
732
  ### Exact native option selection
387
733
 
@@ -417,7 +763,7 @@ used for exact-node input applies to the selected control.
417
763
  ```ts
418
764
  const result =
419
765
  yield *
420
- session.fillForm({
766
+ page.fillForm({
421
767
  observationId: seen.observationId,
422
768
  fields: [
423
769
  { elementId: email, value: "ada@example.test" },
@@ -431,7 +777,7 @@ const result =
431
777
 
432
778
  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.
433
779
 
434
- 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. 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.
780
+ 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.
435
781
 
436
782
  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.
437
783
 
@@ -440,7 +786,7 @@ The first refused or uncertain step ends the form, and nothing after it is sent.
440
786
  ### Bounded waits that leave room for recording
441
787
 
442
788
  `waitFor({ selector, state })` still supports the host's bounded selector conditions: `visible`,
443
- `hidden`, `attached` and `detached`. It captures the selected page, frame and document when admitted.
789
+ `hidden`, `attached` and `detached`. It captures the issued page, frame and document when admitted.
444
790
  `waitForElement({ reference, state, timeoutMillis? })` instead waits on an exact node issued by the
445
791
  current observation, with `visible`, `hidden`, `enabled` or `disabled`. It never searches for a node
446
792
  that replaced the reference. Hidden includes detachment of the original node; for the other
@@ -455,13 +801,12 @@ the configured action timeout and remaining lifetime still cap it, including set
455
801
  can move elsewhere without retargeting the wait. Its condition is a sample, not a reservation
456
802
  that the page will remain unchanged.
457
803
 
458
- While a wait is pending, `checkpoint`, `pages` and ordinary bounded reads can use the normal
804
+ While a wait is pending, `checkpoint`, `listPages` and ordinary bounded reads can use the normal
459
805
  permit. Mutations, another navigation and page holds on the waited page are refused before
460
- dispatch. Input on another page may proceed; replacing the single observation is refused until
461
- the logical wait ends. Closing a page or session remains available and cancels affected waits.
806
+ 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.
462
807
  No general read-under-write bypass has been added.
463
808
 
464
- One native wait may be outstanding per browser session. Cancellation and timeout release the
809
+ One native wait may be outstanding per exact Page, with a finite session bound of 32. Cancellation and timeout release the
465
810
  logical barrier without inventing an uncertain mutation, but retain native capacity until the
466
811
  actual native promise and any required handle disposal settle, or that exact connection retires.
467
812
  Consequently another wait can still receive `Busy/undispatched` after its predecessor's caller
@@ -471,10 +816,10 @@ the observation is replaced, and its late cleanup cannot dispose a successor's r
471
816
 
472
817
  ### Passive checkpoints for a recorder
473
818
 
474
- `observe()` replaces the one observation whose nodes later actions may name, so a recorder calling it would retire the references an agent is about to use. `session.checkpoint()` is the passive path: viewport text, control facts and, when asked, a PNG of the viewport.
819
+ `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.
475
820
 
476
821
  ```ts
477
- const checkpoint = yield * session.checkpoint({ picture: true });
822
+ const checkpoint = yield * page.checkpoint({ picture: true });
478
823
  ```
479
824
 
480
825
  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.
@@ -483,7 +828,7 @@ It issues no references, is not a mutation, and leaves the action observation an
483
828
  `Chromium.layer({ maxHostReads })` or the corresponding provider Layer. The default is 10,000,
484
829
  with an explicit integer bound of 1–1,000,000; invalid values are refused rather than clamped.
485
830
  Exhaustion reports `Limit { dimension: "host-reads", maximum, observed } / undispatched` while the
486
- owner stays open. These operations still obey the same lifetime, bytes, deadline and fail-fast
831
+ owner stays open. These operations still obey the same lifetime, bytes, deadline and admission
487
832
  concurrency bounds: a checkpoint can execute page script and is not unlimited free work.
488
833
  `observe`, `readText`, `screenshot` and `waitFor` continue consuming the model-reachable action
489
834
  allowance, which `status.actions` reports. Neither budget is a tool parameter, and `maxActions`
@@ -494,7 +839,7 @@ has not become mutations-only.
494
839
  `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.
495
840
 
496
841
  ```ts
497
- const handle = session; // ordinary selected-page operations
842
+ const handle = session.initialPage; // exact original Page authority
498
843
 
499
844
  yield * handle.pointerMove(PointerMoveRequest.make({ to: { x: 140, y: 100 } }));
500
845
  yield * handle.hover(HoverRequest.make({ selector: "#menu" }));
@@ -503,9 +848,9 @@ const receipt =
503
848
  yield * handle.wheel(WheelRequest.make({ deltaX: 0, deltaY: 240, at: { x: 420, y: 120 } }));
504
849
  ```
505
850
 
506
- Coordinates are CSS pixels in the main frame's viewport. Each call is one native command, charged as one action and fenced like any other mutation, so a handle bound to a page that is no longer selected sends nothing to either page. Easing, pacing and cursor artwork are yours: send the points you want, and draw the cursor from the positions the receipts report.
851
+ 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.
507
852
 
508
- `hover` places the pointer on one exact element where it is, by selector or by the node an observation named (`session.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`.
853
+ `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.
509
854
 
510
855
  For a child-frame element, hover checks the commanded point through each ancestor
511
856
  frame and then the exact node, including cross-origin documents. The receipt
@@ -523,7 +868,7 @@ An `InputReceipt` carries the target it was sent to, the position this owner com
523
868
  `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.
524
869
 
525
870
  ```ts
526
- const handle = session; // ordinary selected-page operations
871
+ const handle = session.initialPage; // exact original Page authority
527
872
 
528
873
  // Focus is the page's business. A real click gives it, and keys then follow it.
529
874
  yield * handle.click(ClickRequest.make({ selector: "#from" }));
@@ -534,19 +879,38 @@ yield * handle.press(PressRequest.make({ key: "k", modifiers: ["Control"] }));
534
879
  yield * handle.press(PressRequest.make({ key: "Enter", into: "#from" }));
535
880
  ```
536
881
 
537
- Keys go to whatever has focus in the selected 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. `session.pressElement` and `session.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.
882
+ 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.
538
883
 
539
884
  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.
540
885
 
541
- `type` sends up to 256 characters as one charged action, two native commands for each, one after another under a single action timeout. On a slow link a long passage can outlast that timeout, which leaves an unknown outcome and fences the session like any interrupted mutation, so send it as several shorter runs. The owner is checked between characters, which means a fence stops the rest instead of typing into a session that is closing. Two limits come from the pinned engine. 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. And a shifted character arrives 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`. Pacing is yours, as easing is for the pointer: for a typist's cadence, send one character per call and sleep between them, at one action each.
886
+ `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.
542
887
 
543
- A press is dispatched, not awaited. 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. Neither operation is part of the model-facing toolkit in `effect-agent-browser`.
888
+ 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.
889
+
890
+ Performed typing checks focus once before each stroke, never between a key and its release: a
891
+ key whose own default action moves focus (Tab, an Enter that submits, an auto-advancing field)
892
+ still completes its stroke. A fully acknowledged stroke, including its key and modifier releases,
893
+ retains `performed` evidence if a later stroke is refused. An acknowledged text insertion, the
894
+ Backspace that erases a performed Fill's old value, a filled form field and an acknowledged
895
+ scroll sample do the same. Only focus, selection and scroll-into-view preparation remain partial
896
+ and report `rejected` when nothing else landed; unresolved key input still reports `unknown` and
897
+ receives the original containment policy. A later failure never weakens what was acknowledged.
898
+
899
+ 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.
900
+
901
+ 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`.
544
902
 
545
903
  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.
546
904
 
547
- Persistent Browserbase contexts require a live writer permit from `ContextCoordination.withWriter` when writes are persisted. Detach/reconnect is opt-in with `keepAlive`; 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.
905
+ 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.
548
906
 
549
- Human handoff pauses automation before returning host-only Live View material. Resume requires an explicit operator-release signal and obtains a fresh observation while holding the same mutation permit. A failed handoff 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.
907
+ Human handoff blocks new admission and drains native work that can still change a page within a
908
+ bounded interval before pausing automation and returning host-only Live View material. Pure waits
909
+ and reads do not hold it up; the pause refuses them as `Stale`. Work that cannot drain in time,
910
+ such as a navigation still loading, fails the request with `Timeout/undispatched`: no handoff is
911
+ granted, the barrier lifts and automation continues. Resume requires an explicit operator-release signal
912
+ and returns fresh bounded Page inventory under the lifecycle permit; observing a newly acquired
913
+ Page is explicit. 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.
550
914
 
551
915
  ## Registrations, capabilities and document readiness
552
916
 
@@ -593,9 +957,9 @@ const run = Browser.scoped(
593
957
  Chromium.launch(BrowserPolicy.unrestricted(), { bootstrap }),
594
958
  (session) =>
595
959
  Effect.gen(function* () {
596
- yield* session.navigate({ url: "https://portal.example.com" });
597
- yield* session.ready;
598
- return yield* session.observe();
960
+ yield* session.initialPage.navigate({ url: "https://portal.example.com" });
961
+ yield* session.initialPage.ready();
962
+ return yield* session.initialPage.observe();
599
963
  }),
600
964
  );
601
965
  // run requires Chromium and ShowSettings. Browser.scoped discharges both the
@@ -608,23 +972,23 @@ Each binding accepts exactly one JSON-compatible argument and returns the output
608
972
 
609
973
  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.
610
974
 
611
- `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. 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, but reentrant browser work never waits behind that mutation's permit: it fails `busy` with `undispatched` instead.
975
+ `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.
612
976
 
613
977
  `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.
614
978
 
615
- 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.ready` reports the current document without charging an action, and an origin outside the plan is reported as `NotApplicable` rather than waited on.
979
+ 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.
616
980
 
617
981
  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).
618
982
 
619
983
  ## Live capture and presentation
620
984
 
621
- For a frame-processing pipeline, use `Capture.stream(session, 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.
985
+ 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.
622
986
 
623
987
  ```ts
624
988
  import { Stream } from "effect";
625
989
  import * as Capture from "effect-browser/capture";
626
990
 
627
- const firstFrame = Capture.stream(session, { lifetime: "page" }).pipe(
991
+ const firstFrame = Capture.stream(page, { lifetime: "page" }).pipe(
628
992
  Stream.take(1),
629
993
  Stream.runCollect,
630
994
  );
@@ -652,7 +1016,7 @@ import { Clock, Effect, Stream } from "effect";
652
1016
 
653
1017
  const interval =
654
1018
  yield *
655
- Capture.start(session, {
1019
+ Capture.start(page, {
656
1020
  lifetime: "page",
657
1021
  size: { width: 1280, height: 720 },
658
1022
  maxFrames: 400,
@@ -671,14 +1035,14 @@ const delayed = interval.frames.pipe(
671
1035
 
672
1036
  `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.
673
1037
 
674
- Closing, navigating, detaching a relevant frame, or resizing the captured page ends its interval explicitly without ending a sibling page's capture. `Capture.start(session, { 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).
1038
+ 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).
675
1039
 
676
1040
  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.
677
1041
 
678
1042
  ### Read metadata while capture is running
679
1043
 
680
1044
  ```ts
681
- const interval = yield * Capture.start(session, { lifetime: "page" });
1045
+ const interval = yield * Capture.start(page, { lifetime: "page" });
682
1046
  const current = yield * interval.snapshot;
683
1047
  // current.phase is "capturing"; initialUrl and recorded documentBoundaries are available now.
684
1048
  ```
@@ -692,6 +1056,13 @@ ones were let go, and `currentDocument` continues counting. Addresses longer tha
692
1056
  bound remain `null`. The model should not receive these host-only addresses by
693
1057
  accident merely because it can inspect the page.
694
1058
 
1059
+ Every summary and snapshot includes `qualification`: this exact target's authority phase,
1060
+ containment facts, and the owner's phase and generation. A revoked Page may still be awaiting
1061
+ closure; `PageClosed` appears only after positive native closure, independently of `nativeStop`.
1062
+ Later `snapshot` and `completed` reads can retain that confirmation even when capture ended
1063
+ earlier. The original action outcome and capture end reason remain unchanged. A terminal Page
1064
+ requires explicitly fresh authority and intent before another capture can start.
1065
+
695
1066
  While capturing, `reason` and `nativeStop` are `null`. `phase: "stopping"` means
696
1067
  capture has stopped accepting frames but native cleanup is pending. `"stopped"`
697
1068
  means that cleanup attempt settled; only `nativeStop: "confirmed"` confirms the
@@ -711,19 +1082,19 @@ Footage from `capture` is the page surface. It has no pointer, no tab strip and
711
1082
  - 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;
712
1083
  - 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.
713
1084
 
714
- 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. The clock is Effect's `Clock`, read where the session was opened and the capture was started, so `Clock.clockWith((clock) => clock.monotonicTimeNanos)` in the same runtime is on the same timeline as every frame and receipt. A title is page state that changes whenever the page likes, not part of a transition: read it with `session.pages` when you need one, and stamp that read yourself.
1085
+ 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.
715
1086
 
716
1087
  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.
717
1088
 
718
1089
  ## Explicit stage-page holds (opt-in)
719
1090
 
720
- Set `pageControl: true` on `Chromium.layer` or `BrowserbaseBrowser.layer` to use the host-only `page-control` module. The default remains off. `PageControl.suspend(session, page)` returns a live `PageSuspension`; `PageControl.resume(session, receipt)` consumes that exact receipt. Use a `PageInfo` from `session.pages`. 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 `session.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.
1091
+ 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.
721
1092
 
722
1093
  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.
723
1094
 
724
1095
  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.
725
1096
 
726
- Partial native failures fence the session as uncertain; 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.
1097
+ 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.
727
1098
 
728
1099
  ## Network policy
729
1100
 
@@ -735,6 +1106,8 @@ Only explicit trusted-host `Unrestricted` network policy is implemented. `ExactH
735
1106
 
736
1107
  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.
737
1108
 
1109
+ 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.
1110
+
738
1111
  ```ts
739
1112
  import { expect, it } from "@effect/vitest";
740
1113
  import { Effect } from "effect";
@@ -771,13 +1144,13 @@ it.effect("an unknown click is never replayed", () =>
771
1144
  reason: Reasons.Timeout.make({}),
772
1145
  outcome: "unknown",
773
1146
  });
774
- const observation = yield* browser.observe();
1147
+ const observation = yield* browser.initialPage.observe();
775
1148
  const accept = { observationId: observation.observationId, elementId: "accept" };
776
1149
 
777
- const first = yield* browser.clickElement(accept).pipe(Effect.flip);
1150
+ const first = yield* browser.initialPage.clickElement(accept).pipe(Effect.flip);
778
1151
 
779
1152
  expect(first).toMatchObject({ reason: { _tag: "Timeout" }, outcome: "unknown" });
780
- const retry = yield* browser.clickElement(accept).pipe(Effect.flip);
1153
+ const retry = yield* browser.initialPage.clickElement(accept).pipe(Effect.flip);
781
1154
 
782
1155
  expect(retry).toMatchObject({ reason: { _tag: "Closed" }, outcome: "undispatched" });
783
1156
  const clicks = (yield* browser.control.calls).filter((c) => c.operation === "click");
@@ -789,7 +1162,7 @@ it.effect("an unknown click is never replayed", () =>
789
1162
  );
790
1163
  ```
791
1164
 
792
- `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. 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, so the owner 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.
1165
+ `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.
793
1166
 
794
1167
  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.
795
1168
 
@@ -811,6 +1184,10 @@ The native driver, action permits, mutable capture leases and registry lookup ar
811
1184
  | ------------------------ | ------------------------------------------------------------------------------------- |
812
1185
  | `browser` | Common session, bound target, navigation operation and host admission types |
813
1186
  | `browser-data`, `errors` | Credential-free schemas and expected browser/initialization errors |
1187
+ | `plan` | Live plan construction, durable encoding, recording and typed run failures |
1188
+ | `plan-data` | Bounded actions, descriptors, conditions and durable plan schemas |
1189
+ | `timeline` | Live evidence capability, explicit JSON codecs and sanitized lifecycle projections |
1190
+ | `timeline-data` | Bounded evidence, cursors, clock offsets, snapshots and typed gap/resource errors |
814
1191
  | `bootstrap` | Typed bindings, init/permission plans, readiness and host diagnostics |
815
1192
  | `capture` | Bounded live frame intervals, snapshots and final accounting; reexports frame schemas |
816
1193
  | `capture-data` | Capture options, binary frame and result schemas without session operations |