@browserbridge/bbx 1.7.1 → 1.7.3

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.
@@ -20,12 +20,14 @@ import {
20
20
  createFailure,
21
21
  createSuccess,
22
22
  DAEMON_EXISTING_SOCKET_PING_TIMEOUT_MS,
23
+ DAEMON_PENDING_TIMEOUT_MARGIN_MS,
23
24
  DAEMON_RECENT_LOG_LIMIT,
24
25
  DEFAULT_DAEMON_PENDING_TIMEOUT_MS,
25
26
  DEFAULT_LOG_TAIL_LIMIT,
26
27
  ERROR_CODES,
27
28
  getProtocolVersion,
28
29
  getSupportedProtocolVersions,
30
+ MAX_DAEMON_PENDING_TIMEOUT_MS,
29
31
  parseJsonLines,
30
32
  setProtocolPackageVersion,
31
33
  validateBridgeRequest,
@@ -54,7 +56,8 @@ setProtocolPackageVersion(DAEMON_VERSION);
54
56
  /** @typedef {import('../../protocol/src/types.js').SetupStatus} SetupStatus */
55
57
  /** @typedef {import('./config.js').BridgeTransport} BridgeTransport */
56
58
  /** @typedef {import('./daemon-logger.js').DaemonLoggerLike} DaemonLoggerLike */
57
- /** @typedef {import('node:net').Socket & { __clientId?: string, __extensionId?: string, __browserName?: string, __profileLabel?: string, __accessEnabled?: boolean, __lastActiveAt?: number, __authenticated?: boolean }} ClientSocket */
59
+ /** @typedef {'agent' | 'extension'} SocketRole */
60
+ /** @typedef {import('node:net').Socket & { readonly __role?: SocketRole, __clientId?: string, __extensionId?: string, __browserName?: string, __profileLabel?: string, __accessEnabled?: boolean, __lastActiveAt?: number }} ClientSocket */
58
61
  /** @typedef {{ socket: ClientSocket, timeoutId: NodeJS.Timeout, source?: string, method?: string, protocolVersion?: string, targets: Set<ClientSocket>, lastErrorResponse?: import('../../protocol/src/types.js').BridgeResponse }} PendingEntry */
59
62
  /**
60
63
  * @typedef {{
@@ -327,6 +330,17 @@ export class BridgeDaemon {
327
330
  * @returns {void}
328
331
  */
329
332
  registerSocket(socket, message) {
333
+ if (socket.__role) {
334
+ void writeJsonLine(socket, {
335
+ type: 'registration_failed',
336
+ error: {
337
+ code: ERROR_CODES.INVALID_REQUEST,
338
+ message: `Socket is already registered as ${socket.__role}.`,
339
+ },
340
+ });
341
+ return;
342
+ }
343
+
330
344
  if (this.isAuthRequired() && normalizeBridgeAuthToken(message.authToken) !== this.authToken) {
331
345
  this.logger.error('socket registration rejected', { role: message.role ?? null });
332
346
  void writeJsonLine(socket, {
@@ -339,8 +353,24 @@ export class BridgeDaemon {
339
353
  return;
340
354
  }
341
355
 
356
+ if (message.role !== 'extension' && message.role !== 'agent') {
357
+ void writeJsonLine(socket, {
358
+ type: 'registration_failed',
359
+ error: {
360
+ code: ERROR_CODES.INVALID_REQUEST,
361
+ message: 'Socket role must be "agent" or "extension".',
362
+ },
363
+ });
364
+ return;
365
+ }
366
+
367
+ Object.defineProperty(socket, '__role', {
368
+ value: message.role,
369
+ enumerable: false,
370
+ configurable: false,
371
+ writable: false,
372
+ });
342
373
  if (message.role === 'extension') {
343
- socket.__authenticated = true;
344
374
  const extensionId = randomUUID();
345
375
  socket.__extensionId = extensionId;
346
376
  socket.__browserName =
@@ -360,7 +390,6 @@ export class BridgeDaemon {
360
390
  }
361
391
 
362
392
  if (message.role === 'agent') {
363
- socket.__authenticated = true;
364
393
  const clientId = message.clientId || randomUUID();
365
394
  this.agentSockets.set(clientId, socket);
366
395
  socket.__clientId = clientId;
@@ -524,36 +553,59 @@ export class BridgeDaemon {
524
553
  return this.registerSocket(socket, message);
525
554
  }
526
555
 
527
- if (this.isAuthRequired() && !socket.__authenticated) {
528
- return this.rejectUnauthenticatedMessage(socket, message);
556
+ if (!socket.__role) {
557
+ return this.rejectUnregisteredMessage(socket, message);
529
558
  }
530
559
 
531
560
  if (message?.type === 'log') {
561
+ if (socket.__role !== 'extension') {
562
+ return this.rejectMessageForRole(socket, message);
563
+ }
532
564
  this.pushLog(message.entry ?? {});
533
565
  return;
534
566
  }
535
567
 
536
568
  if (message?.type === 'extension.response') {
569
+ if (socket.__role !== 'extension') {
570
+ return this.rejectMessageForRole(socket, message);
571
+ }
537
572
  return this.handleExtensionResponse(socket, message);
538
573
  }
539
574
 
540
575
  if (message?.type === 'extension.identity') {
576
+ if (socket.__role !== 'extension') {
577
+ return this.rejectMessageForRole(socket, message);
578
+ }
541
579
  return this.handleExtensionIdentity(socket, message);
542
580
  }
543
581
 
544
582
  if (message?.type === 'extension.access_update') {
583
+ if (socket.__role !== 'extension') {
584
+ return this.rejectMessageForRole(socket, message);
585
+ }
545
586
  return this.handleExtensionAccessUpdate(socket, message);
546
587
  }
547
588
 
548
589
  if (message?.type === 'extension.activity') {
590
+ if (socket.__role !== 'extension') {
591
+ return this.rejectMessageForRole(socket, message);
592
+ }
549
593
  return this.handleExtensionActivity(socket, message);
550
594
  }
551
595
 
552
596
  if (message?.type === 'extension.setup_status.request') {
597
+ if (socket.__role !== 'extension') {
598
+ return this.rejectMessageForRole(socket, message);
599
+ }
553
600
  return this.handleExtensionSetupStatus(socket, message);
554
601
  }
555
602
 
556
603
  if (message?.type === 'agent.request') {
604
+ const isExtensionSetupRequest =
605
+ socket.__role === 'extension' && message.request?.method === 'setup.install';
606
+ if (socket.__role !== 'agent' && !isExtensionSetupRequest) {
607
+ return this.rejectMessageForRole(socket, message);
608
+ }
557
609
  return this.handleAgentRequest(socket, message);
558
610
  }
559
611
 
@@ -571,7 +623,8 @@ export class BridgeDaemon {
571
623
  * @param {DaemonMessage} message
572
624
  * @returns {Promise<void>}
573
625
  */
574
- async rejectUnauthenticatedMessage(socket, message) {
626
+ async rejectUnregisteredMessage(socket, message) {
627
+ const authRequired = this.isAuthRequired();
575
628
  if (message?.type === 'agent.request') {
576
629
  const candidate =
577
630
  message.request && typeof message.request === 'object'
@@ -579,8 +632,10 @@ export class BridgeDaemon {
579
632
  : {};
580
633
  const response = createFailure(
581
634
  typeof candidate.id === 'string' && candidate.id.trim() ? candidate.id : 'unauthenticated',
582
- ERROR_CODES.ACCESS_DENIED,
583
- 'Register with the daemon auth token before sending bridge requests.',
635
+ authRequired ? ERROR_CODES.ACCESS_DENIED : ERROR_CODES.INVALID_REQUEST,
636
+ authRequired
637
+ ? 'Register with the daemon auth token before sending bridge requests.'
638
+ : 'Register the socket before sending bridge requests.',
584
639
  null,
585
640
  typeof candidate.method === 'string' ? { method: candidate.method } : {}
586
641
  );
@@ -591,8 +646,25 @@ export class BridgeDaemon {
591
646
  await writeJsonLine(socket, {
592
647
  type: 'error',
593
648
  error: {
594
- code: ERROR_CODES.ACCESS_DENIED,
595
- message: 'Register with the daemon auth token before sending bridge messages.',
649
+ code: authRequired ? ERROR_CODES.ACCESS_DENIED : ERROR_CODES.INVALID_REQUEST,
650
+ message: authRequired
651
+ ? 'Register with the daemon auth token before sending bridge messages.'
652
+ : 'Register the socket before sending bridge messages.',
653
+ },
654
+ });
655
+ }
656
+
657
+ /**
658
+ * @param {ClientSocket} socket
659
+ * @param {DaemonMessage} message
660
+ * @returns {Promise<void>}
661
+ */
662
+ async rejectMessageForRole(socket, message) {
663
+ await writeJsonLine(socket, {
664
+ type: 'error',
665
+ error: {
666
+ code: ERROR_CODES.INVALID_REQUEST,
667
+ message: `Message type ${JSON.stringify(message.type ?? null)} is not allowed for ${socket.__role} sockets.`,
596
668
  },
597
669
  });
598
670
  }
@@ -690,6 +762,17 @@ export class BridgeDaemon {
690
762
  }
691
763
 
692
764
  if (request.method === 'setup.install') {
765
+ if (socket.__role === 'agent' && !this.isLocalSocket(socket)) {
766
+ const response = createFailure(
767
+ request.id,
768
+ ERROR_CODES.ACCESS_DENIED,
769
+ 'setup.install is restricted to local Browser Bridge clients.',
770
+ null,
771
+ { method: request.method }
772
+ );
773
+ await writeJsonLine(socket, { type: 'agent.response', response });
774
+ return;
775
+ }
693
776
  try {
694
777
  const response = createSuccess(
695
778
  request.id,
@@ -718,40 +801,9 @@ export class BridgeDaemon {
718
801
  typeof request.meta?.target_profile === 'string' ? request.meta.target_profile : null;
719
802
  const hasExplicitTarget = Boolean(targetBrowser || targetProfile);
720
803
 
721
- /** @type {ClientSocket[]} */
722
- const targets = [];
723
- /** @type {ClientSocket | null} */
724
- let mostRecent = null;
725
- for (const extSocket of this.extensionSockets.values()) {
726
- if (targetBrowser || targetProfile) {
727
- if (targetBrowser && extSocket.__browserName !== targetBrowser) {
728
- continue;
729
- }
730
- if (targetProfile && extSocket.__profileLabel !== targetProfile) {
731
- continue;
732
- }
733
- targets.push(extSocket);
734
- continue;
735
- }
736
-
737
- if (extSocket.__accessEnabled) {
738
- targets.push(extSocket);
739
- continue;
740
- }
741
-
742
- if (
743
- !mostRecent ||
744
- (typeof extSocket.__lastActiveAt === 'number' ? extSocket.__lastActiveAt : 0) >
745
- (typeof mostRecent.__lastActiveAt === 'number' ? mostRecent.__lastActiveAt : 0)
746
- ) {
747
- mostRecent = extSocket;
748
- }
749
- }
750
- if (!hasExplicitTarget && targets.length === 0 && mostRecent) {
751
- targets.push(mostRecent);
752
- }
804
+ const target = this.selectExtensionTarget(targetBrowser, targetProfile);
753
805
 
754
- if (targets.length === 0) {
806
+ if (!target) {
755
807
  const response = createFailure(
756
808
  request.id,
757
809
  ERROR_CODES.EXTENSION_DISCONNECTED,
@@ -768,7 +820,7 @@ export class BridgeDaemon {
768
820
  method: request.method,
769
821
  protocolVersion: request.meta?.protocol_version,
770
822
  source: typeof request.meta?.source === 'string' ? request.meta.source : '',
771
- targets: new Set(targets),
823
+ targets: new Set([target]),
772
824
  timeoutId: setTimeout(() => {
773
825
  const pending = this.pendingRequests.get(request.id);
774
826
  if (!pending) return;
@@ -789,38 +841,101 @@ export class BridgeDaemon {
789
841
  message: error instanceof Error ? error.message : String(error),
790
842
  });
791
843
  });
792
- }, this.pendingTimeoutMs),
844
+ }, this.getPendingTimeoutMs(request)),
793
845
  });
794
846
  this.logger.info('request routed', {
795
847
  requestId: request.id,
796
848
  method: request.method,
797
849
  clientId: socket.__clientId ?? null,
798
- targetCount: targets.length,
850
+ targetCount: 1,
799
851
  });
800
- const broadcastPayload = { type: 'extension.request', request };
801
- await Promise.all(
802
- targets.map(async (extSocket) => {
803
- try {
804
- await writeJsonLine(extSocket, broadcastPayload);
805
- } catch (error) {
806
- this.logger.error('request route failed', {
807
- requestId: request.id,
808
- method: request.method,
809
- message: error instanceof Error ? error.message : String(error),
810
- });
811
- const pending = this.pendingRequests.get(request.id);
812
- if (!pending) {
813
- return;
814
- }
815
- this.removePendingTarget(request.id, pending, extSocket);
816
- if (extSocket.__extensionId) {
817
- this.extensionSockets.delete(extSocket.__extensionId);
818
- this.invalidateConnectedExtensionsCache();
819
- }
820
- extSocket.destroy(error instanceof Error ? error : undefined);
821
- await this.finishPendingRequestIfExhausted(request.id, pending);
822
- }
823
- })
852
+ try {
853
+ await writeJsonLine(target, { type: 'extension.request', request });
854
+ } catch (error) {
855
+ this.logger.error('request route failed', {
856
+ requestId: request.id,
857
+ method: request.method,
858
+ message: error instanceof Error ? error.message : String(error),
859
+ });
860
+ const pending = this.pendingRequests.get(request.id);
861
+ if (!pending) {
862
+ return;
863
+ }
864
+ this.removePendingTarget(request.id, pending, target);
865
+ if (target.__extensionId && this.extensionSockets.get(target.__extensionId) === target) {
866
+ this.extensionSockets.delete(target.__extensionId);
867
+ this.invalidateConnectedExtensionsCache();
868
+ }
869
+ target.destroy(error instanceof Error ? error : undefined);
870
+ await this.finishPendingRequestIfExhausted(request.id, pending);
871
+ }
872
+ }
873
+
874
+ /**
875
+ * @param {ClientSocket} socket
876
+ * @returns {boolean}
877
+ */
878
+ isLocalSocket(socket) {
879
+ if (this.transport.type === 'socket') {
880
+ return true;
881
+ }
882
+ const address = socket.remoteAddress ?? '';
883
+ return (
884
+ address === '127.0.0.1' ||
885
+ address === '::1' ||
886
+ address === 'localhost' ||
887
+ address === '::ffff:127.0.0.1'
888
+ );
889
+ }
890
+
891
+ /**
892
+ * Select from a snapshot so routing neither mutates nor observes mutations to
893
+ * the live extension map while candidates are ordered.
894
+ *
895
+ * @param {string | null} targetBrowser
896
+ * @param {string | null} targetProfile
897
+ * @returns {ClientSocket | null}
898
+ */
899
+ selectExtensionTarget(targetBrowser, targetProfile) {
900
+ const candidates = Array.from(this.extensionSockets.entries()).filter(([, extSocket]) => {
901
+ return (
902
+ (!targetBrowser || extSocket.__browserName === targetBrowser) &&
903
+ (!targetProfile || extSocket.__profileLabel === targetProfile)
904
+ );
905
+ });
906
+ candidates.sort(([leftId, left], [rightId, right]) => {
907
+ const accessDelta =
908
+ Number(Boolean(right.__accessEnabled)) - Number(Boolean(left.__accessEnabled));
909
+ if (accessDelta !== 0) {
910
+ return accessDelta;
911
+ }
912
+ const activityDelta =
913
+ (typeof right.__lastActiveAt === 'number' ? right.__lastActiveAt : 0) -
914
+ (typeof left.__lastActiveAt === 'number' ? left.__lastActiveAt : 0);
915
+ if (activityDelta !== 0) {
916
+ return activityDelta;
917
+ }
918
+ return leftId < rightId ? -1 : leftId > rightId ? 1 : 0;
919
+ });
920
+ return candidates[0]?.[1] ?? null;
921
+ }
922
+
923
+ /**
924
+ * @param {BridgeRequest} request
925
+ * @returns {number}
926
+ */
927
+ getPendingTimeoutMs(request) {
928
+ const configuredBase = Math.min(
929
+ MAX_DAEMON_PENDING_TIMEOUT_MS,
930
+ Math.max(1, this.pendingTimeoutMs)
931
+ );
932
+ const operationTimeout = request.params.timeoutMs;
933
+ if (typeof operationTimeout !== 'number' || !Number.isFinite(operationTimeout)) {
934
+ return configuredBase;
935
+ }
936
+ return Math.min(
937
+ MAX_DAEMON_PENDING_TIMEOUT_MS,
938
+ Math.max(configuredBase, operationTimeout + DAEMON_PENDING_TIMEOUT_MARGIN_MS)
824
939
  );
825
940
  }
826
941
 
@@ -922,7 +1037,7 @@ export class BridgeDaemon {
922
1037
  }
923
1038
 
924
1039
  const pending = this.pendingRequests.get(responseMessage.id);
925
- if (!pending) {
1040
+ if (!pending || !pending.targets.has(socket)) {
926
1041
  return;
927
1042
  }
928
1043
 
@@ -968,7 +1083,7 @@ export class BridgeDaemon {
968
1083
  return;
969
1084
  }
970
1085
 
971
- // Error response — wait for other extensions before forwarding.
1086
+ // A routed request has one target, so its error is final.
972
1087
  pending.lastErrorResponse = responseMessage;
973
1088
 
974
1089
  await this.finishPendingRequestIfExhausted(responseMessage.id, pending);
@@ -981,8 +1096,10 @@ export class BridgeDaemon {
981
1096
  handleSocketClose(socket) {
982
1097
  if (socket.__extensionId) {
983
1098
  this.logger.info('extension disconnected', { extensionId: socket.__extensionId });
984
- this.extensionSockets.delete(socket.__extensionId);
985
- this.invalidateConnectedExtensionsCache();
1099
+ if (this.extensionSockets.get(socket.__extensionId) === socket) {
1100
+ this.extensionSockets.delete(socket.__extensionId);
1101
+ this.invalidateConnectedExtensionsCache();
1102
+ }
986
1103
  }
987
1104
 
988
1105
  if (socket.__clientId) {
@@ -1151,11 +1268,14 @@ export async function pingExistingDaemon(transport) {
1151
1268
  });
1152
1269
 
1153
1270
  socket.once('connect', () => {
1154
- if (authToken) {
1155
- socket.write(
1156
- `${JSON.stringify({ type: 'register', role: 'agent', clientId: 'ping_probe', authToken })}\n`
1157
- );
1158
- }
1271
+ socket.write(
1272
+ `${JSON.stringify({
1273
+ type: 'register',
1274
+ role: 'agent',
1275
+ clientId: 'ping_probe',
1276
+ ...(authToken ? { authToken } : {}),
1277
+ })}\n`
1278
+ );
1159
1279
  socket.write(
1160
1280
  `${JSON.stringify({
1161
1281
  type: 'agent.request',
@@ -30,9 +30,21 @@ export const MAX_JSON_LINE_BYTES = MAX_NATIVE_MESSAGE_BYTES;
30
30
  /** Default timeout for a bridge request awaiting an extension response (ms). */
31
31
  export const DEFAULT_DAEMON_PENDING_TIMEOUT_MS = 30_000;
32
32
 
33
+ /** Transport allowance added after an operation's own normalized timeout. */
34
+ export const DAEMON_PENDING_TIMEOUT_MARGIN_MS = 2_000;
35
+
36
+ /** Upper bound for a daemon request awaiting an extension response. */
37
+ export const MAX_DAEMON_PENDING_TIMEOUT_MS = 122_000;
38
+
33
39
  /** Default timeout for a BridgeClient request (ms). */
34
40
  export const DEFAULT_CLIENT_REQUEST_TIMEOUT_MS = 8_000;
35
41
 
42
+ /** Transport allowance added after an operation timeout on the client side. */
43
+ export const CLIENT_REQUEST_TIMEOUT_MARGIN_MS = 4_000;
44
+
45
+ /** Upper bound for a client request, kept beyond the daemon's maximum deadline. */
46
+ export const MAX_CLIENT_REQUEST_TIMEOUT_MS = 124_000;
47
+
36
48
  /** Maximum number of recent log entries retained by the daemon. */
37
49
  export const DAEMON_RECENT_LOG_LIMIT = 200;
38
50
 
@@ -64,6 +76,11 @@ export const DEBUGGER_BACKED_METHODS = new Set([
64
76
  'performance.get_metrics',
65
77
  'screenshot.capture_element',
66
78
  'screenshot.capture_region',
79
+ 'screenshot.capture_full_page',
80
+ 'network.intercept.add',
81
+ 'network.intercept.remove',
82
+ 'network.intercept.list',
83
+ 'network.intercept.clear',
67
84
  'cdp.get_document',
68
85
  'cdp.get_dom_snapshot',
69
86
  'cdp.get_box_model',
@@ -271,7 +271,23 @@ export function validateBridgeRequest(request) {
271
271
  ) {
272
272
  throw new BridgeError(ERROR_CODES.INVALID_REQUEST, 'Request params must be an object.');
273
273
  }
274
- const parsedTabId = Number(candidate.tab_id);
274
+ const hasTabId = Object.hasOwn(candidate, 'tab_id');
275
+ const candidateTabId = candidate.tab_id;
276
+ /** @type {number | null} */
277
+ let tabId = null;
278
+ if (hasTabId && candidateTabId !== null) {
279
+ if (
280
+ typeof candidateTabId !== 'number' ||
281
+ !Number.isSafeInteger(candidateTabId) ||
282
+ candidateTabId <= 0
283
+ ) {
284
+ throw new BridgeError(
285
+ ERROR_CODES.INVALID_REQUEST,
286
+ 'Request tab_id must be null or a positive safe integer.'
287
+ );
288
+ }
289
+ tabId = candidateTabId;
290
+ }
275
291
 
276
292
  const method = /** @type {BridgeMethod} */ (candidate.method);
277
293
  const params =
@@ -282,7 +298,7 @@ export function validateBridgeRequest(request) {
282
298
  return {
283
299
  id: candidate.id,
284
300
  method,
285
- tab_id: Number.isFinite(parsedTabId) && parsedTabId > 0 ? parsedTabId : null,
301
+ tab_id: tabId,
286
302
  params: normalizeRequestParams(method, params),
287
303
  meta: {
288
304
  ...meta,
@@ -375,6 +391,33 @@ function normalizeRequestParams(method, params) {
375
391
  }
376
392
  }
377
393
 
394
+ /**
395
+ * Return the browser-operation timeout for methods that wait asynchronously.
396
+ * This lets clients and the daemon keep transport deadlines outside the
397
+ * normalized operation deadline, including when callers omit timeoutMs.
398
+ *
399
+ * @param {BridgeMethod} method
400
+ * @param {Record<string, unknown>} [params={}]
401
+ * @returns {number | null}
402
+ */
403
+ export function getBridgeOperationTimeoutMs(method, params = {}) {
404
+ switch (method) {
405
+ case 'navigation.navigate':
406
+ case 'navigation.reload':
407
+ case 'navigation.go_back':
408
+ case 'navigation.go_forward':
409
+ return normalizeNavigationAction(params).timeoutMs;
410
+ case 'page.wait_for_load_state':
411
+ return normalizeWaitForLoadStateParams(params).timeoutMs;
412
+ case 'page.evaluate':
413
+ return normalizeEvaluateParams(params).timeoutMs;
414
+ case 'dom.wait_for':
415
+ return normalizeWaitForParams(params).timeoutMs;
416
+ default:
417
+ return null;
418
+ }
419
+ }
420
+
378
421
  /**
379
422
  * @param {DomQueryParams} [params={}]
380
423
  * @returns {NormalizedDomQuery}
@@ -126,7 +126,7 @@ export const BRIDGE_METHOD_REGISTRY = Object.freeze({
126
126
  'setup.install',
127
127
  'system',
128
128
  false,
129
- ['kind', 'target'],
129
+ ['action', 'kind', 'target'],
130
130
  'trivial'
131
131
  ),
132
132
  'log.tail': createRegistryEntry('log.tail', 'system', false, ['limit'], 'trivial'),
@@ -13,7 +13,7 @@ Permission prompts are controlled by the host agent, not by Browser Bridge. In p
13
13
 
14
14
  Skill name: `browser-bridge` (also known as `bbx`). In GitHub Copilot, invoke as `/browser-bridge`. `bbx` is the CLI command used throughout this skill.
15
15
  When the runtime supports subagents, delegate bridge inspection to a smaller, lower-cost worker and return only concise findings to the parent.
16
- For open-ended investigation, start with structured reads (`page.get_state`, `dom.query`, `page.get_text`, `styles.get_computed`, `bbx batch`) and escalate to screenshots or debugger-backed methods only when structured evidence is insufficient.
16
+ For open-ended investigation, start with structured reads (`page.get_state`, `dom.query`, `page.get_text`, `styles.get_computed`, and `bbx batch` for CLI or `browser_batch` for MCP) and escalate to screenshots or debugger-backed methods only when structured evidence is insufficient. `browser_call` accepts one protocol method at a time; `batch` is not a valid `browser_call` method.
17
17
 
18
18
  ## CLI
19
19
 
@@ -154,12 +154,12 @@ Error responses now include a machine-readable `error.recovery` field with `retr
154
154
  6. **Rollback** - revert every patch before finishing unless user wants mutations kept.
155
155
  7. **Confirm scope** - `status` first; stop if no extension connection.
156
156
  8. **Screenshots last** - only when structured evidence is ambiguous; prefer `screenshot.capture_element`, then a tight `screenshot.capture_region`; use `screenshot.capture_full_page` only when document-level context matters.
157
- 9. **Batch reads** - combine independent reads in one `batch` call (executes concurrently via Promise.all).
157
+ 9. **Batch reads** - combine independent reads with `bbx batch` in CLI mode or `browser_batch` in MCP mode; do not pass `batch` as a `browser_call` method.
158
158
  10. **Avoid debugger first** - prefer DOM/content-script methods (`dom.*`, `styles.*`, `layout.get_box_model`, `page.get_console`, `page.get_text`, `page.get_storage`, `page.get_network`) before any debugger-backed method. Escalate to CDP only when those cannot answer the question.
159
159
  11. **Evaluate only when needed** - `page.evaluate` is powerful but debugger-backed; use it only when DOM, storage, console, network, or text reads cannot expose the needed state.
160
160
  12. **Debugger-backed methods are last resort** - treat `page.evaluate`, `dom.get_accessibility_tree`, `viewport.resize`, `performance.get_metrics`, `screenshot.capture_*`, and all `cdp.*` methods as escalation steps because they attach `chrome.debugger`.
161
- 13. **Wait after change** - after editing source files or triggering navigation, use `dom.wait_for` or `page.wait_for_load_state` before inspecting.
162
- 14. **Console after interaction** - call `page.get_console` after mutations to catch runtime errors early.
161
+ 13. **Wait after change** - after editing source, wait for expected new text, a selector matching the changed attribute, or a detach/attach remount; waiting for `attached` on a selector that already exists does not prove HMR ran. Use `page.wait_for_load_state` for navigation, not HMR.
162
+ 14. **Prime event buffers** - before reproducing a console or network issue, call `page.get_console` and `page.get_network` once with `clear: true`; then reproduce the event and read each buffer without clearing it.
163
163
  15. **Semantic finding** - use `dom.find_by_text` / `dom.find_by_role` when you know the label but not the selector.
164
164
  16. **Text extraction** - use `page.get_text` for full page text instead of `dom.query` on body.
165
165
  17. **Network monitoring** - use `page.get_network` to inspect API calls; auto-installs interceptor.
@@ -218,8 +218,11 @@ bbx rollback patch_2 # clean up
218
218
  ### Verify an API call
219
219
 
220
220
  ```bash
221
- bbx console error # check for runtime errors
222
- bbx network 20 # inspect recent requests
221
+ bbx call page.get_console '{"clear":true}' # install + clear console capture
222
+ bbx call page.get_network '{"clear":true}' # install + clear network capture
223
+ # reproduce the API action here
224
+ bbx console error # read newly captured runtime errors
225
+ bbx network 20 # read newly captured requests
223
226
  bbx eval 'document.querySelector("#app").__vue__.$store.state.user' # read framework state
224
227
  bbx page-text 2000 # extract page content
225
228
  ```
@@ -249,7 +252,7 @@ When the user has a localhost dev server with watch/HMR:
249
252
  3. **Identify the problem** - use `styles.get_computed`, `dom.get_html`, or `page.get_console` for errors.
250
253
  4. **Prototype with patches** - `patch.apply_styles` / `patch.apply_dom` to verify the fix visually.
251
254
  5. **Edit source files** - modify the actual code in the agent's workspace.
252
- 6. **Wait for HMR** - `dom.wait_for` with the selector that should change, or `page.wait_for_load_state`.
255
+ 6. **Wait for HMR** - use `dom.wait_for` for expected new text, a selector matching the changed attribute, or a detach/attach remount. Do not wait for `attached` on an unchanged selector that already exists.
253
256
  7. **Verify the change** - re-inspect the same area; compare with patch expectations.
254
257
  8. **Check for regressions** - `page.get_console` for new errors; scroll and inspect adjacent areas.
255
258
  9. **Rollback patches** - `patch.rollback` all temporary patches.
@@ -257,7 +260,8 @@ When the user has a localhost dev server with watch/HMR:
257
260
  ## Investigate-a-Bug Workflow
258
261
 
259
262
  ```
260
- page.get_state → page.get_console (check for errors)
263
+ page.get_state → page.get_console(clear: true) + page.get_network(clear: true)
264
+ → reproduce the bug → page.get_console + page.get_network
261
265
  → dom.find_by_text('<error text>') or dom.query('<selector>')
262
266
  → styles.get_computed (check layout/visibility)
263
267
  → page.evaluate('document.querySelector(...).dataset') (read data attrs)
@@ -144,19 +144,22 @@ bbx call input.scroll_into_view '{"target":{"selector":"[data-testid=\"submit-bu
144
144
  ## Network Monitoring
145
145
 
146
146
  ```bash
147
- bbx network # recent requests
148
- bbx network 50 # last 50
149
- bbx call page.get_network '{"limit":20,"clear":true}'
147
+ bbx call page.get_console '{"clear":true}' # install capture and clear old console entries
148
+ bbx call page.get_network '{"clear":true}' # install capture and clear old network entries
149
+ # reproduce the interaction here
150
+ bbx network 50 # newly captured requests
151
+ bbx console error # newly captured errors
150
152
  ```
151
153
 
152
154
  Each entry: `method`, `url`, `status`, `duration`, `initiator`.
153
155
 
154
156
  Typical workflow - debug API calls:
155
157
 
156
- 1. `page.get_network` to see recent requests
157
- 2. Filter by URL pattern or status code
158
- 3. Cross-reference with `page.get_console` for errors
159
- 4. Use `page.evaluate` to replay or inspect response data
158
+ 1. Prime and clear `page.get_console` and `page.get_network`
159
+ 2. Reproduce the interaction
160
+ 3. Read and filter `page.get_network` by URL pattern or status code
161
+ 4. Cross-reference with `page.get_console` for errors
162
+ 5. Use `page.evaluate` only if lighter evidence cannot expose the needed response state
160
163
 
161
164
  ## Network Interception
162
165