@yielded/agent-platform-cloudflare 0.1.0-beta.172 → 0.1.0-beta.174

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 (70) hide show
  1. package/dist/{Alarm-BMEAZo3p.mjs → Alarm-4J9B8Fg8.mjs} +54 -32
  2. package/dist/Alarm-4J9B8Fg8.mjs.map +1 -0
  3. package/dist/Alarm.mjs +1 -1
  4. package/dist/BrowserRestCapture.mjs.map +1 -1
  5. package/dist/BrowserRestCrawl.mjs.map +1 -1
  6. package/dist/{BrowserSession-Bx0V1-6_.d.mts → BrowserSession-Cojp7nth.d.mts} +3 -2
  7. package/dist/{BrowserSession-BfNmyayH.mjs → BrowserSession-aovMjfL4.mjs} +14 -14
  8. package/dist/BrowserSession-aovMjfL4.mjs.map +1 -0
  9. package/dist/BrowserSession.d.mts +1 -1
  10. package/dist/BrowserSession.mjs +2 -2
  11. package/dist/BrowserUse.d.mts +40 -64
  12. package/dist/BrowserUse.mjs +223 -2
  13. package/dist/BrowserUse.mjs.map +1 -0
  14. package/dist/CloudflareBrowser-Vt8qTdYW.mjs.map +1 -1
  15. package/dist/CloudflareCodeMode.mjs.map +1 -1
  16. package/dist/CloudflareScheduling.mjs +9 -9
  17. package/dist/CloudflareScheduling.mjs.map +1 -1
  18. package/dist/CloudflareSubscriptions.mjs +15 -15
  19. package/dist/CloudflareSubscriptions.mjs.map +1 -1
  20. package/dist/{CloudflareThreadClient-DgZVrZzF.mjs → CloudflareThreadClient-DU548EEj.mjs} +42 -39
  21. package/dist/CloudflareThreadClient-DU548EEj.mjs.map +1 -0
  22. package/dist/{CloudflareThreadClient-DuRG0MLd.d.mts → CloudflareThreadClient-lTm6baDP.d.mts} +353 -353
  23. package/dist/CloudflareThreadClient.d.mts +1 -1
  24. package/dist/CloudflareThreadClient.mjs +1 -1
  25. package/dist/{InteractiveBrowser-LdKzQeLR.mjs → InteractiveBrowser-BHNlwjZL.mjs} +40 -31
  26. package/dist/InteractiveBrowser-BHNlwjZL.mjs.map +1 -0
  27. package/dist/InteractiveBrowser.mjs +1 -1
  28. package/dist/{ThreadObject-CugPnWsJ.d.mts → ThreadObject-BLYgzcdT.d.mts} +6 -6
  29. package/dist/{ThreadObject-CGIaz6Jv.mjs → ThreadObject-C2LF3btz.mjs} +13 -14
  30. package/dist/ThreadObject-C2LF3btz.mjs.map +1 -0
  31. package/dist/ThreadObject.d.mts +1 -1
  32. package/dist/ThreadObject.mjs +1 -1
  33. package/dist/WakeScheduler.d.mts +1 -1
  34. package/dist/WakeScheduler.mjs +8 -3
  35. package/dist/WakeScheduler.mjs.map +1 -1
  36. package/dist/index.d.mts +3 -3
  37. package/dist/index.mjs +5 -5
  38. package/dist/{prepared-admission-CSdzFM_v.mjs → prepared-admission-BoUPHSNQ.mjs} +2 -2
  39. package/dist/{prepared-admission-CSdzFM_v.mjs.map → prepared-admission-BoUPHSNQ.mjs.map} +1 -1
  40. package/package.json +1 -1
  41. package/src/Alarm.ts +82 -39
  42. package/src/BrowserRestCapture.ts +1 -0
  43. package/src/BrowserRestCrawl.ts +1 -0
  44. package/src/BrowserSession.ts +3 -2
  45. package/src/BrowserUse.ts +287 -1504
  46. package/src/CloudflareCodeMode.ts +6 -0
  47. package/src/CloudflareScheduling.ts +103 -103
  48. package/src/CloudflareSubscriptions.ts +192 -199
  49. package/src/CloudflareThreadClient.ts +256 -268
  50. package/src/InteractiveBrowser.ts +43 -17
  51. package/src/ThreadObject.ts +35 -24
  52. package/src/WakeScheduler.ts +12 -9
  53. package/src/internal/browser-binding.ts +8 -0
  54. package/src/internal/browser-credentials.ts +18 -13
  55. package/src/internal/browser-file-selection.ts +13 -2
  56. package/src/internal/browser-quick-action.ts +1 -0
  57. package/src/internal/browser-session-lifecycle.ts +32 -36
  58. package/src/internal/due-queue.ts +38 -19
  59. package/src/internal/layers.ts +53 -53
  60. package/dist/Alarm-BMEAZo3p.mjs.map +0 -1
  61. package/dist/BrowserSession-BfNmyayH.mjs.map +0 -1
  62. package/dist/BrowserUse-Bc2JdLW2.mjs +0 -1528
  63. package/dist/BrowserUse-Bc2JdLW2.mjs.map +0 -1
  64. package/dist/CloudflareThreadClient-DgZVrZzF.mjs.map +0 -1
  65. package/dist/InteractiveBrowser-LdKzQeLR.mjs.map +0 -1
  66. package/dist/ThreadObject-CGIaz6Jv.mjs.map +0 -1
  67. package/dist/page-function-CmUCcyNP.mjs +0 -11
  68. package/dist/page-function-CmUCcyNP.mjs.map +0 -1
  69. package/src/internal/browser-dom.ts +0 -886
  70. package/src/internal/browser-jev-dom.ts +0 -362
package/src/BrowserUse.ts CHANGED
@@ -1,1591 +1,374 @@
1
1
  import {
2
- type ActOptions,
3
- type ActionResult,
4
- type NavigateRequest,
5
- Action,
6
- BrowserActions,
7
- BrowserControl,
2
+ BrowserCdp,
8
3
  BrowserUseError,
9
- InspectRequest,
10
- Observation,
11
- ScrollRequest,
12
- WaitRequest,
13
- type Control,
14
- DialogRequest,
15
- PressRequest,
16
- TabRequest,
4
+ CloseTabRequest,
5
+ FrameInfo,
6
+ FramesRequest,
7
+ SendRequest,
8
+ Tab,
17
9
  } from "@yielded/agent/browser-use";
18
- import { InteractiveBrowserTargetUrl } from "@yielded/agent/interactive-browser";
19
- import { Deferred, Effect, Fiber, Layer, Schema, Semaphore } from "effect";
20
- import {
21
- ElementHandle,
22
- TimeoutError,
23
- type Frame,
24
- type Page,
25
- type Dialog,
10
+ import { Effect, Layer, Schema, Semaphore } from "effect";
11
+ import type {
12
+ CDPSession,
13
+ Frame,
14
+ Page,
26
15
  } from "puppeteer-core/lib/esm/puppeteer/puppeteer-core-browser.js";
27
16
 
28
- import type { BrowserSession } from "./BrowserSession.ts";
29
- import {
30
- checkDom,
31
- checkFrameDom,
32
- inspectDom,
33
- settleDom,
34
- settleInputDom,
35
- waitDom,
36
- } from "./internal/browser-dom.ts";
37
- import { inspectJevDom } from "./internal/browser-jev-dom.ts";
17
+ import type { BrowserSession, BrowserSessionError } from "./BrowserSession.ts";
38
18
 
39
19
  export type Command =
40
- | { readonly kind: "observe" | "screenshot" }
41
- | { readonly kind: "inspect"; readonly request: typeof InspectRequest.Type }
42
- | { readonly kind: "navigate"; readonly request: typeof NavigateRequest.Type }
43
- | { readonly kind: "scroll"; readonly request: typeof ScrollRequest.Type }
44
- | { readonly kind: "wait"; readonly request: typeof WaitRequest.Type }
45
- | { readonly kind: "select-tab"; readonly request: typeof TabRequest.Type }
46
- | { readonly kind: "respond-dialog"; readonly request: typeof DialogRequest.Type }
47
- | {
48
- readonly kind: "act";
49
- readonly action: Action | ({ readonly kind: "press" } & typeof PressRequest.Type);
50
- };
20
+ | { readonly kind: "connect" | "tabs" | "new-tab" }
21
+ | { readonly kind: "send"; readonly request: typeof SendRequest.Type }
22
+ | { readonly kind: "frames"; readonly request: typeof FramesRequest.Type }
23
+ | { readonly kind: "close-tab"; readonly request: typeof CloseTabRequest.Type };
24
+
25
+ /** Current SDK metadata, never a grant supplied by page content or generated code. */
26
+ export interface AuthorizationContext {
27
+ /** Absent during initial attachment. */
28
+ readonly pageUrl?: string;
29
+ readonly targetId?: string;
30
+ readonly targetUrl?: string;
31
+ readonly frameUrl?: string;
32
+ }
51
33
 
52
34
  export interface Options<R = never> {
53
- /** Rechecked under the native session lock. The host owns origins, identity and consent. */
35
+ /**
36
+ * Runs under the BrowserSession lock before every command. Arbitrary CDP/page JavaScript
37
+ * is a broad capability: URL checks cannot prove that code is read-only or constrain egress.
38
+ * The host must authorize that capability and enforce network policy outside page code.
39
+ */
54
40
  readonly authorize: (
55
41
  command: Command,
56
42
  context: AuthorizationContext,
57
43
  ) => Effect.Effect<void, BrowserUseError, R>;
58
- readonly maxActions: number;
44
+ /** Total native operations for this attachment, including authorization refusals. */
45
+ readonly maxCommands: number;
46
+ /** Per-result UTF-8 JSON limit; does not bound the upstream CDP transfer. */
59
47
  readonly maxReturnedBytes: number;
60
- /** Prefer viewport controls; retain off-screen popups only if none of their controls are in view. Defaults to false. */
61
- readonly viewportOnly?: boolean;
62
- /** Jev reads enabled controls centered in the viewport with accessible names and at most 6,000 visible text characters. Defaults to "default". */
63
- readonly observationMode?: "default" | "jev";
64
- /** True waits for 100 ms of DOM quiet (at most 1 s). "input" waits two frames/50 ms, or visible combobox options/200 ms. Defaults to false. */
65
- readonly settleAfterAction?: boolean | "input";
66
- /** Cap condition waits and return a fresh observation on a condition timeout. Omit to retain timeout errors. */
67
- readonly maxWaitMillis?: number;
68
- }
69
-
70
- /** Host-only current SDK metadata. Opaque refs never replace origin or target authority. */
71
- export interface AuthorizationContext {
72
- /** Absent only before the initial attachment read; the host authorizes that attachment. */
73
- readonly pageUrl?: string;
74
- readonly frameUrl?: string;
75
- /** Destination of an observed select-tab command, checked again before selection. */
76
- readonly tabUrl?: string;
77
- readonly target?: typeof Control.Type;
48
+ /** Maximum pages discoverable through this attachment. Defaults to 8. */
49
+ readonly maxTabs?: number;
78
50
  }
79
51
 
80
52
  const Limits = Schema.Struct({
81
- maxActions: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 1_000 })),
53
+ maxCommands: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 10_000 })),
82
54
  maxReturnedBytes: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 8 * 1024 * 1024 })),
83
- viewportOnly: Schema.Boolean,
84
- observationMode: Schema.Literals(["default", "jev"]),
85
- settleAfterAction: Schema.Union([Schema.Boolean, Schema.Literal("input")]),
86
- maxWaitMillis: Schema.NullOr(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 15_000 }))),
55
+ maxTabs: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 64 })),
87
56
  });
88
57
 
89
58
  const invalid = (message: string) =>
90
- new BrowserUseError({ code: "invalid", message, dispatch: "not-dispatched" });
91
-
92
- // URL evidence identifies pages; opaque document payloads are not page observations.
93
- // Authorization and navigation fencing always use the complete native SDK URL.
94
- const observedUrl = (url: string): string => {
95
- const payload = url.startsWith("data:") ? url.indexOf(",") : -1;
96
-
97
- return payload < 0
98
- ? url.slice(0, 8_192)
99
- : `${url.slice(0, Math.min(payload, 256))},[payload omitted]`;
100
- };
101
-
102
- // CDP can resolve closed roots without modifying the page's realm or attachShadow.
103
- // Keep those nodes in the same isolated realm as native refs and discard old roots per read.
104
- // The command deadline bounds this full-frame read; the node/root caps below only bound
105
- // local adoption, not the CDP response size.
106
- const discoverClosedRoots = async (frame: Frame): Promise<boolean> => {
107
- const realm = frame.isolatedRealm();
108
-
109
- await using document = await realm.evaluateHandle(() => globalThis.document);
110
-
111
- const { node } = await frame.client.send("DOM.describeNode", {
112
- objectId: document.remoteObject().objectId,
113
- depth: -1,
114
- pierce: true,
115
- });
59
+ BrowserUseError.make({ code: "invalid", message, dispatch: "not-dispatched" });
116
60
 
117
- const pending = [node];
118
- const roots: Array<number> = [];
119
- let scanned = 0;
120
-
121
- while (pending.length > 0 && scanned++ < 10_000 && roots.length < 256) {
122
- const current = pending.pop();
123
-
124
- if (current === undefined) break;
125
- if (current.shadowRootType === "closed") roots.push(current.backendNodeId);
126
- pending.push(...(current.children ?? []));
127
- pending.push(
128
- ...(current.shadowRoots ?? []).filter((root) => root.shadowRootType !== "user-agent"),
129
- );
130
- }
131
- await realm.evaluate(() => {
132
- Reflect.set(globalThis, "@effect-agent/native-browser-closed-roots", new Map());
133
- });
134
- for (const id of roots) {
135
- await using root = await realm.adoptBackendNode(id);
136
-
137
- await realm.evaluate((node) => {
138
- if (!(node instanceof ShadowRoot) || node.mode !== "closed")
139
- throw new Error("Closed shadow root is unavailable");
140
- Reflect.get(globalThis, "@effect-agent/native-browser-closed-roots").set(node.host, node);
141
- }, root);
142
- }
61
+ const decode = <A>(schema: Schema.Codec<A>, input: unknown) =>
62
+ Schema.decodeUnknownEffect(schema)(input).pipe(
63
+ Effect.mapError(() => invalid("Invalid browser command or limits")),
64
+ );
143
65
 
144
- return pending.length > 0;
145
- };
66
+ interface AttachedPage {
67
+ readonly page: Page;
68
+ readonly cdp: CDPSession;
69
+ readonly targetId: string;
70
+ }
146
71
 
147
- /** One application-neutral controller over an existing scoped native attachment.
148
- * No allocation, model, provider choice, input retry, or arbitrary page JS is exposed.
149
- * Reads re-authorize at most five times when page/frame URLs change during authorization.
150
- * References expire on inspection; outstanding native work retains the session's fencing.
72
+ /**
73
+ * A scoped CDP bridge over an existing BrowserSession. No DOM classifier, selectors,
74
+ * implicit waits, input retries, or generated-code execution live in this adapter.
75
+ * CDP connections persist within the attachment (including remote object IDs); pages persist
76
+ * for the host-owned session. Scope release detaches connections without closing pages.
151
77
  */
152
78
  export const make = Effect.fnUntraced(function* <R>(
153
79
  session: Pick<BrowserSession, "run">,
154
80
  options: Options<R>,
155
81
  ) {
156
- const authorityServices = yield* Effect.context<R>();
82
+ const limits = yield* decode(Limits, { ...options, maxTabs: options.maxTabs ?? 8 });
83
+ const authority = yield* Effect.context<R>();
84
+ // Code Mode permits concurrent host calls; this native attachment admits one at a time.
85
+ const serial = yield* Semaphore.make(1);
157
86
 
158
87
  const authorize = (command: Command, context: AuthorizationContext) =>
159
- options.authorize(command, context).pipe(Effect.provideContext(authorityServices));
160
-
161
- const limits = yield* Schema.decodeEffect(Limits)({
162
- maxActions: options.maxActions,
163
- maxReturnedBytes: options.maxReturnedBytes,
164
- viewportOnly: options.viewportOnly ?? false,
165
- observationMode: options.observationMode ?? "default",
166
- settleAfterAction: options.settleAfterAction ?? false,
167
- maxWaitMillis: options.maxWaitMillis ?? null,
168
- }).pipe(Effect.mapError(() => invalid("Invalid browser controller limits.")));
169
-
170
- const lock = yield* Semaphore.make(1);
171
- const ownerScope = yield* Effect.scope;
172
-
173
- type Dispatch = NonNullable<(typeof ActionResult.Type)["dispatch"]>;
174
- let pendingInput: { ref: string; fiber: Fiber.Fiber<Dispatch, BrowserUseError> } | undefined;
175
- let inputPage: Page | undefined;
176
- let dialogSignal: Deferred.Deferred<void> | undefined;
177
- let nextInput = 0;
178
- let lastTabs: NonNullable<(typeof Observation.Type)["tabs"]> = [];
88
+ Effect.provideContext(options.authorize(command, context), authority);
179
89
 
180
- const clearInput = () => {
181
- pendingInput = undefined;
182
- dialogSignal = undefined;
183
- inputPage = undefined;
184
- };
185
-
186
- const targets = new Map<string, { frame: string; control: typeof Control.Type }>();
187
- const frames = new Set<string>();
188
- const frameIds = new WeakMap<Frame, string>();
189
- const tabIds = new WeakMap<Page, string>();
190
- const tabs = new Map<string, Page>();
191
- const pendingDialogs = new Map<Page, { ref: string; dialog: Dialog }>();
192
-
193
- const listeners = new Map<
194
- Page,
195
- { dialog: (dialog: Dialog) => void; popup: (page: Page | null) => void }
196
- >();
197
-
198
- let activePage: Page | undefined;
199
- let lastNativePage: Page | undefined;
200
- let nextTab = 0;
201
- let nextDialog = 0;
202
-
203
- const tabId = (page: Page) => {
204
- let id = tabIds.get(page);
205
-
206
- if (id === undefined) {
207
- id = `t${scopeId}-${nextTab++}`;
208
- tabIds.set(page, id);
209
- }
210
-
211
- return id;
212
- };
213
-
214
- const attach = (page: Page) => {
215
- if (listeners.has(page) || listeners.size >= 32) return;
216
-
217
- const dialog = (dialog: Dialog) => {
218
- pendingDialogs.set(page, { ref: `dialog-${scopeId}-${nextDialog++}`, dialog });
219
- if (page === inputPage && dialogSignal !== undefined)
220
- Deferred.doneUnsafe(dialogSignal, Effect.void);
221
- };
90
+ const mapSessionError = (error: BrowserSessionError | BrowserUseError) =>
91
+ error._tag === "BrowserUseError"
92
+ ? error
93
+ : BrowserUseError.make({
94
+ code: "browser",
95
+ message: "Browser command failed; inspect the current state before further input",
96
+ dispatch: error.dispatch,
97
+ });
222
98
 
223
- const popup = (page: Page | null) => {
224
- if (page !== null) attach(page);
225
- };
99
+ let released = false;
226
100
 
227
- listeners.set(page, { dialog, popup });
228
- page.on("dialog", dialog);
229
- page.on("popup", popup);
230
- };
231
-
232
- yield* Effect.addFinalizer(() =>
233
- session
234
- .run(Effect.void, async () => {
235
- for (const { dialog } of pendingDialogs.values()) {
101
+ // Register cleanup before creating any native connection, including a partially failed attachment.
102
+ const connections = yield* Effect.acquireRelease(
103
+ Effect.sync(() => new Map<Page | CDPSession, CDPSession>()),
104
+ (connections) =>
105
+ Effect.promise(async (signal) => {
106
+ released = true;
107
+ for (const cdp of connections.values()) {
108
+ if (signal.aborted) break;
236
109
  try {
237
- await dialog.dismiss();
110
+ await cdp.detach();
238
111
  } catch {
239
- /* The session owner retains cleanup authority. */
112
+ // The session may already have terminated on timeout or interruption.
240
113
  }
241
114
  }
242
- })
243
- .pipe(
244
- Effect.ignore,
245
- Effect.andThen(
246
- Effect.sync(() => {
247
- for (const [page, listener] of listeners) {
248
- page.off("dialog", listener.dialog);
249
- page.off("popup", listener.popup);
250
- }
251
- listeners.clear();
252
- pendingDialogs.clear();
253
- tabs.clear();
254
- activePage = undefined;
255
- targets.clear();
256
- frames.clear();
257
- }),
258
- ),
259
- ),
115
+ connections.clear();
116
+ }).pipe(Effect.interruptible, Effect.timeoutOption("1 second"), Effect.asVoid),
260
117
  );
261
- const scopeId = crypto.getRandomValues(new Uint32Array(2)).join("-");
262
- let nextFrame = 0;
263
118
 
264
- const frameId = (frame: Frame) => {
265
- let id = frameIds.get(frame);
266
-
267
- if (id === undefined) {
268
- id = `f${scopeId}-${nextFrame++}`;
269
- frameIds.set(frame, id);
270
- }
271
-
272
- return id;
273
- };
274
-
275
- const resolveFrame = (page: Page, ref: string) =>
276
- page.frames().find((frame) => frameIds.get(frame) === ref);
277
-
278
- let generation = 0;
279
- let consumed = 0;
280
-
281
- const native = <A>(
282
- command: Command,
283
- action: (page: Page, stage: (name: string) => void) => Promise<A>,
284
- timeoutMillis?: number,
285
- phase?: "prepare" | "input" | "settle",
286
- ) =>
287
- Effect.gen(function* () {
288
- if (pendingInput !== undefined && phase !== "input" && command.kind !== "respond-dialog")
289
- return yield* invalid(
290
- "Native input is pending. Inspect and respond to its dialog before another browser operation.",
291
- );
292
- const span = yield* Effect.currentSpan.pipe(Effect.orDie);
293
- let authorizedUrl: string | undefined;
294
- let authorizedTabUrl: string | undefined;
295
- let authorizedFrame: { ref: string; url: string | undefined } | undefined;
296
- const read = ["observe", "inspect", "wait", "screenshot"].includes(command.kind);
119
+ const pages = new Map<string, AttachedPage>();
297
120
 
298
- for (let attempt = 0; ; attempt++) {
299
- let authorizationChanged = false;
300
- let lostDocument = false;
121
+ const attach = async (page: Page): Promise<AttachedPage> => {
122
+ let cdp = connections.get(page);
301
123
 
302
- const result = yield* session
303
- .run(
304
- Effect.suspend(() => {
305
- const page = activePage ?? lastNativePage;
306
-
307
- const target =
308
- command.kind === "act"
309
- ? targets.get(command.action.ref)
310
- : command.kind === "scroll" && command.request.ref !== undefined
311
- ? targets.get(command.request.ref)
312
- : undefined;
124
+ if (cdp === undefined) {
125
+ cdp = await page.createCDPSession();
126
+ connections.set(page, cdp);
127
+ }
128
+ const { targetInfo } = await cdp.send("Target.getTargetInfo");
129
+ const attached = { page, cdp, targetId: targetInfo.targetId };
313
130
 
314
- authorizedUrl = page?.url();
315
- authorizedTabUrl =
316
- command.kind === "select-tab" ? tabs.get(command.request.ref)?.url() : undefined;
131
+ pages.set(attached.targetId, attached);
317
132
 
318
- const frameRef =
319
- target?.frame ??
320
- (command.kind === "inspect" || command.kind === "wait"
321
- ? command.request.frame
322
- : undefined);
133
+ return attached;
134
+ };
323
135
 
324
- authorizedFrame =
325
- frameRef === undefined
326
- ? undefined
327
- : {
328
- ref: frameRef,
329
- url: page === undefined ? undefined : resolveFrame(page, frameRef)?.url(),
330
- };
136
+ const primary = yield* session
137
+ .run(authorize({ kind: "connect" }, {}), attach)
138
+ .pipe(Effect.mapError(mapSessionError));
331
139
 
332
- return authorize(command, {
333
- ...(authorizedUrl === undefined ? {} : { pageUrl: authorizedUrl }),
334
- ...(authorizedTabUrl === undefined ? {} : { tabUrl: authorizedTabUrl }),
335
- ...(authorizedFrame === undefined ? {} : { frameUrl: authorizedFrame.url }),
336
- ...(target === undefined
337
- ? {}
338
- : {
339
- target: target.control,
340
- }),
341
- });
342
- }).pipe(
343
- Effect.mapError(
344
- (error) =>
345
- new BrowserUseError({
346
- code: error.code,
347
- message: error.message,
348
- dispatch: "not-dispatched",
349
- }),
350
- ),
351
- ),
352
- async (page) => {
353
- attach(page);
354
- const selected = activePage ?? page;
140
+ const context = primary.page.browserContext();
141
+ let calls = 0;
355
142
 
356
- lastNativePage = page;
143
+ const tab = async (attached: AttachedPage): Promise<typeof Tab.Type> => {
144
+ const { targetInfo } = await attached.cdp.send("Target.getTargetInfo");
357
145
 
358
- if (selected.isClosed() || selected.browserContext() !== page.browserContext())
359
- throw new Error("Observed tab is unavailable");
360
- if (authorizedUrl !== undefined && selected.url() !== authorizedUrl) {
361
- authorizationChanged = true;
362
- throw new Error("Page changed during authorization");
363
- }
364
- if (
365
- authorizedFrame !== undefined &&
366
- resolveFrame(selected, authorizedFrame.ref)?.url() !== authorizedFrame.url
367
- ) {
368
- authorizationChanged = true;
369
- throw new Error("Frame changed during authorization");
370
- }
371
- if (
372
- command.kind === "select-tab" &&
373
- tabs.get(command.request.ref)?.url() !== authorizedTabUrl
374
- )
375
- throw new Error("Tab changed during authorization");
376
- attach(selected);
146
+ return Schema.decodeSync(Tab)({
147
+ targetId: targetInfo.targetId,
148
+ url: targetInfo.url,
149
+ title: targetInfo.title,
150
+ });
151
+ };
377
152
 
378
- try {
379
- return await action(selected, (name) =>
380
- span.attribute("browser.native.stage", name),
381
- );
382
- } catch (error) {
383
- // A read that loses its document to a navigation retries with fresh authorization.
384
- // The context can be destroyed before the page reports its new URL.
385
- lostDocument =
386
- error instanceof Error &&
387
- /Execution context was destroyed|Cannot find context with specified id/.test(
388
- error.message,
389
- );
390
- // The first read of a fresh controller has no page URL yet; a lost document
391
- // still retries, and the retry authorizes the page it then finds.
392
- if (
393
- read &&
394
- (lostDocument ||
395
- (authorizedUrl !== undefined && selected.url() !== authorizedUrl))
396
- )
397
- authorizationChanged = true;
398
- throw error;
399
- }
400
- },
401
- timeoutMillis === undefined ? undefined : { timeoutMillis },
153
+ const run = Effect.fnUntraced(function* <A>(
154
+ command: Command,
155
+ action: (selected: AttachedPage, frame?: Frame) => Promise<A | BrowserUseError>,
156
+ targetId?: string,
157
+ frameId?: string,
158
+ ) {
159
+ let selected = primary;
160
+ let pageUrl = "";
161
+ let targetUrl = "";
162
+ let frame: Frame | undefined;
163
+ let frameUrl: string | undefined;
164
+
165
+ const result = yield* session
166
+ .run(
167
+ Effect.gen(function* () {
168
+ if (released) return yield* Effect.fail(invalid("Browser attachment scope is closed"));
169
+ if (calls++ >= limits.maxCommands)
170
+ return yield* Effect.fail(invalid("Browser command limit reached"));
171
+ selected = targetId === undefined ? primary : (pages.get(targetId) ?? primary);
172
+ if (targetId !== undefined && !pages.has(targetId))
173
+ return yield* Effect.fail(
174
+ invalid("Unknown page; call cdp.tabs({}) to discover current targets"),
175
+ );
176
+ if (selected.page.isClosed())
177
+ return yield* Effect.fail(invalid("The selected page is closed"));
178
+ pageUrl = primary.page.url();
179
+ targetUrl = selected.page.url();
180
+ frame =
181
+ frameId === undefined
182
+ ? undefined
183
+ : selected.page.frames().find((candidate) => candidate._id === frameId);
184
+ if (frameId !== undefined && frame === undefined)
185
+ return yield* Effect.fail(invalid("Unknown frame; call cdp.frames({}) again"));
186
+ frameUrl = frame?.url();
187
+ yield* authorize(command, {
188
+ pageUrl,
189
+ targetId: selected.targetId,
190
+ targetUrl,
191
+ ...(frameUrl === undefined ? {} : { frameUrl }),
192
+ });
193
+ }),
194
+ async () => {
195
+ if (
196
+ primary.page.url() !== pageUrl ||
197
+ selected.page.url() !== targetUrl ||
198
+ selected.page.isClosed() ||
199
+ (frame !== undefined && (frame.detached || frame.url() !== frameUrl))
402
200
  )
403
- .pipe(Effect.result);
201
+ return {
202
+ ok: false as const,
203
+ error: invalid("Page changed during authorization; inspect it again"),
204
+ };
404
205
 
405
- if (result._tag === "Success") return result.success;
406
- if (
407
- !read ||
408
- !authorizationChanged ||
409
- attempt >= 4 ||
410
- result.failure._tag !== "BrowserSessionError" ||
411
- result.failure.reason !== "provider" ||
412
- result.failure.cleanup !== "not-requested"
413
- )
414
- return yield* result.failure;
206
+ return { ok: true as const, value: await action(selected, frame) };
207
+ },
208
+ )
209
+ .pipe(serial.withPermits(1), Effect.mapError(mapSessionError));
415
210
 
416
- span.attribute("browser.read.authorization_retries", attempt + 1);
417
- // A destroyed context means the next document has not committed yet; give it time.
418
- yield* Effect.sleep(lostDocument ? "250 millis" : "20 millis");
419
- }
420
- }).pipe(
421
- Effect.mapError((error) => {
422
- if (error._tag === "BrowserUseError") return error;
423
- const read = ["observe", "inspect", "wait", "screenshot"].includes(command.kind);
211
+ if (!result.ok) return yield* Effect.fail(result.error);
212
+ // Only host-created failures belong in E; a protocol response with similar fields is data.
213
+ if (result.value instanceof BrowserUseError) return yield* Effect.fail(result.value);
424
214
 
425
- return new BrowserUseError({
215
+ const encoded = yield* Effect.try({
216
+ try: () => JSON.stringify(result.value),
217
+ catch: () =>
218
+ BrowserUseError.make({
426
219
  code: "browser",
427
- message: read
428
- ? `Browser ${command.kind} failed (${error.reason}); cleanup ${error.cleanup}. No new input was dispatched.`
429
- : `Browser ${error.reason}; native outcome ${error.dispatch}; cleanup ${error.cleanup}. Input is never replayed.`,
430
- dispatch: read || error.dispatch === "not-dispatched" ? "not-dispatched" : "unknown",
431
- });
432
- }),
433
- Effect.withSpan(`BrowserUse.${command.kind}${phase === undefined ? "" : `.${phase}`}`),
434
- );
435
-
436
- const bounded = <A>(value: A) =>
437
- Effect.gen(function* () {
438
- const bytes = new TextEncoder().encode(JSON.stringify(value)).byteLength;
439
-
440
- if (bytes > limits.maxReturnedBytes)
441
- return yield* invalid(
442
- "Browser result exceeds the host byte budget. Narrow inspection before acting.",
443
- );
444
-
445
- return value;
220
+ message: "Browser returned a non-JSON result",
221
+ dispatch: "possibly-dispatched",
222
+ }),
446
223
  });
447
224
 
448
- const inspect = Effect.fnUntraced(function* (
449
- request: typeof InspectRequest.Type,
450
- command: Command = { kind: "inspect", request },
451
- ) {
452
- yield* Schema.decodeEffect(InspectRequest)(request).pipe(
453
- Effect.mapError(() => invalid("Invalid inspection request.")),
454
- );
455
- const selectedFrame = request.frame;
456
-
457
- if (request.frame !== undefined && !frames.has(request.frame))
458
- return yield* invalid("Frame was not observed. Inspect the page first.");
459
- targets.clear();
460
- generation++;
461
-
462
- if (pendingInput?.fiber.pollUnsafe()?._tag === "Failure") {
463
- const failed = pendingInput.fiber;
464
-
465
- clearInput();
466
- yield* Fiber.join(failed);
467
- }
468
- const blocked = inputPage === undefined ? undefined : pendingDialogs.get(inputPage);
469
-
470
- if (pendingInput !== undefined && blocked !== undefined) {
471
- yield* authorize(command, { pageUrl: inputPage?.url() });
472
-
473
- const observation = yield* bounded({
474
- text: "Native input is awaiting this JavaScript dialog. Respond before further page operations; never replay the input.",
475
- controls: [],
476
- tabs: lastTabs,
477
- dialogs: [
478
- {
479
- ref: blocked.ref,
480
- type: blocked.dialog.type(),
481
- message: blocked.dialog.message().slice(0, 4096),
482
- defaultValue: blocked.dialog.defaultValue().slice(0, 4096),
483
- },
484
- ],
485
- });
486
-
487
- return observation;
488
- }
489
-
490
- const value = yield* native(command, async (page) => {
491
- const currentTabs = await page.browserContext().pages();
492
-
493
- tabs.clear();
494
- currentTabs.slice(0, 32).forEach((tab) => {
495
- tabs.set(tabId(tab), tab);
496
- attach(tab);
497
- });
498
-
499
- const tabObservation = currentTabs
500
- .slice(0, 32)
501
- .map((tab) => ({ ref: tabId(tab), url: observedUrl(tab.url()), active: tab === page }));
502
-
503
- const pending = pendingDialogs.get(page);
225
+ if (
226
+ encoded === undefined ||
227
+ new TextEncoder().encode(encoded).byteLength > limits.maxReturnedBytes
228
+ )
229
+ return yield* Effect.fail(
230
+ BrowserUseError.make({
231
+ code: "browser",
232
+ message:
233
+ "Browser result exceeded its byte limit; request a smaller result without repeating mutations",
234
+ dispatch: "possibly-dispatched",
235
+ }),
236
+ );
504
237
 
505
- lastTabs = tabObservation;
238
+ return result.value;
239
+ });
506
240
 
507
- if (pending !== undefined)
508
- return {
509
- text: "A native JavaScript dialog blocks page interaction. Respond to its observed reference before continuing.",
510
- controls: [],
511
- tabs: tabObservation,
512
- truncated: currentTabs.length > 32,
513
- dialogs: [
514
- {
515
- ref: pending.ref,
516
- type: pending.dialog.type(),
517
- message: pending.dialog.message().slice(0, 4096),
518
- defaultValue: pending.dialog.defaultValue().slice(0, 4096),
519
- },
520
- ],
521
- };
522
- const currentFrames = page.frames();
241
+ const client: BrowserCdp["Service"] = {
242
+ send: Effect.fnUntraced(function* (request) {
243
+ const value = yield* decode(SendRequest, request);
523
244
 
524
245
  if (
525
- selectedFrame !== undefined &&
526
- command.kind !== "observe" &&
527
- !currentFrames.some((frame) => frameId(frame) === selectedFrame)
246
+ value.method.startsWith("Browser.") ||
247
+ value.method.startsWith("Target.") ||
248
+ value.method === "Page.close" ||
249
+ value.method === "Page.crash"
528
250
  )
529
- throw new Error("Observed frame is detached");
530
- frames.clear();
531
- currentFrames.slice(0, 32).forEach((frame) => frames.add(frameId(frame)));
532
-
533
- const inspected =
534
- selectedFrame === undefined
535
- ? [page.mainFrame()]
536
- : currentFrames.filter((frame) => frameId(frame) === selectedFrame);
537
-
538
- if (inspected.length === 0) inspected.push(page.mainFrame());
539
-
540
- let text = "";
541
- let readyState: (typeof Observation.Type)["readyState"];
542
- let truncated = currentFrames.length > 32 || currentTabs.length > 32;
543
- let metrics: (typeof Observation.Type)["page"];
544
- const controls: Array<typeof Control.Type> = [];
545
-
546
- for (const frame of inspected) {
547
- if (controls.length >= 256) {
548
- truncated = true;
549
- break;
550
- }
551
- const ref = frameId(frame);
552
-
553
- if (ref === undefined) continue;
554
-
555
- const read = async (): Promise<unknown> => {
556
- const rootsTruncated = await discoverClosedRoots(frame);
557
-
558
- truncated ||= rootsTruncated;
559
-
560
- return limits.observationMode === "jev"
561
- ? frame
562
- .isolatedRealm()
563
- .evaluate(
564
- inspectJevDom,
565
- request.selector,
566
- `r${scopeId}-${generation}-${controls.length}`,
567
- 256 - controls.length,
568
- request.optionFilter,
569
- )
570
- : frame
571
- .isolatedRealm()
572
- .evaluate(
573
- inspectDom,
574
- request.selector,
575
- `r${scopeId}-${generation}-${controls.length}`,
576
- 256 - controls.length,
577
- request.optionFilter,
578
- limits.viewportOnly,
579
- );
580
- };
581
-
582
- let result = Schema.decodeUnknownSync(Observation)(await read());
583
-
584
- // Native click acknowledgement can precede document parsing. Settle this
585
- // explicit readiness condition before returning an empty loading document. A document
586
- // still parsing after 2 s is observed as it stands, reporting readyState "loading".
587
- if (result.readyState === "loading") {
588
- const ready = await frame
589
- .isolatedRealm()
590
- .waitForFunction(() => document.readyState !== "loading", {
591
- timeout: 2_000,
592
- polling: "raf",
593
- })
594
- .catch((error: unknown) => {
595
- if (error instanceof TimeoutError) return undefined;
596
- throw error;
597
- });
598
-
599
- await ready?.dispose();
600
- result = Schema.decodeUnknownSync(Observation)(await read());
601
- }
602
- readyState = result.readyState;
603
- if (frame === page.mainFrame()) metrics = result.page;
604
-
605
- text += limits.observationMode === "jev" ? result.text : `\n[${ref}]${result.text}`;
606
- truncated ||= result.truncated ?? false;
607
- for (const control of result.controls) {
608
- const current = { ...control, frame: ref };
609
-
610
- targets.set(control.ref, { frame: ref, control: current });
611
- controls.push(current);
612
- }
613
- }
614
-
615
- const textLimit = limits.observationMode === "jev" ? 6_000 : 24_000;
616
-
617
- return {
618
- text: text.slice(0, textLimit),
619
- controls,
620
- ...(readyState === undefined ? {} : { readyState }),
621
- ...(metrics === undefined ? {} : { page: metrics }),
622
- truncated: truncated || text.length > textLimit,
623
- frames: currentFrames.slice(0, 32).map((frame) => ({
624
- ref: frameId(frame),
625
- name: frame.name().slice(0, 300),
626
- // Fragments are client-side state; payment frames put kilobytes of parameters there.
627
- url: observedUrl(
628
- frame.url().startsWith("data:") ? frame.url() : frame.url().split("#", 1)[0],
629
- ),
630
- inspected: inspected.includes(frame),
631
- })),
632
- tabs: tabObservation,
633
- dialogs: [],
634
- };
635
- }).pipe(Effect.tapError(() => Effect.sync(() => targets.clear())));
636
-
637
- const observation = yield* bounded(value).pipe(
638
- Effect.tapError(() => Effect.sync(() => targets.clear())),
639
- );
640
-
641
- return observation;
642
- });
643
-
644
- const charge = Effect.fnUntraced(function* () {
645
- if (consumed >= limits.maxActions) return yield* invalid("Browser action budget exhausted.");
646
- consumed++;
647
- });
648
-
649
- const checkedPoint = async (
650
- element: ElementHandle<Element>,
651
- frame: Frame,
652
- expected: typeof Control.Type,
653
- scroll: boolean,
654
- pointer = true,
655
- ) => {
656
- let point = await element.evaluate(checkDom, expected, scroll, pointer);
657
-
658
- if (point === false) return false;
659
- let current = frame;
660
- let parent = current.parentFrame();
661
-
662
- while (parent !== null) {
663
- await discoverClosedRoots(parent);
664
- await using owner = await current.frameElement();
665
-
666
- if (owner === null) return false;
667
- await using isolatedOwner = await parent.isolatedRealm().adoptHandle(owner);
668
-
669
- point = await isolatedOwner.evaluate(checkFrameDom, point, scroll);
670
- if (point === false) return false;
671
- current = parent;
672
- parent = current.parentFrame();
673
- }
674
-
675
- return point;
676
- };
677
-
678
- // Separate settling from the following read's authorization snapshot.
679
- const settle = (input?: { readonly frame: string; readonly ref: string }) =>
680
- Effect.suspend(() =>
681
- limits.settleAfterAction
682
- ? native(
683
- { kind: "observe" },
684
- async (page) => {
685
- const frame =
686
- limits.settleAfterAction === "input" && input !== undefined
687
- ? resolveFrame(page, input.frame)
688
- : page.mainFrame();
689
-
690
- if (frame === undefined) return;
691
-
692
- if (limits.settleAfterAction === "input")
693
- await frame.isolatedRealm().evaluate(settleInputDom, input?.ref);
694
- else await frame.isolatedRealm().evaluate(settleDom);
695
- },
696
- undefined,
697
- "settle",
698
- ).pipe(Effect.asVoid)
699
- : Effect.void,
700
- );
701
-
702
- const after = Effect.fnUntraced(function* (
703
- completed: number,
704
- error: string | null,
705
- dispatch: NonNullable<(typeof ActionResult.Type)["dispatch"]>,
706
- frame?: string,
707
- alreadySettled = false,
708
- observe = true,
709
- ) {
710
- if (dispatch === "acknowledged" && !alreadySettled)
711
- yield* settle().pipe(
712
- Effect.catch((failure) =>
713
- Effect.sync(() => {
714
- error ??= failure.message;
715
- }),
716
- ),
717
- );
718
- // Without an observation, refs from before the input can no longer be authorized.
719
- if (!observe) targets.clear();
720
-
721
- const observation = !observe
722
- ? null
723
- : yield* inspect(frame === undefined ? {} : { frame }, {
724
- kind: "observe",
725
- }).pipe(
726
- Effect.catch((failure) => {
727
- error ??= `Input receipt retained; observation failed. Inspect before continuing. ${failure.message}`;
728
-
729
- return Effect.succeed(null);
730
- }),
251
+ return yield* Effect.fail(
252
+ invalid("Browser lifecycle commands belong to the host; use the tab helpers"),
731
253
  );
732
254
 
733
- return {
734
- completed,
735
- error,
736
- observation,
737
- dispatch,
738
- ...(pendingInput === undefined
739
- ? {}
740
- : { pendingInput: { ref: pendingInput.ref, reason: "dialog" as const } }),
741
- };
742
- });
743
-
744
- const action = Effect.fnUntraced(function* (
745
- value: Action | ({ readonly kind: "press" } & typeof PressRequest.Type),
746
- ) {
747
- if (pendingInput !== undefined)
748
- return yield* invalid("Native input is pending; respond to its dialog without replaying it.");
749
- const target = targets.get(value.ref);
750
-
751
- if (target === undefined)
752
- return yield* invalid("Target was not observed. Inspect before acting.");
753
- if (value.kind === "select" && !target.control.options.includes(value.value))
754
- return yield* invalid("Select option was not observed. Inspect with optionFilter first.");
755
- yield* charge();
756
- const command: Command = { kind: "act", action: value };
757
-
758
- // Preparation has its own budget: 2 s, or 8 s inside a frame, where each frame level adds
759
- // owner checks and a newly mounted frame first creates its isolated world. It never waits
760
- // for a missing selector; an actually pending CDP request is terminated by BrowserSession,
761
- // not abandoned.
762
- const inFrame = ![...tabs.values()].some(
763
- (tab) => frameIds.get(tab.mainFrame()) === target.frame,
764
- );
765
-
766
- let dispatch: NonNullable<(typeof ActionResult.Type)["dispatch"]> = "not-dispatched";
767
- let refusal: string | undefined;
768
-
769
- const ready = yield* native(
770
- command,
771
- async (page, stage) => {
772
- const frame = resolveFrame(page, target.frame);
773
-
774
- if (frame === undefined) return false;
775
- // Main-frame refs are checked in place, without resolving and releasing a handle.
776
- if (frame.parentFrame() === null) {
777
- stage("validate-and-scroll");
778
-
779
- const checked = await frame
780
- .isolatedRealm()
781
- .evaluate(checkDom, value.ref, target.control, true, value.kind !== "press");
782
-
783
- return checked !== false;
784
- }
785
- stage("resolve-reference");
786
-
787
- const handle = await frame
788
- .isolatedRealm()
789
- .evaluateHandle(
790
- (ref) => Reflect.get(globalThis, "@effect-agent/native-browser")?.get(ref) ?? null,
791
- value.ref,
792
- );
793
-
794
- try {
795
- const element = handle.asElement();
255
+ const result: unknown = yield* run(
256
+ { kind: "send", request: value },
257
+ async ({ cdp, page }, frame): Promise<unknown> => {
258
+ let target = cdp;
796
259
 
797
- stage("validate-and-scroll");
260
+ if (frame !== undefined) {
261
+ const source = frame.client;
262
+ const originalUrl = primary.page.url();
263
+ const pageUrl = page.url();
264
+ const frameUrl = frame.url();
265
+ const existing = connections.get(source);
798
266
 
799
- return (
800
- element !== null &&
801
- (await checkedPoint(element, frame, target.control, true, value.kind !== "press")) !==
802
- false
803
- );
804
- } finally {
805
- stage("release-reference");
806
- await handle.dispose();
807
- }
808
- },
809
- inFrame ? 8_000 : 2_000,
810
- "prepare",
811
- ).pipe(
812
- Effect.mapError(
813
- (error) =>
814
- new BrowserUseError({
815
- code: error.code,
816
- message: error.message,
817
- dispatch: "not-dispatched",
818
- }),
819
- ),
820
- );
821
-
822
- if (!ready)
823
- return yield* invalid(
824
- "Observed target is stale, disabled or obstructed. No input dispatched. Inspect for a current target.",
825
- );
826
-
827
- const input = native(
828
- command,
829
- async (page, stage) => {
830
- inputPage = page;
831
- const frame = resolveFrame(page, target.frame);
832
-
833
- if (frame === undefined) return "not-dispatched" as const;
834
- // Main-frame input works on the ref in place, without resolving and releasing a handle.
835
- // Focus and its check stay separate reads so focus handlers run before the check.
836
- if (frame.parentFrame() === null) {
837
- const realm = frame.isolatedRealm();
838
-
839
- const point = await realm.evaluate(
840
- checkDom,
841
- value.ref,
842
- target.control,
843
- false,
844
- value.kind !== "press",
845
- );
846
-
847
- if (point === false) return "not-dispatched" as const;
848
- stage("dispatch-input");
849
- if (value.kind === "click") {
850
- dispatch = "unknown";
851
- await page.mouse.click(point.x, point.y);
852
- } else if (value.kind === "select") {
853
- dispatch = "unknown";
854
-
855
- // Puppeteer's select: an enabled matching option, then input and change events.
856
- const selected = await realm.evaluate(
857
- (ref, value) => {
858
- const node: unknown = Reflect.get(globalThis, "@effect-agent/native-browser")?.get(
859
- ref,
860
- );
861
-
862
- if (
863
- !(node instanceof HTMLSelectElement) ||
864
- !Array.from(node.options).some(
865
- (option) =>
866
- !option.disabled &&
867
- !(
868
- option.parentElement instanceof HTMLOptGroupElement &&
869
- option.parentElement.disabled
870
- ) &&
871
- option.value === value,
872
- )
873
- )
874
- return false;
875
- if (node.multiple)
876
- for (const option of Array.from(node.options))
877
- option.selected = option.value === value;
878
- else {
879
- for (const option of Array.from(node.options)) option.selected = false;
880
- const match = Array.from(node.options).find((option) => option.value === value);
881
-
882
- if (match !== undefined) match.selected = true;
883
- }
884
- node.dispatchEvent(new Event("input", { bubbles: true }));
885
- node.dispatchEvent(new Event("change", { bubbles: true }));
886
-
887
- return true;
888
- },
889
- value.ref,
890
- value.value,
891
- );
892
-
893
- if (!selected) {
894
- dispatch = "not-dispatched";
895
-
896
- return "not-dispatched" as const;
897
- }
898
- } else if (value.kind === "press") {
899
- const focused =
900
- (await realm.evaluate((ref) => {
901
- const node: unknown = Reflect.get(globalThis, "@effect-agent/native-browser")?.get(
902
- ref,
903
- );
904
-
905
- if (!(node instanceof HTMLElement)) return false;
906
- node.focus();
907
-
908
- return true;
909
- }, value.ref)) &&
910
- (await realm.evaluate((ref) => {
911
- const node: unknown = Reflect.get(globalThis, "@effect-agent/native-browser")?.get(
912
- ref,
913
- );
267
+ if (existing !== undefined) target = existing;
268
+ else {
269
+ const connection = source.connection();
914
270
 
915
- return (
916
- node instanceof Node &&
917
- node.isConnected &&
918
- Reflect.get(node.getRootNode(), "activeElement") === node
919
- );
920
- }, value.ref));
271
+ if (connection === undefined) return invalid("Frame connection is closed");
272
+ const { targetInfo } = await source.send("Target.getTargetInfo");
921
273
 
922
- if (!focused) return "not-dispatched" as const;
923
- dispatch = "unknown";
924
- await page.keyboard.press(value.key);
925
- } else {
926
- const focused = await realm.evaluate((ref) => {
927
- const node: unknown = Reflect.get(globalThis, "@effect-agent/native-browser")?.get(
928
- ref,
929
- );
930
-
931
- if (
932
- !(
933
- (node instanceof HTMLInputElement &&
934
- ![
935
- "password",
936
- "file",
937
- "checkbox",
938
- "radio",
939
- "hidden",
940
- "submit",
941
- "button",
942
- ].includes(node.type) &&
943
- !node.readOnly) ||
944
- (node instanceof HTMLTextAreaElement && !node.readOnly) ||
945
- (node instanceof HTMLElement &&
946
- node.isContentEditable &&
947
- node.getAttribute("aria-readonly") !== "true")
948
- )
949
- )
950
- return false;
951
- node.focus();
952
-
953
- return true;
954
- }, value.ref);
955
-
956
- if (!focused) return "not-dispatched" as const;
957
-
958
- // Focus handlers can replace a field. Never transfer input to the new focus.
959
- const selected = await realm.evaluate((ref) => {
960
- const node: unknown = Reflect.get(globalThis, "@effect-agent/native-browser")?.get(
961
- ref,
962
- );
963
-
964
- if (
965
- !(node instanceof HTMLElement) ||
966
- !node.isConnected ||
967
- !(
968
- node.getRootNode() instanceof Document || node.getRootNode() instanceof ShadowRoot
969
- ) ||
970
- Reflect.get(node.getRootNode(), "activeElement") !== node
971
- )
972
- return false;
973
- let selected = false;
974
-
975
- if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) {
976
- node.select();
977
- selected =
978
- node.value.length === 0 ||
979
- (node.selectionStart === 0 && node.selectionEnd === node.value.length) ||
980
- globalThis.getSelection()?.toString() === node.value;
981
- } else {
982
- const selection = globalThis.getSelection();
983
-
984
- if (selection === null) return false;
985
- const range = document.createRange();
986
-
987
- range.selectNodeContents(node);
988
- selection.removeAllRanges();
989
- selection.addRange(range);
990
- selected =
991
- selection.rangeCount === 1 &&
992
- selection.getRangeAt(0).compareBoundaryPoints(Range.START_TO_START, range) ===
993
- 0 &&
994
- selection.getRangeAt(0).compareBoundaryPoints(Range.END_TO_END, range) === 0;
995
- }
996
-
997
- return (
998
- selected &&
999
- node.isConnected &&
1000
- Reflect.get(node.getRootNode(), "activeElement") === node
1001
- );
1002
- }, value.ref);
1003
-
1004
- if (!selected) {
1005
- refusal =
1006
- "Native selection could not prepare this field for replacement. No input dispatched.";
1007
-
1008
- return "not-dispatched" as const;
274
+ // Keep arbitrary commands off Puppeteer's own event-tracking session.
275
+ target = await connection.createSession(targetInfo);
276
+ connections.set(source, target);
1009
277
  }
1010
- dispatch = "unknown";
1011
- await page.keyboard.sendCharacter(value.value);
1012
- }
1013
- dispatch = "acknowledged";
1014
-
1015
- return "acknowledged" as const;
1016
- }
1017
-
1018
- const handle = await frame
1019
- .isolatedRealm()
1020
- .evaluateHandle(
1021
- (ref) => Reflect.get(globalThis, "@effect-agent/native-browser")?.get(ref) ?? null,
1022
- value.ref,
1023
- );
1024
-
1025
- try {
1026
- const element = handle.asElement();
1027
-
1028
- if (element === null || !(element instanceof ElementHandle))
1029
- return "not-dispatched" as const;
1030
-
1031
- const point = await checkedPoint(
1032
- element,
1033
- frame,
1034
- target.control,
1035
- false,
1036
- value.kind !== "press",
1037
- );
1038
-
1039
- if (point === false) return "not-dispatched" as const;
1040
- stage("dispatch-input");
1041
- if (value.kind === "click") {
1042
- dispatch = "unknown";
1043
- await page.mouse.click(point.x, point.y);
1044
- } else if (value.kind === "select") {
1045
- const valid = await element.evaluate(
1046
- (node, value) =>
1047
- node instanceof HTMLSelectElement &&
1048
- Array.from(node.options).some(
1049
- (option) =>
1050
- !option.disabled &&
1051
- !(
1052
- option.parentElement instanceof HTMLOptGroupElement &&
1053
- option.parentElement.disabled
1054
- ) &&
1055
- option.value === value,
1056
- ),
1057
- value.value,
1058
- );
1059
-
1060
- if (!valid) return "not-dispatched" as const;
1061
- dispatch = "unknown";
1062
- await element.select(value.value);
1063
- } else if (value.kind === "press") {
1064
- await element.focus();
1065
-
1066
- const focused = await element.evaluate(
1067
- (node) =>
1068
- node.isConnected && Reflect.get(node.getRootNode(), "activeElement") === node,
1069
- );
1070
-
1071
- if (!focused) return "not-dispatched" as const;
1072
- dispatch = "unknown";
1073
- await page.keyboard.press(value.key);
1074
- } else {
1075
- const fillable = await element.evaluate(
1076
- (node) =>
1077
- (node instanceof HTMLInputElement &&
1078
- !["password", "file", "checkbox", "radio", "hidden", "submit", "button"].includes(
1079
- node.type,
1080
- ) &&
1081
- !node.readOnly) ||
1082
- (node instanceof HTMLTextAreaElement && !node.readOnly) ||
1083
- (node instanceof HTMLElement &&
1084
- node.isContentEditable &&
1085
- node.getAttribute("aria-readonly") !== "true"),
1086
- );
1087
-
1088
- if (!fillable) return "not-dispatched" as const;
1089
- await element.focus();
1090
- // Focus handlers can replace a field. Never transfer input to the new focus.
1091
278
  if (
1092
- !(await element.evaluate(
1093
- (node) =>
1094
- node.isConnected &&
1095
- (node.getRootNode() instanceof Document ||
1096
- node.getRootNode() instanceof ShadowRoot) &&
1097
- Reflect.get(node.getRootNode(), "activeElement") === node,
1098
- ))
279
+ primary.page.url() !== originalUrl ||
280
+ page.url() !== pageUrl ||
281
+ frame.detached ||
282
+ frame.url() !== frameUrl ||
283
+ frame.client !== source
1099
284
  )
1100
- return "not-dispatched" as const;
1101
-
1102
- const selected = await element.evaluate((node) => {
1103
- let selected = false;
1104
-
1105
- if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) {
1106
- node.select();
1107
- selected =
1108
- node.value.length === 0 ||
1109
- (node.selectionStart === 0 && node.selectionEnd === node.value.length) ||
1110
- globalThis.getSelection()?.toString() === node.value;
1111
- } else {
1112
- const selection = globalThis.getSelection();
1113
-
1114
- if (selection === null) return false;
1115
- const range = document.createRange();
1116
-
1117
- range.selectNodeContents(node);
1118
- selection.removeAllRanges();
1119
- selection.addRange(range);
1120
- selected =
1121
- selection.rangeCount === 1 &&
1122
- selection.getRangeAt(0).compareBoundaryPoints(Range.START_TO_START, range) ===
1123
- 0 &&
1124
- selection.getRangeAt(0).compareBoundaryPoints(Range.END_TO_END, range) === 0;
1125
- }
1126
-
1127
- return (
1128
- selected &&
1129
- node.isConnected &&
1130
- Reflect.get(node.getRootNode(), "activeElement") === node
1131
- );
1132
- });
1133
-
1134
- if (!selected) {
1135
- refusal =
1136
- "Native selection could not prepare this field for replacement. No input dispatched.";
1137
-
1138
- return "not-dispatched" as const;
1139
- }
1140
-
1141
- dispatch = "unknown";
1142
- await page.keyboard.sendCharacter(value.value);
285
+ return invalid("Frame changed during attachment; inspect it again");
1143
286
  }
1144
287
 
1145
- dispatch = "acknowledged";
1146
-
1147
- return "acknowledged" as const;
1148
- } finally {
1149
- await handle.dispose();
1150
- }
1151
- },
1152
- undefined,
1153
- "input",
1154
- ).pipe(
1155
- Effect.mapError(
1156
- (error) => new BrowserUseError({ code: error.code, message: error.message, dispatch }),
1157
- ),
1158
- );
1159
-
1160
- // The session lock and native deadline remain owned by a controller-scoped fiber.
1161
- // A dialog suspends input without abandoning SDK work or acknowledging it early.
1162
- const start = yield* Deferred.make<void>();
1163
- const dialogReady = yield* Deferred.make<void>();
1164
- const ref = `op${scopeId}-${nextInput++}`;
1165
-
1166
- const fiber = yield* Deferred.await(start).pipe(
1167
- Effect.andThen(input),
1168
- Effect.forkIn(ownerScope),
1169
- );
1170
-
1171
- pendingInput = { ref, fiber };
1172
- dialogSignal = dialogReady;
1173
- yield* Deferred.succeed(start, undefined);
1174
-
1175
- const result = yield* Effect.raceFirst(
1176
- Fiber.join(fiber),
1177
- Deferred.await(dialogReady).pipe(Effect.as("unknown" as const)),
1178
- ).pipe(
1179
- Effect.onInterrupt(() => Fiber.interrupt(fiber)),
1180
- Effect.result,
1181
- );
1182
-
1183
- if (result._tag === "Failure") {
1184
- clearInput();
288
+ // Bind the native receiver explicitly; decode the foreign response below.
289
+ return Reflect.apply(target.send.bind(target), undefined, [value.method, value.params]);
290
+ },
291
+ value.targetId,
292
+ value.frameId,
293
+ );
1185
294
 
1186
- return yield* result.failure;
1187
- }
1188
- if (result.success !== "unknown") clearInput();
1189
- if (result.success === "not-dispatched" && refusal !== undefined)
1190
- return yield* invalid(refusal);
1191
- if (result.success === "acknowledged")
1192
- yield* settle(
1193
- value.kind === "fill" ? { frame: target.frame, ref: value.ref } : undefined,
1194
- ).pipe(
1195
- Effect.mapError(
1196
- (failure) =>
1197
- new BrowserUseError({
1198
- code: failure.code,
1199
- message: failure.message,
1200
- dispatch: "acknowledged",
1201
- }),
295
+ return yield* Schema.decodeUnknownEffect(Schema.Json)(result).pipe(
296
+ Effect.mapError(() =>
297
+ BrowserUseError.make({
298
+ code: "browser",
299
+ message: "Browser returned a non-JSON result",
300
+ dispatch: "possibly-dispatched",
301
+ }),
1202
302
  ),
1203
303
  );
1204
-
1205
- return result.success;
1206
- });
1207
-
1208
- const act = Effect.fnUntraced(function* (
1209
- values: ReadonlyArray<Action>,
1210
- options: ActOptions = {},
1211
- ) {
1212
- yield* Schema.decodeEffect(
1213
- Schema.Array(Action).check(Schema.isMinLength(1), Schema.isMaxLength(8)),
1214
- )(values).pipe(Effect.mapError(() => invalid("Expected 1–8 valid actions.")));
1215
- let completed = 0;
1216
- let error: string | null = null;
1217
- let dispatch: NonNullable<(typeof ActionResult.Type)["dispatch"]> = "not-dispatched";
1218
- let frame: string | undefined;
1219
-
1220
- for (const value of values) {
1221
- frame = targets.get(value.ref)?.frame ?? frame;
1222
- const outcome = yield* action(value).pipe(Effect.result);
1223
-
1224
- if (outcome._tag === "Failure") {
1225
- error = outcome.failure.message;
1226
- if (outcome.failure.dispatch === "acknowledged") completed++;
1227
- if (outcome.failure.dispatch === "unknown") dispatch = "unknown";
1228
- else if (completed > 0) dispatch = "acknowledged";
1229
- break;
1230
- }
1231
- if (outcome.success === "not-dispatched") {
1232
- error = "Observed target changed before input. Inspect before continuing.";
1233
- break;
1234
- }
1235
- if (outcome.success === "unknown") {
1236
- dispatch = "unknown";
1237
- error =
1238
- "Native input is awaiting a JavaScript dialog. Respond to it; do not replay this input.";
1239
- break;
1240
- }
1241
- dispatch = "acknowledged";
1242
- completed++;
1243
- }
1244
-
1245
- yield* Effect.annotateCurrentSpan({
1246
- "browser.completed": completed,
1247
- "browser.dispatch": dispatch,
1248
- });
1249
-
1250
- return yield* after(completed, error, dispatch, frame, true, options.observe !== false);
1251
- });
1252
-
1253
- const navigate = Effect.fnUntraced(function* (request: typeof NavigateRequest.Type) {
1254
- yield* Schema.decodeEffect(InteractiveBrowserTargetUrl)(request.url).pipe(
1255
- Effect.mapError(() => invalid("Navigation requires a credential-free HTTP(S) URL.")),
1256
- );
1257
- yield* charge();
1258
- targets.clear();
1259
-
1260
- const outcome = yield* native({ kind: "navigate", request }, (page) =>
1261
- page.goto(request.url, { waitUntil: "domcontentloaded" }),
1262
- ).pipe(Effect.result);
1263
-
1264
- return yield* after(
1265
- outcome._tag === "Success" ? 1 : 0,
1266
- outcome._tag === "Failure" ? outcome.failure.message : null,
1267
- outcome._tag === "Success" ? "acknowledged" : (outcome.failure.dispatch ?? "unknown"),
1268
- );
1269
- });
1270
-
1271
- const scroll = Effect.fnUntraced(function* (request: typeof ScrollRequest.Type) {
1272
- yield* Schema.decodeEffect(ScrollRequest)(request).pipe(
1273
- Effect.mapError(() => invalid("Invalid scroll request.")),
1274
- );
1275
- yield* charge();
1276
- const target = request.ref === undefined ? undefined : targets.get(request.ref);
1277
-
1278
- if (request.ref !== undefined && target === undefined)
1279
- return yield* invalid("Scroll target was not observed.");
1280
-
1281
- const outcome = yield* native(
1282
- { kind: "scroll", request },
1283
- async (page) => {
1284
- if (target === undefined) {
1285
- const viewport = await page.evaluate(() => ({ width: innerWidth, height: innerHeight }));
1286
-
1287
- await page.mouse.move(viewport.width / 2, viewport.height / 2);
1288
- await page.mouse.wheel({ deltaX: request.deltaX, deltaY: request.deltaY });
1289
-
1290
- return true;
1291
- }
1292
-
1293
- const frame = resolveFrame(page, target.frame);
1294
-
1295
- if (frame === undefined) return false;
1296
-
1297
- const handle = await frame
1298
- .isolatedRealm()
1299
- .evaluateHandle(
1300
- (ref) => Reflect.get(globalThis, "@effect-agent/native-browser")?.get(ref) ?? null,
1301
- request.ref ?? "",
1302
- );
1303
-
1304
- try {
1305
- return await handle.evaluate(
1306
- (node, x, y) => {
1307
- if (!(node instanceof HTMLElement) || !node.isConnected) return false;
1308
- node.scrollBy({ left: x, top: y, behavior: "instant" });
1309
-
1310
- return true;
1311
- },
1312
- request.deltaX,
1313
- request.deltaY,
1314
- );
1315
- } finally {
1316
- await handle.dispose();
304
+ }),
305
+ tabs: Effect.gen(function* () {
306
+ const result = yield* run({ kind: "tabs" }, async () => {
307
+ const current = await context.pages();
308
+
309
+ if (current.length > limits.maxTabs) return null;
310
+ const result: Array<typeof Tab.Type> = [];
311
+
312
+ for (const page of current) result.push(await tab(await attach(page)));
313
+ for (const [id, attached] of pages) {
314
+ if (attached.page.isClosed()) {
315
+ pages.delete(id);
316
+ connections.delete(attached.page);
317
+ }
1317
318
  }
1318
- },
1319
- 2_000,
1320
- ).pipe(Effect.result);
1321
-
1322
- return yield* after(
1323
- outcome._tag === "Success" && outcome.success ? 1 : 0,
1324
- outcome._tag === "Failure"
1325
- ? outcome.failure.message
1326
- : outcome.success
1327
- ? null
1328
- : "Scroll target is stale.",
1329
- outcome._tag === "Failure"
1330
- ? (outcome.failure.dispatch ?? "unknown")
1331
- : outcome.success
1332
- ? "acknowledged"
1333
- : "not-dispatched",
1334
- );
1335
- });
1336
-
1337
- const wait = Effect.fnUntraced(function* (request: typeof WaitRequest.Type) {
1338
- yield* Schema.decodeEffect(WaitRequest)(request).pipe(
1339
- Effect.mapError(() => invalid("Invalid condition wait.")),
1340
- );
1341
- const frameRef = request.frame;
1342
-
1343
- if (request.state === "text" && request.text === undefined)
1344
- return yield* invalid("A text wait requires expected text.");
1345
-
1346
- if (frameRef !== undefined && !frames.has(frameRef))
1347
- return yield* invalid("Frame was not observed.");
1348
- yield* native({ kind: "wait", request }, async (page) => {
1349
- const frame = frameRef === undefined ? page.mainFrame() : resolveFrame(page, frameRef);
1350
-
1351
- if (frame === undefined) throw new Error("Frame detached");
1352
- await discoverClosedRoots(frame);
1353
-
1354
- try {
1355
- const handle = await frame.isolatedRealm().waitForFunction(
1356
- waitDom,
1357
- {
1358
- timeout: Math.min(request.timeoutMillis, limits.maxWaitMillis ?? request.timeoutMillis),
1359
- polling: "raf",
1360
- },
1361
- request.selector,
1362
- request.state,
1363
- request.text,
1364
- );
1365
-
1366
- await handle.dispose();
1367
- } catch (error) {
1368
- if (
1369
- limits.maxWaitMillis === null ||
1370
- !(error instanceof Error) ||
1371
- error.name !== "TimeoutError"
1372
- )
1373
- throw error;
1374
- }
1375
- });
1376
-
1377
- return yield* inspect(frameRef === undefined ? {} : { frame: frameRef });
1378
- });
1379
319
 
1380
- const screenshot = native({ kind: "screenshot" }, async (page) => {
1381
- return {
1382
- mediaType: "image/png" as const,
1383
- base64: await page.screenshot({ type: "png", encoding: "base64" }),
1384
- };
1385
- }).pipe(Effect.flatMap(bounded));
1386
-
1387
- const press = Effect.fnUntraced(function* (request: typeof PressRequest.Type) {
1388
- yield* Schema.decodeEffect(PressRequest)(request).pipe(
1389
- Effect.mapError(() => invalid("Invalid native key request.")),
1390
- );
1391
- const frame = targets.get(request.ref)?.frame;
1392
- const result = yield* action({ kind: "press", ...request }).pipe(Effect.result);
1393
-
1394
- const dispatch =
1395
- result._tag === "Success" ? result.success : (result.failure.dispatch ?? "not-dispatched");
1396
-
1397
- return yield* after(
1398
- dispatch === "acknowledged" ? 1 : 0,
1399
- result._tag === "Failure"
1400
- ? result.failure.message
1401
- : result.success === "not-dispatched"
1402
- ? "Target changed before keyboard input."
1403
- : null,
1404
- dispatch,
1405
- frame,
1406
- true,
1407
- );
1408
- });
1409
-
1410
- const selectTab = Effect.fnUntraced(function* (request: typeof TabRequest.Type) {
1411
- yield* Schema.decodeEffect(TabRequest)(request).pipe(
1412
- Effect.mapError(() => invalid("Invalid tab request.")),
1413
- );
1414
- const tab = tabs.get(request.ref);
1415
-
1416
- if (tab === undefined)
1417
- return yield* invalid("Tab was not observed. Inspect before selecting it.");
1418
- yield* native({ kind: "select-tab", request }, async (page) => {
1419
- if (tab.isClosed() || tab.browserContext() !== page.browserContext())
1420
- throw new Error("Observed tab is unavailable");
1421
- await tab.bringToFront();
1422
- activePage = tab;
1423
- targets.clear();
1424
- frames.clear();
1425
- });
1426
-
1427
- return yield* inspect({});
1428
- });
1429
-
1430
- const respondDialog = Effect.fnUntraced(function* (request: typeof DialogRequest.Type) {
1431
- yield* Schema.decodeEffect(DialogRequest)(request).pipe(
1432
- Effect.mapError(() => invalid("Invalid dialog response.")),
1433
- );
1434
- yield* charge();
1435
- const outstanding = pendingInput;
1436
-
1437
- if (outstanding !== undefined && inputPage !== undefined) {
1438
- const page = inputPage;
1439
- const pending = pendingDialogs.get(page);
1440
-
1441
- if (
1442
- pending === undefined ||
1443
- pending.ref !== request.ref ||
1444
- (request.text !== undefined && pending.dialog.type() !== "prompt")
1445
- )
1446
- return yield* invalid("Observed dialog is no longer current.");
1447
- if (outstanding.fiber.pollUnsafe()?._tag === "Failure") {
1448
- clearInput();
1449
- const settled = yield* Fiber.join(outstanding.fiber).pipe(Effect.result);
1450
-
1451
- return {
1452
- ...(yield* after(
1453
- 0,
1454
- "Pending native input failed. No dialog response dispatched.",
1455
- "not-dispatched",
1456
- )),
1457
- settledInput: {
1458
- ref: outstanding.ref,
1459
- dispatch:
1460
- settled._tag === "Success"
1461
- ? settled.success
1462
- : (settled.failure.dispatch ?? "unknown"),
1463
- },
1464
- };
1465
- }
1466
-
1467
- const dialogReady = yield* Deferred.make<void>();
1468
-
1469
- // Re-arm before answering: the handler may open another dialog before
1470
- // this response settles. The original input keeps its fiber and deadline.
1471
- dialogSignal = dialogReady;
1472
-
1473
- const response =
1474
- outstanding.fiber.pollUnsafe() === undefined
1475
- ? // Suspended input owns the session lock. This is its only allowed interleaving;
1476
- // host authority is checked immediately before responding on that exact connection.
1477
- authorize({ kind: "respond-dialog", request }, { pageUrl: page.url() }).pipe(
1478
- Effect.andThen(
1479
- Effect.tryPromise({
1480
- try: () =>
1481
- request.accept ? pending.dialog.accept(request.text) : pending.dialog.dismiss(),
1482
- catch: () =>
1483
- new BrowserUseError({
1484
- code: "browser",
1485
- message: "Native dialog response failed. Do not replay it.",
1486
- dispatch: "unknown",
1487
- }),
1488
- }),
1489
- ),
1490
- )
1491
- : native({ kind: "respond-dialog", request }, async () => {
1492
- if (request.accept) await pending.dialog.accept(request.text);
1493
- else await pending.dialog.dismiss();
1494
- });
1495
-
1496
- const result = yield* response.pipe(
1497
- Effect.withSpan("BrowserUse.respond-dialog.input"),
1498
- Effect.result,
1499
- );
1500
-
1501
- if (result._tag === "Failure")
1502
- return yield* after(0, result.failure.message, result.failure.dispatch ?? "not-dispatched");
1503
- if (pendingDialogs.get(page) === pending) pendingDialogs.delete(page);
320
+ return result;
321
+ });
1504
322
 
1505
- const settled = yield* Effect.raceFirst(
1506
- Fiber.join(outstanding.fiber),
1507
- Deferred.await(dialogReady).pipe(Effect.as("unknown" as const)),
1508
- ).pipe(
1509
- Effect.onInterrupt(() => Fiber.interrupt(outstanding.fiber)),
1510
- Effect.result,
323
+ if (result === null) return yield* Effect.fail(invalid("Browser tab limit exceeded"));
324
+
325
+ return result;
326
+ }),
327
+ frames: Effect.fnUntraced(function* (request) {
328
+ const value = yield* decode(FramesRequest, request);
329
+
330
+ const frames = yield* run(
331
+ { kind: "frames", request: value },
332
+ async ({ page }) =>
333
+ page.frames().map((frame) => ({
334
+ frameId: frame._id,
335
+ parentFrameId: frame.parentFrame()?._id ?? null,
336
+ url: frame.url(),
337
+ })),
338
+ value.targetId,
1511
339
  );
1512
340
 
1513
- if (settled._tag === "Success" && settled.success === "unknown")
1514
- return yield* after(1, null, "acknowledged", undefined, true);
1515
-
1516
- clearInput();
1517
-
1518
- return {
1519
- ...(yield* after(
1520
- 1,
1521
- settled._tag === "Failure" ? settled.failure.message : null,
1522
- "acknowledged",
1523
- )),
1524
- settledInput: {
1525
- ref: outstanding.ref,
1526
- dispatch:
1527
- settled._tag === "Success" ? settled.success : (settled.failure.dispatch ?? "unknown"),
1528
- },
1529
- };
1530
- }
341
+ return yield* decode(Schema.Array(FrameInfo), frames);
342
+ }),
343
+ newTab: Effect.gen(function* () {
344
+ const result = yield* run({ kind: "new-tab" }, async () => {
345
+ if ((await context.pages()).length >= limits.maxTabs) return null;
1531
346
 
1532
- const receipt: { dispatch: NonNullable<(typeof ActionResult.Type)["dispatch"]> } = {
1533
- dispatch: "not-dispatched",
1534
- };
1535
-
1536
- const result = yield* native({ kind: "respond-dialog", request }, async (page) => {
1537
- const pending = pendingDialogs.get(page);
1538
-
1539
- if (
1540
- pending === undefined ||
1541
- pending.ref !== request.ref ||
1542
- (request.text !== undefined && pending.dialog.type() !== "prompt")
1543
- )
1544
- return false;
1545
- receipt.dispatch = "unknown";
1546
- if (request.accept) await pending.dialog.accept(request.text);
1547
- else await pending.dialog.dismiss();
1548
- receipt.dispatch = "acknowledged";
1549
- if (pendingDialogs.get(page) === pending) pendingDialogs.delete(page);
1550
-
1551
- return true;
1552
- }).pipe(Effect.result);
347
+ return tab(await attach(await context.newPage()));
348
+ });
1553
349
 
1554
- return yield* after(
1555
- receipt.dispatch === "acknowledged" ? 1 : 0,
1556
- result._tag === "Failure"
1557
- ? result.failure.message
1558
- : result.success
1559
- ? null
1560
- : "Observed dialog is no longer current.",
1561
- receipt.dispatch,
1562
- );
1563
- });
350
+ if (result === null) return yield* Effect.fail(invalid("Browser tab limit reached"));
1564
351
 
1565
- const actions = BrowserActions.of({
1566
- observe: lock.withPermit(inspect({}, { kind: "observe" })),
1567
- act: (values, options) =>
1568
- lock.withPermit(act(values, options)).pipe(Effect.withSpan("BrowserUse.act")),
1569
- });
352
+ return result;
353
+ }),
354
+ closeTab: Effect.fnUntraced(function* (request) {
355
+ const value = yield* decode(CloseTabRequest, request);
1570
356
 
1571
- const control = BrowserControl.of({
1572
- inspect: (request) => lock.withPermit(inspect(request)),
1573
- navigate: (request) => lock.withPermit(navigate(request)),
1574
- scroll: (request) => lock.withPermit(scroll(request)),
1575
- wait: (request) => lock.withPermit(wait(request)),
1576
- screenshot: lock.withPermit(screenshot),
1577
- press: (request) => lock.withPermit(press(request)),
1578
- selectTab: (request) => lock.withPermit(selectTab(request)),
1579
- respondDialog: (request) =>
1580
- lock.withPermit(respondDialog(request)).pipe(Effect.withSpan("BrowserUse.respond-dialog")),
1581
- });
357
+ if (value.targetId === primary.targetId)
358
+ return yield* Effect.fail(invalid("The original page belongs to the host"));
359
+ yield* run(
360
+ { kind: "close-tab", request: value },
361
+ async ({ page }) => {
362
+ await page.close();
363
+ pages.delete(value.targetId);
364
+ connections.delete(page);
1582
365
 
1583
- return {
1584
- actions,
1585
- control,
1586
- layer: Layer.merge(
1587
- Layer.succeed(BrowserActions, actions),
1588
- Layer.succeed(BrowserControl, control),
1589
- ),
366
+ return null;
367
+ },
368
+ value.targetId,
369
+ );
370
+ }),
1590
371
  };
372
+
373
+ return { client, layer: Layer.succeed(BrowserCdp, client) };
1591
374
  });