@browserbridge/bbx 1.9.0 → 1.11.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.
Files changed (33) hide show
  1. package/README.md +1 -1
  2. package/package.json +8 -10
  3. package/packages/agent-client/src/cli-args.js +35 -6
  4. package/packages/agent-client/src/cli-batch.js +10 -5
  5. package/packages/agent-client/src/cli-helpers.js +8 -0
  6. package/packages/agent-client/src/cli-proxy-remote.js +7 -4
  7. package/packages/agent-client/src/cli-setup-commands.js +3 -1
  8. package/packages/agent-client/src/cli.js +35 -15
  9. package/packages/agent-client/src/command-registry.js +7 -3
  10. package/packages/agent-client/src/mcp-config.js +8 -8
  11. package/packages/agent-client/src/npm-self-update.js +9 -2
  12. package/packages/agent-client/src/runtime.js +14 -5
  13. package/packages/agent-client/src/types.ts +2 -0
  14. package/packages/mcp-server/src/bin.js +1 -1
  15. package/packages/mcp-server/src/guidance.js +43 -6
  16. package/packages/mcp-server/src/handlers-capture.js +1 -1
  17. package/packages/mcp-server/src/handlers-page.js +162 -9
  18. package/packages/mcp-server/src/handlers-utils.js +145 -169
  19. package/packages/mcp-server/src/handlers.js +17 -5
  20. package/packages/mcp-server/src/server.js +255 -91
  21. package/packages/mcp-server/src/toolset.js +73 -0
  22. package/packages/native-host/src/daemon.js +24 -107
  23. package/packages/native-host/src/dom-baseline-owners.js +210 -0
  24. package/packages/native-host/src/native-host.js +14 -28
  25. package/packages/protocol/src/budget-presets.js +182 -0
  26. package/packages/protocol/src/har.js +34 -0
  27. package/packages/protocol/src/index.js +2 -0
  28. package/packages/protocol/src/json-lines.js +3 -2
  29. package/packages/protocol/src/protocol.js +10 -0
  30. package/packages/protocol/src/registry.js +83 -0
  31. package/packages/protocol/src/types.ts +3 -0
  32. package/skills/browser-bridge/SKILL.md +4 -0
  33. package/skills/browser-bridge/references/protocol.md +11 -5
@@ -1,17 +1,54 @@
1
1
  // @ts-check
2
2
 
3
- export const MCP_SERVER_INSTRUCTIONS = [
4
- "Browser Bridge MCP inspects and interacts with the user's real Chrome tab through typed MCP tools.",
5
- 'In permission-ask hosts, use browser_call as the default Browser Bridge MCP tool so the user can approve one BBX tool instead of separate browser_status, browser_page, browser_dom, browser_input, and other tools.',
3
+ /**
4
+ * Guidance lines shared by the progressive tool surface.
5
+ *
6
+ * @type {readonly string[]}
7
+ */
8
+ const SHARED_INSTRUCTIONS = Object.freeze([
6
9
  'Prefer Browser Bridge MCP tools over shelling out to bbx. Use bbx only for explicit CLI setup, doctor, logs, or raw debugging requests.',
7
10
  'Start with browser_call method health.ping. If window access is disabled, call browser_call method access.request once, ask the user to click Enable in the Browser Bridge popup or side panel, then retry once.',
8
11
  'Use structured reads first through browser_call: page.get_state, dom.query, page.get_text, page.extract_content, styles.get_computed, or layout.get_box_model. Prefer semantic extraction for articles and documentation. Use browser_batch only for parallel reads; run mutations sequentially and keep limits tight before widening.',
9
- 'Reuse elementRef values returned by DOM tools. Use attribute allowlists for focused DOM reads.',
12
+ 'Reuse elementRef values returned by DOM reads. Use attribute allowlists for focused DOM reads.',
10
13
  'Escalate to screenshot.capture_element, screenshot.capture_region, dom.get_accessibility_tree, page.evaluate, viewport.resize, or CDP only when structured reads cannot answer the question.',
11
14
  'Use patch.apply_styles or patch.apply_dom for temporary experiments, and rollback patches before finishing unless the user asks to keep them.',
12
- 'Only use the specialized Browser Bridge MCP tools directly when the host has already allowed them or the user explicitly wants typed tool calls.',
15
+ ]);
16
+
17
+ /**
18
+ * Workflow guidance appended after the tool-selection rules.
19
+ *
20
+ * @type {readonly string[]}
21
+ */
22
+ const WORKFLOW_INSTRUCTIONS = Object.freeze([
13
23
  '',
14
24
  'Page investigation: read page state, scoped DOM, and relevant page text first, batching independent reads. Find targets by text or role when selectors are unknown. Add styles, console, or network reads only when they directly answer the objective, and use screenshots or evaluation only when structured evidence is insufficient.',
15
25
  'Layout debugging: locate the target, read only relevant computed properties and its box model, and inspect matched rules only when the cascade is unclear. Prototype the smallest fix with a reversible style patch, verify the result, check for new console errors, then rollback before editing source unless the user asks to keep the patch.',
16
26
  'Flow verification: read initial page state, locate controls semantically, reuse elementRef values, interact through input tools, and wait for navigation or UI state changes. Verify the final DOM or page text plus console and network state when relevant. Do not create a new tab unless requested or required by the flow.',
17
- ].join('\n');
27
+ ]);
28
+
29
+ const SERVER_HEADER =
30
+ "Browser Bridge MCP inspects and interacts with the user's real Chrome tab through a compact tool set.";
31
+
32
+ const LEGACY_TOOL_RULES = Object.freeze([
33
+ 'The common tools are available immediately. When a specialized typed tool is useful, call browser_toolset with its exact tool name; browser_call always reaches every bridge method without loading another tool.',
34
+ 'Use browser_call method protocol.describe with method or group params to load unfamiliar signatures cheaply.',
35
+ ]);
36
+
37
+ const MODERN_TOOL_RULES = Object.freeze([
38
+ 'The compact tool list is static for stateless MCP. browser_call reaches every bridge method, and browser_skill returns runtime groups and limits.',
39
+ 'Use browser_call method protocol.describe with method or group params to load unfamiliar signatures cheaply.',
40
+ ]);
41
+
42
+ /**
43
+ * Build instructions for the negotiated MCP era.
44
+ *
45
+ * @param {'legacy' | 'modern'} [era='legacy']
46
+ * @returns {string}
47
+ */
48
+ export function getMcpServerInstructions(era = 'legacy') {
49
+ const toolRules = era === 'modern' ? MODERN_TOOL_RULES : LEGACY_TOOL_RULES;
50
+ return [SERVER_HEADER, ...toolRules, ...SHARED_INSTRUCTIONS, ...WORKFLOW_INSTRUCTIONS].join('\n');
51
+ }
52
+
53
+ export const MCP_SERVER_INSTRUCTIONS = getMcpServerInstructions();
54
+ export const MODERN_MCP_SERVER_INSTRUCTIONS = getMcpServerInstructions('modern');
@@ -153,7 +153,7 @@ export function createScreenshotResult(response, method) {
153
153
  return summarizeToolError(new Error(`${method} returned invalid artifact metadata.`));
154
154
  }
155
155
  return createToolResult(
156
- `Captured screenshot artifact (${artifact.byteLength} bytes, expires ${artifact.expiresAt ?? 'soon'}).`,
156
+ `Captured screenshot artifact (${artifact.byteLength} bytes, expires ${artifact.expiresAt ?? 'soon'}). Read it in chunks with the browser_artifact tool (action "read", paging offset until nextOffset is null), then delete it with browser_artifact action "delete" when done.`,
157
157
  {
158
158
  ok: true,
159
159
  method,
@@ -4,9 +4,11 @@ import {
4
4
  DEFAULT_CONSOLE_LIMIT,
5
5
  DEFAULT_HAR_LIMIT,
6
6
  DEFAULT_NETWORK_LIMIT,
7
+ describeBridgeMethods,
7
8
  ERROR_CODES,
8
9
  getErrorRecovery,
9
10
  isBatchSafeBridgeCall,
11
+ isHarDocument,
10
12
  MAX_BATCH_CALLS,
11
13
  MAX_BATCH_CONCURRENCY,
12
14
  METHOD_SET,
@@ -166,6 +168,157 @@ export async function handlePageTool(args) {
166
168
  });
167
169
  }
168
170
 
171
+ /**
172
+ * Read one chunk of a daemon-owned artifact or delete it. Reads are paged by
173
+ * the caller: pass the returned nextOffset until it comes back null, then
174
+ * verify the reassembled bytes against sha256.
175
+ *
176
+ * @param {{ action: string, artifactId?: string, offset?: number, limit?: number, destinationId?: string, budgetPreset?: 'quick' | 'normal' | 'deep' }} args
177
+ * @returns {Promise<ToolResult>}
178
+ */
179
+ export async function handleArtifactTool(args) {
180
+ if (args.action !== 'read' && args.action !== 'delete') {
181
+ return summarizeToolError(`Unsupported artifact action "${args.action}".`);
182
+ }
183
+ if (typeof args.artifactId !== 'string' || !args.artifactId.trim()) {
184
+ return summarizeToolError('artifactId is required.');
185
+ }
186
+ if (args.offset !== undefined && (!Number.isInteger(args.offset) || args.offset < 0)) {
187
+ return summarizeToolError('offset must be a non-negative integer.');
188
+ }
189
+ if (args.limit !== undefined && (!Number.isInteger(args.limit) || args.limit < 1)) {
190
+ return summarizeToolError('limit must be a positive integer.');
191
+ }
192
+ const method = /** @type {BridgeMethod} */ (
193
+ args.action === 'read' ? 'artifact.read' : 'artifact.delete'
194
+ );
195
+ const params =
196
+ args.action === 'read'
197
+ ? {
198
+ artifactId: args.artifactId,
199
+ offset: args.offset ?? 0,
200
+ ...(typeof args.limit === 'number' ? { maxBytes: args.limit } : {}),
201
+ }
202
+ : { artifactId: args.artifactId };
203
+ return withToolClient(
204
+ async (client) => {
205
+ const response = await requestBridgeWithRetry(client, method, params, {
206
+ tabId: null,
207
+ source: REQUEST_SOURCE,
208
+ tokenBudget: getToolTokenBudget(args),
209
+ });
210
+ return method === 'artifact.read'
211
+ ? createArtifactReadResult(response)
212
+ : summarizeToolResponse(response, method, params);
213
+ },
214
+ { destinationId: args.destinationId ?? null }
215
+ );
216
+ }
217
+
218
+ /**
219
+ * Manage CDP Fetch request interception rules for a tab.
220
+ *
221
+ * @param {{ action: string, urlPattern?: string, ruleAction?: 'block' | 'fulfill' | 'continue', statusCode?: number, body?: string, headers?: Record<string, string>, ruleId?: string, tabId?: number, destinationId?: string, budgetPreset?: 'quick' | 'normal' | 'deep' }} args
222
+ * @returns {Promise<ToolResult>}
223
+ */
224
+ export async function handleInterceptTool(args) {
225
+ const requestedTabId = typeof args.tabId === 'number' ? args.tabId : null;
226
+ return withToolClient(
227
+ async (client) => {
228
+ /** @type {BridgeMethod} */
229
+ let method;
230
+ /** @type {Record<string, unknown>} */
231
+ let params;
232
+ switch (args.action) {
233
+ case 'add': {
234
+ if (typeof args.urlPattern !== 'string' || !args.urlPattern.trim()) {
235
+ return summarizeToolError('urlPattern is required for intercept add.');
236
+ }
237
+ method = 'network.intercept.add';
238
+ params = {
239
+ urlPattern: args.urlPattern,
240
+ ...(args.ruleAction ? { action: args.ruleAction } : {}),
241
+ ...(typeof args.statusCode === 'number' ? { statusCode: args.statusCode } : {}),
242
+ ...(typeof args.body === 'string' ? { body: args.body } : {}),
243
+ ...(args.headers ? { headers: args.headers } : {}),
244
+ };
245
+ break;
246
+ }
247
+ case 'remove': {
248
+ if (typeof args.ruleId !== 'string' || !args.ruleId.trim()) {
249
+ return summarizeToolError('ruleId is required for intercept remove.');
250
+ }
251
+ method = 'network.intercept.remove';
252
+ params = { ruleId: args.ruleId };
253
+ break;
254
+ }
255
+ case 'list':
256
+ method = 'network.intercept.list';
257
+ params = {};
258
+ break;
259
+ case 'clear':
260
+ method = 'network.intercept.clear';
261
+ params = {};
262
+ break;
263
+ default:
264
+ return summarizeToolError(`Unsupported intercept action "${args.action}".`);
265
+ }
266
+ const response = await requestBridgeWithRetry(client, method, params, {
267
+ tabId: requestedTabId,
268
+ source: REQUEST_SOURCE,
269
+ tokenBudget: getToolTokenBudget(args),
270
+ });
271
+ return summarizeToolResponse(response, method, params);
272
+ },
273
+ { destinationId: args.destinationId ?? null }
274
+ );
275
+ }
276
+
277
+ /**
278
+ * @param {import('../../protocol/src/types.js').BridgeResponse} response
279
+ * @returns {ToolResult}
280
+ */
281
+ function createArtifactReadResult(response) {
282
+ if (!response.ok) return summarizeToolResponse(response, 'artifact.read', {});
283
+ const result =
284
+ response.result && typeof response.result === 'object' && !Array.isArray(response.result)
285
+ ? /** @type {Record<string, unknown>} */ (response.result)
286
+ : {};
287
+ if (
288
+ typeof result.artifactId !== 'string' ||
289
+ typeof result.data !== 'string' ||
290
+ typeof result.offset !== 'number' ||
291
+ typeof result.byteLength !== 'number' ||
292
+ typeof result.totalBytes !== 'number' ||
293
+ typeof result.sha256 !== 'string' ||
294
+ (result.nextOffset !== null && typeof result.nextOffset !== 'number')
295
+ ) {
296
+ return summarizeToolError('artifact.read returned invalid chunk metadata.');
297
+ }
298
+ const nextOffset = /** @type {number | null} */ (result.nextOffset);
299
+ return createToolResult(
300
+ `Read ${result.byteLength} bytes from artifact ${result.artifactId} at offset ${result.offset} (${result.totalBytes} bytes total). ${
301
+ nextOffset === null
302
+ ? 'Artifact fully read; verify the reassembled bytes against sha256.'
303
+ : `Continue reading with offset ${nextOffset}.`
304
+ }`,
305
+ {
306
+ ok: true,
307
+ method: 'artifact.read',
308
+ artifactId: result.artifactId,
309
+ data: result.data,
310
+ offset: result.offset,
311
+ byteLength: result.byteLength,
312
+ nextOffset,
313
+ totalBytes: result.totalBytes,
314
+ sha256: result.sha256,
315
+ chunkIndex: result.chunkIndex,
316
+ chunkCount: result.chunkCount,
317
+ expiresAt: result.expiresAt,
318
+ }
319
+ );
320
+ }
321
+
169
322
  /**
170
323
  * Deliberately read one exact storage value without retries, budgeting, or
171
324
  * value-derived delivery metrics.
@@ -355,6 +508,14 @@ export async function handleRawCallTool(args) {
355
508
  if (!METHOD_SET.has(/** @type {BridgeMethod} */ (args.method))) {
356
509
  return summarizeToolError(`Unknown bridge method "${args.method}".`);
357
510
  }
511
+ if (args.method === 'protocol.describe') {
512
+ try {
513
+ const result = describeBridgeMethods(args.params);
514
+ return createToolResult('Bridge protocol description loaded.', result);
515
+ } catch (error) {
516
+ return summarizeToolError(error);
517
+ }
518
+ }
358
519
  return withToolClient(
359
520
  async (client) => {
360
521
  const method = /** @type {BridgeMethod} */ (args.method);
@@ -422,15 +583,7 @@ export function createHarExportResult(response) {
422
583
  }
423
584
 
424
585
  if (result.delivery === 'inline') {
425
- const har =
426
- result.har && typeof result.har === 'object' && !Array.isArray(result.har)
427
- ? /** @type {Record<string, unknown>} */ (result.har)
428
- : {};
429
- const log =
430
- har.log && typeof har.log === 'object' && !Array.isArray(har.log)
431
- ? /** @type {Record<string, unknown>} */ (har.log)
432
- : {};
433
- if (log.version !== '1.2' || !Array.isArray(log.entries)) {
586
+ if (!isHarDocument(result.har)) {
434
587
  return summarizeToolError('network.export_har returned an invalid inline HAR document.');
435
588
  }
436
589
  } else if (result.delivery === 'artifact') {
@@ -1,12 +1,12 @@
1
1
  // @ts-check
2
2
 
3
+ import { AsyncLocalStorage } from 'node:async_hooks';
3
4
  import { randomUUID } from 'node:crypto';
4
5
 
5
6
  import {
6
7
  BridgeError,
7
8
  bridgeMethodNeedsTab,
8
9
  DEFAULT_CONSOLE_LIMIT,
9
- DEFAULT_HAR_LIMIT,
10
10
  DEFAULT_LOG_TAIL_LIMIT,
11
11
  DEFAULT_MAX_HTML_LENGTH,
12
12
  DEFAULT_MAX_NODES,
@@ -15,11 +15,21 @@ import {
15
15
  DEFAULT_TEXT_BUDGET,
16
16
  estimateJsonPayloadCost,
17
17
  getBudgetPreset,
18
+ getBudgetPresetName,
18
19
  getErrorRecovery,
19
- isBudgetPresetName,
20
20
  summarizeBatchErrorItem,
21
21
  summarizeBatchResponseItem,
22
22
  } from '../../protocol/src/index.js';
23
+
24
+ export {
25
+ applyHtmlBudgetPreset,
26
+ applyLimitBudgetPreset,
27
+ applyMethodBudgetPreset,
28
+ applyPageTextBudgetPreset,
29
+ applyTextBudgetPreset,
30
+ applyTreeBudgetPreset,
31
+ getBudgetPresetName,
32
+ } from '../../protocol/src/index.js';
23
33
  import {
24
34
  getDoctorReport,
25
35
  requestBridge,
@@ -37,6 +47,18 @@ import { annotateBridgeSummary, summarizeBridgeResponse } from '../../agent-clie
37
47
 
38
48
  export const REQUEST_SOURCE = 'mcp';
39
49
  const MCP_CLIENT_ID = `mcp_${randomUUID()}`;
50
+ /** @type {AsyncLocalStorage<import('../../protocol/src/types.js').McpProtocolEra>} */
51
+ const MCP_REQUEST_ERA = new AsyncLocalStorage();
52
+
53
+ /**
54
+ * @template T
55
+ * @param {import('../../protocol/src/types.js').McpProtocolEra} era
56
+ * @param {() => T} callback
57
+ * @returns {T}
58
+ */
59
+ export function runWithMcpRequestEra(era, callback) {
60
+ return MCP_REQUEST_ERA.run(era, callback);
61
+ }
40
62
 
41
63
  /** @type {ReadonlySet<BridgeMethod>} */
42
64
  const RETRY_SAFE_METHODS = new Set([
@@ -68,6 +90,8 @@ const RETRY_SAFE_METHODS = new Set([
68
90
  'screenshot.capture_region',
69
91
  'screenshot.capture_element',
70
92
  'screenshot.capture_full_page',
93
+ 'artifact.read',
94
+ 'network.intercept.list',
71
95
  'performance.get_metrics',
72
96
  'cdp.get_document',
73
97
  'cdp.get_dom_snapshot',
@@ -240,14 +264,6 @@ export async function resolveToolRef(client, input, tabId = null) {
240
264
  throw new BridgeError('INVALID_REQUEST', 'Provide either elementRef or selector.');
241
265
  }
242
266
 
243
- /**
244
- * @param {unknown} value
245
- * @returns {'quick' | 'normal' | 'deep' | null}
246
- */
247
- export function getBudgetPresetName(value) {
248
- return isBudgetPresetName(value) ? value : null;
249
- }
250
-
251
267
  /**
252
268
  * @param {{ budgetPreset?: unknown, selector?: unknown, elementRef?: unknown }} args
253
269
  * @returns {'quick' | 'normal' | 'deep' | null}
@@ -271,162 +287,6 @@ export function getToolTokenBudget(args) {
271
287
  return presetName ? getBudgetPreset(presetName).tokenBudget : null;
272
288
  }
273
289
 
274
- /**
275
- * @template {{ budgetPreset?: unknown, maxNodes?: unknown, maxDepth?: unknown, textBudget?: unknown }} T
276
- * @param {T} args
277
- * @returns {T}
278
- */
279
- export function applyTreeBudgetPreset(args) {
280
- const presetName = getBudgetPresetName(args.budgetPreset);
281
- if (!presetName) {
282
- return args;
283
- }
284
- const preset = getBudgetPreset(presetName);
285
- return /** @type {T} */ ({
286
- ...args,
287
- maxNodes: args.maxNodes ?? preset.maxNodes,
288
- maxDepth: args.maxDepth ?? preset.maxDepth,
289
- textBudget: args.textBudget ?? preset.textBudget,
290
- });
291
- }
292
-
293
- /**
294
- * @template {{ budgetPreset?: unknown, textBudget?: unknown }} T
295
- * @param {T} args
296
- * @returns {T}
297
- */
298
- export function applyTextBudgetPreset(args) {
299
- const presetName = getBudgetPresetName(args.budgetPreset);
300
- if (!presetName) {
301
- return args;
302
- }
303
- const preset = getBudgetPreset(presetName);
304
- return /** @type {T} */ ({
305
- ...args,
306
- textBudget: args.textBudget ?? preset.textBudget,
307
- });
308
- }
309
-
310
- /**
311
- * @template {{ budgetPreset?: unknown, textBudget?: unknown }} T
312
- * @param {T} args
313
- * @returns {T}
314
- */
315
- export function applyPageTextBudgetPreset(args) {
316
- const presetName = getBudgetPresetName(args.budgetPreset);
317
- if (!presetName) {
318
- return args;
319
- }
320
- const textBudgetByPreset = {
321
- quick: 2000,
322
- normal: DEFAULT_PAGE_TEXT_BUDGET,
323
- deep: DEFAULT_PAGE_TEXT_BUDGET * 2,
324
- };
325
- return /** @type {T} */ ({
326
- ...args,
327
- textBudget: args.textBudget ?? textBudgetByPreset[presetName],
328
- });
329
- }
330
-
331
- /**
332
- * @template {{ budgetPreset?: unknown, limit?: unknown }} T
333
- * @param {T} args
334
- * @param {{ quick: number, normal: number, deep: number }} defaults
335
- * @returns {T}
336
- */
337
- export function applyLimitBudgetPreset(args, defaults) {
338
- const presetName = getBudgetPresetName(args.budgetPreset);
339
- if (!presetName) {
340
- return args;
341
- }
342
- return /** @type {T} */ ({
343
- ...args,
344
- limit: args.limit ?? defaults[presetName],
345
- });
346
- }
347
-
348
- /**
349
- * @template {{ budgetPreset?: unknown, maxLength?: unknown }} T
350
- * @param {T} args
351
- * @returns {T}
352
- */
353
- export function applyHtmlBudgetPreset(args) {
354
- const presetName = getBudgetPresetName(args.budgetPreset);
355
- if (!presetName) {
356
- return args;
357
- }
358
- const maxLengthByPreset = {
359
- quick: 600,
360
- normal: DEFAULT_MAX_HTML_LENGTH,
361
- deep: 6000,
362
- };
363
- return /** @type {T} */ ({
364
- ...args,
365
- maxLength: args.maxLength ?? maxLengthByPreset[presetName],
366
- });
367
- }
368
-
369
- /**
370
- * Apply a preset to method parameters before dispatch. Explicit method params
371
- * always win over preset defaults.
372
- *
373
- * @param {BridgeMethod} method
374
- * @param {Record<string, unknown>} params
375
- * @param {unknown} budgetPreset
376
- * @returns {Record<string, unknown>}
377
- */
378
- export function applyMethodBudgetPreset(method, params, budgetPreset) {
379
- const args = { ...params, budgetPreset };
380
- /** @type {Record<string, unknown>} */
381
- let normalized = args;
382
- if (
383
- method === 'dom.query' ||
384
- method === 'dom.get_accessibility_tree' ||
385
- method === 'dom.baseline.create'
386
- ) {
387
- normalized = applyTreeBudgetPreset(args);
388
- } else if (method === 'dom.baseline.compare') {
389
- const preset = getBudgetPresetName(budgetPreset);
390
- const defaults = { quick: 10, normal: 50, deep: 100 };
391
- normalized = {
392
- ...args,
393
- maxChanges: params.maxChanges ?? (preset ? defaults[preset] : undefined),
394
- };
395
- } else if (method === 'dom.get_text') {
396
- normalized = applyTextBudgetPreset(args);
397
- } else if (method === 'dom.get_html') {
398
- normalized = applyHtmlBudgetPreset(args);
399
- } else if (method === 'page.get_text' || method === 'page.extract_content') {
400
- normalized = applyPageTextBudgetPreset(args);
401
- } else if (method === 'page.get_console') {
402
- normalized = applyLimitBudgetPreset(args, {
403
- quick: 10,
404
- normal: DEFAULT_CONSOLE_LIMIT,
405
- deep: 100,
406
- });
407
- } else if (method === 'page.get_network') {
408
- normalized = applyLimitBudgetPreset(args, {
409
- quick: 10,
410
- normal: DEFAULT_NETWORK_LIMIT,
411
- deep: 100,
412
- });
413
- } else if (method === 'network.export_har') {
414
- normalized = applyLimitBudgetPreset(args, {
415
- quick: 20,
416
- normal: DEFAULT_HAR_LIMIT,
417
- deep: 100,
418
- });
419
- } else if (method === 'log.tail') {
420
- normalized = applyLimitBudgetPreset(args, {
421
- quick: 10,
422
- normal: DEFAULT_LOG_TAIL_LIMIT,
423
- deep: 100,
424
- });
425
- }
426
- const { budgetPreset: _budgetPreset, ...methodParams } = normalized;
427
- return methodParams;
428
- }
429
-
430
290
  /**
431
291
  * @param {unknown} input
432
292
  * @param {{ maxEntries?: number, maxCharacters?: number, maxStringLength?: number }} [options]
@@ -586,6 +446,14 @@ function getRequestAwareEvidence(rawResult, method, params, fallback) {
586
446
  limit: { maxEntries },
587
447
  };
588
448
  }
449
+ if (method?.startsWith('network.intercept.')) {
450
+ const bounded = boundToolValue(result, { maxEntries: 100, maxCharacters: 10_000 });
451
+ return {
452
+ value: bounded.value,
453
+ truncated: bounded.truncated,
454
+ limit: { maxEntries: 100, maxCharacters: 10_000 },
455
+ };
456
+ }
589
457
  if (
590
458
  (method === 'page.get_console' || method === 'page.get_network' || method === 'log.tail') &&
591
459
  Array.isArray(result.entries)
@@ -661,15 +529,123 @@ function compactDomNode(value) {
661
529
  return compact;
662
530
  }
663
531
 
532
+ /**
533
+ * Socket-level Node error codes that mean the daemon connection itself was
534
+ * lost, as opposed to an application-level bridge failure.
535
+ *
536
+ * @type {ReadonlySet<string>}
537
+ */
538
+ const CONNECTION_LOSS_CODES = new Set([
539
+ 'ENOTCONN',
540
+ 'ECONNREFUSED',
541
+ 'ECONNRESET',
542
+ 'EPIPE',
543
+ 'ERR_SOCKET_CLOSED',
544
+ ]);
545
+
546
+ const RECONNECT_WAIT_TIMEOUT_MS = 5_000;
547
+ const RECONNECT_POLL_INTERVAL_MS = 250;
548
+
549
+ /**
550
+ * Detect thrown transport errors caused by a lost daemon connection. These
551
+ * never arrive as bridge failure responses because the socket died before (or
552
+ * while) the daemon could reply.
553
+ *
554
+ * @param {unknown} error
555
+ * @returns {boolean}
556
+ */
557
+ export function isConnectionLossError(error) {
558
+ if (!error || typeof error !== 'object') {
559
+ return false;
560
+ }
561
+ const code = /** @type {{ code?: unknown }} */ (error).code;
562
+ if (typeof code === 'string' && CONNECTION_LOSS_CODES.has(code)) {
563
+ return true;
564
+ }
565
+ const message = error instanceof Error ? error.message : '';
566
+ return /Bridge socket closed|BridgeClient is not connected/u.test(message);
567
+ }
568
+
569
+ /**
570
+ * Wait, bounded, for the client to regain its daemon connection.
571
+ *
572
+ * When the client's own auto-reconnect loop is active it owns reconnecting and
573
+ * emits `reconnected`; otherwise one manual connect is attempted per poll
574
+ * interval. Application state is never touched here - this only restores the
575
+ * transport.
576
+ *
577
+ * @param {import('../../agent-client/src/client.js').BridgeClient} client
578
+ * @param {number} [timeoutMs=RECONNECT_WAIT_TIMEOUT_MS]
579
+ * @returns {Promise<boolean>}
580
+ */
581
+ export async function waitForClientReconnect(client, timeoutMs = RECONNECT_WAIT_TIMEOUT_MS) {
582
+ const deadline = Date.now() + timeoutMs;
583
+ while (!client.connected && Date.now() < deadline) {
584
+ if (client.autoReconnect) {
585
+ await new Promise((resolve) => {
586
+ const remaining = Math.max(0, deadline - Date.now());
587
+ /** @type {ReturnType<typeof setTimeout>} */
588
+ let timer;
589
+ const cleanup = () => {
590
+ clearTimeout(timer);
591
+ client.off('reconnected', onReconnected);
592
+ resolve(undefined);
593
+ };
594
+ const onReconnected = () => cleanup();
595
+ timer = setTimeout(cleanup, Math.min(RECONNECT_POLL_INTERVAL_MS, remaining));
596
+ client.on('reconnected', onReconnected);
597
+ });
598
+ continue;
599
+ }
600
+ try {
601
+ await client.connect();
602
+ } catch (error) {
603
+ if (error instanceof Error && /already connected/u.test(error.message)) {
604
+ break;
605
+ }
606
+ // Daemon still unreachable; keep waiting inside the bounded window.
607
+ }
608
+ if (!client.connected) {
609
+ const remaining = Math.max(0, deadline - Date.now());
610
+ await new Promise((resolve) =>
611
+ setTimeout(resolve, Math.min(RECONNECT_POLL_INTERVAL_MS, remaining))
612
+ );
613
+ }
614
+ }
615
+ return client.connected;
616
+ }
617
+
664
618
  /**
665
619
  * @param {import('../../agent-client/src/client.js').BridgeClient} client
666
620
  * @param {BridgeMethod} method
667
621
  * @param {Record<string, unknown>} params
668
- * @param {{ tabId?: number | null, source?: import('../../protocol/src/types.js').BridgeRequestSource, tokenBudget?: number | null, automaticRetry?: 'mcp_second_attempt' }} options
622
+ * @param {{ tabId?: number | null, source?: import('../../protocol/src/types.js').BridgeRequestSource, mcpEra?: import('../../protocol/src/types.js').McpProtocolEra, tokenBudget?: number | null, automaticRetry?: 'mcp_second_attempt' }} options
669
623
  * @returns {Promise<BridgeResponse>}
670
624
  */
671
625
  export async function requestBridgeWithRetry(client, method, params, options) {
672
- const response = await requestBridge(client, method, params, options);
626
+ const requestOptions = {
627
+ ...options,
628
+ mcpEra: options.mcpEra ?? MCP_REQUEST_ERA.getStore(),
629
+ };
630
+ /** @type {BridgeResponse} */
631
+ let response;
632
+ try {
633
+ response = await requestBridge(client, method, params, requestOptions);
634
+ } catch (error) {
635
+ if (!isConnectionLossError(error) || !isRetrySafeBridgeMethod(method, params)) {
636
+ throw error;
637
+ }
638
+ process.stderr.write(
639
+ `[bbx-mcp] ${method} lost its daemon connection; waiting up to ${RECONNECT_WAIT_TIMEOUT_MS}ms for reconnect before one retry.\n`
640
+ );
641
+ if (!(await waitForClientReconnect(client))) {
642
+ throw error;
643
+ }
644
+ return requestBridge(client, method, params, {
645
+ ...requestOptions,
646
+ automaticRetry: 'mcp_second_attempt',
647
+ });
648
+ }
673
649
  const recovery =
674
650
  !response.ok && response.error
675
651
  ? (response.error.recovery ?? getErrorRecovery(response.error.code))
@@ -681,7 +657,7 @@ export async function requestBridgeWithRetry(client, method, params, options) {
681
657
  );
682
658
  await new Promise((r) => setTimeout(r, delay));
683
659
  return requestBridge(client, method, params, {
684
- ...options,
660
+ ...requestOptions,
685
661
  automaticRetry: 'mcp_second_attempt',
686
662
  });
687
663
  }