@browserbridge/bbx 1.7.3 → 1.7.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/package.json +7 -7
  2. package/packages/agent-client/src/atomic-write.js +99 -0
  3. package/packages/agent-client/src/cli-helpers.js +20 -3
  4. package/packages/agent-client/src/cli.js +273 -76
  5. package/packages/agent-client/src/command-registry.js +12 -6
  6. package/packages/agent-client/src/install.js +39 -4
  7. package/packages/agent-client/src/mcp-config.js +28 -14
  8. package/packages/agent-client/src/remotes.js +93 -15
  9. package/packages/agent-client/src/runtime.js +6 -3
  10. package/packages/mcp-server/src/bin.js +10 -1
  11. package/packages/mcp-server/src/guidance.js +2 -2
  12. package/packages/mcp-server/src/handlers-capture.js +62 -32
  13. package/packages/mcp-server/src/handlers-dom.js +2 -2
  14. package/packages/mcp-server/src/handlers-navigation.js +30 -9
  15. package/packages/mcp-server/src/handlers-page.js +170 -143
  16. package/packages/mcp-server/src/handlers-utils.js +277 -8
  17. package/packages/mcp-server/src/handlers.js +31 -10
  18. package/packages/mcp-server/src/lifecycle.js +413 -0
  19. package/packages/mcp-server/src/server.js +59 -30
  20. package/packages/native-host/src/daemon.js +3 -1
  21. package/packages/protocol/src/capabilities.js +1 -1
  22. package/packages/protocol/src/defaults.js +2 -0
  23. package/packages/protocol/src/protocol.js +122 -9
  24. package/packages/protocol/src/registry.js +14 -14
  25. package/packages/protocol/src/summary.js +61 -30
  26. package/packages/protocol/src/types.ts +30 -1
  27. package/skills/browser-bridge/SKILL.md +4 -4
  28. package/skills/browser-bridge/references/interaction.md +1 -1
  29. package/skills/browser-bridge/references/protocol.md +3 -3
@@ -1,9 +1,15 @@
1
1
  // @ts-check
2
2
 
3
3
  import {
4
+ BridgeError,
4
5
  bridgeMethodNeedsTab,
6
+ DEFAULT_CONSOLE_LIMIT,
7
+ DEFAULT_LOG_TAIL_LIMIT,
5
8
  DEFAULT_MAX_HTML_LENGTH,
9
+ DEFAULT_MAX_NODES,
10
+ DEFAULT_NETWORK_LIMIT,
6
11
  DEFAULT_PAGE_TEXT_BUDGET,
12
+ DEFAULT_TEXT_BUDGET,
7
13
  estimateJsonPayloadCost,
8
14
  getBudgetPreset,
9
15
  getErrorRecovery,
@@ -121,10 +127,28 @@ export function createToolResult(
121
127
  /**
122
128
  * @param {BridgeResponse} response
123
129
  * @param {string} [method]
130
+ * @param {Record<string, unknown>} [params]
124
131
  * @returns {ToolResult}
125
132
  */
126
- export function summarizeToolResponse(response, method) {
133
+ export function summarizeToolResponse(response, method, params = {}) {
127
134
  const summary = annotateBridgeSummary(summarizeBridgeResponse(response, method), response);
135
+ const summaryRecord = /** @type {Record<string, unknown>} */ (summary);
136
+ if (response.ok) {
137
+ const evidence = getRequestAwareEvidence(response.result, method, params, summary.evidence);
138
+ summary.evidence = evidence.value;
139
+ if (evidence.truncated) {
140
+ summaryRecord.outputTruncated = true;
141
+ summaryRecord.outputLimit = evidence.limit;
142
+ }
143
+ } else {
144
+ const bounded = boundToolValue(summary.evidence);
145
+ summary.evidence = bounded.value;
146
+ summaryRecord.error = {
147
+ ...response.error,
148
+ details: boundToolValue(response.error.details).value,
149
+ };
150
+ if (bounded.truncated) summaryRecord.outputTruncated = true;
151
+ }
128
152
  return createToolResult(summary.summary, summary, !summary.ok);
129
153
  }
130
154
 
@@ -134,11 +158,25 @@ export function summarizeToolResponse(response, method) {
134
158
  */
135
159
  export function summarizeToolError(error) {
136
160
  const message = error instanceof Error ? error.message : String(error);
161
+ const record =
162
+ error && typeof error === 'object' ? /** @type {Record<string, unknown>} */ (error) : {};
163
+ const code =
164
+ typeof record.code === 'string'
165
+ ? record.code
166
+ : typeof error === 'string'
167
+ ? 'INVALID_REQUEST'
168
+ : 'INTERNAL_ERROR';
169
+ const details = Object.hasOwn(record, 'details') ? record.details : null;
170
+ const boundedDetails = boundToolValue(details);
171
+ const recovery = getErrorRecovery(code);
137
172
  return createToolResult(
138
- `ERROR: ${message}`,
173
+ `${code}: ${message}${recovery?.hint ? ` ${recovery.hint}` : ''}`,
139
174
  {
140
175
  ok: false,
141
- evidence: null,
176
+ evidence: boundedDetails.value,
177
+ error: { code, message, details: boundedDetails.value },
178
+ recovery,
179
+ ...(boundedDetails.truncated ? { outputTruncated: true } : {}),
142
180
  },
143
181
  true
144
182
  );
@@ -179,7 +217,7 @@ export async function resolveToolRef(client, input, tabId = null) {
179
217
  if (typeof input.selector === 'string' && input.selector) {
180
218
  return resolveRef(client, input.selector, tabId, REQUEST_SOURCE);
181
219
  }
182
- throw new Error('Provide either elementRef or selector.');
220
+ throw new BridgeError('INVALID_REQUEST', 'Provide either elementRef or selector.');
183
221
  }
184
222
 
185
223
  /**
@@ -308,6 +346,233 @@ export function applyHtmlBudgetPreset(args) {
308
346
  });
309
347
  }
310
348
 
349
+ /**
350
+ * Apply a preset to method parameters before dispatch. Explicit method params
351
+ * always win over preset defaults.
352
+ *
353
+ * @param {BridgeMethod} method
354
+ * @param {Record<string, unknown>} params
355
+ * @param {unknown} budgetPreset
356
+ * @returns {Record<string, unknown>}
357
+ */
358
+ export function applyMethodBudgetPreset(method, params, budgetPreset) {
359
+ const args = { ...params, budgetPreset };
360
+ let normalized = args;
361
+ if (method === 'dom.query' || method === 'dom.get_accessibility_tree') {
362
+ normalized = applyTreeBudgetPreset(args);
363
+ } else if (method === 'dom.get_text') {
364
+ normalized = applyTextBudgetPreset(args);
365
+ } else if (method === 'dom.get_html') {
366
+ normalized = applyHtmlBudgetPreset(args);
367
+ } else if (method === 'page.get_text') {
368
+ normalized = applyPageTextBudgetPreset(args);
369
+ } else if (method === 'page.get_console') {
370
+ normalized = applyLimitBudgetPreset(args, {
371
+ quick: 10,
372
+ normal: DEFAULT_CONSOLE_LIMIT,
373
+ deep: 100,
374
+ });
375
+ } else if (method === 'page.get_network') {
376
+ normalized = applyLimitBudgetPreset(args, {
377
+ quick: 10,
378
+ normal: DEFAULT_NETWORK_LIMIT,
379
+ deep: 100,
380
+ });
381
+ } else if (method === 'log.tail') {
382
+ normalized = applyLimitBudgetPreset(args, {
383
+ quick: 10,
384
+ normal: DEFAULT_LOG_TAIL_LIMIT,
385
+ deep: 100,
386
+ });
387
+ }
388
+ const { budgetPreset: _budgetPreset, ...methodParams } = normalized;
389
+ return methodParams;
390
+ }
391
+
392
+ /**
393
+ * @param {unknown} input
394
+ * @param {{ maxEntries?: number, maxCharacters?: number, maxStringLength?: number }} [options]
395
+ * @returns {{ value: unknown, truncated: boolean, limit: Record<string, number> }}
396
+ */
397
+ export function boundToolValue(input, options = {}) {
398
+ const maxEntries = options.maxEntries ?? 500;
399
+ const maxCharacters = options.maxCharacters ?? 20_000;
400
+ const maxStringLength = options.maxStringLength ?? 4_000;
401
+ const state = { entries: 0, characters: 0, truncated: false };
402
+ /** @type {(value: unknown, depth: number) => unknown} */
403
+ const visit = (value, depth) => {
404
+ if (state.entries >= maxEntries || state.characters >= maxCharacters || depth > 8) {
405
+ state.truncated = true;
406
+ return '[truncated]';
407
+ }
408
+ state.entries += 1;
409
+ if (typeof value === 'string') {
410
+ const limit = Math.min(value.length, maxStringLength, maxCharacters - state.characters);
411
+ state.characters += limit;
412
+ if (limit === value.length) return value;
413
+ state.truncated = true;
414
+ return value.slice(0, Math.max(0, limit));
415
+ }
416
+ if (Array.isArray(value)) {
417
+ const limit = Math.min(value.length, 100, maxEntries - state.entries);
418
+ if (limit < value.length) state.truncated = true;
419
+ return value.slice(0, limit).map((entry) => visit(entry, depth + 1));
420
+ }
421
+ if (value && typeof value === 'object') {
422
+ /** @type {Record<string, unknown>} */
423
+ const output = {};
424
+ const entries = Object.entries(/** @type {Record<string, unknown>} */ (value));
425
+ const limit = Math.min(entries.length, 100);
426
+ if (limit < entries.length) state.truncated = true;
427
+ for (const [key, entry] of entries.slice(0, limit)) {
428
+ if (state.entries >= maxEntries || state.characters >= maxCharacters) {
429
+ state.truncated = true;
430
+ break;
431
+ }
432
+ state.characters += key.length;
433
+ output[key] = visit(entry, depth + 1);
434
+ }
435
+ return output;
436
+ }
437
+ return value;
438
+ };
439
+ return {
440
+ value: visit(input, 0),
441
+ truncated: state.truncated,
442
+ limit: { maxEntries, maxCharacters, maxStringLength },
443
+ };
444
+ }
445
+
446
+ /**
447
+ * Preserve useful evidence up to the caller's requested limits instead of
448
+ * fetching normal/deep results and silently returning only a fixed preview.
449
+ *
450
+ * @param {unknown} rawResult
451
+ * @param {string | undefined} method
452
+ * @param {Record<string, unknown>} params
453
+ * @param {unknown} fallback
454
+ * @returns {{ value: unknown, truncated: boolean, limit: Record<string, number> }}
455
+ */
456
+ function getRequestAwareEvidence(rawResult, method, params, fallback) {
457
+ const result =
458
+ rawResult && typeof rawResult === 'object' && !Array.isArray(rawResult)
459
+ ? /** @type {Record<string, unknown>} */ (rawResult)
460
+ : {};
461
+ if (method === 'page.get_text' || method === 'dom.get_text') {
462
+ const text = typeof result.text === 'string' ? result.text : result.value;
463
+ if (typeof text === 'string') {
464
+ const defaultLimit =
465
+ method === 'page.get_text' ? DEFAULT_PAGE_TEXT_BUDGET : DEFAULT_TEXT_BUDGET;
466
+ const requested = positiveInteger(params.textBudget) ?? defaultLimit;
467
+ const maxCharacters = Math.min(requested, 16_000);
468
+ const outputTruncated = text.length > maxCharacters;
469
+ return {
470
+ value: {
471
+ text: text.slice(0, maxCharacters),
472
+ length: typeof result.length === 'number' ? result.length : text.length,
473
+ truncated: result.truncated === true,
474
+ returnedChars: Math.min(text.length, maxCharacters),
475
+ },
476
+ truncated: outputTruncated,
477
+ limit: { maxCharacters },
478
+ };
479
+ }
480
+ }
481
+ if (method === 'dom.get_html' && typeof result.html === 'string') {
482
+ const requested = positiveInteger(params.maxLength) ?? DEFAULT_MAX_HTML_LENGTH;
483
+ const maxCharacters = Math.min(requested, 10_000);
484
+ return {
485
+ value: {
486
+ html: result.html.slice(0, maxCharacters),
487
+ truncated: result.truncated === true,
488
+ returnedChars: Math.min(result.html.length, maxCharacters),
489
+ },
490
+ truncated: result.html.length > maxCharacters,
491
+ limit: { maxCharacters },
492
+ };
493
+ }
494
+ if (method === 'dom.query' && Array.isArray(result.nodes)) {
495
+ const maxEntries = Math.min(positiveInteger(params.maxNodes) ?? DEFAULT_MAX_NODES, 100);
496
+ const nodes = result.nodes.slice(0, maxEntries).map(compactDomNode);
497
+ return {
498
+ value: nodes,
499
+ truncated: result.truncated === true || result.nodes.length > maxEntries,
500
+ limit: { maxEntries },
501
+ };
502
+ }
503
+ if (method === 'dom.get_accessibility_tree' && Array.isArray(result.nodes)) {
504
+ const maxEntries = Math.min(positiveInteger(params.maxNodes) ?? DEFAULT_MAX_NODES, 100);
505
+ const nodes = result.nodes.slice(0, maxEntries).map((value) => {
506
+ const node =
507
+ value && typeof value === 'object' ? /** @type {Record<string, unknown>} */ (value) : {};
508
+ return {
509
+ role: node.role,
510
+ name: node.name,
511
+ ...(node.interactive === true ? { interactive: true } : {}),
512
+ ...(node.value !== undefined ? { value: node.value } : {}),
513
+ };
514
+ });
515
+ return {
516
+ value: nodes,
517
+ truncated: result.truncated === true || result.nodes.length > maxEntries,
518
+ limit: { maxEntries },
519
+ };
520
+ }
521
+ if (
522
+ (method === 'page.get_console' || method === 'page.get_network' || method === 'log.tail') &&
523
+ Array.isArray(result.entries)
524
+ ) {
525
+ const defaultLimit =
526
+ method === 'page.get_console'
527
+ ? DEFAULT_CONSOLE_LIMIT
528
+ : method === 'page.get_network'
529
+ ? DEFAULT_NETWORK_LIMIT
530
+ : DEFAULT_LOG_TAIL_LIMIT;
531
+ const maxEntries = Math.min(positiveInteger(params.limit) ?? defaultLimit, 100);
532
+ const selected =
533
+ method === 'log.tail'
534
+ ? result.entries.slice(-maxEntries)
535
+ : result.entries.slice(0, maxEntries);
536
+ const bounded = boundToolValue(selected, { maxEntries: 500, maxCharacters: 20_000 });
537
+ return {
538
+ value: bounded.value,
539
+ truncated: result.entries.length > maxEntries || bounded.truncated,
540
+ limit: { maxEntries, maxCharacters: 20_000 },
541
+ };
542
+ }
543
+ return boundToolValue(fallback);
544
+ }
545
+
546
+ /** @param {unknown} value */
547
+ function positiveInteger(value) {
548
+ return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : null;
549
+ }
550
+
551
+ /**
552
+ * @param {unknown} value
553
+ * @returns {Record<string, unknown>}
554
+ */
555
+ function compactDomNode(value) {
556
+ const node =
557
+ value && typeof value === 'object' ? /** @type {Record<string, unknown>} */ (value) : {};
558
+ const attrs =
559
+ node.attrs && typeof node.attrs === 'object'
560
+ ? /** @type {Record<string, unknown>} */ (node.attrs)
561
+ : {};
562
+ /** @type {Record<string, unknown>} */
563
+ const compact = { ref: node.elementRef, tag: node.tag };
564
+ if (node.id ?? attrs.id) compact.id = node.id ?? attrs.id;
565
+ if (typeof attrs.class === 'string') compact.cls = attrs.class.split(' ').slice(0, 3).join(' ');
566
+ if (node.role ?? attrs.role) compact.role = node.role ?? attrs.role;
567
+ if (node.name ?? attrs['aria-label']) compact.label = node.name ?? attrs['aria-label'];
568
+ if (attrs['data-testid']) compact.testId = attrs['data-testid'];
569
+ const text = typeof node.textExcerpt === 'string' ? node.textExcerpt : node.text;
570
+ if (typeof text === 'string' && text) compact.text = text.slice(0, 120);
571
+ if (Array.isArray(node.children) && node.children.length)
572
+ compact.childCount = node.children.length;
573
+ return compact;
574
+ }
575
+
311
576
  /**
312
577
  * @param {import('../../agent-client/src/client.js').BridgeClient} client
313
578
  * @param {BridgeMethod} method
@@ -317,7 +582,10 @@ export function applyHtmlBudgetPreset(args) {
317
582
  */
318
583
  export async function requestBridgeWithRetry(client, method, params, options) {
319
584
  const response = await requestBridge(client, method, params, options);
320
- const recovery = !response.ok && response.error ? getErrorRecovery(response.error.code) : null;
585
+ const recovery =
586
+ !response.ok && response.error
587
+ ? (response.error.recovery ?? getErrorRecovery(response.error.code))
588
+ : null;
321
589
  if (!response.ok && recovery?.retry && isRetrySafeBridgeMethod(method, params)) {
322
590
  const delay = recovery.retryAfterMs ?? 1000;
323
591
  process.stderr.write(
@@ -343,7 +611,7 @@ export async function callBridgeTool(method, params = {}, options = {}) {
343
611
  source: REQUEST_SOURCE,
344
612
  tokenBudget: options.tokenBudget ?? null,
345
613
  });
346
- return summarizeToolResponse(response, options.summaryMethod || method);
614
+ return summarizeToolResponse(response, options.summaryMethod || method, params);
347
615
  },
348
616
  { destinationId: options.destinationId ?? null }
349
617
  );
@@ -379,12 +647,13 @@ export async function dispatchToolAction(actions, args, toolName) {
379
647
  requestedTabId
380
648
  )
381
649
  : undefined;
382
- const response = await requestBridgeWithRetry(client, entry.method, entry.params(args, ref), {
650
+ const params = entry.params(args, ref);
651
+ const response = await requestBridgeWithRetry(client, entry.method, params, {
383
652
  tabId: requestedTabId,
384
653
  source: REQUEST_SOURCE,
385
654
  tokenBudget: getToolTokenBudget(/** @type {{ budgetPreset?: unknown }} */ (args)),
386
655
  });
387
- return summarizeToolResponse(response, entry.method);
656
+ return summarizeToolResponse(response, entry.method, params);
388
657
  },
389
658
  { destinationId: typeof args.destinationId === 'string' ? args.destinationId : null }
390
659
  );
@@ -1,7 +1,7 @@
1
1
  // @ts-check
2
2
 
3
3
  import os from 'node:os';
4
- import { DEFAULT_CONSOLE_LIMIT, createRuntimeContext } from '../../protocol/src/index.js';
4
+ import { DEFAULT_LOG_TAIL_LIMIT, createRuntimeContext } from '../../protocol/src/index.js';
5
5
  import { collectSetupStatus } from '../../agent-client/src/setup-status.js';
6
6
  import {
7
7
  callBridgeTool,
@@ -82,15 +82,17 @@ export async function handleStatusTool(args = {}) {
82
82
  );
83
83
  const result = await callRemoteHealth(requestedDestinationId);
84
84
  return createToolResult(
85
- result.reachable
86
- ? `Browser Bridge destination "${requestedDestinationId}" is reachable.`
87
- : `Browser Bridge destination "${requestedDestinationId}" is not reachable.`,
85
+ result.routeReady
86
+ ? `Browser Bridge destination "${requestedDestinationId}" is ready.`
87
+ : result.reachable
88
+ ? `Browser Bridge destination "${requestedDestinationId}" is reachable, but its browser route is not ready.`
89
+ : `Browser Bridge destination "${requestedDestinationId}" is not reachable.`,
88
90
  {
89
- ok: result.reachable === true,
91
+ ok: result.routeReady === true,
90
92
  destination: destination ?? { id: requestedDestinationId, local: false },
91
93
  ...result,
92
94
  },
93
- result.reachable !== true
95
+ result.routeReady !== true
94
96
  );
95
97
  }
96
98
 
@@ -131,17 +133,36 @@ export async function handleStatusTool(args = {}) {
131
133
  */
132
134
  async function callRemoteHealth(destinationId) {
133
135
  const result = await callBridgeTool('health.ping', {}, { destinationId });
136
+ const evidence =
137
+ result.structuredContent.evidence && typeof result.structuredContent.evidence === 'object'
138
+ ? /** @type {Record<string, unknown>} */ (result.structuredContent.evidence)
139
+ : {};
140
+ const access =
141
+ evidence.access && typeof evidence.access === 'object'
142
+ ? /** @type {Record<string, unknown>} */ (evidence.access)
143
+ : {};
144
+ const reachable = result.structuredContent.ok === true && evidence.daemon === 'ok';
134
145
  return {
135
- reachable: result.structuredContent.ok === true,
146
+ reachable,
147
+ daemonReachable: reachable,
148
+ extensionConnected: evidence.extensionConnected === true,
149
+ accessEnabled: access.enabled === true,
150
+ routeReady: reachable && access.routeReady === true,
151
+ routeTabId: typeof access.routeTabId === 'number' ? access.routeTabId : null,
152
+ routeReason: typeof access.reason === 'string' ? access.reason : null,
136
153
  health: result.structuredContent,
137
154
  };
138
155
  }
139
156
 
140
157
  /**
158
+ * @param {{ destinationId?: string }} [args]
141
159
  * @returns {Promise<ToolResult>}
142
160
  */
143
- export async function handleSkillTool() {
161
+ export async function handleSkillTool(args = {}) {
144
162
  try {
163
+ if (typeof args.destinationId === 'string') {
164
+ return callBridgeTool('skill.get_runtime_context', {}, { destinationId: args.destinationId });
165
+ }
145
166
  const ctx = createRuntimeContext();
146
167
  return createToolResult('Runtime context retrieved.', {
147
168
  ok: true,
@@ -176,13 +197,13 @@ export async function handleSetupTool(args) {
176
197
  export async function handleLogTool(args) {
177
198
  const normalizedArgs = applyLimitBudgetPreset(args, {
178
199
  quick: 10,
179
- normal: DEFAULT_CONSOLE_LIMIT,
200
+ normal: DEFAULT_LOG_TAIL_LIMIT,
180
201
  deep: 100,
181
202
  });
182
203
  return callBridgeTool(
183
204
  'log.tail',
184
205
  {
185
- limit: normalizedArgs.limit ?? DEFAULT_CONSOLE_LIMIT,
206
+ limit: normalizedArgs.limit ?? DEFAULT_LOG_TAIL_LIMIT,
186
207
  },
187
208
  {
188
209
  tokenBudget: getToolTokenBudget(normalizedArgs),