barebrowse 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/mcp-server.js CHANGED
@@ -10,8 +10,47 @@
10
10
  */
11
11
 
12
12
  import { browse, connect } from './src/index.js';
13
- import { mkdirSync, writeFileSync } from 'node:fs';
14
- import { join } from 'node:path';
13
+ import { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
14
+ import { join, dirname } from 'node:path';
15
+ import { pathToFileURL, fileURLToPath } from 'node:url';
16
+
17
+ // Read version from package.json so serverInfo.version doesn't drift behind
18
+ // release bumps (pre-fix this was hardcoded 0.7.1 while package.json was 0.8.0).
19
+ const _pkgPath = join(dirname(fileURLToPath(import.meta.url)), 'package.json');
20
+ const PKG_VERSION = JSON.parse(readFileSync(_pkgPath, 'utf8')).version;
21
+
22
+ /**
23
+ * Per-tool timeouts (ms). One blanket 30s was too short for SPA cold loads
24
+ * (goto regularly exceeded it on slow sites) and too long for instant ops
25
+ * like scroll. The split below is the H5 plan:
26
+ * - navigation (goto/reload): 60s
27
+ * - browser-history nav (back/forward): 30s
28
+ * - interactive ops (click/type/press/scroll/hover/select/drag): 15s
29
+ * - read-only ops (snapshot/tabs/eval/wait_for): 15s (wait_for has its own
30
+ * internal deadline; this is the outer cap)
31
+ * - heavy I/O (pdf/screenshot/upload): 45s
32
+ * Exported so tests can pin the contract.
33
+ */
34
+ export const TIMEOUTS = {
35
+ goto: 60000,
36
+ reload: 60000,
37
+ back: 30000,
38
+ forward: 30000,
39
+ snapshot: 15000,
40
+ click: 15000,
41
+ type: 15000,
42
+ press: 15000,
43
+ scroll: 15000,
44
+ hover: 15000,
45
+ select: 15000,
46
+ drag: 15000,
47
+ tabs: 5000,
48
+ eval: 15000,
49
+ wait_for: 60000,
50
+ upload: 45000,
51
+ pdf: 45000,
52
+ screenshot: 45000,
53
+ };
15
54
 
16
55
  // Optional: privacy assessment via wearehere
17
56
  let assessFn = null;
@@ -20,20 +59,43 @@ try {
20
59
  } catch {}
21
60
 
22
61
 
23
- function isCdpDead(err) {
62
+ function isTransient(err) {
24
63
  const m = err.message || '';
25
- return m.includes('WebSocket') || m.includes('Target closed') || m.includes('Session closed') || m.includes('CDP');
64
+ return m.includes('WebSocket') || m.includes('Target closed') || m.includes('Session closed')
65
+ || m.includes('CDP') || m.includes('Timeout waiting for CDP event') || m.includes('timed out');
26
66
  }
27
67
 
28
- /** Retry-once wrapper for transient CDP failures. Resets session and retries. */
29
- async function withRetry(fn) {
68
+ /**
69
+ * Run fn with a per-attempt timeout. On transient failure (CDP death OR
70
+ * timeout), reset the session. If `retry` is true (default), retry once on
71
+ * a fresh page; if false, rethrow without retrying — required for
72
+ * non-idempotent ops (click/type/etc.) where a partial first attempt
73
+ * shouldn't be replayed against a blank fresh page.
74
+ * @param {Function} fn - async function to execute
75
+ * @param {number} timeoutMs - per-attempt timeout in ms
76
+ * @param {object} [opts]
77
+ * @param {boolean} [opts.retry=true] - whether to retry once on transient failure
78
+ */
79
+ async function withRetry(fn, timeoutMs, { retry = true } = {}) {
80
+ async function attempt() {
81
+ if (!timeoutMs) return await fn();
82
+ let timer;
83
+ const result = await Promise.race([
84
+ fn(),
85
+ new Promise((_, rej) => { timer = setTimeout(() => rej(new Error(`timed out after ${timeoutMs / 1000}s`)), timeoutMs); }),
86
+ ]);
87
+ clearTimeout(timer);
88
+ return result;
89
+ }
90
+
30
91
  try {
31
- return await fn();
92
+ return await attempt();
32
93
  } catch (err) {
33
- if (!isCdpDead(err)) throw err;
34
- // CDP died — reset session and retry once
94
+ if (!isTransient(err)) throw err;
95
+ // Transient failure — reset session so the next request gets a fresh page.
35
96
  _page = null;
36
- return await fn();
97
+ if (!retry) throw err;
98
+ return await attempt();
37
99
  }
38
100
  }
39
101
 
@@ -79,10 +141,10 @@ function acquireAssessSlot() {
79
141
  }
80
142
 
81
143
 
82
- const TOOLS = [
144
+ export const TOOLS = [
83
145
  {
84
146
  name: 'browse',
85
- description: 'Browse a URL in a real browser. Use instead of web fetch when the page needs JavaScript, login cookies, consent dismissal, or bot detection. Returns a pruned ARIA snapshot with [ref=N] markers for interaction. Stateless — does not use the session page.',
147
+ description: 'One-shot headless browse — fetches a URL through a real browser (executes JS, injects cookies, dismisses consent, evades bot detection). Only when plain HTTP fetch can\'t render the page. Returns a pruned ARIA snapshot with [ref=N] markers. Stateless — for multi-step interaction use goto.',
86
148
  inputSchema: {
87
149
  type: 'object',
88
150
  properties: {
@@ -95,7 +157,7 @@ const TOOLS = [
95
157
  },
96
158
  {
97
159
  name: 'goto',
98
- description: 'Navigate the session page to a URL. Injects cookies from the user\'s browser for authenticated access. Returns ok — call snapshot to observe.',
160
+ description: 'Open URL in a persistent interactive browser session (pair with snapshot/click/type/press for multi-step flows). Use when the task needs clicking, typing, or form submission. Injects auth cookies. Returns ok — call snapshot to observe.',
99
161
  inputSchema: {
100
162
  type: 'object',
101
163
  properties: {
@@ -204,8 +266,92 @@ const TOOLS = [
204
266
  },
205
267
  },
206
268
  },
269
+ {
270
+ name: 'reload',
271
+ description: 'Reload the current page in the session. Returns ok — call snapshot to observe.',
272
+ inputSchema: {
273
+ type: 'object',
274
+ properties: {
275
+ ignoreCache: { type: 'boolean', description: 'Bypass HTTP cache (hard reload). Default: false.' },
276
+ },
277
+ },
278
+ },
279
+ {
280
+ name: 'screenshot',
281
+ description: 'Capture a screenshot of the current page. Saves to .barebrowse/screenshot-*.png (or .jpeg/.webp) and returns the file path. Use the file with your image tools.',
282
+ inputSchema: {
283
+ type: 'object',
284
+ properties: {
285
+ format: { type: 'string', enum: ['png', 'jpeg', 'webp'], description: 'Image format (default: png)' },
286
+ quality: { type: 'number', description: 'JPEG/WebP quality 0-100 (default: 80, ignored for PNG)' },
287
+ },
288
+ },
289
+ },
290
+ {
291
+ name: 'wait_for',
292
+ description: 'Wait for visible text or a CSS selector to appear on the current page. Returns ok when found, throws on timeout.',
293
+ inputSchema: {
294
+ type: 'object',
295
+ properties: {
296
+ text: { type: 'string', description: 'Substring that must appear in document.body.innerText' },
297
+ selector: { type: 'string', description: 'CSS selector that must match document.querySelector' },
298
+ timeout: { type: 'number', description: 'Timeout in ms (default: 30000)' },
299
+ },
300
+ },
301
+ },
302
+ {
303
+ name: 'tabs',
304
+ description: 'List open tabs in the session, or switch to one by index. Returns JSON array of { index, url, title } or "ok" after switch.',
305
+ inputSchema: {
306
+ type: 'object',
307
+ properties: {
308
+ switchTo: { type: 'number', description: 'Tab index to activate. Omit to just list tabs.' },
309
+ },
310
+ },
311
+ },
312
+ {
313
+ name: 'select',
314
+ description: 'Set the value of a <select> dropdown (or custom listbox) by ref. Returns ok.',
315
+ inputSchema: {
316
+ type: 'object',
317
+ properties: {
318
+ ref: { type: 'string', description: 'Element ref from snapshot' },
319
+ value: { type: 'string', description: 'Option value or visible text to select' },
320
+ },
321
+ required: ['ref', 'value'],
322
+ },
323
+ },
324
+ {
325
+ name: 'hover',
326
+ description: 'Hover over an element by ref (triggers tooltips, hover menus). Returns ok.',
327
+ inputSchema: {
328
+ type: 'object',
329
+ properties: {
330
+ ref: { type: 'string', description: 'Element ref from snapshot' },
331
+ },
332
+ required: ['ref'],
333
+ },
334
+ },
207
335
  ];
208
336
 
337
+ // Powerful escape hatch — guarded behind an explicit env-var opt-in.
338
+ // Runtime.evaluate in the user's authenticated session lets an agent read
339
+ // cookies/localStorage, dispatch arbitrary events, hit any endpoint, etc.
340
+ // Off by default; flip BAREBROWSE_MCP_EVAL=1 to enable.
341
+ if (process.env.BAREBROWSE_MCP_EVAL === '1') {
342
+ TOOLS.push({
343
+ name: 'eval',
344
+ description: 'Run a JavaScript expression in the current page and return the result. POWERFUL: full access to the authenticated session — DOM, cookies, localStorage, fetch. Enabled because BAREBROWSE_MCP_EVAL=1 is set.',
345
+ inputSchema: {
346
+ type: 'object',
347
+ properties: {
348
+ expression: { type: 'string', description: 'JavaScript expression to evaluate' },
349
+ },
350
+ required: ['expression'],
351
+ },
352
+ });
353
+ }
354
+
209
355
  // Add assess tool if wearehere is installed
210
356
  if (assessFn) {
211
357
  TOOLS.push({
@@ -226,7 +372,12 @@ if (assessFn) {
226
372
  async function handleToolCall(name, args) {
227
373
  switch (name) {
228
374
  case 'browse': {
229
- const text = await browse(args.url, { mode: args.mode });
375
+ let timer;
376
+ const text = await Promise.race([
377
+ browse(args.url, { mode: args.mode }),
378
+ new Promise((_, rej) => { timer = setTimeout(() => rej(new Error('browse timed out after 60s')), 60000); }),
379
+ ]);
380
+ clearTimeout(timer);
230
381
  const limit = args.maxChars ?? MAX_CHARS_DEFAULT;
231
382
  if (text.length > limit) {
232
383
  const file = saveSnapshot(text);
@@ -239,7 +390,7 @@ async function handleToolCall(name, args) {
239
390
  try { await page.injectCookies(args.url); } catch {}
240
391
  await page.goto(args.url);
241
392
  return 'ok';
242
- });
393
+ }, TIMEOUTS.goto);
243
394
  case 'snapshot': return withRetry(async () => {
244
395
  const page = await getPage();
245
396
  const text = await page.snapshot();
@@ -249,22 +400,22 @@ async function handleToolCall(name, args) {
249
400
  return `Snapshot (${text.length} chars) saved to ${file}`;
250
401
  }
251
402
  return text;
252
- });
403
+ }, TIMEOUTS.snapshot);
253
404
  case 'click': return withRetry(async () => {
254
405
  const page = await getPage();
255
406
  await page.click(args.ref);
256
407
  return 'ok';
257
- });
408
+ }, TIMEOUTS.click, { retry: false });
258
409
  case 'type': return withRetry(async () => {
259
410
  const page = await getPage();
260
411
  await page.type(args.ref, args.text, { clear: args.clear });
261
412
  return 'ok';
262
- });
413
+ }, TIMEOUTS.type, { retry: false });
263
414
  case 'press': return withRetry(async () => {
264
415
  const page = await getPage();
265
416
  await page.press(args.key);
266
417
  return 'ok';
267
- });
418
+ }, TIMEOUTS.press, { retry: false });
268
419
  case 'scroll': return withRetry(async () => {
269
420
  const page = await getPage();
270
421
  let dy = args.deltaY;
@@ -276,31 +427,90 @@ async function handleToolCall(name, args) {
276
427
  }
277
428
  await page.scroll(dy);
278
429
  return 'ok';
279
- });
430
+ }, TIMEOUTS.scroll, { retry: false });
280
431
  case 'back': return withRetry(async () => {
281
432
  const page = await getPage();
282
433
  await page.goBack();
283
434
  return 'ok';
284
- });
435
+ }, TIMEOUTS.back, { retry: false });
285
436
  case 'forward': return withRetry(async () => {
286
437
  const page = await getPage();
287
438
  await page.goForward();
288
439
  return 'ok';
289
- });
440
+ }, TIMEOUTS.forward, { retry: false });
290
441
  case 'drag': return withRetry(async () => {
291
442
  const page = await getPage();
292
443
  await page.drag(args.fromRef, args.toRef);
293
444
  return 'ok';
294
- });
445
+ }, TIMEOUTS.drag, { retry: false });
295
446
  case 'upload': return withRetry(async () => {
296
447
  const page = await getPage();
297
448
  await page.upload(args.ref, args.files);
298
449
  return 'ok';
299
- });
450
+ }, TIMEOUTS.upload, { retry: false });
300
451
  case 'pdf': return withRetry(async () => {
301
452
  const page = await getPage();
302
453
  return await page.pdf({ landscape: args.landscape });
303
- });
454
+ }, TIMEOUTS.pdf);
455
+ case 'reload': return withRetry(async () => {
456
+ const page = await getPage();
457
+ await page.reload({ ignoreCache: !!args.ignoreCache });
458
+ return 'ok';
459
+ }, TIMEOUTS.reload);
460
+ case 'screenshot': return withRetry(async () => {
461
+ const page = await getPage();
462
+ const format = args.format || 'png';
463
+ const b64 = await page.screenshot({ format, quality: args.quality });
464
+ mkdirSync(OUTPUT_DIR, { recursive: true });
465
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
466
+ const file = join(OUTPUT_DIR, `screenshot-${ts}.${format}`);
467
+ writeFileSync(file, Buffer.from(b64, 'base64'));
468
+ return file;
469
+ }, TIMEOUTS.screenshot);
470
+ case 'wait_for': return withRetry(async () => {
471
+ const page = await getPage();
472
+ await page.waitFor({ text: args.text, selector: args.selector, timeout: args.timeout });
473
+ return 'ok';
474
+ }, TIMEOUTS.wait_for, { retry: false });
475
+ case 'tabs': return withRetry(async () => {
476
+ const page = await getPage();
477
+ if (typeof args.switchTo === 'number') {
478
+ await page.switchTab(args.switchTo);
479
+ return 'ok';
480
+ }
481
+ const list = await page.tabs();
482
+ return JSON.stringify(list, null, 2);
483
+ }, TIMEOUTS.tabs, { retry: false });
484
+ case 'select': return withRetry(async () => {
485
+ const page = await getPage();
486
+ await page.select(args.ref, args.value);
487
+ return 'ok';
488
+ }, TIMEOUTS.select, { retry: false });
489
+ case 'hover': return withRetry(async () => {
490
+ const page = await getPage();
491
+ await page.hover(args.ref);
492
+ return 'ok';
493
+ }, TIMEOUTS.hover, { retry: false });
494
+ case 'eval': {
495
+ // Only reachable when BAREBROWSE_MCP_EVAL=1 — the tool isn't registered
496
+ // otherwise, but this guard is the second line of defense in case the
497
+ // env var changes between tools/list and tools/call.
498
+ if (process.env.BAREBROWSE_MCP_EVAL !== '1') {
499
+ throw new Error('eval is disabled. Set BAREBROWSE_MCP_EVAL=1 to enable.');
500
+ }
501
+ return withRetry(async () => {
502
+ const page = await getPage();
503
+ const { result, exceptionDetails } = await page.cdp.send('Runtime.evaluate', {
504
+ expression: args.expression,
505
+ returnByValue: true,
506
+ awaitPromise: true,
507
+ });
508
+ if (exceptionDetails) {
509
+ throw new Error(exceptionDetails.text + (exceptionDetails.exception?.description ? `: ${exceptionDetails.exception.description}` : ''));
510
+ }
511
+ return result.value === undefined ? 'undefined' : JSON.stringify(result.value);
512
+ }, TIMEOUTS.eval, { retry: false });
513
+ }
304
514
  case 'assess': {
305
515
  if (!assessFn) throw new Error('wearehere is not installed. Run: npm install wearehere');
306
516
  const releaseSlot = await acquireAssessSlot();
@@ -342,7 +552,7 @@ async function handleToolCall(name, args) {
342
552
  } catch (err) {
343
553
  clearTimeout(timer);
344
554
  await tab.close().catch(() => {});
345
- if (isCdpDead(err)) _page = null;
555
+ if (isTransient(err)) _page = null;
346
556
  throw err;
347
557
  }
348
558
  } finally {
@@ -369,7 +579,7 @@ async function handleMessage(msg) {
369
579
  return jsonrpcResponse(id, {
370
580
  protocolVersion: '2024-11-05',
371
581
  capabilities: { tools: {} },
372
- serverInfo: { name: 'barebrowse', version: '0.7.0' },
582
+ serverInfo: { name: 'barebrowse', version: PKG_VERSION },
373
583
  });
374
584
  }
375
585
 
@@ -383,19 +593,13 @@ async function handleMessage(msg) {
383
593
 
384
594
  if (method === 'tools/call') {
385
595
  const { name, arguments: args } = params;
386
- const TOOL_TIMEOUT = name === 'browse' || name === 'assess' ? 60000 : 30000;
387
596
  try {
388
- let timer;
389
- const result = await Promise.race([
390
- handleToolCall(name, args || {}),
391
- new Promise((_, rej) => { timer = setTimeout(() => rej(new Error(`Tool "${name}" timed out after ${TOOL_TIMEOUT / 1000}s`)), TOOL_TIMEOUT); }),
392
- ]);
393
- clearTimeout(timer);
597
+ const result = await handleToolCall(name, args || {});
394
598
  return jsonrpcResponse(id, {
395
599
  content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result) }],
396
600
  });
397
601
  } catch (err) {
398
- if (isCdpDead(err)) _page = null;
602
+ if (isTransient(err)) _page = null;
399
603
  return jsonrpcResponse(id, {
400
604
  content: [{ type: 'text', text: `Error: ${err.message}` }],
401
605
  isError: true,
@@ -407,55 +611,71 @@ async function handleMessage(msg) {
407
611
  }
408
612
 
409
613
  // --- Stdio transport ---
614
+ //
615
+ // Exported as runStdio() so callers (notably cli.js) can explicitly start the
616
+ // JSON-RPC loop. The previous "auto-start when isMain" guard broke the
617
+ // `npx barebrowse mcp` path because cli.js launches the server via
618
+ // `await import('./mcp-server.js')` — process.argv[1] is cli.js, not
619
+ // mcp-server.js, so isMain was false and the loop never started. Both the
620
+ // direct `node mcp-server.js` invocation and the cli.js path now call
621
+ // runStdio() explicitly. Tests import TIMEOUTS/TOOLS without calling it.
622
+
623
+ export function runStdio() {
624
+ // One-line startup banner to stderr (stderr because stdout is the JSON-RPC
625
+ // channel — must not contain non-JSON-RPC bytes). Captured by Claude Code's
626
+ // MCP log, makes "I added barebrowse twice and got the wrong one" issues
627
+ // diagnosable: the path here is the absolute file actually being served,
628
+ // so a scope conflict shows two different paths in two log files.
629
+ const _selfPath = fileURLToPath(import.meta.url);
630
+ process.stderr.write(`barebrowse mcp v${PKG_VERSION} | serving from ${_selfPath} | pid ${process.pid}\n`);
631
+
632
+ let buffer = '';
633
+
634
+ process.stdin.setEncoding('utf8');
635
+ process.stdin.on('data', (chunk) => {
636
+ buffer += chunk;
637
+
638
+ let newlineIdx;
639
+ while ((newlineIdx = buffer.indexOf('\n')) !== -1) {
640
+ const line = buffer.slice(0, newlineIdx).trim();
641
+ buffer = buffer.slice(newlineIdx + 1);
642
+ if (!line) continue;
410
643
 
411
- let buffer = '';
412
-
413
- process.stdin.setEncoding('utf8');
414
- process.stdin.on('data', (chunk) => {
415
- buffer += chunk;
416
-
417
- let newlineIdx;
418
- while ((newlineIdx = buffer.indexOf('\n')) !== -1) {
419
- const line = buffer.slice(0, newlineIdx).trim();
420
- buffer = buffer.slice(newlineIdx + 1);
421
- if (!line) continue;
422
-
423
- try {
424
- const msg = JSON.parse(line);
425
-
426
- handleMessage(msg).then((response) => {
427
- if (response) {
644
+ try {
645
+ const msg = JSON.parse(line);
428
646
 
429
- process.stdout.write(response + '\n');
647
+ handleMessage(msg).then((response) => {
648
+ if (response) {
649
+ process.stdout.write(response + '\n');
650
+ }
651
+ }).catch((err) => {
652
+ process.stdout.write(jsonrpcError(msg.id, -32700, `Error: ${err.message}`) + '\n');
653
+ });
654
+ } catch (err) {
655
+ process.stdout.write(jsonrpcError(null, -32700, `Parse error: ${err.message}`) + '\n');
656
+ }
657
+ }
658
+ });
430
659
 
431
- }
432
- }).catch((err) => {
660
+ // Prevent unhandled rejections and uncaught exceptions from crashing the server.
661
+ // Browser OOM/crash rejects all pending CDP promises — some may not be awaited.
662
+ process.on('unhandledRejection', () => { _page = null; });
663
+ process.on('uncaughtException', () => { _page = null; });
433
664
 
434
- process.stdout.write(jsonrpcError(msg.id, -32700, `Error: ${err.message}`) + '\n');
435
- });
436
- } catch (err) {
665
+ // Clean up on exit
666
+ process.on('SIGINT', async () => {
667
+ if (_page) await _page.close().catch(() => {});
668
+ process.exit(0);
669
+ });
670
+ process.on('SIGTERM', async () => {
671
+ if (_page) await _page.close().catch(() => {});
672
+ process.exit(0);
673
+ });
674
+ }
437
675
 
438
- process.stdout.write(jsonrpcError(null, -32700, `Parse error: ${err.message}`) + '\n');
439
- }
440
- }
441
- });
442
-
443
- // Prevent unhandled rejections and uncaught exceptions from crashing the server.
444
- // Browser OOM/crash rejects all pending CDP promises — some may not be awaited.
445
- process.on('unhandledRejection', (err) => {
446
- _page = null;
447
- });
448
- process.on('uncaughtException', (err) => {
449
- _page = null;
450
- });
451
-
452
- // Clean up on exit
453
- process.on('SIGINT', async () => {
454
- if (_page) await _page.close().catch(() => {});
455
- process.exit(0);
456
- });
457
-
458
- process.on('SIGTERM', async () => {
459
- if (_page) await _page.close().catch(() => {});
460
- process.exit(0);
461
- });
676
+ // Direct invocation (`node mcp-server.js`) still works without cli.js — auto-
677
+ // start if this file IS process.argv[1]. The cli.js path imports + calls
678
+ // runStdio() explicitly so we never depend on argv[1] matching.
679
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
680
+ runStdio();
681
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "barebrowse",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Authenticated web browsing for autonomous agents via CDP. URL in, pruned ARIA snapshot out.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -31,5 +31,5 @@
31
31
  "optionalDependencies": {
32
32
  "wearehere": "^1.0.0"
33
33
  },
34
- "license": "MIT"
34
+ "license": "Apache-2.0"
35
35
  }
package/src/bareagent.js CHANGED
@@ -244,6 +244,39 @@ export function createBrowseTools(opts = {}) {
244
244
  return await page.screenshot({ format });
245
245
  },
246
246
  },
247
+ {
248
+ name: 'reload',
249
+ description: 'Reload the current page. Returns the updated snapshot.',
250
+ parameters: {
251
+ type: 'object',
252
+ properties: {
253
+ ignoreCache: { type: 'boolean', description: 'Bypass HTTP cache (hard reload). Default: false.' },
254
+ },
255
+ },
256
+ execute: async ({ ignoreCache } = {}) => actionAndSnapshot((page) => page.reload({ ignoreCache })),
257
+ },
258
+ {
259
+ name: 'wait_for',
260
+ description: 'Wait for visible text or a CSS selector to appear on the current page. Returns the updated snapshot once found.',
261
+ parameters: {
262
+ type: 'object',
263
+ properties: {
264
+ text: { type: 'string', description: 'Substring that must appear in document.body.innerText' },
265
+ selector: { type: 'string', description: 'CSS selector that must match document.querySelector' },
266
+ timeout: { type: 'number', description: 'Timeout in ms (default: 30000)' },
267
+ },
268
+ },
269
+ execute: async ({ text, selector, timeout } = {}) => actionAndSnapshot((page) => page.waitFor({ text, selector, timeout })),
270
+ },
271
+ {
272
+ name: 'downloads',
273
+ description: 'List files captured via Content-Disposition: attachment downloads during this session. Returns JSON array of { url, suggestedFilename, savedPath, state, totalBytes, receivedBytes } per file.',
274
+ parameters: { type: 'object', properties: {} },
275
+ execute: async () => {
276
+ const page = await getPage();
277
+ return JSON.stringify(page.downloads.map((d) => ({ ...d })), null, 2);
278
+ },
279
+ },
247
280
  ];
248
281
 
249
282
  // Add assess tool if wearehere is installed