@effect-uai/browser 0.9.0

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.
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Minimal raw Chrome DevTools Protocol client over a WebSocket.
3
+ *
4
+ * CDP is JSON-RPC-shaped: commands are `{ id, method, params, sessionId? }`,
5
+ * replies are `{ id, result }` or `{ id, error }`, and events are `{ method,
6
+ * params }` with no id. Commands are correlated via a pending-`Deferred`
7
+ * map; events are demuxed into a `PubSub` that callers subscribe to (e.g.
8
+ * navigation waits on `Page.loadEventFired` instead of polling).
9
+ *
10
+ * Commands are typed against the official `devtools-protocol` schema (types
11
+ * only, zero runtime): `send("Page.navigate", params)` gets its params and
12
+ * return type from the protocol. The transport rides Effect's native
13
+ * `WebSocket` (via `layerWebSocketConstructorGlobal`), so no `ws` dependency is
14
+ * needed on modern runtimes; CDP connect uses a bare `ws://` URL with no custom
15
+ * headers.
16
+ *
17
+ * Failures are transport-shaped ({@link CdpError}), not the public
18
+ * `BrowserError`: the transport cannot know which browser verb a command was
19
+ * serving, so the session layer maps `CdpError` onto the typed `BrowserError`
20
+ * using the operation it knows.
21
+ */
22
+ import {
23
+ Cause,
24
+ Data,
25
+ Deferred,
26
+ Effect,
27
+ HashMap,
28
+ Option,
29
+ PubSub,
30
+ Ref,
31
+ Schema,
32
+ type Scope,
33
+ } from "effect"
34
+ import * as Socket from "effect/unstable/socket/Socket"
35
+ import type { ProtocolMapping } from "devtools-protocol/types/protocol-mapping.js"
36
+ import * as JSONL from "@effect-uai/core/JSONL"
37
+
38
+ type Commands = ProtocolMapping.Commands
39
+
40
+ export type CdpMethod = keyof Commands
41
+ export type CdpParams<M extends CdpMethod> = Commands[M]["paramsType"][0]
42
+ export type CdpReturn<M extends CdpMethod> = Commands[M]["returnType"]
43
+
44
+ /**
45
+ * Transport-level failure. `reply` is the browser rejecting a command,
46
+ * `write` is a failed socket send, `closed` is the connection going away
47
+ * (or never opening; the socket connects lazily) with the command still in
48
+ * flight. `method` names the CDP command that was in flight.
49
+ */
50
+ export class CdpError extends Data.TaggedError("CdpError")<{
51
+ kind: "reply" | "write" | "closed"
52
+ method?: string
53
+ reason?: string
54
+ raw?: unknown
55
+ }> {}
56
+
57
+ /** An id-less CDP message: an event emitted by the browser. */
58
+ export type CdpEvent = {
59
+ readonly method: string
60
+ readonly params: unknown
61
+ readonly sessionId: string | undefined
62
+ }
63
+
64
+ export type Cdp = {
65
+ readonly send: <M extends CdpMethod>(
66
+ method: M,
67
+ params?: CdpParams<M>,
68
+ sessionId?: string,
69
+ ) => Effect.Effect<CdpReturn<M>, CdpError>
70
+ /**
71
+ * Subscribe to browser events. Subscribe BEFORE issuing the command
72
+ * whose event you await, or the event can slip past. Subscription
73
+ * lifetime is the scope.
74
+ */
75
+ readonly subscribe: Effect.Effect<PubSub.Subscription<CdpEvent>, never, Scope.Scope>
76
+ }
77
+
78
+ // Replies are heterogeneous per command; the map is keyed by request id and the
79
+ // awaited value is narrowed back to `CdpReturn<M>` at the `send` boundary. The
80
+ // method rides along so a failure can name the command it was for.
81
+ type Pending = {
82
+ readonly method: string
83
+ readonly deferred: Deferred.Deferred<unknown, CdpError>
84
+ }
85
+
86
+ const CdpMessage = Schema.Struct({
87
+ id: Schema.optional(Schema.Number),
88
+ result: Schema.optional(Schema.Unknown),
89
+ error: Schema.optional(Schema.Struct({ message: Schema.optional(Schema.String) })),
90
+ method: Schema.optional(Schema.String),
91
+ params: Schema.optional(Schema.Unknown),
92
+ sessionId: Schema.optional(Schema.String),
93
+ })
94
+ const decodeMessage = Schema.decodeUnknownEffect(CdpMessage)
95
+
96
+ const frame = (
97
+ id: number,
98
+ method: string,
99
+ params: unknown,
100
+ sessionId: string | undefined,
101
+ ): string =>
102
+ JSON.stringify(
103
+ sessionId === undefined ? { id, method, params } : { id, method, params, sessionId },
104
+ )
105
+
106
+ /**
107
+ * Open a scoped CDP connection to a browser-level WebSocket endpoint. The
108
+ * socket and its reader fiber are torn down on scope close. The underlying
109
+ * socket connects lazily, so a bad endpoint surfaces on the first `send` as
110
+ * a `CdpError` of kind `closed` (carrying the socket failure on `raw`), not
111
+ * here.
112
+ */
113
+ export const openCdp = (endpoint: string): Effect.Effect<Cdp, never, Scope.Scope> =>
114
+ Effect.gen(function* () {
115
+ const socket = yield* Socket.makeWebSocket(endpoint, {
116
+ // Effect's Socket treats every close code as an error; whitelist the
117
+ // standard clean-close codes.
118
+ closeCodeIsError: (code) => code !== 1000 && code !== 1001 && code !== 1005,
119
+ }).pipe(Effect.provide(Socket.layerWebSocketConstructorGlobal))
120
+
121
+ const pending = yield* Ref.make(HashMap.empty<number, Pending>())
122
+ const counter = yield* Ref.make(0)
123
+ const events = yield* PubSub.unbounded<CdpEvent>()
124
+ // Why the socket died (failed to open, dropped, dirty close); carried on
125
+ // the `closed` errors handed to in-flight commands.
126
+ const closeCause = yield* Ref.make<unknown>(undefined)
127
+ const write = yield* socket.writer
128
+
129
+ const dispatch = (raw: string): Effect.Effect<void> =>
130
+ Effect.gen(function* () {
131
+ const json = yield* JSONL.parseSafe(raw)
132
+ if (json === undefined) return
133
+ const message = yield* decodeMessage(json).pipe(Effect.option)
134
+ if (Option.isNone(message)) return
135
+ if (message.value.id === undefined) {
136
+ if (message.value.method !== undefined) {
137
+ yield* PubSub.publish(events, {
138
+ method: message.value.method,
139
+ params: message.value.params,
140
+ sessionId: message.value.sessionId,
141
+ })
142
+ }
143
+ return
144
+ }
145
+ const id = message.value.id
146
+ const taken = yield* Ref.modify(pending, (m) => [HashMap.get(m, id), HashMap.remove(m, id)])
147
+ if (Option.isNone(taken)) return
148
+ yield* message.value.error === undefined
149
+ ? Deferred.succeed(taken.value.deferred, message.value.result ?? {})
150
+ : Deferred.fail(
151
+ taken.value.deferred,
152
+ new CdpError({
153
+ kind: "reply",
154
+ method: taken.value.method,
155
+ reason: message.value.error.message ?? "CDP error",
156
+ raw: message.value.error,
157
+ }),
158
+ )
159
+ })
160
+
161
+ // Reader fiber: fail every still-pending command when the socket closes so
162
+ // callers don't hang.
163
+ const failPending = Effect.gen(function* () {
164
+ const raw = yield* Ref.get(closeCause)
165
+ const taken = yield* Ref.getAndSet(pending, HashMap.empty())
166
+ yield* Effect.forEach(HashMap.values(taken), ({ deferred, method }) =>
167
+ Deferred.fail(
168
+ deferred,
169
+ new CdpError({ kind: "closed", method, reason: "connection closed", raw }),
170
+ ),
171
+ )
172
+ })
173
+ yield* socket.runString(dispatch).pipe(
174
+ Effect.tapCause((cause) => Ref.set(closeCause, Cause.squash(cause))),
175
+ Effect.ensuring(failPending),
176
+ Effect.forkScoped,
177
+ )
178
+
179
+ const send = <M extends CdpMethod>(
180
+ method: M,
181
+ params?: CdpParams<M>,
182
+ sessionId?: string,
183
+ ): Effect.Effect<CdpReturn<M>, CdpError> =>
184
+ Effect.gen(function* () {
185
+ const id = yield* Ref.updateAndGet(counter, (n) => n + 1)
186
+ const deferred = yield* Deferred.make<unknown, CdpError>()
187
+ const entry: Pending = { method, deferred }
188
+ yield* Ref.update(pending, HashMap.set(id, entry))
189
+ // `ensuring` reclaims the entry on write failure and on caller
190
+ // interruption; on the success path dispatch has already removed it.
191
+ const result = yield* write(frame(id, method, params ?? {}, sessionId)).pipe(
192
+ Effect.mapError(
193
+ (raw) => new CdpError({ kind: "write", method, reason: "socket write failed", raw }),
194
+ ),
195
+ Effect.flatMap(() => Deferred.await(deferred)),
196
+ Effect.ensuring(Ref.update(pending, HashMap.remove(id))),
197
+ )
198
+ return result as CdpReturn<M>
199
+ })
200
+
201
+ const cdp: Cdp = { send, subscribe: PubSub.subscribe(events) }
202
+ return cdp
203
+ })
@@ -0,0 +1,413 @@
1
+ /**
2
+ * The page agent: the code that runs INSIDE the browser page.
3
+ *
4
+ * Everything lives in one self-contained function, {@link installPageAgent},
5
+ * which is serialized with `Function.prototype.toString` and shipped through
6
+ * `Runtime.evaluate` (the lowest-common-denominator CDP method, so it works
7
+ * against partial CDP servers like obscura). It installs an idempotent
8
+ * global (`globalThis.__effectUaiPageAgent`) holding the helpers the session
9
+ * verbs call. Arguments cross the wire as `JSON.stringify` values, never as
10
+ * interpolated code, so there is no escaping surface.
11
+ *
12
+ * Rules for code inside `installPageAgent`:
13
+ * - Self-contained: no captures of module scope, no imports. Types are fine
14
+ * (they erase); values are not.
15
+ * - Helpers signal failure by throwing `Error` with a descriptive message;
16
+ * the host surfaces it via `exceptionDetails`. The exception is
17
+ * `waitForSelector`, which reports timeout as `false` so the host can
18
+ * raise a typed `BrowserTimeout` instead of an action failure.
19
+ *
20
+ * The whole in-page world (including the ref registry) is reset by every
21
+ * navigation, which is exactly the documented lifetime of element refs.
22
+ */
23
+
24
+ import { Schema } from "effect"
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Wire shapes. Values crossing page -> host are untrusted JSON; the host
28
+ // decodes them with these schemas instead of casting. The derived types
29
+ // double as the agent's own annotations (types erase, so serialization
30
+ // stays self-contained).
31
+ // ---------------------------------------------------------------------------
32
+
33
+ /** Viewport-relative center of an element, for `Input.dispatch*` events. */
34
+ export const InjectedPoint = Schema.Struct({ x: Schema.Number, y: Schema.Number })
35
+ export type InjectedPoint = typeof InjectedPoint.Type
36
+
37
+ /** Element box: viewport-relative from `query`, page coords from `pageBox`. */
38
+ export const InjectedBox = Schema.Struct({
39
+ x: Schema.Number,
40
+ y: Schema.Number,
41
+ width: Schema.Number,
42
+ height: Schema.Number,
43
+ })
44
+ export type InjectedBox = typeof InjectedBox.Type
45
+
46
+ export const InjectedSize = Schema.Struct({ width: Schema.Number, height: Schema.Number })
47
+ export type InjectedSize = typeof InjectedSize.Type
48
+
49
+ /** Element data returned by `query`; serializable, never a live handle. */
50
+ export const InjectedElementInfo = Schema.Struct({
51
+ ref: Schema.String,
52
+ tag: Schema.String,
53
+ text: Schema.String,
54
+ attributes: Schema.Record(Schema.String, Schema.String),
55
+ box: InjectedBox,
56
+ })
57
+ export type InjectedElementInfo = typeof InjectedElementInfo.Type
58
+
59
+ /** The surface installed on `globalThis.__effectUaiPageAgent`. */
60
+ export type PageAgentApi = {
61
+ readonly query: (selector: string, limit: number) => Array<InjectedElementInfo>
62
+ readonly targetPoint: (selector: string) => InjectedPoint
63
+ readonly pageBox: (selector: string) => InjectedBox
64
+ readonly contentSize: () => InjectedSize
65
+ readonly fill: (selector: string, text: string) => void
66
+ readonly selectValue: (selector: string, value: string) => void
67
+ readonly isChecked: (selector: string) => boolean
68
+ readonly focusEl: (selector: string) => void
69
+ readonly scrollBy: (direction: "up" | "down" | "left" | "right", pixels: number) => void
70
+ readonly scrollIntoView: (selector: string) => void
71
+ readonly waitForSelector: (selector: string, timeoutMs: number) => Promise<boolean>
72
+ readonly html: () => string
73
+ readonly markdown: () => string
74
+ readonly readyState: () => string
75
+ }
76
+
77
+ const AGENT_GLOBAL = "__effectUaiPageAgent"
78
+
79
+ const installPageAgent = (): void => {
80
+ const holder = globalThis as Record<string, unknown>
81
+ // Global name must match AGENT_GLOBAL; duplicated because this function
82
+ // is serialized and cannot capture module scope.
83
+ if (holder["__effectUaiPageAgent"] !== undefined) return
84
+
85
+ // -- ref registry: `@e1`-style refs minted by `query`, valid until the
86
+ // -- next navigation (a navigation resets this whole world).
87
+ const byRef = new Map<string, Element>()
88
+ const refOf = new WeakMap<Element, string>()
89
+ let refCounter = 0
90
+
91
+ const refFor = (el: Element): string => {
92
+ const existing = refOf.get(el)
93
+ if (existing !== undefined) return existing
94
+ refCounter += 1
95
+ const ref = `@e${refCounter}`
96
+ byRef.set(ref, el)
97
+ refOf.set(el, ref)
98
+ return ref
99
+ }
100
+
101
+ const mustResolve = (selector: string): Element => {
102
+ if (selector.startsWith("@")) {
103
+ const el = byRef.get(selector)
104
+ if (el === undefined)
105
+ throw new Error(`stale or unknown ref ${selector} (refs reset on navigation)`)
106
+ if (!el.isConnected) throw new Error(`ref ${selector} is no longer in the document`)
107
+ return el
108
+ }
109
+ const el = document.querySelector(selector)
110
+ if (el === null) throw new Error(`no element matches selector ${selector}`)
111
+ return el
112
+ }
113
+
114
+ // -- markdown serialization ------------------------------------------------
115
+
116
+ const SKIP = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "IFRAME", "SVG", "HEAD"])
117
+
118
+ const isHidden = (el: Element): boolean =>
119
+ el.hasAttribute("hidden") || el.getAttribute("aria-hidden") === "true"
120
+
121
+ const collapse = (text: string): string => text.replace(/\s+/g, " ").trim()
122
+
123
+ const inlineText = (node: Node): string => {
124
+ if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? "").replace(/\s+/g, " ")
125
+ if (!(node instanceof Element)) return ""
126
+ const tag = node.tagName.toUpperCase()
127
+ if (SKIP.has(tag) || isHidden(node)) return ""
128
+ const kids = (): string => Array.from(node.childNodes).map(inlineText).join("")
129
+ if (tag === "BR") return "\n"
130
+ if (tag === "STRONG" || tag === "B") {
131
+ const t = collapse(kids())
132
+ return t === "" ? "" : `**${t}**`
133
+ }
134
+ if (tag === "EM" || tag === "I") {
135
+ const t = collapse(kids())
136
+ return t === "" ? "" : `*${t}*`
137
+ }
138
+ if (tag === "CODE") {
139
+ const t = collapse(node.textContent ?? "")
140
+ return t === "" ? "" : `\`${t}\``
141
+ }
142
+ if (tag === "A") {
143
+ const t = collapse(kids())
144
+ if (t === "") return ""
145
+ const href = node.getAttribute("href")
146
+ return href === null || href.startsWith("javascript:") ? t : `[${t}](${href})`
147
+ }
148
+ if (tag === "IMG") {
149
+ const src = node.getAttribute("src")
150
+ return src === null ? "" : `![${node.getAttribute("alt") ?? ""}](${src})`
151
+ }
152
+ return kids()
153
+ }
154
+
155
+ const renderList = (el: Element, depth: number): string => {
156
+ const ordered = el.tagName.toUpperCase() === "OL"
157
+ const items: Array<string> = []
158
+ let index = 0
159
+ for (const li of Array.from(el.children)) {
160
+ if (li.tagName.toUpperCase() !== "LI") continue
161
+ index += 1
162
+ let own = ""
163
+ const sublists: Array<Element> = []
164
+ for (const child of Array.from(li.childNodes)) {
165
+ const childTag = child instanceof Element ? child.tagName.toUpperCase() : ""
166
+ if (childTag === "UL" || childTag === "OL") sublists.push(child as Element)
167
+ else own += inlineText(child)
168
+ }
169
+ let line = " ".repeat(depth) + (ordered ? `${index}. ` : "- ") + collapse(own)
170
+ for (const sub of sublists) line += "\n" + renderList(sub, depth + 1)
171
+ items.push(line)
172
+ }
173
+ return items.join("\n")
174
+ }
175
+
176
+ const renderTable = (el: Element): string => {
177
+ const rows = Array.from(el.querySelectorAll("tr"))
178
+ const first = rows[0]
179
+ if (first === undefined) return ""
180
+ const cells = (tr: Element): Array<string> =>
181
+ Array.from(tr.children)
182
+ .filter((c) => c.tagName.toUpperCase() === "TD" || c.tagName.toUpperCase() === "TH")
183
+ .map((c) => collapse(inlineText(c)) || " ")
184
+ const lines = rows.map((tr) => `| ${cells(tr).join(" | ")} |`)
185
+ const separator = `| ${cells(first)
186
+ .map(() => "---")
187
+ .join(" | ")} |`
188
+ return [lines[0], separator, ...lines.slice(1)].join("\n")
189
+ }
190
+
191
+ const walkBlocks = (el: Element, out: Array<string>): void => {
192
+ for (const child of Array.from(el.children)) {
193
+ const tag = child.tagName.toUpperCase()
194
+ if (SKIP.has(tag) || isHidden(child)) continue
195
+ const heading = /^H([1-6])$/.exec(tag)
196
+ if (heading !== null) {
197
+ const t = collapse(inlineText(child))
198
+ if (t !== "") out.push("#".repeat(Number(heading[1] ?? "1")) + " " + t)
199
+ continue
200
+ }
201
+ if (tag === "P") {
202
+ const t = collapse(inlineText(child))
203
+ if (t !== "") out.push(t)
204
+ continue
205
+ }
206
+ if (tag === "PRE") {
207
+ out.push("```\n" + (child.textContent ?? "").replace(/\s+$/, "") + "\n```")
208
+ continue
209
+ }
210
+ if (tag === "BLOCKQUOTE") {
211
+ const t = collapse(inlineText(child))
212
+ if (t !== "") out.push("> " + t)
213
+ continue
214
+ }
215
+ if (tag === "UL" || tag === "OL") {
216
+ const t = renderList(child, 0)
217
+ if (t !== "") out.push(t)
218
+ continue
219
+ }
220
+ if (tag === "TABLE") {
221
+ const t = renderTable(child)
222
+ if (t !== "") out.push(t)
223
+ continue
224
+ }
225
+ if (tag === "HR") {
226
+ out.push("---")
227
+ continue
228
+ }
229
+ if (child.children.length > 0) {
230
+ walkBlocks(child, out)
231
+ continue
232
+ }
233
+ const t = collapse(inlineText(child))
234
+ if (t !== "") out.push(t)
235
+ }
236
+ }
237
+
238
+ // -- the agent surface -----------------------------------------------------
239
+
240
+ const agent: PageAgentApi = {
241
+ query: (selector, limit) => {
242
+ const els = selector.startsWith("@")
243
+ ? [mustResolve(selector)]
244
+ : Array.from(document.querySelectorAll(selector))
245
+ return els.slice(0, limit).map((el) => {
246
+ const rect = el.getBoundingClientRect()
247
+ const attributes: Record<string, string> = {}
248
+ for (const attr of Array.from(el.attributes)) attributes[attr.name] = attr.value
249
+ return {
250
+ ref: refFor(el),
251
+ tag: el.tagName.toLowerCase(),
252
+ text: collapse(el.textContent ?? "").slice(0, 200),
253
+ attributes,
254
+ box: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
255
+ }
256
+ })
257
+ },
258
+
259
+ targetPoint: (selector) => {
260
+ const el = mustResolve(selector)
261
+ el.scrollIntoView({ block: "center", inline: "center" })
262
+ const rect = el.getBoundingClientRect()
263
+ if (rect.width === 0 || rect.height === 0) {
264
+ throw new Error(`element ${selector} has no visible box`)
265
+ }
266
+ return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }
267
+ },
268
+
269
+ pageBox: (selector) => {
270
+ const rect = mustResolve(selector).getBoundingClientRect()
271
+ return {
272
+ x: rect.x + window.scrollX,
273
+ y: rect.y + window.scrollY,
274
+ width: rect.width,
275
+ height: rect.height,
276
+ }
277
+ },
278
+
279
+ contentSize: () => ({
280
+ width: document.documentElement.scrollWidth,
281
+ height: document.documentElement.scrollHeight,
282
+ }),
283
+
284
+ fill: (selector, text) => {
285
+ const el = mustResolve(selector)
286
+ if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
287
+ // Set through the native prototype setter so framework value
288
+ // trackers (React et al.) see the change, then fire the events a
289
+ // real keyboard session would.
290
+ const proto =
291
+ el instanceof HTMLInputElement
292
+ ? HTMLInputElement.prototype
293
+ : HTMLTextAreaElement.prototype
294
+ const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set
295
+ el.focus()
296
+ if (setter !== undefined) setter.call(el, text)
297
+ else el.value = text
298
+ el.dispatchEvent(new Event("input", { bubbles: true }))
299
+ el.dispatchEvent(new Event("change", { bubbles: true }))
300
+ return
301
+ }
302
+ if (el instanceof HTMLElement && el.isContentEditable) {
303
+ el.focus()
304
+ el.textContent = text
305
+ el.dispatchEvent(new Event("input", { bubbles: true }))
306
+ return
307
+ }
308
+ throw new Error(`element ${selector} is not fillable (input, textarea, or contenteditable)`)
309
+ },
310
+
311
+ selectValue: (selector, value) => {
312
+ const el = mustResolve(selector)
313
+ if (!(el instanceof HTMLSelectElement)) {
314
+ throw new Error(`element ${selector} is not a <select>`)
315
+ }
316
+ const option = Array.from(el.options).find((o) => o.value === value)
317
+ if (option === undefined) {
318
+ throw new Error(`select ${selector} has no option with value ${JSON.stringify(value)}`)
319
+ }
320
+ el.value = value
321
+ el.dispatchEvent(new Event("input", { bubbles: true }))
322
+ el.dispatchEvent(new Event("change", { bubbles: true }))
323
+ },
324
+
325
+ isChecked: (selector) => {
326
+ const el = mustResolve(selector)
327
+ if (el instanceof HTMLInputElement && (el.type === "checkbox" || el.type === "radio")) {
328
+ return el.checked
329
+ }
330
+ throw new Error(`element ${selector} is not a checkbox or radio input`)
331
+ },
332
+
333
+ focusEl: (selector) => {
334
+ const el = mustResolve(selector)
335
+ if (!(el instanceof HTMLElement)) throw new Error(`element ${selector} is not focusable`)
336
+ el.focus()
337
+ },
338
+
339
+ scrollBy: (direction, pixels) => {
340
+ const [dx, dy] =
341
+ direction === "up"
342
+ ? [0, -pixels]
343
+ : direction === "down"
344
+ ? [0, pixels]
345
+ : direction === "left"
346
+ ? [-pixels, 0]
347
+ : [pixels, 0]
348
+ window.scrollBy({ left: dx, top: dy, behavior: "instant" })
349
+ },
350
+
351
+ scrollIntoView: (selector) => {
352
+ mustResolve(selector).scrollIntoView({ block: "center", inline: "center" })
353
+ },
354
+
355
+ waitForSelector: (selector, timeoutMs) =>
356
+ new Promise((resolve) => {
357
+ const found = (): boolean =>
358
+ selector.startsWith("@")
359
+ ? (byRef.get(selector)?.isConnected ?? false)
360
+ : document.querySelector(selector) !== null
361
+ if (found()) return resolve(true)
362
+ const observer = new MutationObserver(() => {
363
+ if (!found()) return
364
+ observer.disconnect()
365
+ window.clearTimeout(timer)
366
+ resolve(true)
367
+ })
368
+ const timer = window.setTimeout(() => {
369
+ observer.disconnect()
370
+ resolve(false)
371
+ }, timeoutMs)
372
+ observer.observe(document.documentElement, {
373
+ childList: true,
374
+ subtree: true,
375
+ attributes: true,
376
+ })
377
+ }),
378
+
379
+ html: () => document.documentElement.outerHTML,
380
+
381
+ markdown: () => {
382
+ const out: Array<string> = []
383
+ const title = document.title.trim()
384
+ if (title !== "") out.push(`# ${title}`)
385
+ // Prefer the primary content region so the serialization leads with the
386
+ // article, not a site-wide nav/sidebar (which in DOM order often comes
387
+ // first and would otherwise dominate a truncated view).
388
+ const root = document.querySelector("main, [role=main], article") ?? document.body
389
+ if (root !== null) walkBlocks(root, out)
390
+ return out.join("\n\n")
391
+ },
392
+
393
+ readyState: () => document.readyState,
394
+ }
395
+
396
+ holder["__effectUaiPageAgent"] = agent
397
+ }
398
+
399
+ /**
400
+ * Build the `Runtime.evaluate` expression for one agent call: install the
401
+ * agent (idempotent) and invoke the named helper. Arguments are embedded via
402
+ * `JSON.stringify`, so user input never becomes code.
403
+ *
404
+ * The whole thing is one IIFE that `return`s the helper result, so the
405
+ * expression has a single, well-defined completion value. A bare sequence of
406
+ * `;`-separated statements would leave the completion value engine-dependent
407
+ * (obscura's `Runtime.evaluate` yields `undefined` for it), and a returned
408
+ * promise still resolves correctly under `awaitPromise`.
409
+ */
410
+ export const agentExpression = (fn: keyof PageAgentApi, args: ReadonlyArray<unknown>): string =>
411
+ `(()=>{(${installPageAgent.toString()})();return globalThis.${AGENT_GLOBAL}.${fn}(${args
412
+ .map((a) => JSON.stringify(a))
413
+ .join(",")});})()`