crawlforge-mcp-server 6.12.0 → 6.14.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.
@@ -2,38 +2,47 @@
2
2
  * Snapshot — the accessibility-style page tree that hands an agent stable
3
3
  * element refs (`@e1`, `@e2`, …) to act on instead of guessed CSS selectors.
4
4
  *
5
- * Why ours and not Playwright's: 1.62's `locator.ariaSnapshot()` emits YAML
6
- * with no element refs at all, and `page._snapshotForAI()` is private API we
7
- * will not depend on. The walk below is injected by us and returns the tree
8
- * and the refs from one pass.
5
+ * TWO SOURCES, one public format:
9
6
  *
10
- * A REF LIVES IN TWO PLACES, and the halves do different jobs:
7
+ * 1. `page.ariaSnapshot({ mode: 'ai' })` (Playwright ≥ 1.59; verified on
8
+ * 1.62.1, Chromium and Camoufox). It descends open shadow roots and
9
+ * iframes, and tags each visible node `[ref=e5]` (`[ref=f1e3]` in a
10
+ * frame) that the public `aria-ref=` selector engine resolves — shadow
11
+ * and frame refs included. Its YAML is translated into our tree below.
12
+ * 2. The injected walk (snapshotScript), kept as the fallback for a page
13
+ * where the native call throws. It stamps `data-cf-ref="e1"` on the
14
+ * elements it refs and sees neither shadow roots nor frames.
11
15
  *
12
- * 1. In the page — the walk stamps `data-cf-ref="e1"` onto every element it
13
- * refs, so a ref resolves to an ordinary CSS selector,
14
- * `[data-cf-ref="e1"]`. That is what makes refs work with every existing
15
- * action path for free, including the stealth human-behaviour code that
16
- * takes a raw selector string.
17
- * 2. In Node — a WeakMap<Page, state> cleared on every main-frame
18
- * navigation. This is the half that DETECTS staleness. Without it a ref
19
- * from a previous page would merely fail to match, and the caller would
20
- * be told "selector not found" instead of "take a new snapshot".
16
+ * The result's `source` ('aria' | 'walk') says which one described the page.
21
17
  *
22
- * So a stale ref fails loudly, with the reason and the fix (StaleRefError),
23
- * and never silently hits the wrong element.
18
+ * Refs are OURS, not Playwright's: `@e1…@eN`, contiguous, in document order
19
+ * across the page, its shadow roots and its frames. Playwright numbers every
20
+ * visible node (the body is usually its e1), so its ids have gaps and a page's
21
+ * one button would be e2; each of our refs instead maps to the selector that
22
+ * finds its element — `aria-ref=f1e3` natively, `[data-cf-ref="e1"]` from the
23
+ * walk. A resolved selector is therefore NOT always CSS: a consumer must hand
24
+ * it to page.locator()/waitForSelector(), never to document.querySelector().
25
+ *
26
+ * STALENESS lives in Node — a WeakMap<Page, state> cleared on every main-frame
27
+ * navigation. Without it a ref from a previous page would merely fail to
28
+ * match, and the caller would be told "selector not found" instead of "take a
29
+ * new snapshot". So a stale ref fails loudly, with the reason and the fix
30
+ * (StaleRefError), and never silently hits the wrong element.
24
31
  *
25
32
  * Not to be confused with src/core/SnapshotManager.js, which is change-
26
33
  * tracking history.
27
34
  */
28
35
 
29
36
  import { randomUUID } from 'node:crypto';
37
+ import { settlePage } from './settle.js';
30
38
 
31
39
  export const REF_ATTRIBUTE = 'data-cf-ref';
32
40
  export const DEFAULT_MAX_NODES = 200;
33
41
  export const MAX_NODES_LIMIT = 1000;
34
42
 
35
- // Walks of one snapshot call, including the retry when the page navigates
36
- // mid-walk. Two: one retry is enough for a page that settles, and a page
43
+ // Captures of one snapshot call, including the retry when the page navigates
44
+ // mid-capture (a fallback from the native snapshot to the walk is not one of
45
+ // them). Two: one retry is enough for a page that settles, and a page
37
46
  // navigating repeatedly is not one a snapshot can describe.
38
47
  const MAX_WALK_ATTEMPTS = 2;
39
48
 
@@ -44,6 +53,26 @@ const MAX_NAME_LENGTH = 120;
44
53
 
45
54
  const REF_PATTERN = /^@e[1-9]\d*$/;
46
55
 
56
+ // Shared by both sources: the walk gets them as arguments, the translation of
57
+ // the native snapshot reads them directly.
58
+ const INTERACTIVE_ROLES = [
59
+ 'button', 'link', 'checkbox', 'radio', 'textbox', 'combobox', 'menuitem',
60
+ 'menuitemcheckbox', 'menuitemradio', 'tab', 'switch', 'option', 'searchbox',
61
+ 'slider', 'spinbutton'
62
+ ];
63
+ const STRUCTURAL_ROLES = [
64
+ 'heading', 'banner', 'navigation', 'contentinfo', 'complementary', 'main',
65
+ 'form', 'region', 'search', 'iframe'
66
+ ];
67
+ const INTERACTIVE_ROLE_SET = new Set(INTERACTIVE_ROLES);
68
+ const STRUCTURAL_ROLE_SET = new Set(STRUCTURAL_ROLES);
69
+
70
+ // An editing host (`contenteditable`) has no ARIA role, so the native snapshot
71
+ // shows it as a plain `generic`. Only a page that has one pays for the per-node
72
+ // check that finds it, and each check gets this long before it counts as "no".
73
+ const EDITABLE_SELECTOR = '[contenteditable]:not([contenteditable="false"])';
74
+ const EDITABLE_CHECK_TIMEOUT_MS = 1000;
75
+
47
76
  /** Thrown when a ref cannot be resolved against the page's current snapshot. */
48
77
  export class StaleRefError extends Error {
49
78
  constructor(message) {
@@ -66,17 +95,12 @@ export function isRef(selector) {
66
95
  * JavaScript, so it has nothing to do with the ALLOW_JAVASCRIPT_EXECUTION flag
67
96
  * that gates the `executeJavaScript` action. Do not put it behind that flag.
68
97
  */
69
- function snapshotScript({ refAttribute, interactiveOnly, maxNodes, maxIndent, maxNameLength }) {
98
+ function snapshotScript({
99
+ refAttribute, interactiveOnly, maxNodes, maxIndent, maxNameLength, interactiveRoles, structuralRoles
100
+ }) {
70
101
  const SKIP_TAGS = new Set(['script', 'style', 'noscript', 'template', 'head']);
71
- const INTERACTIVE_ROLES = new Set([
72
- 'button', 'link', 'checkbox', 'radio', 'textbox', 'combobox', 'menuitem',
73
- 'menuitemcheckbox', 'menuitemradio', 'tab', 'switch', 'option', 'searchbox',
74
- 'slider', 'spinbutton'
75
- ]);
76
- const STRUCTURAL_ROLES = new Set([
77
- 'heading', 'banner', 'navigation', 'contentinfo', 'complementary', 'main',
78
- 'form', 'region', 'search'
79
- ]);
102
+ const INTERACTIVE_ROLES = new Set(interactiveRoles);
103
+ const STRUCTURAL_ROLES = new Set(structuralRoles);
80
104
  const LANDMARK_TAGS = {
81
105
  main: 'main', nav: 'navigation', header: 'banner', footer: 'contentinfo',
82
106
  aside: 'complementary', form: 'form'
@@ -214,6 +238,148 @@ function snapshotScript({ refAttribute, interactiveOnly, maxNodes, maxIndent, ma
214
238
  return { title: clean(document.title), lines, refs, truncated };
215
239
  }
216
240
 
241
+ // Same name rules as the walk's clean()/truncate(): one line, no double quotes.
242
+ const cleanName = (value) => (value || '').replace(/\s+/g, ' ').trim().replace(/"/g, "'");
243
+ const truncateName = (value) =>
244
+ (value.length > MAX_NAME_LENGTH ? `${value.slice(0, MAX_NAME_LENGTH - 1)}…` : value);
245
+
246
+ /**
247
+ * One line of `page.ariaSnapshot({ mode: 'ai' })`:
248
+ *
249
+ * - role "name" [checked] [level=1] [ref=e5] [cursor=pointer]: text
250
+ *
251
+ * The name is JSON-quoted, or printed bare when it is itself `/…/`. A key YAML
252
+ * would misread (a name holding `: ` or ` #`) comes wrapped in single quotes
253
+ * with `''` for `'`; a text value that needs it is double-quoted. Property
254
+ * lines (`- /url: …`) and bare text (`- text: …`) are not nodes and return null.
255
+ */
256
+ function parseAriaLine(line) {
257
+ const item = /^( *)- (.*)$/.exec(line);
258
+ if (!item) return null;
259
+
260
+ let key = item[2];
261
+ let rest = '';
262
+ const quoted = /^'((?:[^']|'')*)'(.*)$/.exec(key);
263
+ if (quoted) {
264
+ key = quoted[1].replace(/''/g, "'");
265
+ rest = quoted[2];
266
+ } else {
267
+ // An unquoted key never contains `:` followed by a space or the end.
268
+ const colon = key.search(/:(\s|$)/);
269
+ if (colon >= 0) [key, rest] = [key.slice(0, colon), key.slice(colon)];
270
+ }
271
+
272
+ const parts = /^([a-z]+)(?: ("(?:[^"\\]|\\.)*"|\/.*\/(?= \[|$)))?(.*)$/.exec(key);
273
+ if (!parts || parts[1] === 'text') return null;
274
+
275
+ let text = rest.replace(/^:\s?/, '');
276
+ if (text.startsWith('"')) {
277
+ try {
278
+ text = JSON.parse(text.replace(/\\x([0-9a-f]{2})/gi, '\\u00$1'));
279
+ } catch {
280
+ // keep the raw value; it is only ever a fallback name
281
+ }
282
+ }
283
+
284
+ const ref = /\[ref=([^\]\s]+)\]/.exec(parts[3]);
285
+ return {
286
+ indent: item[1].length,
287
+ role: parts[1],
288
+ name: parts[2] ? (parts[2].startsWith('"') ? JSON.parse(parts[2]) : parts[2]) : '',
289
+ text,
290
+ ref: ref ? ref[1] : null,
291
+ pointer: parts[3].includes('[cursor=pointer]')
292
+ };
293
+ }
294
+
295
+ /** Playwright refs of the `generic` nodes that are contenteditable editing hosts. */
296
+ async function editingHosts(page, nodes) {
297
+ const found = await Promise.all(page.frames().map((frame) =>
298
+ frame.locator(EDITABLE_SELECTOR).count().catch(() => 0)));
299
+ if (!found.some(Boolean)) return new Set();
300
+
301
+ const generics = nodes.filter((node) => node.role === 'generic');
302
+ const hosts = await Promise.all(generics.map((node) =>
303
+ page.locator(`aria-ref=${node.ref}`)
304
+ .evaluate(
305
+ (el) => el.isContentEditable && !(el.parentElement && el.parentElement.isContentEditable),
306
+ undefined,
307
+ { timeout: EDITABLE_CHECK_TIMEOUT_MS }
308
+ )
309
+ .catch(() => false)));
310
+ return new Set(generics.filter((_, i) => hosts[i]).map((node) => node.ref));
311
+ }
312
+
313
+ /**
314
+ * Describe the page with Playwright's native snapshot, in the walk's format.
315
+ *
316
+ * A node is interactive by role; or, having no such role, when it is an
317
+ * editing host (printed as the textbox it is typed into like) or the outermost
318
+ * node with a pointer cursor — the mark an onclick `<div>` usually carries.
319
+ * Nodes without a Playwright ref are left out: those are the invisible ones
320
+ * (display:none, zero-size, a closed <select>'s options). A `tabindex` or
321
+ * `onclick` element with neither a role nor a pointer cursor is not found —
322
+ * the YAML does not carry those attributes.
323
+ */
324
+ async function ariaCapture(page, { interactiveOnly, maxNodes, timeout }) {
325
+ const yaml = await page.ariaSnapshot({ mode: 'ai', timeout });
326
+ const nodes = yaml.split('\n').map(parseAriaLine).filter((node) => node && node.ref);
327
+ const editable = await editingHosts(page, nodes);
328
+
329
+ const lines = [];
330
+ const refs = [];
331
+ let truncated = false;
332
+ // The emitted ancestors of the current node. Indentation follows EMITTED
333
+ // nesting, as in the walk, so it is this stack's depth, not the YAML's.
334
+ const emitted = [];
335
+
336
+ for (const node of nodes) {
337
+ while (emitted.length && emitted[emitted.length - 1].indent >= node.indent) emitted.pop();
338
+
339
+ const isHost = editable.has(node.ref);
340
+ const interactive = INTERACTIVE_ROLE_SET.has(node.role) || isHost ||
341
+ (node.pointer && !emitted.some((ancestor) => ancestor.interactive));
342
+ const structural = !interactive && !interactiveOnly && STRUCTURAL_ROLE_SET.has(node.role);
343
+ if (!interactive && !structural) continue;
344
+
345
+ if (lines.length >= maxNodes) {
346
+ truncated = true;
347
+ break;
348
+ }
349
+ const role = isHost ? 'textbox' : node.role;
350
+ // A control with no accessible name is captioned by its text, as the walk
351
+ // does; a landmark is named by its label only.
352
+ const name = truncateName(cleanName(node.name || (interactive ? node.text : '')));
353
+ let ref = '';
354
+ if (interactive) {
355
+ const id = `e${refs.length + 1}`;
356
+ refs.push({ id, role, name, selector: `aria-ref=${node.ref}` });
357
+ ref = `@${id} `;
358
+ }
359
+ lines.push(`${' '.repeat(Math.min(emitted.length + 1, MAX_INDENT))}${ref}[${role}]${name ? ` "${name}"` : ''}`);
360
+ emitted.push({ indent: node.indent, interactive });
361
+ }
362
+
363
+ return { title: cleanName(await page.title()), lines, refs, truncated };
364
+ }
365
+
366
+ /** The injected walk, with each ref's selector pointing at the stamp it left. */
367
+ async function walkCapture(page, { interactiveOnly, maxNodes }) {
368
+ const walk = await page.evaluate(snapshotScript, {
369
+ refAttribute: REF_ATTRIBUTE,
370
+ interactiveOnly,
371
+ maxNodes,
372
+ maxIndent: MAX_INDENT,
373
+ maxNameLength: MAX_NAME_LENGTH,
374
+ interactiveRoles: INTERACTIVE_ROLES,
375
+ structuralRoles: STRUCTURAL_ROLES
376
+ });
377
+ return {
378
+ ...walk,
379
+ refs: walk.refs.map(({ id, role, name }) => ({ id, role, name, selector: `[${REF_ATTRIBUTE}="${id}"]` }))
380
+ };
381
+ }
382
+
217
383
  function stateFor(page) {
218
384
  let state = pageState.get(page);
219
385
  if (!state) {
@@ -224,12 +390,15 @@ function stateFor(page) {
224
390
  }
225
391
 
226
392
  /**
227
- * Walk the page and return its tree plus the refs it assigned.
393
+ * Describe the page and return its tree plus the refs it assigned. `source`
394
+ * in the result says whether the native snapshot or the walk produced it.
228
395
  *
229
396
  * @param {import('playwright').Page} page
230
397
  * @param {object} [options]
231
398
  * @param {boolean} [options.interactiveOnly=true] — false also emits headings and landmarks, unreffed
232
399
  * @param {number} [options.maxNodes=200] — cap on emitted nodes, clamped to [1, MAX_NODES_LIMIT]
400
+ * @param {number} [options.timeout] — budget for the render wait before the capture (see settle.js),
401
+ * and for the native snapshot itself
233
402
  */
234
403
  export async function captureSnapshot(page, options = {}) {
235
404
  const interactiveOnly = options.interactiveOnly !== false;
@@ -242,27 +411,42 @@ export async function captureSnapshot(page, options = {}) {
242
411
  // refs rather than leaving them to fail as a missing selector.
243
412
  attachRefTracking(page);
244
413
 
414
+ // Walking before the page has rendered describes an empty shell. Both the
415
+ // snapshot action and browser_session's snapshot operation come through here.
416
+ const settle = await settlePage(page, { timeout: options.timeout });
417
+
245
418
  const state = stateFor(page);
246
419
  let title, lines, refs, truncated;
247
-
248
- // A navigation that commits WHILE the walk is running would otherwise leave us
249
- // holding refs for a document that has gone — the attributes were stamped on
250
- // the old page, so `@e1` would match nothing and surface as a locator timeout
251
- // instead of the named error D2 requires. `generation` moves on every
252
- // main-frame navigation, so a change across the evaluate means exactly that.
253
- // Walk the new document instead; if it navigates again, give up and leave the
254
- // refs invalidated rather than publishing a tree for a page nobody is on.
255
- for (let attempt = 0; ; attempt++) {
420
+ // The native snapshot unless this page object has none (a stub, an old
421
+ // Playwright) or it throws here; the walk is the fallback either way.
422
+ let source = typeof page.ariaSnapshot === 'function' ? 'aria' : 'walk';
423
+
424
+ // A navigation that commits WHILE the snapshot is running would otherwise
425
+ // leave us holding refs for a document that has gone, so `@e1` would match
426
+ // nothing and surface as a locator timeout instead of the named error D2
427
+ // requires. `generation` moves on every main-frame navigation, so a change
428
+ // across the capture means exactly that — whether the capture returned or
429
+ // threw because its context was destroyed. Capture the new document instead;
430
+ // if it navigates again, give up and leave the refs invalidated rather than
431
+ // publishing a tree for a page nobody is on.
432
+ for (let attempt = 0; ;) {
256
433
  const generation = state.generation;
257
- ({ title, lines, refs, truncated } = await page.evaluate(snapshotScript, {
258
- refAttribute: REF_ATTRIBUTE,
259
- interactiveOnly,
260
- maxNodes,
261
- maxIndent: MAX_INDENT,
262
- maxNameLength: MAX_NAME_LENGTH
263
- }));
264
- if (state.generation === generation) break;
265
- if (attempt >= MAX_WALK_ATTEMPTS - 1) {
434
+ const capture = source === 'aria' ? ariaCapture : walkCapture;
435
+ let result;
436
+ try {
437
+ result = await capture(page, { interactiveOnly, maxNodes, timeout: options.timeout });
438
+ } catch (error) {
439
+ if (state.generation === generation) {
440
+ if (source === 'walk') throw error;
441
+ source = 'walk'; // the native snapshot failed on a page that stayed put
442
+ continue;
443
+ }
444
+ }
445
+ if (state.generation === generation) {
446
+ ({ title, lines, refs, truncated } = result);
447
+ break;
448
+ }
449
+ if (++attempt >= MAX_WALK_ATTEMPTS) {
266
450
  clearRefs(page);
267
451
  throw new StaleRefError(
268
452
  'The page navigated while the snapshot was being taken — take a new snapshot.'
@@ -272,7 +456,7 @@ export async function captureSnapshot(page, options = {}) {
272
456
 
273
457
  const snapshotId = randomUUID().slice(0, 8);
274
458
  state.snapshotId = snapshotId;
275
- state.refs = new Map(refs.map(({ id, role, name, tag }) => [id, { role, name, tag }]));
459
+ state.refs = new Map(refs.map(({ id, role, name, selector }) => [id, { role, name, selector }]));
276
460
  state.invalidated = false;
277
461
 
278
462
  return {
@@ -283,12 +467,18 @@ export async function captureSnapshot(page, options = {}) {
283
467
  refCount: refs.length,
284
468
  nodeCount: lines.length,
285
469
  truncated,
286
- interactiveOnly
470
+ interactiveOnly,
471
+ source,
472
+ waited_ms: settle.waited_ms,
473
+ settled_by: settle.settled_by
287
474
  };
288
475
  }
289
476
 
290
477
  /**
291
- * Turn `@e1` into the CSS selector every action path already understands.
478
+ * Turn `@e1` into the selector for the element it named: `aria-ref=…` from the
479
+ * native snapshot, `[data-cf-ref="e1"]` from the walk. Either works anywhere a
480
+ * Playwright selector does (locator, waitForSelector, page.type) — not in a
481
+ * raw document.querySelector().
292
482
  * Throws StaleRefError when the ref does not belong to the page's current
293
483
  * snapshot — it never guesses.
294
484
  */
@@ -318,7 +508,7 @@ export function resolveRef(page, selector) {
318
508
  : `Unknown element ref ${selector}: the current snapshot has ${count} refs (@e1-@e${count}) — take a new snapshot.`);
319
509
  }
320
510
 
321
- return `[${REF_ATTRIBUTE}="${id}"]`;
511
+ return state.refs.get(id).selector;
322
512
  }
323
513
 
324
514
  /** Register the navigation listener that invalidates this page's refs. Idempotent. */
@@ -0,0 +1,50 @@
1
+ /**
2
+ * screenshotResources — move a scrape_with_actions result's screenshot bytes
3
+ * out of the inline result and into crawlforge://screenshot/{actionId}
4
+ * resources.
5
+ *
6
+ * The tool carries the same base64 in up to four places: `screenshots`,
7
+ * `actionResults[].result` (screenshot actions), `content.screenshots`
8
+ * (formats:["screenshots"] copies the same array) and `attempts[].results`
9
+ * (one per chain attempt). Stripping only the first left the others inline
10
+ * (Phase 0, 0.4). Each actionId is published once.
11
+ */
12
+
13
+ /**
14
+ * @param {object} result - the tool result, mutated in place
15
+ * @param {(actionId: string, data: string) => void} publish - stores the base64
16
+ * @returns {object} the same result
17
+ */
18
+ export function stripScreenshotData(result, publish) {
19
+ const published = new Set();
20
+
21
+ const strip = (shot, actionId) => {
22
+ if (!actionId || typeof shot?.data !== 'string') return shot;
23
+ if (!published.has(actionId)) {
24
+ publish(actionId, shot.data);
25
+ published.add(actionId);
26
+ }
27
+ const { data, ...rest } = shot;
28
+ return { ...rest, resourceUri: `crawlforge://screenshot/${actionId}` };
29
+ };
30
+
31
+ const stripResults = (results) => results.map((r) => (
32
+ r?.type === 'screenshot' ? { ...r, result: strip(r.result, r.id) } : r
33
+ ));
34
+
35
+ if (Array.isArray(result.screenshots)) {
36
+ result.screenshots = result.screenshots.map((shot) => strip(shot, shot?.actionId));
37
+ }
38
+ if (Array.isArray(result.content?.screenshots)) {
39
+ result.content.screenshots = result.content.screenshots.map((shot) => strip(shot, shot?.actionId));
40
+ }
41
+ if (Array.isArray(result.actionResults)) {
42
+ result.actionResults = stripResults(result.actionResults);
43
+ }
44
+ if (Array.isArray(result.attempts)) {
45
+ result.attempts = result.attempts.map((a) => (
46
+ Array.isArray(a?.results) ? { ...a, results: stripResults(a.results) } : a
47
+ ));
48
+ }
49
+ return result;
50
+ }
@@ -157,7 +157,7 @@ main frame only — elements inside iframes and shadow DOM get no refs.
157
157
  | `formAutoFill` | — | Declarative form fill: `fields[]` + `submitSelector`. |
158
158
  | `browserOptions` | — | `headless`, `userAgent`, `viewportWidth/Height`, `timeout`. |
159
159
  | `continueOnActionError` | `false` | Keep going if one action fails. |
160
- | `maxRetries` | `1` | 0–3 retries on failure. |
160
+ | `maxRetries` | `0` | 0–3 whole-chain retries on failure. A retry re-navigates to the starting URL and replays every action; each run is reported under `attempts[]`. |
161
161
  | `screenshotOnError` | `true` | Capture a screenshot when an error occurs. |
162
162
 
163
163
  ## CLI action-script format
@@ -91,6 +91,12 @@ Cloudflare scores the address and its ASN before it serves a challenge, so a
91
91
  datacenter proxy changes the address without changing the class of address being
92
92
  scored. Route through your own residential proxy — CrawlForge supplies none.
93
93
 
94
+ A self-hosted server can instead hand every stealth session to Browserbase:
95
+ set `CRAWLFORGE_BROWSER_BACKEND=browserbase` and `BROWSERBASE_API_KEY` (see
96
+ `docs/cloud-browser.md`). That account supplies the residential exit and any
97
+ CAPTCHA solving on its own terms; CrawlForge itself never solves a challenge —
98
+ a challenge page is reported as blocked.
99
+
94
100
  Server-wide, set `CRAWLFORGE_STEALTH_PROXIES` (comma-separated proxy URLs): the
95
101
  `scrape` escalation stage, `stealth_mode`, `browser_session`,
96
102
  `scrape_with_actions`, the `deep_research` retry and the `agent` tool's automatic
@@ -42,6 +42,7 @@ import { isRemoteTransport } from '../../utils/remoteMode.js';
42
42
  import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js';
43
43
  import { stealthDocumentVerdict } from '../../utils/stealthVerdict.js';
44
44
  import { resolveStealthEngine } from '../../core/StealthBrowserManager.js';
45
+ import { actionQueue as sharedActionQueue } from '../../core/browser/actionQueue.js';
45
46
 
46
47
  const SECOND = 1000;
47
48
 
@@ -207,7 +208,10 @@ export class BrowserSessionTool {
207
208
  extractContentTool = null,
208
209
  store = null,
209
210
  storeOptions = {},
210
- enableLogging = true
211
+ enableLogging = true,
212
+ // The process-wide browser-slot limiter shared with scrape_with_actions.
213
+ // Injectable for tests only.
214
+ actionQueue = sharedActionQueue
211
215
  } = options;
212
216
 
213
217
  // An injected executor belongs to whoever built it (server.js hands us
@@ -218,6 +222,7 @@ export class BrowserSessionTool {
218
222
  this.extractContentTool = extractContentTool || new ExtractContentTool();
219
223
  this.storeOptions = storeOptions;
220
224
  this.store = store || new BrowserSessionStore(storeOptions);
225
+ this.actionQueue = actionQueue;
221
226
  }
222
227
 
223
228
  async execute(params) {
@@ -337,7 +342,18 @@ export class BrowserSessionTool {
337
342
  // only then creates a page and navigates — closing the page itself if any
338
343
  // of that fails. That is the whole of 2.5's gating on `open`, which is why
339
344
  // this is not page.goto() behind a gate written here.
340
- const page = await this.actionExecutor.initializePage(params.url, browserOptions);
345
+ //
346
+ // Page creation and the first navigation take a slot from the browser
347
+ // budget scrape_with_actions draws on, so a burst of opens queues behind
348
+ // (and alongside) action chains instead of piling onto the box. The slot is
349
+ // released as soon as the page is up — a live session must NOT hold it for
350
+ // its TTL, or three open sessions would starve every scrape_with_actions
351
+ // call for up to an hour. How many sessions may stay open is the store's
352
+ // per-owner and total caps below, which still refuse rather than queue.
353
+ const page = await this.actionQueue.run(
354
+ () => this.actionExecutor.initializePage(params.url, browserOptions),
355
+ { label: 'browser_session open' }
356
+ );
341
357
  const releasePage = this.releaser(page, params.stealth);
342
358
 
343
359
  let session;