@ricsam/r5d-browser 0.0.70 → 0.0.71

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.
package/README.md CHANGED
@@ -22,6 +22,8 @@ r5d-browser start --chrome-path "/Applications/Google Chrome.app/Contents/MacOS/
22
22
 
23
23
  The profile is stored under `~/.r5d/browser/chrome-profile`. The previous Chrome for Testing profile at `~/.r5d/browser/profile` is preserved but is not migrated automatically, so sign in again the first time you use the new profile. Browser binaries previously installed under `~/.r5d/browser/browsers` are also left untouched, but new versions of `r5d-browser` do not use them. Completed downloads are retained under `~/.r5d/browser/downloads`; agents can list them and copy a selected file into the active session's artifacts. That artifact is then synchronized to every connected worker. Browser screenshots are session artifacts in r5d.dev and are not retained by the browser process.
24
24
 
25
+ Agents read pages through `browser_read_page`, an accessibility outline with stable element refs (`[ref=e12]`) on interactable elements and `[box=x,y,w,h]` viewport coordinates on everything visible. Refs feed the one-call interaction tools: `browser_mouse_click` clicks a ref (scrolled into view, with actionability checks) or an x/y point, `browser_form_input` sets values on text inputs, selects, checkboxes, and contenteditable elements with trusted events that controlled (React-style) components honor, and `browser_scroll` scrolls an element into view or dispatches trusted wheel input. Same-origin iframe content appears inline with frame-prefixed refs such as `f1e3`; cross-origin frame content may be omitted. Refs come from the most recent snapshot of a tab, so agents re-read the page after navigations or large re-renders. `browser_wait_for` waits for a selector, visible text, a URL glob, or a load state instead of sleeping, and `browser_keyboard_type` has a `keys` mode that presses each character as a real key event (up to 500 characters per call) for type-ahead widgets — characters without key codes, such as emoji, still fall back to text insertion. Screenshots capture the viewport by default; `fullPage: true` captures the scrollable page up to 4000 CSS px of height.
26
+
25
27
  Agents running JavaScript in a page can save captured text straight into the active session's artifacts by calling `r5dCreateArtifact(filename, content)`, which takes two strings and must be called before the agent's code returns. Each captured file is written to the session artifacts in r5d.dev, synchronized to every connected worker, and reported back as a `$R5D_ARTIFACTS_DIR` path; a single call may create at most 32 files totalling 4 MiB. The function is passed into the agent's code rather than assigned to `window`, so pages can neither call it nor use it to detect automation.
26
28
 
27
29
  Agents can also create loopback-only TCP port forwards from this Mac to the worker bound to a chat. For example, a forward can map Chrome's `localhost:4323` to `127.0.0.1:3232` on that worker. The relay preserves raw TCP traffic, including HTTP, HTTPS, WebSockets, HMR, and SSE; it never exposes a public listener or permits an arbitrary target host.
@@ -37,6 +37,38 @@ const MAX_DOWNLOAD_CHUNK_BYTES = 1024 * 1024;
37
37
  const MAX_RUN_JS_RESULT_BYTES = 1024 * 1024;
38
38
  const MAX_RUN_JS_ARTIFACT_BYTES = 4 * 1024 * 1024;
39
39
  const MAX_RUN_JS_ARTIFACT_COUNT = 32;
40
+ const REF_ACTION_TIMEOUT_MS = 5e3;
41
+ const NAVIGATION_TIMEOUT_MS = 2e4;
42
+ const READ_PAGE_TIMEOUT_MS = 1e4;
43
+ const MAX_FULL_PAGE_HEIGHT = 4e3;
44
+ const MAX_FULL_PAGE_WIDTH = 4096;
45
+ const MAX_TYPED_KEYS_CHARS = 500;
46
+ const MAX_TYPED_KEYS_TOTAL_MS = 2e4;
47
+ function clampInt(value, min, max, fallback) {
48
+ const num = value === void 0 ? fallback : Number(value);
49
+ if (!Number.isFinite(num)) return fallback;
50
+ return Math.min(max, Math.max(min, Math.round(num)));
51
+ }
52
+ function requireRef(value) {
53
+ if (typeof value !== "string" || !/^(f\d+)?e\d+$/.test(value)) {
54
+ throw new Error('ref must look like "e12" or "f1e3", exactly as printed by browser_read_page ([ref=e12]).');
55
+ }
56
+ return value;
57
+ }
58
+ function stripCallLog(message) {
59
+ const withoutCallLog = message.split(/\n\s*Call log:/)[0] ?? message;
60
+ return withoutCallLog.split("\n").filter((line) => !/^\s+at\s/.test(line)).join("\n").trim();
61
+ }
62
+ function isDestroyedContext(message) {
63
+ return /Execution context was destroyed|because of a navigation|Cannot find context with specified id|Target closed/i.test(message);
64
+ }
65
+ function refActionError(error, ref, verb) {
66
+ const message = stripCallLog(error instanceof Error ? error.message : String(error));
67
+ return new Error(
68
+ `${message}
69
+ Ref ${ref} comes from the most recent browser_read_page snapshot of this tab. If the page navigated or re-rendered since, take a new snapshot and retry. If another element covers the target, dismiss the overlay before the ${verb}.`
70
+ );
71
+ }
40
72
  class BrowserRuntime {
41
73
  constructor(context, downloadsPath) {
42
74
  this.context = context;
@@ -171,6 +203,18 @@ class BrowserRuntime {
171
203
  if (!page || page.isClosed()) throw new Error(`Browser tab ${tabId} is no longer open.`);
172
204
  return page;
173
205
  }
206
+ refLocator(page, ref) {
207
+ return page.locator(`aria-ref=${ref}`);
208
+ }
209
+ async gotoSettled(page, url) {
210
+ try {
211
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: NAVIGATION_TIMEOUT_MS });
212
+ } catch (error) {
213
+ const message = error instanceof Error ? error.message : String(error);
214
+ if (!/ERR_ABORTED|interrupted by another navigation/i.test(message)) throw new Error(stripCallLog(message));
215
+ await page.waitForLoadState("domcontentloaded", { timeout: 5e3 }).catch(() => void 0);
216
+ }
217
+ }
174
218
  async tabInfo(page) {
175
219
  const tabId = this.track(page);
176
220
  let windowId = "unknown";
@@ -216,7 +260,7 @@ class BrowserRuntime {
216
260
  page2 = await this.context.newPage();
217
261
  }
218
262
  const url = typeof input.url === "string" ? input.url : "about:blank";
219
- if (url !== "about:blank") await page2.goto(url, { waitUntil: "domcontentloaded" });
263
+ if (url !== "about:blank") await this.gotoSettled(page2, url);
220
264
  return { tab: await this.tabInfo(page2) };
221
265
  }
222
266
  const page = this.page(input.tabId);
@@ -226,10 +270,57 @@ class BrowserRuntime {
226
270
  await page.close();
227
271
  return { closed: true, tabId };
228
272
  case "navigate":
229
- await page.goto(String(input.url), { waitUntil: "domcontentloaded" });
273
+ await this.gotoSettled(page, String(input.url));
230
274
  return { tab: await this.tabInfo(page) };
275
+ case "read_page": {
276
+ const maxChars = clampInt(input.maxChars, 1e3, 2e5, 5e4);
277
+ const depth = input.depth === void 0 ? void 0 : clampInt(input.depth, 1, 50, 50);
278
+ const options = { mode: "ai", boxes: true, timeout: READ_PAGE_TIMEOUT_MS, ...depth === void 0 ? {} : { depth } };
279
+ let snapshot;
280
+ try {
281
+ snapshot = await page.ariaSnapshot(options);
282
+ } catch (error) {
283
+ const message = error instanceof Error ? error.message : String(error);
284
+ if (isDestroyedContext(message)) {
285
+ await page.waitForLoadState("domcontentloaded", { timeout: 5e3 }).catch(() => void 0);
286
+ snapshot = await page.ariaSnapshot(options);
287
+ } else if (/Timeout/i.test(message)) {
288
+ throw new Error(
289
+ `read_page timed out after ${READ_PAGE_TIMEOUT_MS}ms building the outline. The page is very large or busy; retry with a depth limit (e.g. depth: 10).`
290
+ );
291
+ } else {
292
+ throw new Error(stripCallLog(message));
293
+ }
294
+ }
295
+ let text = snapshot;
296
+ let truncated = false;
297
+ if (text.length > maxChars) {
298
+ const cut = text.lastIndexOf("\n", maxChars);
299
+ text = text.slice(0, cut > 0 ? cut : maxChars);
300
+ truncated = true;
301
+ }
302
+ return {
303
+ tabId,
304
+ url: page.url(),
305
+ title: await page.title().catch(() => ""),
306
+ text,
307
+ truncated,
308
+ ...truncated ? { note: `Truncated at ${maxChars} of ${snapshot.length} chars. Raise maxChars, pass depth for an overview, or scroll and re-read.` } : {}
309
+ };
310
+ }
231
311
  case "screenshot": {
232
- const bytes = await page.screenshot({ type: "png" });
312
+ if (input.fullPage === true) {
313
+ const metrics = await page.evaluate(() => ({
314
+ docWidth: Math.max(document.documentElement.scrollWidth, window.innerWidth),
315
+ docHeight: Math.max(document.documentElement.scrollHeight, window.innerHeight)
316
+ }));
317
+ const width = Math.min(metrics.docWidth, MAX_FULL_PAGE_WIDTH);
318
+ const height = Math.min(metrics.docHeight, MAX_FULL_PAGE_HEIGHT);
319
+ const truncated = metrics.docHeight > MAX_FULL_PAGE_HEIGHT || metrics.docWidth > MAX_FULL_PAGE_WIDTH;
320
+ const bytes2 = truncated ? await page.screenshot({ type: "png", scale: "css", timeout: 15e3, clip: { x: 0, y: 0, width, height } }) : await page.screenshot({ type: "png", scale: "css", timeout: 15e3, fullPage: true });
321
+ return { base64: bytes2.toString("base64"), width, height, fullPage: true, pageHeight: metrics.docHeight, truncated };
322
+ }
323
+ const bytes = await page.screenshot({ type: "png", scale: "css" });
233
324
  const viewport = page.viewportSize();
234
325
  const cursor = this.cursor.get(tabId);
235
326
  return {
@@ -249,12 +340,181 @@ class BrowserRuntime {
249
340
  return { tabId, x, y };
250
341
  }
251
342
  case "mouse_click": {
252
- const cursor = this.cursor.get(tabId);
253
- if (!cursor) throw new Error("Move the mouse in this tab before clicking.");
254
343
  const button = input.button === "middle" || input.button === "right" ? input.button : "left";
255
- await page.mouse.click(cursor.x, cursor.y, { button, clickCount: input.clickCount === 2 ? 2 : 1 });
344
+ const clickCount = input.clickCount === 2 ? 2 : 1;
345
+ if (input.ref !== void 0) {
346
+ if (input.x !== void 0 || input.y !== void 0) throw new Error("mouse_click accepts ref or x/y, not both.");
347
+ const ref = requireRef(input.ref);
348
+ const locator = this.refLocator(page, ref);
349
+ try {
350
+ await locator.click({ button, clickCount, timeout: REF_ACTION_TIMEOUT_MS });
351
+ } catch (error) {
352
+ throw refActionError(error, ref, "click");
353
+ }
354
+ const box = await locator.boundingBox().catch(() => null);
355
+ if (box) this.cursor.set(tabId, { x: box.x + box.width / 2, y: box.y + box.height / 2 });
356
+ return {
357
+ tabId,
358
+ ref,
359
+ button,
360
+ clickCount,
361
+ ...box ? { x: Math.round(box.x + box.width / 2), y: Math.round(box.y + box.height / 2) } : {}
362
+ };
363
+ }
364
+ if (input.x !== void 0 || input.y !== void 0) {
365
+ const x = Number(input.x);
366
+ const y = Number(input.y);
367
+ if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error("mouse_click by coordinates requires numeric x and y.");
368
+ await page.mouse.move(x, y);
369
+ this.cursor.set(tabId, { x, y });
370
+ await page.mouse.click(x, y, { button, clickCount });
371
+ return { tabId, x, y, button, clickCount };
372
+ }
373
+ const cursor = this.cursor.get(tabId);
374
+ if (!cursor) throw new Error("Pass ref or x/y, or move the mouse in this tab before clicking.");
375
+ await page.mouse.click(cursor.x, cursor.y, { button, clickCount });
256
376
  return { tabId, ...cursor, button };
257
377
  }
378
+ case "scroll": {
379
+ if (input.ref !== void 0) {
380
+ if (input.direction !== void 0) throw new Error("scroll accepts ref or direction, not both.");
381
+ const ref = requireRef(input.ref);
382
+ const locator = this.refLocator(page, ref);
383
+ try {
384
+ await locator.scrollIntoViewIfNeeded({ timeout: REF_ACTION_TIMEOUT_MS });
385
+ } catch (error) {
386
+ throw refActionError(error, ref, "scroll");
387
+ }
388
+ const box = await locator.boundingBox().catch(() => null);
389
+ if (box) this.cursor.set(tabId, { x: box.x + box.width / 2, y: box.y + box.height / 2 });
390
+ return { tabId, ref, ...box ? { x: Math.round(box.x + box.width / 2), y: Math.round(box.y + box.height / 2) } : {} };
391
+ }
392
+ const direction = input.direction;
393
+ if (direction !== "up" && direction !== "down" && direction !== "left" && direction !== "right") {
394
+ throw new Error('scroll requires ref, or direction "up", "down", "left", or "right".');
395
+ }
396
+ const amount = clampInt(input.amount, 1, 2e4, 600);
397
+ let pointer;
398
+ if (input.x !== void 0 || input.y !== void 0) {
399
+ const x = Number(input.x);
400
+ const y = Number(input.y);
401
+ if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error("scroll requires numeric x and y when provided.");
402
+ pointer = { x, y };
403
+ } else {
404
+ pointer = this.cursor.get(tabId) ?? await page.evaluate(() => ({ x: Math.round(window.innerWidth / 2), y: Math.round(window.innerHeight / 2) }));
405
+ }
406
+ await page.mouse.move(pointer.x, pointer.y);
407
+ this.cursor.set(tabId, pointer);
408
+ const deltaX = direction === "left" ? -amount : direction === "right" ? amount : 0;
409
+ const deltaY = direction === "up" ? -amount : direction === "down" ? amount : 0;
410
+ await page.mouse.wheel(deltaX, deltaY);
411
+ return { tabId, direction, amount, ...pointer };
412
+ }
413
+ case "wait_for": {
414
+ const timeout = clampInt(input.timeoutMs, 500, 25e3, 1e4);
415
+ const provided = ["selector", "text", "url", "loadState"].filter((key) => input[key] !== void 0);
416
+ const condition = provided[0];
417
+ if (provided.length !== 1 || condition === void 0) {
418
+ throw new Error("wait_for requires exactly one of selector, text, url, or loadState.");
419
+ }
420
+ const state = input.state === "hidden" ? "hidden" : "visible";
421
+ const started = Date.now();
422
+ try {
423
+ if (typeof input.selector === "string") {
424
+ await page.locator(input.selector).first().waitFor({ state, timeout });
425
+ } else if (typeof input.text === "string") {
426
+ await page.getByText(input.text).first().waitFor({ state, timeout });
427
+ } else if (typeof input.url === "string") {
428
+ await page.waitForURL(input.url, { timeout, waitUntil: "domcontentloaded" });
429
+ } else if (input.loadState === "load" || input.loadState === "domcontentloaded" || input.loadState === "networkidle") {
430
+ await page.waitForLoadState(input.loadState, { timeout });
431
+ } else {
432
+ throw new Error('wait_for loadState must be "load", "domcontentloaded", or "networkidle".');
433
+ }
434
+ } catch (error) {
435
+ const message = error instanceof Error ? error.message : String(error);
436
+ if (!/Timeout|exceeded/i.test(message)) throw new Error(stripCallLog(message));
437
+ throw new Error(
438
+ `wait_for gave up after ${timeout}ms waiting for ${condition} ${JSON.stringify(input[condition])}${condition === "selector" || condition === "text" ? ` to become ${state}` : ""}. Current URL: ${page.url()}. Take a browser_read_page snapshot to see the actual page state.`
439
+ );
440
+ }
441
+ return { tabId, matched: true, elapsedMs: Date.now() - started, url: page.url(), title: await page.title().catch(() => "") };
442
+ }
443
+ case "form_input": {
444
+ const ref = requireRef(input.ref);
445
+ const provided = ["value", "values", "checked"].filter((key) => input[key] !== void 0);
446
+ if (provided.length !== 1) throw new Error("form_input requires exactly one of value, values, or checked.");
447
+ const locator = this.refLocator(page, ref);
448
+ let info;
449
+ try {
450
+ info = await locator.evaluate(
451
+ (el) => {
452
+ const tag = el.tagName.toLowerCase();
453
+ if (el instanceof HTMLSelectElement) {
454
+ return {
455
+ kind: el.multiple ? "multiselect" : "select",
456
+ tag,
457
+ options: [...el.options].map((option) => ({ value: option.value, label: option.label.trim() }))
458
+ };
459
+ }
460
+ if (el instanceof HTMLInputElement && (el.type === "checkbox" || el.type === "radio")) return { kind: "checkable", tag };
461
+ if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) return { kind: "text", tag };
462
+ if (el instanceof HTMLElement && el.isContentEditable) return { kind: "contenteditable", tag };
463
+ return { kind: "other", tag };
464
+ },
465
+ void 0,
466
+ { timeout: REF_ACTION_TIMEOUT_MS }
467
+ );
468
+ } catch (error) {
469
+ throw refActionError(error, ref, "form_input");
470
+ }
471
+ const expectField = (field) => {
472
+ if (provided[0] !== field) throw new Error(`Ref ${ref} is a ${info.kind} (${info.tag}); pass ${field}, not ${provided[0]}.`);
473
+ };
474
+ const matchOption = (entry) => {
475
+ const match = info.options.find((option) => option.value === entry) ?? info.options.find((option) => option.label === entry.trim());
476
+ if (!match) {
477
+ const available = info.options.slice(0, 20).map((option) => `${JSON.stringify(option.label)} (value ${JSON.stringify(option.value)})`).join(", ");
478
+ throw new Error(`No option matches ${JSON.stringify(entry)}. Available: ${available}`);
479
+ }
480
+ return match.value;
481
+ };
482
+ try {
483
+ switch (info.kind) {
484
+ case "select": {
485
+ expectField("value");
486
+ await locator.selectOption(matchOption(input.value), { timeout: REF_ACTION_TIMEOUT_MS });
487
+ break;
488
+ }
489
+ case "multiselect": {
490
+ expectField("values");
491
+ const entries = input.values;
492
+ if (!Array.isArray(entries) || entries.some((entry) => typeof entry !== "string")) {
493
+ throw new Error("form_input values must be an array of strings.");
494
+ }
495
+ await locator.selectOption(entries.map((entry) => matchOption(entry)), { timeout: REF_ACTION_TIMEOUT_MS });
496
+ break;
497
+ }
498
+ case "checkable":
499
+ expectField("checked");
500
+ await locator.setChecked(input.checked === true, { timeout: REF_ACTION_TIMEOUT_MS });
501
+ break;
502
+ case "text":
503
+ case "contenteditable":
504
+ expectField("value");
505
+ await locator.fill(input.value, { timeout: REF_ACTION_TIMEOUT_MS });
506
+ break;
507
+ default:
508
+ throw new Error(
509
+ `Ref ${ref} (${info.tag}) does not accept direct value input. Click it with browser_mouse_click and type with browser_keyboard_type mode "keys".`
510
+ );
511
+ }
512
+ } catch (error) {
513
+ if (error instanceof Error && !/Timeout|exceeded|waiting for/i.test(error.message)) throw error;
514
+ throw refActionError(error, ref, "form_input");
515
+ }
516
+ return { tabId, ref, kind: info.kind };
517
+ }
258
518
  case "keyboard": {
259
519
  const keys = Array.isArray(input.keys) ? input.keys : [];
260
520
  const modifiers = Array.isArray(input.modifiers) ? input.modifiers.filter((value) => typeof value === "string") : [];
@@ -269,47 +529,72 @@ class BrowserRuntime {
269
529
  }
270
530
  return { tabId, keys, modifiers };
271
531
  }
272
- case "keyboard_type":
532
+ case "keyboard_type": {
273
533
  if (typeof input.text !== "string") throw new Error("keyboard_type requires text.");
534
+ if (input.mode === "keys") {
535
+ if (input.text.length > MAX_TYPED_KEYS_CHARS) {
536
+ throw new Error(
537
+ `keyboard_type mode "keys" is limited to ${MAX_TYPED_KEYS_CHARS} characters per call; split the text or use the default insert mode.`
538
+ );
539
+ }
540
+ const delay = clampInt(input.delayMs, 0, 100, 20);
541
+ if (input.text.length * delay > MAX_TYPED_KEYS_TOTAL_MS) {
542
+ throw new Error(`text.length x delayMs must stay at or below ${MAX_TYPED_KEYS_TOTAL_MS}ms; lower delayMs or split the text.`);
543
+ }
544
+ await page.keyboard.type(input.text, { delay });
545
+ return { tabId, characters: input.text.length, mode: "keys" };
546
+ }
274
547
  await page.keyboard.insertText(input.text);
275
548
  return { tabId, characters: input.text.length };
549
+ }
276
550
  case "run_js": {
277
551
  if (typeof input.code !== "string") throw new Error("run_js requires code.");
278
- const evaluated = await page.evaluate(
279
- async ({ code, maxBytes, maxCount }) => {
280
- const artifacts = [];
281
- let totalBytes = 0;
282
- const r5dCreateArtifact = (filename, content) => {
283
- if (typeof filename !== "string" || !filename.trim()) {
284
- throw new TypeError("r5dCreateArtifact(filename, content): filename must be a non-empty string.");
285
- }
286
- if (typeof content !== "string") {
287
- throw new TypeError(
288
- `r5dCreateArtifact(${JSON.stringify(filename)}, content): content must be a string. Use JSON.stringify(value) for objects.`
289
- );
290
- }
291
- if (artifacts.length >= maxCount) {
292
- throw new RangeError(`r5dCreateArtifact: at most ${maxCount} artifacts can be created in one browser_run_js call.`);
293
- }
294
- totalBytes += new TextEncoder().encode(content).length;
295
- if (totalBytes > maxBytes) {
296
- throw new RangeError(`r5dCreateArtifact: artifact content exceeded the ${maxBytes} byte budget for one browser_run_js call.`);
297
- }
298
- artifacts.push({ filename, content });
299
- return filename;
300
- };
301
- const invoke = new Function("r5dCreateArtifact", `"use strict"; return (async () => {
552
+ let evaluated;
553
+ try {
554
+ evaluated = await page.evaluate(
555
+ async ({ code, maxBytes, maxCount }) => {
556
+ const artifacts = [];
557
+ let totalBytes = 0;
558
+ const r5dCreateArtifact = (filename, content) => {
559
+ if (typeof filename !== "string" || !filename.trim()) {
560
+ throw new TypeError("r5dCreateArtifact(filename, content): filename must be a non-empty string.");
561
+ }
562
+ if (typeof content !== "string") {
563
+ throw new TypeError(
564
+ `r5dCreateArtifact(${JSON.stringify(filename)}, content): content must be a string. Use JSON.stringify(value) for objects.`
565
+ );
566
+ }
567
+ if (artifacts.length >= maxCount) {
568
+ throw new RangeError(`r5dCreateArtifact: at most ${maxCount} artifacts can be created in one browser_run_js call.`);
569
+ }
570
+ totalBytes += new TextEncoder().encode(content).length;
571
+ if (totalBytes > maxBytes) {
572
+ throw new RangeError(`r5dCreateArtifact: artifact content exceeded the ${maxBytes} byte budget for one browser_run_js call.`);
573
+ }
574
+ artifacts.push({ filename, content });
575
+ return filename;
576
+ };
577
+ const invoke = new Function("r5dCreateArtifact", `"use strict"; return (async () => {
302
578
  ${code}
303
579
  })()`);
304
- try {
305
- return { result: await invoke(r5dCreateArtifact), artifacts };
306
- } catch (error) {
307
- if (artifacts.length === 0) throw error;
308
- return { artifacts, failure: error instanceof Error ? error.message : String(error) };
309
- }
310
- },
311
- { code: input.code, maxBytes: MAX_RUN_JS_ARTIFACT_BYTES, maxCount: MAX_RUN_JS_ARTIFACT_COUNT }
312
- );
580
+ try {
581
+ return { result: await invoke(r5dCreateArtifact), artifacts };
582
+ } catch (error) {
583
+ if (artifacts.length === 0) throw error;
584
+ return { artifacts, failure: error instanceof Error ? error.message : String(error) };
585
+ }
586
+ },
587
+ { code: input.code, maxBytes: MAX_RUN_JS_ARTIFACT_BYTES, maxCount: MAX_RUN_JS_ARTIFACT_COUNT }
588
+ );
589
+ } catch (error) {
590
+ const message = error instanceof Error ? error.message : String(error);
591
+ if (isDestroyedContext(message)) {
592
+ throw new Error(
593
+ "The page navigated or reloaded while the script was running, destroying its JavaScript context. The script was NOT retried because it may have side effects \u2014 if part of it ran (clicks, submits), verify the page state with browser_read_page before repeating anything. Wait out navigations with browser_wait_for, or restructure the script so navigation-triggering actions come last."
594
+ );
595
+ }
596
+ throw new Error(stripCallLog(message));
597
+ }
313
598
  const serialized = JSON.stringify(evaluated.result);
314
599
  if (serialized && Buffer.byteLength(serialized) > MAX_RUN_JS_RESULT_BYTES) {
315
600
  throw new Error("Browser JavaScript result exceeded the 1 MiB limit.");
package/dist/cjs/main.cjs CHANGED
@@ -150,7 +150,16 @@ async function connectLoop(params) {
150
150
  version: findVersion(),
151
151
  chromiumVersion: params.chromiumVersion,
152
152
  profilePath: params.profilePath,
153
- capabilities: { portForwarding: true, runJsArtifacts: true }
153
+ capabilities: {
154
+ portForwarding: true,
155
+ runJsArtifacts: true,
156
+ readPage: true,
157
+ refActions: true,
158
+ waitFor: true,
159
+ formInput: true,
160
+ typedKeys: true,
161
+ screenshotFullPage: true
162
+ }
154
163
  },
155
164
  tabs: await params.runtime.listTabs(),
156
165
  portForwards: params.portForwards.list()
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-browser",
3
- "version": "0.0.70",
3
+ "version": "0.0.71",
4
4
  "type": "commonjs"
5
5
  }
@@ -4,6 +4,38 @@ const MAX_DOWNLOAD_CHUNK_BYTES = 1024 * 1024;
4
4
  const MAX_RUN_JS_RESULT_BYTES = 1024 * 1024;
5
5
  const MAX_RUN_JS_ARTIFACT_BYTES = 4 * 1024 * 1024;
6
6
  const MAX_RUN_JS_ARTIFACT_COUNT = 32;
7
+ const REF_ACTION_TIMEOUT_MS = 5e3;
8
+ const NAVIGATION_TIMEOUT_MS = 2e4;
9
+ const READ_PAGE_TIMEOUT_MS = 1e4;
10
+ const MAX_FULL_PAGE_HEIGHT = 4e3;
11
+ const MAX_FULL_PAGE_WIDTH = 4096;
12
+ const MAX_TYPED_KEYS_CHARS = 500;
13
+ const MAX_TYPED_KEYS_TOTAL_MS = 2e4;
14
+ function clampInt(value, min, max, fallback) {
15
+ const num = value === void 0 ? fallback : Number(value);
16
+ if (!Number.isFinite(num)) return fallback;
17
+ return Math.min(max, Math.max(min, Math.round(num)));
18
+ }
19
+ function requireRef(value) {
20
+ if (typeof value !== "string" || !/^(f\d+)?e\d+$/.test(value)) {
21
+ throw new Error('ref must look like "e12" or "f1e3", exactly as printed by browser_read_page ([ref=e12]).');
22
+ }
23
+ return value;
24
+ }
25
+ function stripCallLog(message) {
26
+ const withoutCallLog = message.split(/\n\s*Call log:/)[0] ?? message;
27
+ return withoutCallLog.split("\n").filter((line) => !/^\s+at\s/.test(line)).join("\n").trim();
28
+ }
29
+ function isDestroyedContext(message) {
30
+ return /Execution context was destroyed|because of a navigation|Cannot find context with specified id|Target closed/i.test(message);
31
+ }
32
+ function refActionError(error, ref, verb) {
33
+ const message = stripCallLog(error instanceof Error ? error.message : String(error));
34
+ return new Error(
35
+ `${message}
36
+ Ref ${ref} comes from the most recent browser_read_page snapshot of this tab. If the page navigated or re-rendered since, take a new snapshot and retry. If another element covers the target, dismiss the overlay before the ${verb}.`
37
+ );
38
+ }
7
39
  class BrowserRuntime {
8
40
  constructor(context, downloadsPath) {
9
41
  this.context = context;
@@ -138,6 +170,18 @@ class BrowserRuntime {
138
170
  if (!page || page.isClosed()) throw new Error(`Browser tab ${tabId} is no longer open.`);
139
171
  return page;
140
172
  }
173
+ refLocator(page, ref) {
174
+ return page.locator(`aria-ref=${ref}`);
175
+ }
176
+ async gotoSettled(page, url) {
177
+ try {
178
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: NAVIGATION_TIMEOUT_MS });
179
+ } catch (error) {
180
+ const message = error instanceof Error ? error.message : String(error);
181
+ if (!/ERR_ABORTED|interrupted by another navigation/i.test(message)) throw new Error(stripCallLog(message));
182
+ await page.waitForLoadState("domcontentloaded", { timeout: 5e3 }).catch(() => void 0);
183
+ }
184
+ }
141
185
  async tabInfo(page) {
142
186
  const tabId = this.track(page);
143
187
  let windowId = "unknown";
@@ -183,7 +227,7 @@ class BrowserRuntime {
183
227
  page2 = await this.context.newPage();
184
228
  }
185
229
  const url = typeof input.url === "string" ? input.url : "about:blank";
186
- if (url !== "about:blank") await page2.goto(url, { waitUntil: "domcontentloaded" });
230
+ if (url !== "about:blank") await this.gotoSettled(page2, url);
187
231
  return { tab: await this.tabInfo(page2) };
188
232
  }
189
233
  const page = this.page(input.tabId);
@@ -193,10 +237,57 @@ class BrowserRuntime {
193
237
  await page.close();
194
238
  return { closed: true, tabId };
195
239
  case "navigate":
196
- await page.goto(String(input.url), { waitUntil: "domcontentloaded" });
240
+ await this.gotoSettled(page, String(input.url));
197
241
  return { tab: await this.tabInfo(page) };
242
+ case "read_page": {
243
+ const maxChars = clampInt(input.maxChars, 1e3, 2e5, 5e4);
244
+ const depth = input.depth === void 0 ? void 0 : clampInt(input.depth, 1, 50, 50);
245
+ const options = { mode: "ai", boxes: true, timeout: READ_PAGE_TIMEOUT_MS, ...depth === void 0 ? {} : { depth } };
246
+ let snapshot;
247
+ try {
248
+ snapshot = await page.ariaSnapshot(options);
249
+ } catch (error) {
250
+ const message = error instanceof Error ? error.message : String(error);
251
+ if (isDestroyedContext(message)) {
252
+ await page.waitForLoadState("domcontentloaded", { timeout: 5e3 }).catch(() => void 0);
253
+ snapshot = await page.ariaSnapshot(options);
254
+ } else if (/Timeout/i.test(message)) {
255
+ throw new Error(
256
+ `read_page timed out after ${READ_PAGE_TIMEOUT_MS}ms building the outline. The page is very large or busy; retry with a depth limit (e.g. depth: 10).`
257
+ );
258
+ } else {
259
+ throw new Error(stripCallLog(message));
260
+ }
261
+ }
262
+ let text = snapshot;
263
+ let truncated = false;
264
+ if (text.length > maxChars) {
265
+ const cut = text.lastIndexOf("\n", maxChars);
266
+ text = text.slice(0, cut > 0 ? cut : maxChars);
267
+ truncated = true;
268
+ }
269
+ return {
270
+ tabId,
271
+ url: page.url(),
272
+ title: await page.title().catch(() => ""),
273
+ text,
274
+ truncated,
275
+ ...truncated ? { note: `Truncated at ${maxChars} of ${snapshot.length} chars. Raise maxChars, pass depth for an overview, or scroll and re-read.` } : {}
276
+ };
277
+ }
198
278
  case "screenshot": {
199
- const bytes = await page.screenshot({ type: "png" });
279
+ if (input.fullPage === true) {
280
+ const metrics = await page.evaluate(() => ({
281
+ docWidth: Math.max(document.documentElement.scrollWidth, window.innerWidth),
282
+ docHeight: Math.max(document.documentElement.scrollHeight, window.innerHeight)
283
+ }));
284
+ const width = Math.min(metrics.docWidth, MAX_FULL_PAGE_WIDTH);
285
+ const height = Math.min(metrics.docHeight, MAX_FULL_PAGE_HEIGHT);
286
+ const truncated = metrics.docHeight > MAX_FULL_PAGE_HEIGHT || metrics.docWidth > MAX_FULL_PAGE_WIDTH;
287
+ const bytes2 = truncated ? await page.screenshot({ type: "png", scale: "css", timeout: 15e3, clip: { x: 0, y: 0, width, height } }) : await page.screenshot({ type: "png", scale: "css", timeout: 15e3, fullPage: true });
288
+ return { base64: bytes2.toString("base64"), width, height, fullPage: true, pageHeight: metrics.docHeight, truncated };
289
+ }
290
+ const bytes = await page.screenshot({ type: "png", scale: "css" });
200
291
  const viewport = page.viewportSize();
201
292
  const cursor = this.cursor.get(tabId);
202
293
  return {
@@ -216,12 +307,181 @@ class BrowserRuntime {
216
307
  return { tabId, x, y };
217
308
  }
218
309
  case "mouse_click": {
219
- const cursor = this.cursor.get(tabId);
220
- if (!cursor) throw new Error("Move the mouse in this tab before clicking.");
221
310
  const button = input.button === "middle" || input.button === "right" ? input.button : "left";
222
- await page.mouse.click(cursor.x, cursor.y, { button, clickCount: input.clickCount === 2 ? 2 : 1 });
311
+ const clickCount = input.clickCount === 2 ? 2 : 1;
312
+ if (input.ref !== void 0) {
313
+ if (input.x !== void 0 || input.y !== void 0) throw new Error("mouse_click accepts ref or x/y, not both.");
314
+ const ref = requireRef(input.ref);
315
+ const locator = this.refLocator(page, ref);
316
+ try {
317
+ await locator.click({ button, clickCount, timeout: REF_ACTION_TIMEOUT_MS });
318
+ } catch (error) {
319
+ throw refActionError(error, ref, "click");
320
+ }
321
+ const box = await locator.boundingBox().catch(() => null);
322
+ if (box) this.cursor.set(tabId, { x: box.x + box.width / 2, y: box.y + box.height / 2 });
323
+ return {
324
+ tabId,
325
+ ref,
326
+ button,
327
+ clickCount,
328
+ ...box ? { x: Math.round(box.x + box.width / 2), y: Math.round(box.y + box.height / 2) } : {}
329
+ };
330
+ }
331
+ if (input.x !== void 0 || input.y !== void 0) {
332
+ const x = Number(input.x);
333
+ const y = Number(input.y);
334
+ if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error("mouse_click by coordinates requires numeric x and y.");
335
+ await page.mouse.move(x, y);
336
+ this.cursor.set(tabId, { x, y });
337
+ await page.mouse.click(x, y, { button, clickCount });
338
+ return { tabId, x, y, button, clickCount };
339
+ }
340
+ const cursor = this.cursor.get(tabId);
341
+ if (!cursor) throw new Error("Pass ref or x/y, or move the mouse in this tab before clicking.");
342
+ await page.mouse.click(cursor.x, cursor.y, { button, clickCount });
223
343
  return { tabId, ...cursor, button };
224
344
  }
345
+ case "scroll": {
346
+ if (input.ref !== void 0) {
347
+ if (input.direction !== void 0) throw new Error("scroll accepts ref or direction, not both.");
348
+ const ref = requireRef(input.ref);
349
+ const locator = this.refLocator(page, ref);
350
+ try {
351
+ await locator.scrollIntoViewIfNeeded({ timeout: REF_ACTION_TIMEOUT_MS });
352
+ } catch (error) {
353
+ throw refActionError(error, ref, "scroll");
354
+ }
355
+ const box = await locator.boundingBox().catch(() => null);
356
+ if (box) this.cursor.set(tabId, { x: box.x + box.width / 2, y: box.y + box.height / 2 });
357
+ return { tabId, ref, ...box ? { x: Math.round(box.x + box.width / 2), y: Math.round(box.y + box.height / 2) } : {} };
358
+ }
359
+ const direction = input.direction;
360
+ if (direction !== "up" && direction !== "down" && direction !== "left" && direction !== "right") {
361
+ throw new Error('scroll requires ref, or direction "up", "down", "left", or "right".');
362
+ }
363
+ const amount = clampInt(input.amount, 1, 2e4, 600);
364
+ let pointer;
365
+ if (input.x !== void 0 || input.y !== void 0) {
366
+ const x = Number(input.x);
367
+ const y = Number(input.y);
368
+ if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error("scroll requires numeric x and y when provided.");
369
+ pointer = { x, y };
370
+ } else {
371
+ pointer = this.cursor.get(tabId) ?? await page.evaluate(() => ({ x: Math.round(window.innerWidth / 2), y: Math.round(window.innerHeight / 2) }));
372
+ }
373
+ await page.mouse.move(pointer.x, pointer.y);
374
+ this.cursor.set(tabId, pointer);
375
+ const deltaX = direction === "left" ? -amount : direction === "right" ? amount : 0;
376
+ const deltaY = direction === "up" ? -amount : direction === "down" ? amount : 0;
377
+ await page.mouse.wheel(deltaX, deltaY);
378
+ return { tabId, direction, amount, ...pointer };
379
+ }
380
+ case "wait_for": {
381
+ const timeout = clampInt(input.timeoutMs, 500, 25e3, 1e4);
382
+ const provided = ["selector", "text", "url", "loadState"].filter((key) => input[key] !== void 0);
383
+ const condition = provided[0];
384
+ if (provided.length !== 1 || condition === void 0) {
385
+ throw new Error("wait_for requires exactly one of selector, text, url, or loadState.");
386
+ }
387
+ const state = input.state === "hidden" ? "hidden" : "visible";
388
+ const started = Date.now();
389
+ try {
390
+ if (typeof input.selector === "string") {
391
+ await page.locator(input.selector).first().waitFor({ state, timeout });
392
+ } else if (typeof input.text === "string") {
393
+ await page.getByText(input.text).first().waitFor({ state, timeout });
394
+ } else if (typeof input.url === "string") {
395
+ await page.waitForURL(input.url, { timeout, waitUntil: "domcontentloaded" });
396
+ } else if (input.loadState === "load" || input.loadState === "domcontentloaded" || input.loadState === "networkidle") {
397
+ await page.waitForLoadState(input.loadState, { timeout });
398
+ } else {
399
+ throw new Error('wait_for loadState must be "load", "domcontentloaded", or "networkidle".');
400
+ }
401
+ } catch (error) {
402
+ const message = error instanceof Error ? error.message : String(error);
403
+ if (!/Timeout|exceeded/i.test(message)) throw new Error(stripCallLog(message));
404
+ throw new Error(
405
+ `wait_for gave up after ${timeout}ms waiting for ${condition} ${JSON.stringify(input[condition])}${condition === "selector" || condition === "text" ? ` to become ${state}` : ""}. Current URL: ${page.url()}. Take a browser_read_page snapshot to see the actual page state.`
406
+ );
407
+ }
408
+ return { tabId, matched: true, elapsedMs: Date.now() - started, url: page.url(), title: await page.title().catch(() => "") };
409
+ }
410
+ case "form_input": {
411
+ const ref = requireRef(input.ref);
412
+ const provided = ["value", "values", "checked"].filter((key) => input[key] !== void 0);
413
+ if (provided.length !== 1) throw new Error("form_input requires exactly one of value, values, or checked.");
414
+ const locator = this.refLocator(page, ref);
415
+ let info;
416
+ try {
417
+ info = await locator.evaluate(
418
+ (el) => {
419
+ const tag = el.tagName.toLowerCase();
420
+ if (el instanceof HTMLSelectElement) {
421
+ return {
422
+ kind: el.multiple ? "multiselect" : "select",
423
+ tag,
424
+ options: [...el.options].map((option) => ({ value: option.value, label: option.label.trim() }))
425
+ };
426
+ }
427
+ if (el instanceof HTMLInputElement && (el.type === "checkbox" || el.type === "radio")) return { kind: "checkable", tag };
428
+ if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) return { kind: "text", tag };
429
+ if (el instanceof HTMLElement && el.isContentEditable) return { kind: "contenteditable", tag };
430
+ return { kind: "other", tag };
431
+ },
432
+ void 0,
433
+ { timeout: REF_ACTION_TIMEOUT_MS }
434
+ );
435
+ } catch (error) {
436
+ throw refActionError(error, ref, "form_input");
437
+ }
438
+ const expectField = (field) => {
439
+ if (provided[0] !== field) throw new Error(`Ref ${ref} is a ${info.kind} (${info.tag}); pass ${field}, not ${provided[0]}.`);
440
+ };
441
+ const matchOption = (entry) => {
442
+ const match = info.options.find((option) => option.value === entry) ?? info.options.find((option) => option.label === entry.trim());
443
+ if (!match) {
444
+ const available = info.options.slice(0, 20).map((option) => `${JSON.stringify(option.label)} (value ${JSON.stringify(option.value)})`).join(", ");
445
+ throw new Error(`No option matches ${JSON.stringify(entry)}. Available: ${available}`);
446
+ }
447
+ return match.value;
448
+ };
449
+ try {
450
+ switch (info.kind) {
451
+ case "select": {
452
+ expectField("value");
453
+ await locator.selectOption(matchOption(input.value), { timeout: REF_ACTION_TIMEOUT_MS });
454
+ break;
455
+ }
456
+ case "multiselect": {
457
+ expectField("values");
458
+ const entries = input.values;
459
+ if (!Array.isArray(entries) || entries.some((entry) => typeof entry !== "string")) {
460
+ throw new Error("form_input values must be an array of strings.");
461
+ }
462
+ await locator.selectOption(entries.map((entry) => matchOption(entry)), { timeout: REF_ACTION_TIMEOUT_MS });
463
+ break;
464
+ }
465
+ case "checkable":
466
+ expectField("checked");
467
+ await locator.setChecked(input.checked === true, { timeout: REF_ACTION_TIMEOUT_MS });
468
+ break;
469
+ case "text":
470
+ case "contenteditable":
471
+ expectField("value");
472
+ await locator.fill(input.value, { timeout: REF_ACTION_TIMEOUT_MS });
473
+ break;
474
+ default:
475
+ throw new Error(
476
+ `Ref ${ref} (${info.tag}) does not accept direct value input. Click it with browser_mouse_click and type with browser_keyboard_type mode "keys".`
477
+ );
478
+ }
479
+ } catch (error) {
480
+ if (error instanceof Error && !/Timeout|exceeded|waiting for/i.test(error.message)) throw error;
481
+ throw refActionError(error, ref, "form_input");
482
+ }
483
+ return { tabId, ref, kind: info.kind };
484
+ }
225
485
  case "keyboard": {
226
486
  const keys = Array.isArray(input.keys) ? input.keys : [];
227
487
  const modifiers = Array.isArray(input.modifiers) ? input.modifiers.filter((value) => typeof value === "string") : [];
@@ -236,47 +496,72 @@ class BrowserRuntime {
236
496
  }
237
497
  return { tabId, keys, modifiers };
238
498
  }
239
- case "keyboard_type":
499
+ case "keyboard_type": {
240
500
  if (typeof input.text !== "string") throw new Error("keyboard_type requires text.");
501
+ if (input.mode === "keys") {
502
+ if (input.text.length > MAX_TYPED_KEYS_CHARS) {
503
+ throw new Error(
504
+ `keyboard_type mode "keys" is limited to ${MAX_TYPED_KEYS_CHARS} characters per call; split the text or use the default insert mode.`
505
+ );
506
+ }
507
+ const delay = clampInt(input.delayMs, 0, 100, 20);
508
+ if (input.text.length * delay > MAX_TYPED_KEYS_TOTAL_MS) {
509
+ throw new Error(`text.length x delayMs must stay at or below ${MAX_TYPED_KEYS_TOTAL_MS}ms; lower delayMs or split the text.`);
510
+ }
511
+ await page.keyboard.type(input.text, { delay });
512
+ return { tabId, characters: input.text.length, mode: "keys" };
513
+ }
241
514
  await page.keyboard.insertText(input.text);
242
515
  return { tabId, characters: input.text.length };
516
+ }
243
517
  case "run_js": {
244
518
  if (typeof input.code !== "string") throw new Error("run_js requires code.");
245
- const evaluated = await page.evaluate(
246
- async ({ code, maxBytes, maxCount }) => {
247
- const artifacts = [];
248
- let totalBytes = 0;
249
- const r5dCreateArtifact = (filename, content) => {
250
- if (typeof filename !== "string" || !filename.trim()) {
251
- throw new TypeError("r5dCreateArtifact(filename, content): filename must be a non-empty string.");
252
- }
253
- if (typeof content !== "string") {
254
- throw new TypeError(
255
- `r5dCreateArtifact(${JSON.stringify(filename)}, content): content must be a string. Use JSON.stringify(value) for objects.`
256
- );
257
- }
258
- if (artifacts.length >= maxCount) {
259
- throw new RangeError(`r5dCreateArtifact: at most ${maxCount} artifacts can be created in one browser_run_js call.`);
260
- }
261
- totalBytes += new TextEncoder().encode(content).length;
262
- if (totalBytes > maxBytes) {
263
- throw new RangeError(`r5dCreateArtifact: artifact content exceeded the ${maxBytes} byte budget for one browser_run_js call.`);
264
- }
265
- artifacts.push({ filename, content });
266
- return filename;
267
- };
268
- const invoke = new Function("r5dCreateArtifact", `"use strict"; return (async () => {
519
+ let evaluated;
520
+ try {
521
+ evaluated = await page.evaluate(
522
+ async ({ code, maxBytes, maxCount }) => {
523
+ const artifacts = [];
524
+ let totalBytes = 0;
525
+ const r5dCreateArtifact = (filename, content) => {
526
+ if (typeof filename !== "string" || !filename.trim()) {
527
+ throw new TypeError("r5dCreateArtifact(filename, content): filename must be a non-empty string.");
528
+ }
529
+ if (typeof content !== "string") {
530
+ throw new TypeError(
531
+ `r5dCreateArtifact(${JSON.stringify(filename)}, content): content must be a string. Use JSON.stringify(value) for objects.`
532
+ );
533
+ }
534
+ if (artifacts.length >= maxCount) {
535
+ throw new RangeError(`r5dCreateArtifact: at most ${maxCount} artifacts can be created in one browser_run_js call.`);
536
+ }
537
+ totalBytes += new TextEncoder().encode(content).length;
538
+ if (totalBytes > maxBytes) {
539
+ throw new RangeError(`r5dCreateArtifact: artifact content exceeded the ${maxBytes} byte budget for one browser_run_js call.`);
540
+ }
541
+ artifacts.push({ filename, content });
542
+ return filename;
543
+ };
544
+ const invoke = new Function("r5dCreateArtifact", `"use strict"; return (async () => {
269
545
  ${code}
270
546
  })()`);
271
- try {
272
- return { result: await invoke(r5dCreateArtifact), artifacts };
273
- } catch (error) {
274
- if (artifacts.length === 0) throw error;
275
- return { artifacts, failure: error instanceof Error ? error.message : String(error) };
276
- }
277
- },
278
- { code: input.code, maxBytes: MAX_RUN_JS_ARTIFACT_BYTES, maxCount: MAX_RUN_JS_ARTIFACT_COUNT }
279
- );
547
+ try {
548
+ return { result: await invoke(r5dCreateArtifact), artifacts };
549
+ } catch (error) {
550
+ if (artifacts.length === 0) throw error;
551
+ return { artifacts, failure: error instanceof Error ? error.message : String(error) };
552
+ }
553
+ },
554
+ { code: input.code, maxBytes: MAX_RUN_JS_ARTIFACT_BYTES, maxCount: MAX_RUN_JS_ARTIFACT_COUNT }
555
+ );
556
+ } catch (error) {
557
+ const message = error instanceof Error ? error.message : String(error);
558
+ if (isDestroyedContext(message)) {
559
+ throw new Error(
560
+ "The page navigated or reloaded while the script was running, destroying its JavaScript context. The script was NOT retried because it may have side effects \u2014 if part of it ran (clicks, submits), verify the page state with browser_read_page before repeating anything. Wait out navigations with browser_wait_for, or restructure the script so navigation-triggering actions come last."
561
+ );
562
+ }
563
+ throw new Error(stripCallLog(message));
564
+ }
280
565
  const serialized = JSON.stringify(evaluated.result);
281
566
  if (serialized && Buffer.byteLength(serialized) > MAX_RUN_JS_RESULT_BYTES) {
282
567
  throw new Error("Browser JavaScript result exceeded the 1 MiB limit.");
package/dist/mjs/main.mjs CHANGED
@@ -127,7 +127,16 @@ async function connectLoop(params) {
127
127
  version: findVersion(),
128
128
  chromiumVersion: params.chromiumVersion,
129
129
  profilePath: params.profilePath,
130
- capabilities: { portForwarding: true, runJsArtifacts: true }
130
+ capabilities: {
131
+ portForwarding: true,
132
+ runJsArtifacts: true,
133
+ readPage: true,
134
+ refActions: true,
135
+ waitFor: true,
136
+ formInput: true,
137
+ typedKeys: true,
138
+ screenshotFullPage: true
139
+ }
131
140
  },
132
141
  tabs: await params.runtime.listTabs(),
133
142
  portForwards: params.portForwards.list()
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-browser",
3
- "version": "0.0.70",
3
+ "version": "0.0.71",
4
4
  "type": "module"
5
5
  }
@@ -1,5 +1,5 @@
1
1
  import type { BrowserContext } from "playwright-core";
2
- export type BrowserAction = "list_tabs" | "list_downloads" | "get_download" | "open_tab" | "close_tab" | "navigate" | "screenshot" | "move_mouse" | "mouse_click" | "keyboard" | "keyboard_type" | "run_js";
2
+ export type BrowserAction = "list_tabs" | "list_downloads" | "get_download" | "open_tab" | "close_tab" | "navigate" | "read_page" | "screenshot" | "move_mouse" | "mouse_click" | "scroll" | "wait_for" | "form_input" | "keyboard" | "keyboard_type" | "run_js";
3
3
  export type TabInfo = {
4
4
  tabId: string;
5
5
  windowId: string;
@@ -29,6 +29,8 @@ export declare class BrowserRuntime {
29
29
  private listDownloads;
30
30
  private getDownloadChunk;
31
31
  private page;
32
+ private refLocator;
33
+ private gotoSettled;
32
34
  private tabInfo;
33
35
  listTabs(): Promise<TabInfo[]>;
34
36
  drainDownloads(): Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-browser",
3
- "version": "0.0.70",
3
+ "version": "0.0.71",
4
4
  "type": "module",
5
5
  "main": "./dist/cjs/main.cjs",
6
6
  "module": "./dist/mjs/main.mjs",