@browserbridge/bbx 1.8.0 → 1.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.
Files changed (39) hide show
  1. package/PRIVACY.md +29 -3
  2. package/README.md +10 -3
  3. package/SECURITY.md +12 -0
  4. package/package.json +7 -1
  5. package/packages/agent-client/src/cli-args.js +35 -0
  6. package/packages/agent-client/src/cli-batch.js +121 -58
  7. package/packages/agent-client/src/cli-output.js +8 -1
  8. package/packages/agent-client/src/cli.js +227 -19
  9. package/packages/agent-client/src/client.js +156 -3
  10. package/packages/agent-client/src/command-registry.js +8 -2
  11. package/packages/agent-client/src/config.js +195 -0
  12. package/packages/agent-client/src/npm-self-update.js +305 -0
  13. package/packages/agent-client/src/remotes.js +4 -2
  14. package/packages/agent-client/src/runtime.js +97 -5
  15. package/packages/agent-client/src/types.ts +38 -0
  16. package/packages/mcp-server/src/handlers-capture.js +29 -2
  17. package/packages/mcp-server/src/handlers-dom.js +54 -3
  18. package/packages/mcp-server/src/handlers-page.js +180 -60
  19. package/packages/mcp-server/src/handlers-utils.js +35 -4
  20. package/packages/mcp-server/src/handlers.js +10 -3
  21. package/packages/mcp-server/src/server.js +75 -6
  22. package/packages/native-host/src/artifact-store.js +353 -0
  23. package/packages/native-host/src/config.js +9 -1
  24. package/packages/native-host/src/daemon-logger.js +3 -1
  25. package/packages/native-host/src/daemon.js +543 -27
  26. package/packages/native-host/src/native-host.js +25 -2
  27. package/packages/protocol/src/capability-values.js +2 -0
  28. package/packages/protocol/src/defaults.js +28 -0
  29. package/packages/protocol/src/errors.js +48 -5
  30. package/packages/protocol/src/incidental-sanitizer.js +197 -0
  31. package/packages/protocol/src/index.js +2 -0
  32. package/packages/protocol/src/protocol.js +316 -6
  33. package/packages/protocol/src/recovery-telemetry.js +302 -0
  34. package/packages/protocol/src/registry.js +143 -6
  35. package/packages/protocol/src/summary.js +116 -25
  36. package/packages/protocol/src/types.ts +489 -4
  37. package/skills/browser-bridge/SKILL.md +22 -8
  38. package/skills/browser-bridge/references/protocol.md +117 -13
  39. package/skills/browser-bridge/references/token-efficiency.md +17 -7
@@ -4,6 +4,8 @@
4
4
  import fs from 'node:fs';
5
5
  import os from 'node:os';
6
6
  import path from 'node:path';
7
+ import { spawnSync } from 'node:child_process';
8
+ import { createHash } from 'node:crypto';
7
9
  import { fileURLToPath } from 'node:url';
8
10
 
9
11
  import { applyWindowsTcpTransportDefaults } from '../../native-host/src/config.js';
@@ -16,6 +18,7 @@ import {
16
18
  import { startBridgeMcpServer } from '../../mcp-server/src/server.js';
17
19
  import {
18
20
  extractRemoteFlag,
21
+ extractHarFlags,
19
22
  extractScreenshotFlags,
20
23
  extractTabFlag,
21
24
  parseCallCommand,
@@ -32,12 +35,16 @@ import {
32
35
  uninstallBrowserBridge,
33
36
  } from './cli-setup-commands.js';
34
37
  import { SHORTCUT_COMMANDS } from './command-registry.js';
38
+ import { getAutoUpdatePolicy, parseAutoUpdatePolicy, setAutoUpdatePolicy } from './config.js';
35
39
  import { formatMcpConfig, isMcpClientName, MCP_CLIENT_NAMES } from './mcp-config.js';
36
40
  import { getDoctorReport, requestBridge, resolveRef } from './runtime.js';
37
41
  import { createBridgeClientForDestination } from './remotes.js';
42
+ import { atomicWriteFile } from './atomic-write.js';
38
43
 
39
44
  /** @typedef {import('./types.js').BridgeMethod} BridgeMethod */
40
45
  /** @typedef {import('./types.js').ScreenshotResult} ScreenshotResult */
46
+ /** @typedef {import('../../protocol/src/types.js').ArtifactDescriptor} ArtifactDescriptor */
47
+ /** @typedef {import('../../protocol/src/types.js').HarExportResult} HarExportResult */
41
48
 
42
49
  const REQUEST_SOURCE = 'cli';
43
50
  const TEST_TIMEOUT_ENV = 'BBX_CLIENT_REQUEST_TIMEOUT_MS';
@@ -64,6 +71,7 @@ const LOCAL_ONLY_COMMANDS = new Set([
64
71
  'mcp',
65
72
  'proxy',
66
73
  'remote',
74
+ 'config',
67
75
  'doctor',
68
76
  'restart',
69
77
  ]);
@@ -167,6 +175,11 @@ if (command === 'remote') {
167
175
  process.exit();
168
176
  }
169
177
 
178
+ if (command === 'config') {
179
+ await runLocalCommand(() => handleConfigCommand(rest));
180
+ process.exit();
181
+ }
182
+
170
183
  const clientTimeoutMs = getClientTimeoutOverride();
171
184
  applyWindowsTcpTransportDefaults();
172
185
  const client = await createCliClient();
@@ -189,6 +202,26 @@ async function runLocalCommand(run) {
189
202
  }
190
203
  }
191
204
 
205
+ /**
206
+ * @param {string[]} args
207
+ * @returns {Promise<void>}
208
+ */
209
+ async function handleConfigCommand(args) {
210
+ const [action, key, value] = args;
211
+ if (action === 'get' && key === 'auto-update' && value === undefined) {
212
+ process.stdout.write(`${await getAutoUpdatePolicy()}\n`);
213
+ return;
214
+ }
215
+ if (action === 'set' && key === 'auto-update' && value !== undefined && args.length === 3) {
216
+ const policy = parseAutoUpdatePolicy(value);
217
+ const configPath = await setAutoUpdatePolicy(policy);
218
+ process.stdout.write(`Browser Bridge auto-update policy set to "${policy}".\n`);
219
+ process.stdout.write(`Config: ${configPath}\n`);
220
+ return;
221
+ }
222
+ throw new Error('Usage: bbx config <get auto-update|set auto-update off|compatible>');
223
+ }
224
+
192
225
  /**
193
226
  * Create the bridge client for this invocation, honoring the --remote flag
194
227
  * and BBX_REMOTE env var. Exits with a friendly error when the destination
@@ -213,6 +246,7 @@ async function createCliClient() {
213
246
  }
214
247
 
215
248
  async function main() {
249
+ let relaunchAfterUpdate = false;
216
250
  try {
217
251
  if (command === 'status') {
218
252
  const healthResponse = await requestBridge(
@@ -338,7 +372,7 @@ async function main() {
338
372
  tabId,
339
373
  source: REQUEST_SOURCE,
340
374
  });
341
- printCallResponse(response);
375
+ printCallResponse(response, method);
342
376
  return;
343
377
  }
344
378
 
@@ -357,7 +391,7 @@ async function main() {
357
391
  tabId,
358
392
  source: REQUEST_SOURCE,
359
393
  });
360
- printCallResponse(response);
394
+ printCallResponse(response, method);
361
395
  return;
362
396
  }
363
397
 
@@ -431,6 +465,7 @@ async function main() {
431
465
  elementRef,
432
466
  format: screenshotOptions.format,
433
467
  quality: screenshotOptions.quality,
468
+ delivery: 'artifact',
434
469
  },
435
470
  { tabId: parsed.tabId, source: REQUEST_SOURCE }
436
471
  );
@@ -453,8 +488,11 @@ async function main() {
453
488
  ) {
454
489
  throw new Error(`Screenshot path extension must match ${screenshotOptions.format} format.`);
455
490
  }
456
- const data = screenshotResult.image.replace(/^data:image\/(?:png|jpeg|webp);base64,/, '');
457
- await fs.promises.writeFile(filePath, Buffer.from(data, 'base64'));
491
+ const imageBytes =
492
+ screenshotResult.delivery === 'artifact'
493
+ ? await downloadArtifact(client, screenshotResult.artifact, 'screenshot')
494
+ : decodeScreenshotDataUrl(screenshotResult.image);
495
+ await atomicWriteFile(filePath, imageBytes);
458
496
  printJson({
459
497
  ok: true,
460
498
  summary: `Screenshot saved to ${filePath}.`,
@@ -469,6 +507,50 @@ async function main() {
469
507
  return;
470
508
  }
471
509
 
510
+ if (command === 'har') {
511
+ const parsed = extractTabFlag(rest);
512
+ const harOptions = extractHarFlags(parsed.rest);
513
+ const [outputPath = 'browser-bridge.har', ...extra] = harOptions.rest;
514
+ if (extra.length > 0) {
515
+ throw new Error(
516
+ 'Usage: har [--tab <tabId>] [--limit 1-200] [--url-pattern <pattern>] [--delivery inline|artifact|auto] [outPath]'
517
+ );
518
+ }
519
+ if (path.extname(outputPath) !== '.har') {
520
+ throw new Error('HAR output path must use the .har extension.');
521
+ }
522
+ const response = await requestBridge(
523
+ client,
524
+ 'network.export_har',
525
+ {
526
+ limit: harOptions.limit,
527
+ urlPattern: harOptions.urlPattern,
528
+ delivery: harOptions.delivery,
529
+ },
530
+ { tabId: parsed.tabId, source: REQUEST_SOURCE }
531
+ );
532
+ if (!response.ok) {
533
+ await printSummary(response);
534
+ return;
535
+ }
536
+ const result = /** @type {HarExportResult} */ (response.result);
537
+ let harBytes;
538
+ if (result.delivery === 'artifact') {
539
+ harBytes = await downloadArtifact(client, result.artifact, 'har');
540
+ assertValidHarBytes(harBytes);
541
+ } else {
542
+ assertValidHarDocument(result.har);
543
+ harBytes = Buffer.from(`${JSON.stringify(result.har, null, 2)}\n`, 'utf8');
544
+ }
545
+ await atomicWriteFile(outputPath, harBytes);
546
+ printJson({
547
+ ok: true,
548
+ summary: `HAR saved to ${outputPath}.`,
549
+ evidence: { savedTo: outputPath, delivery: result.delivery },
550
+ });
551
+ return;
552
+ }
553
+
472
554
  if (command === 'intercept') {
473
555
  const { tabId, rest: iArgs } = extractTabFlag(rest);
474
556
  const sub = iArgs[0];
@@ -559,25 +641,151 @@ async function main() {
559
641
  } catch (error) {
560
642
  const message = error instanceof Error ? error.message : String(error);
561
643
  const raw = getErrorCode(error);
562
- let code = 'ERROR';
563
- if (raw === 'ENOENT' || raw === 'ECONNREFUSED' || raw === 'EINVAL') {
564
- code = 'DAEMON_OFFLINE';
565
- } else if (raw === 'BRIDGE_TIMEOUT') {
566
- code = 'BRIDGE_TIMEOUT';
567
- } else if (/socket closed/i.test(message)) {
568
- code = 'CONNECTION_LOST';
569
- } else if (raw) {
570
- code = String(raw);
644
+ if (raw === 'BBX_NPM_UPDATED') {
645
+ relaunchAfterUpdate = true;
646
+ } else {
647
+ let code = 'ERROR';
648
+ if (raw === 'ENOENT' || raw === 'ECONNREFUSED' || raw === 'EINVAL') {
649
+ code = 'DAEMON_OFFLINE';
650
+ } else if (raw === 'BRIDGE_TIMEOUT') {
651
+ code = 'BRIDGE_TIMEOUT';
652
+ } else if (/socket closed/i.test(message)) {
653
+ code = 'CONNECTION_LOST';
654
+ } else if (raw) {
655
+ code = String(raw);
656
+ }
657
+ printJson({
658
+ ok: false,
659
+ summary: `${code}: ${message}`,
660
+ evidence: null,
661
+ });
662
+ process.exitCode = 1;
571
663
  }
572
- printJson({
573
- ok: false,
574
- summary: `${code}: ${message}`,
575
- evidence: null,
576
- });
577
- process.exitCode = 1;
578
664
  } finally {
579
665
  await client.close();
580
666
  }
667
+ if (relaunchAfterUpdate) {
668
+ relaunchCli();
669
+ }
670
+ }
671
+
672
+ /** @param {string} value */
673
+ function decodeScreenshotDataUrl(value) {
674
+ const match = /^data:image\/(?:png|jpeg|webp);base64,([A-Za-z0-9+/]*={0,2})$/u.exec(value);
675
+ if (!match) throw new Error('Screenshot response contains invalid base64 image data.');
676
+ const bytes = Buffer.from(match[1], 'base64');
677
+ if (bytes.toString('base64') !== match[1]) {
678
+ throw new Error('Screenshot response contains non-canonical base64 image data.');
679
+ }
680
+ return bytes;
681
+ }
682
+
683
+ /**
684
+ * @param {import('./client.js').BridgeClient} client
685
+ * @param {ArtifactDescriptor} artifact
686
+ * @param {'screenshot' | 'har'} [expectedKind]
687
+ */
688
+ async function downloadArtifact(client, artifact, expectedKind) {
689
+ const chunks = [];
690
+ let offset = 0;
691
+ try {
692
+ if (expectedKind && artifact.kind !== expectedKind) {
693
+ throw new Error(`Artifact kind does not match the expected ${expectedKind} result.`);
694
+ }
695
+ if (expectedKind === 'har' && artifact.mimeType !== 'application/json') {
696
+ throw new Error('HAR artifact MIME type is invalid.');
697
+ }
698
+ while (offset < artifact.byteLength) {
699
+ const response = await requestBridge(
700
+ client,
701
+ 'artifact.read',
702
+ { artifactId: artifact.artifactId, offset },
703
+ { source: REQUEST_SOURCE }
704
+ );
705
+ if (!response.ok) throw new Error(response.error.message);
706
+ const result = /** @type {Record<string, unknown>} */ (response.result);
707
+ if (result.artifactId !== artifact.artifactId || result.offset !== offset) {
708
+ throw new Error('Artifact chunk metadata is inconsistent.');
709
+ }
710
+ const data = String(result.data ?? '');
711
+ const bytes = Buffer.from(data, 'base64');
712
+ if (bytes.toString('base64') !== data || bytes.length !== result.byteLength) {
713
+ throw new Error('Artifact chunk contains invalid base64 data.');
714
+ }
715
+ chunks.push(bytes);
716
+ if (result.nextOffset === null) break;
717
+ if (typeof result.nextOffset !== 'number' || result.nextOffset <= offset) {
718
+ throw new Error('Artifact chunk did not advance the read offset.');
719
+ }
720
+ offset = result.nextOffset;
721
+ }
722
+ const output = Buffer.concat(chunks);
723
+ const sha256 = createHash('sha256').update(output).digest('hex');
724
+ if (output.length !== artifact.byteLength || sha256 !== artifact.sha256) {
725
+ throw new Error('Artifact length or checksum does not match the capture metadata.');
726
+ }
727
+ return output;
728
+ } finally {
729
+ await requestBridge(
730
+ client,
731
+ 'artifact.delete',
732
+ { artifactId: artifact.artifactId },
733
+ { source: REQUEST_SOURCE }
734
+ ).catch(() => {});
735
+ }
736
+ }
737
+
738
+ /** @param {Buffer} bytes */
739
+ function assertValidHarBytes(bytes) {
740
+ const text = bytes.toString('utf8');
741
+ if (!Buffer.from(text, 'utf8').equals(bytes)) {
742
+ throw new Error('HAR artifact is not valid UTF-8 JSON.');
743
+ }
744
+ let document;
745
+ try {
746
+ document = JSON.parse(text);
747
+ } catch {
748
+ throw new Error('HAR artifact is not valid JSON.');
749
+ }
750
+ assertValidHarDocument(document);
751
+ }
752
+
753
+ /** @param {unknown} document */
754
+ function assertValidHarDocument(document) {
755
+ if (!document || typeof document !== 'object' || Array.isArray(document)) {
756
+ throw new Error('HAR response does not contain an inline HAR object.');
757
+ }
758
+ const log = Reflect.get(document, 'log');
759
+ if (
760
+ !log ||
761
+ typeof log !== 'object' ||
762
+ Array.isArray(log) ||
763
+ Reflect.get(log, 'version') !== '1.2' ||
764
+ !Array.isArray(Reflect.get(log, 'entries'))
765
+ ) {
766
+ throw new Error('HAR response does not contain a valid HAR 1.2 document.');
767
+ }
768
+ }
769
+
770
+ /**
771
+ * Replace this invocation with a fresh Node process after npm has replaced the
772
+ * package on disk. Child stdio is inherited so command output remains stable.
773
+ *
774
+ * @returns {void}
775
+ */
776
+ function relaunchCli() {
777
+ const result = spawnSync(
778
+ process.execPath,
779
+ [fileURLToPath(import.meta.url), ...process.argv.slice(2)],
780
+ {
781
+ stdio: 'inherit',
782
+ env: process.env,
783
+ }
784
+ );
785
+ if (result.error) {
786
+ throw result.error;
787
+ }
788
+ process.exitCode = result.status ?? 1;
581
789
  }
582
790
 
583
791
  /**
@@ -12,6 +12,10 @@ import {
12
12
  DEFAULT_CLIENT_REQUEST_TIMEOUT_MS,
13
13
  ERROR_CODES,
14
14
  getProtocolVersion,
15
+ isBatchSafeBridgeCall,
16
+ isBridgeMethod,
17
+ MAX_BATCH_CALLS,
18
+ MAX_BATCH_CONCURRENCY,
15
19
  parseJsonLines,
16
20
  setProtocolPackageVersion,
17
21
  } from '../../protocol/src/index.js';
@@ -23,6 +27,12 @@ import {
23
27
  } from '../../native-host/src/config.js';
24
28
  import { normalizeBridgeAuthToken, readBridgeAuthToken } from '../../native-host/src/auth-token.js';
25
29
  import { restartBridgeDaemon } from '../../native-host/src/daemon-process.js';
30
+ import {
31
+ comparePackageVersions,
32
+ NpmPackageUpdatedError,
33
+ parseStableVersion,
34
+ updateCompatibleNpmPackage,
35
+ } from './npm-self-update.js';
26
36
 
27
37
  /** @typedef {import('./types.js').BridgeMeta} BridgeMeta */
28
38
  /** @typedef {import('./types.js').BridgeMethod} BridgeMethod */
@@ -32,7 +42,8 @@ import { restartBridgeDaemon } from '../../native-host/src/daemon-process.js';
32
42
  /** @typedef {import('./types.js').PendingRequest} PendingRequest */
33
43
  /** @typedef {import('./types.js').ProtocolHealthResult} ProtocolHealthResult */
34
44
 
35
- setProtocolPackageVersion(loadPackageVersion());
45
+ const CLIENT_PACKAGE_VERSION = loadPackageVersion();
46
+ setProtocolPackageVersion(CLIENT_PACKAGE_VERSION);
36
47
 
37
48
  /**
38
49
  * @returns {string | null}
@@ -119,6 +130,9 @@ export class BridgeClient extends EventEmitter {
119
130
  checkProtocolOnConnect = true,
120
131
  restartDaemonOnVersionMismatch = shouldAutoRestartDaemonOnVersionMismatch(),
121
132
  restartDaemonFn = restartBridgeDaemon,
133
+ updateNpmOnCompatibleVersion = false,
134
+ exitProcessOnNpmUpdate = false,
135
+ updateCompatibleNpmPackageFn = updateCompatibleNpmPackage,
122
136
  authToken = undefined,
123
137
  } = {}) {
124
138
  super();
@@ -131,6 +145,9 @@ export class BridgeClient extends EventEmitter {
131
145
  this.checkProtocolOnConnect = checkProtocolOnConnect;
132
146
  this.restartDaemonOnVersionMismatch = restartDaemonOnVersionMismatch;
133
147
  this.restartDaemonFn = restartDaemonFn;
148
+ this.updateNpmOnCompatibleVersion = updateNpmOnCompatibleVersion;
149
+ this.exitProcessOnNpmUpdate = exitProcessOnNpmUpdate;
150
+ this.updateCompatibleNpmPackageFn = updateCompatibleNpmPackageFn;
134
151
  this.authToken = authToken;
135
152
  this.socket = null;
136
153
  this.connected = false;
@@ -140,6 +157,9 @@ export class BridgeClient extends EventEmitter {
140
157
  this.waiting = new Map();
141
158
  this._reconnecting = false;
142
159
  this._attemptedVersionMismatchRestart = false;
160
+ this._attemptedNpmUpdate = false;
161
+ /** @type {import('./types.js').NpmUpdateResult | null} */
162
+ this.npmUpdateResult = null;
143
163
  }
144
164
 
145
165
  /**
@@ -280,6 +300,9 @@ export class BridgeClient extends EventEmitter {
280
300
  if (this.protocolCompatibility.compatible) {
281
301
  this._attemptedVersionMismatchRestart = false;
282
302
  }
303
+ if (await this.tryCompatibleNpmUpdate(healthResult)) {
304
+ return;
305
+ }
283
306
  if (this.shouldRestartDaemonForProtocolMismatch(healthResult)) {
284
307
  this._attemptedVersionMismatchRestart = true;
285
308
  await this.disconnectForDaemonRestart();
@@ -288,6 +311,95 @@ export class BridgeClient extends EventEmitter {
288
311
  }
289
312
  }
290
313
 
314
+ /**
315
+ * @param {ProtocolHealthResult} healthResult
316
+ * @returns {Promise<boolean>}
317
+ */
318
+ async tryCompatibleNpmUpdate(healthResult) {
319
+ const extensionVersion = healthResult.extensionVersion;
320
+ const processVersion = CLIENT_PACKAGE_VERSION;
321
+ const extensionVersions = Array.isArray(healthResult.extension_supported_versions)
322
+ ? healthResult.extension_supported_versions
323
+ : healthResult.extensionConnected === true && Array.isArray(healthResult.supported_versions)
324
+ ? healthResult.supported_versions
325
+ : [];
326
+ if (
327
+ !this.updateNpmOnCompatibleVersion ||
328
+ this._attemptedNpmUpdate ||
329
+ healthResult.extensionConnected !== true ||
330
+ typeof extensionVersion !== 'string' ||
331
+ !parseStableVersion(extensionVersion) ||
332
+ typeof processVersion !== 'string' ||
333
+ !parseStableVersion(processVersion) ||
334
+ comparePackageVersions(extensionVersion, processVersion) <= 0 ||
335
+ extensionVersions.length === 0
336
+ ) {
337
+ return false;
338
+ }
339
+
340
+ this._attemptedNpmUpdate = true;
341
+ const previousAutoReconnect = this.autoReconnect;
342
+ this.autoReconnect = false;
343
+ try {
344
+ this.npmUpdateResult = await this.updateCompatibleNpmPackageFn({
345
+ extensionVersion,
346
+ supportedVersions: extensionVersions,
347
+ });
348
+ if (!this.npmUpdateResult.updated) {
349
+ if (
350
+ typeof this.npmUpdateResult.previousVersion === 'string' &&
351
+ parseStableVersion(this.npmUpdateResult.previousVersion) &&
352
+ comparePackageVersions(this.npmUpdateResult.previousVersion, processVersion) > 0
353
+ ) {
354
+ this.requireFreshProcess(this.npmUpdateResult.previousVersion);
355
+ }
356
+ if (this.npmUpdateResult.reason === 'not_global_install') {
357
+ this.setProtocolWarning(
358
+ 'Automatic npm update skipped because this Browser Bridge client is not running from the global npm installation.'
359
+ );
360
+ }
361
+ return false;
362
+ }
363
+
364
+ this.requireFreshProcess(this.npmUpdateResult.version || extensionVersion);
365
+ } catch (error) {
366
+ if (error instanceof NpmPackageUpdatedError || this.npmUpdateResult?.updated) {
367
+ throw error;
368
+ }
369
+ this.setProtocolWarning(
370
+ `Automatic Browser Bridge npm update failed: ${error instanceof Error ? error.message : String(error)}`
371
+ );
372
+ return false;
373
+ } finally {
374
+ this.autoReconnect = previousAutoReconnect;
375
+ }
376
+ }
377
+
378
+ /**
379
+ * @param {string} version
380
+ * @returns {never}
381
+ */
382
+ requireFreshProcess(version) {
383
+ if (this.exitProcessOnNpmUpdate) {
384
+ setTimeout(() => process.exit(0), 250).unref();
385
+ }
386
+ throw new NpmPackageUpdatedError(version);
387
+ }
388
+
389
+ /**
390
+ * @param {string} warning
391
+ * @returns {void}
392
+ */
393
+ setProtocolWarning(warning) {
394
+ this.protocolWarning = this.protocolWarning ? `${this.protocolWarning} ${warning}` : warning;
395
+ if (this.protocolCompatibility) {
396
+ this.protocolCompatibility = {
397
+ ...this.protocolCompatibility,
398
+ warning: this.protocolWarning,
399
+ };
400
+ }
401
+ }
402
+
291
403
  /**
292
404
  * @param {{
293
405
  * method: BridgeMethod,
@@ -456,7 +568,7 @@ export class BridgeClient extends EventEmitter {
456
568
  }
457
569
 
458
570
  /**
459
- * Send multiple bridge requests without waiting for each response serially.
571
+ * Send up to 20 read-only bridge requests with bounded concurrency.
460
572
  *
461
573
  * @param {Array<{
462
574
  * method: BridgeMethod,
@@ -471,8 +583,27 @@ export class BridgeClient extends EventEmitter {
471
583
  if (!Array.isArray(calls)) {
472
584
  throw new TypeError('BridgeClient.batch expects an array of request objects.');
473
585
  }
586
+ if (calls.length === 0 || calls.length > MAX_BATCH_CALLS) {
587
+ throw new RangeError(
588
+ `BridgeClient.batch expects between 1 and ${MAX_BATCH_CALLS} request objects.`
589
+ );
590
+ }
591
+ for (const [index, call] of calls.entries()) {
592
+ if (!call || typeof call !== 'object' || !isBridgeMethod(call.method)) {
593
+ throw new TypeError(`BridgeClient.batch calls[${index}] needs a known bridge method.`);
594
+ }
595
+ const params = call.params ?? {};
596
+ if (!params || typeof params !== 'object' || Array.isArray(params)) {
597
+ throw new TypeError(`BridgeClient.batch calls[${index}].params must be an object.`);
598
+ }
599
+ if (!isBatchSafeBridgeCall(call.method, params)) {
600
+ throw new TypeError(
601
+ `${call.method} is not safe for BridgeClient.batch. Call it sequentially.`
602
+ );
603
+ }
604
+ }
474
605
 
475
- return Promise.all(calls.map((call) => this.request(call)));
606
+ return mapWithConcurrency(calls, MAX_BATCH_CONCURRENCY, (call) => this.request(call));
476
607
  }
477
608
 
478
609
  /**
@@ -642,6 +773,28 @@ export class BridgeClient extends EventEmitter {
642
773
  }
643
774
  }
644
775
 
776
+ /**
777
+ * @template T, R
778
+ * @param {T[]} values
779
+ * @param {number} concurrency
780
+ * @param {(value: T) => Promise<R>} callback
781
+ * @returns {Promise<R[]>}
782
+ */
783
+ async function mapWithConcurrency(values, concurrency, callback) {
784
+ /** @type {R[]} */
785
+ const results = new Array(values.length);
786
+ let nextIndex = 0;
787
+ const workers = Array.from({ length: Math.min(concurrency, values.length) }, async () => {
788
+ while (nextIndex < values.length) {
789
+ const index = nextIndex;
790
+ nextIndex += 1;
791
+ results[index] = await callback(values[index]);
792
+ }
793
+ });
794
+ await Promise.all(workers);
795
+ return results;
796
+ }
797
+
645
798
  /**
646
799
  * @param {unknown} result
647
800
  * @returns {{ html: string, truncated: boolean, omitted: number }}
@@ -204,7 +204,10 @@ export const SHORTCUT_COMMANDS = {
204
204
  maxDepth: r[1] ? parseIntArg(r[1], 'maxDepth') : undefined,
205
205
  })
206
206
  ),
207
- perf: createShortcutCommand('performance.get_metrics', 'bbx perf', () => ({})),
207
+ perf: createShortcutCommand('performance.get_metrics', 'bbx perf', () => ({}), {
208
+ description:
209
+ 'Read a raw Chrome/CDP counter point sample (names and units vary; no BBX navigation window or LCP/CLS/INP measurement)',
210
+ }),
208
211
  scroll: createShortcutCommand('viewport.scroll', 'bbx scroll <top> [left]', (r) => {
209
212
  if (!r[0] && !r[1]) throw new Error('Usage: scroll <top> [left]');
210
213
  return {
@@ -250,6 +253,7 @@ export const CLI_METHOD_BINDINGS = Object.freeze({
250
253
  'press-key': 'input.press_key',
251
254
  'cdp-press-key': 'cdp.dispatch_key_event',
252
255
  screenshot: 'screenshot.capture_element',
256
+ har: 'network.export_har',
253
257
  eval: 'page.evaluate',
254
258
  });
255
259
 
@@ -265,6 +269,7 @@ export const CLI_HELP_SECTIONS = Object.freeze([
265
269
  'bbx status Check bridge connection',
266
270
  'bbx doctor Consolidated local runtime, setup, protocol, and readiness diagnostics',
267
271
  'bbx restart Restart the local daemon and running MCP servers',
272
+ 'bbx config set auto-update <off|compatible> Configure protocol-compatible npm updates',
268
273
  'bbx proxy enable [--port 9223] [--bind-host 127.0.0.1] [--rotate-token] Enable loopback TCP proxy mode for SSH tunneling',
269
274
  ' Non-loopback binds require --unsafe-plaintext because raw TCP is unencrypted.',
270
275
  'bbx proxy disable Disable proxy mode and restart the daemon',
@@ -288,7 +293,7 @@ export const CLI_HELP_SECTIONS = Object.freeze([
288
293
  lines: [
289
294
  'bbx call [--tab <tabId>] <method> [paramsJson|-] Call any bridge method (- reads JSON from stdin)',
290
295
  'bbx <method> [--tab <tabId>] [paramsJson|-] Direct alias for exact bridge methods such as page.get_state',
291
- "bbx batch '[{method,params,tabId?},...]' Parallel method calls",
296
+ "bbx batch '[{method,params,tabId?},...]' Up to 20 parallel read-only calls",
292
297
  'Every bridge command accepts --remote <name> (or BBX_REMOTE env) to target a remote destination from `bbx remote list`.',
293
298
  'Advanced bridge params stay available through `bbx call`, even when shortcuts expose only the common case.',
294
299
  'For open-ended investigation, start with `bbx batch` on `page.get_state`, `dom.query`, and `page.get_text` before any screenshot or CDP call.',
@@ -376,6 +381,7 @@ export const CLI_HELP_SECTIONS = Object.freeze([
376
381
  title: 'Capture',
377
382
  lines: [
378
383
  'bbx screenshot [--tab <tabId>] [--format png|jpeg|webp] [--quality 0-100] <ref|selector> [path]',
384
+ 'bbx har [--tab <tabId>] [--limit 1-200] [--url-pattern <pattern>] [--delivery inline|artifact|auto] [outPath]',
379
385
  ],
380
386
  },
381
387
  ]);