@browserbridge/bbx 1.8.0 → 1.10.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 (48) 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 +70 -6
  6. package/packages/agent-client/src/cli-batch.js +127 -59
  7. package/packages/agent-client/src/cli-helpers.js +8 -0
  8. package/packages/agent-client/src/cli-output.js +8 -1
  9. package/packages/agent-client/src/cli-setup-commands.js +3 -1
  10. package/packages/agent-client/src/cli.js +254 -26
  11. package/packages/agent-client/src/client.js +156 -3
  12. package/packages/agent-client/src/command-registry.js +14 -4
  13. package/packages/agent-client/src/config.js +195 -0
  14. package/packages/agent-client/src/mcp-config.js +8 -8
  15. package/packages/agent-client/src/npm-self-update.js +305 -0
  16. package/packages/agent-client/src/remotes.js +4 -2
  17. package/packages/agent-client/src/runtime.js +99 -7
  18. package/packages/agent-client/src/types.ts +38 -0
  19. package/packages/mcp-server/src/guidance.js +37 -6
  20. package/packages/mcp-server/src/handlers-capture.js +29 -2
  21. package/packages/mcp-server/src/handlers-dom.js +54 -3
  22. package/packages/mcp-server/src/handlers-page.js +333 -60
  23. package/packages/mcp-server/src/handlers-utils.js +141 -151
  24. package/packages/mcp-server/src/handlers.js +17 -4
  25. package/packages/mcp-server/src/server.js +269 -51
  26. package/packages/mcp-server/src/toolset.js +48 -0
  27. package/packages/native-host/src/artifact-store.js +353 -0
  28. package/packages/native-host/src/config.js +9 -1
  29. package/packages/native-host/src/daemon-logger.js +3 -1
  30. package/packages/native-host/src/daemon.js +457 -27
  31. package/packages/native-host/src/dom-baseline-owners.js +210 -0
  32. package/packages/native-host/src/native-host.js +38 -29
  33. package/packages/protocol/src/budget-presets.js +182 -0
  34. package/packages/protocol/src/capability-values.js +2 -0
  35. package/packages/protocol/src/defaults.js +28 -0
  36. package/packages/protocol/src/errors.js +48 -5
  37. package/packages/protocol/src/har.js +34 -0
  38. package/packages/protocol/src/incidental-sanitizer.js +197 -0
  39. package/packages/protocol/src/index.js +4 -0
  40. package/packages/protocol/src/json-lines.js +3 -2
  41. package/packages/protocol/src/protocol.js +316 -6
  42. package/packages/protocol/src/recovery-telemetry.js +302 -0
  43. package/packages/protocol/src/registry.js +226 -6
  44. package/packages/protocol/src/summary.js +116 -25
  45. package/packages/protocol/src/types.ts +490 -4
  46. package/skills/browser-bridge/SKILL.md +26 -8
  47. package/skills/browser-bridge/references/protocol.md +125 -15
  48. package/skills/browser-bridge/references/token-efficiency.md +17 -7
@@ -0,0 +1,302 @@
1
+ // @ts-check
2
+
3
+ export const RECOVERY_WINDOW_MS = 5 * 60 * 1000;
4
+ export const RECOVERY_BUCKET_MS = 5 * 1000;
5
+ export const RECOVERY_LOOP_WINDOW_MS = 60 * 1000;
6
+ export const RECOVERY_LOOP_THRESHOLD = 3;
7
+ export const RECOVERY_MAX_GROUPS = 64;
8
+ export const RECOVERY_PUBLIC_COUNT_LIMIT = 10_000;
9
+
10
+ /** @type {readonly import('./types.js').RecoveryEventKind[]} */
11
+ export const RECOVERY_EVENT_KINDS = Object.freeze([
12
+ 'automatic_mcp_retry',
13
+ 'stale_ref_recovery',
14
+ 'debugger_reattach',
15
+ 'content_script_reinjection',
16
+ 'native_host_reconnect',
17
+ 'request_outcome',
18
+ ]);
19
+
20
+ const RECOVERY_EVENT_KIND_SET = new Set(RECOVERY_EVENT_KINDS);
21
+ const BUCKET_COUNT = RECOVERY_WINDOW_MS / RECOVERY_BUCKET_MS + 1;
22
+
23
+ /**
24
+ * Fixed-bucket, process-local recovery telemetry. Group names are used only to
25
+ * detect repeated failures and are never included in public snapshots.
26
+ */
27
+ export class RecoveryTelemetryCollector {
28
+ /** @param {{ now?: () => number }} [options] */
29
+ constructor({ now = Date.now } = {}) {
30
+ this.now = now;
31
+ /** @type {Array<{ start: number, events: Map<import('./types.js').RecoveryEventKind, { attempts: number, successes: number, failures: number, lastEventAt: number }> } | null>} */
32
+ this.buckets = Array.from({ length: BUCKET_COUNT }, () => null);
33
+ /** @type {Map<string, { failures: [number, number, number], next: number, count: number, touchedAt: number, kind: import('./types.js').RecoveryEventKind }>} */
34
+ this.groups = new Map();
35
+ }
36
+
37
+ /**
38
+ * @param {import('./types.js').RecoveryEventKind} kind
39
+ * @param {'success' | 'failure'} outcome
40
+ * @param {string} [group='default']
41
+ */
42
+ record(kind, outcome, group = 'default') {
43
+ if (!RECOVERY_EVENT_KIND_SET.has(kind)) return;
44
+ const now = normalizeNow(this.now());
45
+ const start = Math.floor(now / RECOVERY_BUCKET_MS) * RECOVERY_BUCKET_MS;
46
+ const index = Math.floor(start / RECOVERY_BUCKET_MS) % BUCKET_COUNT;
47
+ let bucket = this.buckets[index];
48
+ if (!bucket || bucket.start !== start) {
49
+ bucket = { start, events: new Map() };
50
+ this.buckets[index] = bucket;
51
+ }
52
+ const event = bucket.events.get(kind) ?? {
53
+ attempts: 0,
54
+ successes: 0,
55
+ failures: 0,
56
+ lastEventAt: 0,
57
+ };
58
+ event.attempts += 1;
59
+ event.successes += outcome === 'success' ? 1 : 0;
60
+ event.failures += outcome === 'failure' ? 1 : 0;
61
+ event.lastEventAt = now;
62
+ bucket.events.set(kind, event);
63
+
64
+ this.pruneGroups(now);
65
+ if (outcome !== 'failure') return;
66
+ const groupKey = `${kind}\u0000${group}`;
67
+ let state = this.groups.get(groupKey);
68
+ if (!state) {
69
+ while (this.groups.size >= RECOVERY_MAX_GROUPS) {
70
+ const oldest = [...this.groups].reduce((left, right) =>
71
+ left[1].touchedAt <= right[1].touchedAt ? left : right
72
+ );
73
+ this.groups.delete(oldest[0]);
74
+ }
75
+ state = {
76
+ failures: [0, 0, 0],
77
+ next: 0,
78
+ count: 0,
79
+ touchedAt: now,
80
+ kind,
81
+ };
82
+ this.groups.set(groupKey, state);
83
+ }
84
+ state.failures[state.next] = now;
85
+ state.next = (state.next + 1) % RECOVERY_LOOP_THRESHOLD;
86
+ state.count = Math.min(RECOVERY_LOOP_THRESHOLD, state.count + 1);
87
+ state.touchedAt = now;
88
+ }
89
+
90
+ /** @param {import('./types.js').RecoveryScope} scope */
91
+ snapshot(scope) {
92
+ const now = normalizeNow(this.now());
93
+ this.pruneGroups(now);
94
+ /** @type {Record<import('./types.js').RecoveryEventKind, import('./types.js').RecoveryEventSummary>} */
95
+ const events =
96
+ /** @type {Record<import('./types.js').RecoveryEventKind, import('./types.js').RecoveryEventSummary>} */ ({});
97
+ for (const kind of RECOVERY_EVENT_KINDS) {
98
+ let attempts = 0;
99
+ let successes = 0;
100
+ let failures = 0;
101
+ let lastEventAt = 0;
102
+ for (const bucket of this.buckets) {
103
+ if (
104
+ !bucket ||
105
+ bucket.start + RECOVERY_BUCKET_MS <= now - RECOVERY_WINDOW_MS ||
106
+ bucket.start > now
107
+ ) {
108
+ continue;
109
+ }
110
+ const event = bucket.events.get(kind);
111
+ if (!event) continue;
112
+ attempts += event.attempts;
113
+ successes += event.successes;
114
+ failures += event.failures;
115
+ lastEventAt = Math.max(lastEventAt, event.lastEventAt);
116
+ }
117
+ const activeLoop = [...this.groups.values()].some((group) => {
118
+ if (group.kind !== kind || group.count < RECOVERY_LOOP_THRESHOLD) return false;
119
+ return group.failures.every((at) => at >= now - RECOVERY_LOOP_WINDOW_MS && at <= now);
120
+ });
121
+ events[kind] = createPublicEventSummary(
122
+ attempts,
123
+ successes,
124
+ failures,
125
+ activeLoop,
126
+ lastEventAt
127
+ );
128
+ }
129
+ return {
130
+ scope,
131
+ windowMs: RECOVERY_WINDOW_MS,
132
+ bucketMs: RECOVERY_BUCKET_MS,
133
+ loopWindowMs: RECOVERY_LOOP_WINDOW_MS,
134
+ loopThreshold: RECOVERY_LOOP_THRESHOLD,
135
+ asOf: now,
136
+ activeLoop: RECOVERY_EVENT_KINDS.some((kind) => events[kind].activeLoop),
137
+ events,
138
+ };
139
+ }
140
+
141
+ /** @param {number} now */
142
+ pruneGroups(now) {
143
+ const cutoff = now - RECOVERY_LOOP_WINDOW_MS;
144
+ for (const [key, group] of this.groups) {
145
+ let recent = 0;
146
+ for (let index = 0; index < group.count; index += 1) {
147
+ const at = group.failures[index];
148
+ if (at >= cutoff && at <= now) recent += 1;
149
+ }
150
+ if (recent === 0) this.groups.delete(key);
151
+ }
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Strictly reconstruct a public summary from an untrusted transport value.
157
+ * @param {unknown} value
158
+ * @param {import('./types.js').RecoveryScope} expectedScope
159
+ * @returns {import('./types.js').RecoveryTelemetrySummary | null}
160
+ */
161
+ export function normalizeRecoveryTelemetrySummary(value, expectedScope) {
162
+ const source = asRecord(value);
163
+ if (!source || source.scope !== expectedScope) return null;
164
+ const asOf = strictTimestamp(source.asOf);
165
+ if (asOf === null) return null;
166
+ const eventsSource = asRecord(source.events);
167
+ if (!eventsSource) return null;
168
+ /** @type {Record<import('./types.js').RecoveryEventKind, import('./types.js').RecoveryEventSummary>} */
169
+ const events =
170
+ /** @type {Record<import('./types.js').RecoveryEventKind, import('./types.js').RecoveryEventSummary>} */ ({});
171
+ for (const kind of RECOVERY_EVENT_KINDS) {
172
+ const event = asRecord(eventsSource[kind]);
173
+ if (!event) return null;
174
+ const attempts = rawCount(event.attempts);
175
+ const successes = rawCount(event.successes);
176
+ const failures = rawCount(event.failures);
177
+ const pending = rawCount(event.pending);
178
+ const saturated = event.saturated;
179
+ if (
180
+ attempts === null ||
181
+ successes === null ||
182
+ failures === null ||
183
+ pending === null ||
184
+ typeof saturated !== 'boolean' ||
185
+ successes + failures + pending !== attempts ||
186
+ (saturated
187
+ ? attempts !== RECOVERY_PUBLIC_COUNT_LIMIT
188
+ : attempts > RECOVERY_PUBLIC_COUNT_LIMIT)
189
+ ) {
190
+ return null;
191
+ }
192
+ const lastEventAt = normalizeLastEventAt(event.lastEventAt, attempts, asOf);
193
+ if (lastEventAt === undefined) return null;
194
+ events[kind] = {
195
+ attempts,
196
+ successes,
197
+ failures,
198
+ pending,
199
+ saturated,
200
+ successRate: saturated || attempts === 0 ? null : roundRate(successes / attempts),
201
+ failureRate: saturated || attempts === 0 ? null : roundRate(failures / attempts),
202
+ activeLoop: event.activeLoop === true && failures >= RECOVERY_LOOP_THRESHOLD,
203
+ lastEventAt,
204
+ };
205
+ }
206
+ return {
207
+ scope: expectedScope,
208
+ windowMs: RECOVERY_WINDOW_MS,
209
+ bucketMs: RECOVERY_BUCKET_MS,
210
+ loopWindowMs: RECOVERY_LOOP_WINDOW_MS,
211
+ loopThreshold: RECOVERY_LOOP_THRESHOLD,
212
+ asOf,
213
+ activeLoop: RECOVERY_EVENT_KINDS.some((kind) => events[kind].activeLoop),
214
+ events,
215
+ };
216
+ }
217
+
218
+ /** @param {unknown} value */
219
+ function asRecord(value) {
220
+ return value && typeof value === 'object' && !Array.isArray(value)
221
+ ? /** @type {Record<string, unknown>} */ (value)
222
+ : null;
223
+ }
224
+
225
+ /** @param {unknown} value @returns {number | null} */
226
+ function rawCount(value) {
227
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null;
228
+ }
229
+
230
+ /** @param {unknown} value @returns {number | null} */
231
+ function strictTimestamp(value) {
232
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null;
233
+ }
234
+
235
+ /** @param {number} value */
236
+ function normalizeNow(value) {
237
+ return Number.isSafeInteger(value) && value >= 0 ? value : 0;
238
+ }
239
+
240
+ /**
241
+ * @param {unknown} value
242
+ * @param {number} attempts
243
+ * @param {number} asOf
244
+ * @returns {number | null | undefined}
245
+ */
246
+ function normalizeLastEventAt(value, attempts, asOf) {
247
+ if (attempts === 0) return value === null ? null : undefined;
248
+ const timestamp = strictTimestamp(value);
249
+ if (
250
+ timestamp === null ||
251
+ timestamp > asOf ||
252
+ timestamp < asOf - RECOVERY_WINDOW_MS - RECOVERY_BUCKET_MS
253
+ ) {
254
+ return undefined;
255
+ }
256
+ return timestamp;
257
+ }
258
+
259
+ /** @param {number} value */
260
+ function roundRate(value) {
261
+ return Math.round(value * 10_000) / 10_000;
262
+ }
263
+
264
+ /**
265
+ * @param {number} attempts
266
+ * @param {number} successes
267
+ * @param {number} failures
268
+ * @param {boolean} activeLoop
269
+ * @param {number} lastEventAt
270
+ * @returns {import('./types.js').RecoveryEventSummary}
271
+ */
272
+ function createPublicEventSummary(attempts, successes, failures, activeLoop, lastEventAt) {
273
+ if (attempts <= RECOVERY_PUBLIC_COUNT_LIMIT) {
274
+ return {
275
+ attempts,
276
+ successes,
277
+ failures,
278
+ pending: 0,
279
+ saturated: false,
280
+ successRate: attempts > 0 ? roundRate(successes / attempts) : null,
281
+ failureRate: attempts > 0 ? roundRate(failures / attempts) : null,
282
+ activeLoop,
283
+ lastEventAt: attempts > 0 ? lastEventAt : null,
284
+ };
285
+ }
286
+ const proportionalFailures = Math.floor((failures / attempts) * RECOVERY_PUBLIC_COUNT_LIMIT);
287
+ const cappedFailures = Math.min(
288
+ RECOVERY_PUBLIC_COUNT_LIMIT,
289
+ Math.max(activeLoop ? RECOVERY_LOOP_THRESHOLD : 0, proportionalFailures)
290
+ );
291
+ return {
292
+ attempts: RECOVERY_PUBLIC_COUNT_LIMIT,
293
+ successes: RECOVERY_PUBLIC_COUNT_LIMIT - cappedFailures,
294
+ failures: cappedFailures,
295
+ pending: 0,
296
+ saturated: true,
297
+ successRate: null,
298
+ failureRate: null,
299
+ activeLoop,
300
+ lastEventAt,
301
+ };
302
+ }
@@ -1,6 +1,7 @@
1
1
  // @ts-check
2
2
 
3
3
  import { CAPABILITIES } from './capability-values.js';
4
+ import { BridgeError, ERROR_CODES } from './errors.js';
4
5
 
5
6
  /**
6
7
  * @typedef {'trivial' | 'low' | 'moderate' | 'high'} BridgeMethodComplexity
@@ -40,6 +41,7 @@ const METHOD_POLICIES = Object.freeze({
40
41
  capability: CAPABILITIES.NETWORK_INTERCEPT,
41
42
  debuggerBacked: true,
42
43
  }),
44
+ sensitiveRead: Object.freeze({ capability: CAPABILITIES.SENSITIVE_READ }),
43
45
  navigationControl: Object.freeze({ capability: CAPABILITIES.NAVIGATION_CONTROL }),
44
46
  navigationControlDebugger: Object.freeze({
45
47
  capability: CAPABILITIES.NAVIGATION_CONTROL,
@@ -96,6 +98,8 @@ const METHOD_POLICIES = Object.freeze({
96
98
  const BRIDGE_METHOD_DESCRIPTIONS = Object.freeze({
97
99
  'access.request':
98
100
  'Request Browser Bridge access for the focused window. Do not repeat while access is already pending.',
101
+ 'protocol.describe':
102
+ 'Describe bridge method parameters and metadata by method or registry group.',
99
103
  'tabs.list': 'List tabs in the enabled window.',
100
104
  'tabs.create': 'Create a new tab in the enabled window.',
101
105
  'tabs.close': 'Close a tab in the enabled window.',
@@ -110,12 +114,15 @@ const BRIDGE_METHOD_DESCRIPTIONS = Object.freeze({
110
114
  'Inspect or explicitly act on the current JavaScript dialog. expectedDialogId is only a stale-decision check immediately before dispatch; Chrome cannot bind the CDP command atomically to that identifier.',
111
115
  'page.wait_for_load_state':
112
116
  'Wait for truthful tab-complete state and/or an event-aware URL condition.',
113
- 'page.get_storage': 'Read local or session storage values.',
117
+ 'page.get_storage': 'Read local or session storage key metadata without values.',
118
+ 'sensitive.read': 'Deliberately read one exact local or session storage value.',
114
119
  'page.get_text': 'Read bounded visible text from the page.',
115
120
  'page.extract_content':
116
121
  'Extract bounded semantic page content as text or Markdown without returning source HTML.',
117
122
  'page.get_network':
118
123
  'Read buffered fetch/XHR activity or explicitly manage bounded all-resource CDP capture.',
124
+ 'network.export_har':
125
+ 'Export bounded captured network evidence as a HAR 1.2 document, inline or as a daemon-owned artifact.',
119
126
  'network.intercept.add': 'Add a request interception rule (CDP Fetch domain).',
120
127
  'network.intercept.remove': 'Remove a request interception rule by ID.',
121
128
  'network.intercept.list': 'List active interception rules.',
@@ -125,6 +132,10 @@ const BRIDGE_METHOD_DESCRIPTIONS = Object.freeze({
125
132
  'navigation.go_back': 'Navigate backward in tab history.',
126
133
  'navigation.go_forward': 'Navigate forward in tab history.',
127
134
  'dom.query': 'Query a DOM subtree and return compact node summaries.',
135
+ 'dom.baseline.create': 'Create a short-lived compact semantic DOM baseline for later comparison.',
136
+ 'dom.baseline.compare': 'Compare the current DOM with a retained semantic baseline.',
137
+ 'dom.baseline.describe': 'Describe a retained semantic DOM baseline and its scope.',
138
+ 'dom.baseline.release': 'Release a retained semantic DOM baseline.',
128
139
  'dom.describe': 'Describe one element by elementRef.',
129
140
  'dom.get_text': 'Read bounded text for one element.',
130
141
  'dom.get_attributes': 'Read selected attributes for one element.',
@@ -156,6 +167,8 @@ const BRIDGE_METHOD_DESCRIPTIONS = Object.freeze({
156
167
  'screenshot.capture_region': 'Capture a screenshot of a viewport region.',
157
168
  'screenshot.capture_element': 'Capture a screenshot of one element.',
158
169
  'screenshot.capture_full_page': 'Capture a full-page screenshot beyond the viewport.',
170
+ 'artifact.read': 'Read one bounded chunk from a daemon-owned artifact.',
171
+ 'artifact.delete': 'Delete one daemon-owned artifact.',
159
172
  'patch.apply_styles': 'Apply a reversible inline style patch.',
160
173
  'patch.apply_dom': 'Apply a reversible DOM patch.',
161
174
  'patch.list': 'List active reversible patches.',
@@ -167,7 +180,8 @@ const BRIDGE_METHOD_DESCRIPTIONS = Object.freeze({
167
180
  'cdp.get_box_model': 'Read a CDP box model for a node.',
168
181
  'cdp.get_computed_styles_for_node': 'Read CDP computed styles for a node.',
169
182
  'cdp.dispatch_key_event': 'Dispatch a key press through Chrome DevTools Protocol input.',
170
- 'performance.get_metrics': 'Read browser performance metrics.',
183
+ 'performance.get_metrics':
184
+ 'Read a raw Chrome/CDP counter point sample; names and units vary, and BBX measures no navigation window, LCP, CLS, or INP.',
171
185
  'log.tail': 'Tail recent bridge log entries.',
172
186
  'health.ping': 'Check daemon, extension, and access-routing health.',
173
187
  'daemon.metrics': 'Daemon health and performance metrics.',
@@ -211,6 +225,13 @@ function createRegistryEntry(
211
225
  export const BRIDGE_METHOD_REGISTRY = Object.freeze({
212
226
  // system - trivial
213
227
  'access.request': createRegistryEntry('access.request', 'system', false, ['intent'], 'trivial'),
228
+ 'protocol.describe': createRegistryEntry(
229
+ 'protocol.describe',
230
+ 'system',
231
+ false,
232
+ ['method', 'group'],
233
+ 'trivial'
234
+ ),
214
235
  'skill.get_runtime_context': createRegistryEntry(
215
236
  'skill.get_runtime_context',
216
237
  'system',
@@ -304,6 +325,14 @@ export const BRIDGE_METHOD_REGISTRY = Object.freeze({
304
325
  'low',
305
326
  METHOD_POLICIES.pageRead
306
327
  ),
328
+ 'sensitive.read': createRegistryEntry(
329
+ 'sensitive.read',
330
+ 'sensitive',
331
+ true,
332
+ ['source', 'key'],
333
+ 'low',
334
+ METHOD_POLICIES.sensitiveRead
335
+ ),
307
336
  'page.get_text': createRegistryEntry(
308
337
  'page.get_text',
309
338
  'page',
@@ -328,6 +357,14 @@ export const BRIDGE_METHOD_REGISTRY = Object.freeze({
328
357
  'low',
329
358
  METHOD_POLICIES.networkRead
330
359
  ),
360
+ 'network.export_har': createRegistryEntry(
361
+ 'network.export_har',
362
+ 'page',
363
+ true,
364
+ ['limit', 'urlPattern', 'delivery'],
365
+ 'moderate',
366
+ METHOD_POLICIES.networkRead
367
+ ),
331
368
  // network intercept - moderate (holds debugger session)
332
369
  'network.intercept.add': createRegistryEntry(
333
370
  'network.intercept.add',
@@ -411,6 +448,38 @@ export const BRIDGE_METHOD_REGISTRY = Object.freeze({
411
448
  'low',
412
449
  METHOD_POLICIES.domRead
413
450
  ),
451
+ 'dom.baseline.create': createRegistryEntry(
452
+ 'dom.baseline.create',
453
+ 'inspect',
454
+ true,
455
+ ['selector', 'maxNodes', 'maxDepth', 'textBudget', 'attributeAllowlist'],
456
+ 'low',
457
+ METHOD_POLICIES.domRead
458
+ ),
459
+ 'dom.baseline.compare': createRegistryEntry(
460
+ 'dom.baseline.compare',
461
+ 'inspect',
462
+ true,
463
+ ['baselineId', 'maxChanges'],
464
+ 'low',
465
+ METHOD_POLICIES.domRead
466
+ ),
467
+ 'dom.baseline.describe': createRegistryEntry(
468
+ 'dom.baseline.describe',
469
+ 'inspect',
470
+ true,
471
+ ['baselineId'],
472
+ 'low',
473
+ METHOD_POLICIES.domRead
474
+ ),
475
+ 'dom.baseline.release': createRegistryEntry(
476
+ 'dom.baseline.release',
477
+ 'inspect',
478
+ true,
479
+ ['baselineId'],
480
+ 'low',
481
+ METHOD_POLICIES.domRead
482
+ ),
414
483
  'dom.describe': createRegistryEntry(
415
484
  'dom.describe',
416
485
  'inspect',
@@ -612,7 +681,7 @@ export const BRIDGE_METHOD_REGISTRY = Object.freeze({
612
681
  'screenshot.capture_region',
613
682
  'capture',
614
683
  true,
615
- ['x', 'y', 'width', 'height', 'format', 'quality'],
684
+ ['x', 'y', 'width', 'height', 'format', 'quality', 'delivery', 'scale'],
616
685
  'high',
617
686
  METHOD_POLICIES.screenshotPartialDebugger
618
687
  ),
@@ -620,7 +689,7 @@ export const BRIDGE_METHOD_REGISTRY = Object.freeze({
620
689
  'screenshot.capture_element',
621
690
  'capture',
622
691
  true,
623
- ['elementRef', 'format', 'quality'],
692
+ ['elementRef', 'format', 'quality', 'delivery', 'scale'],
624
693
  'high',
625
694
  METHOD_POLICIES.screenshotPartialDebugger
626
695
  ),
@@ -628,10 +697,24 @@ export const BRIDGE_METHOD_REGISTRY = Object.freeze({
628
697
  'screenshot.capture_full_page',
629
698
  'capture',
630
699
  true,
631
- ['format', 'quality'],
700
+ ['format', 'quality', 'delivery', 'scale'],
632
701
  'high',
633
702
  METHOD_POLICIES.screenshotPartialDebugger
634
703
  ),
704
+ 'artifact.read': createRegistryEntry(
705
+ 'artifact.read',
706
+ 'artifact',
707
+ false,
708
+ ['artifactId', 'offset', 'maxBytes'],
709
+ 'low'
710
+ ),
711
+ 'artifact.delete': createRegistryEntry(
712
+ 'artifact.delete',
713
+ 'artifact',
714
+ false,
715
+ ['artifactId'],
716
+ 'low'
717
+ ),
635
718
  // patch - moderate (side effects)
636
719
  'patch.apply_styles': createRegistryEntry(
637
720
  'patch.apply_styles',
@@ -686,7 +769,7 @@ export const BRIDGE_METHOD_REGISTRY = Object.freeze({
686
769
  'cdp.get_dom_snapshot',
687
770
  'cdp',
688
771
  true,
689
- [],
772
+ ['computedStyles'],
690
773
  'high',
691
774
  METHOD_POLICIES.cdpDomSnapshotDebugger
692
775
  ),
@@ -733,6 +816,70 @@ export const BRIDGE_METHODS = Object.freeze(
733
816
  /** @type {ReadonlySet<import('./types.js').BridgeMethod>} */
734
817
  export const METHOD_SET = new Set(BRIDGE_METHODS);
735
818
 
819
+ /** Methods whose read-only forms may run concurrently without side effects. */
820
+ const BATCH_SAFE_METHODS = new Set([
821
+ 'health.ping',
822
+ 'daemon.metrics',
823
+ 'tabs.list',
824
+ 'skill.get_runtime_context',
825
+ 'setup.get_status',
826
+ 'log.tail',
827
+ 'page.get_state',
828
+ 'page.get_console',
829
+ 'page.wait_for_load_state',
830
+ 'page.get_storage',
831
+ 'page.get_text',
832
+ 'page.extract_content',
833
+ 'page.get_network',
834
+ 'dom.query',
835
+ 'dom.baseline.compare',
836
+ 'dom.baseline.describe',
837
+ 'dom.describe',
838
+ 'dom.get_text',
839
+ 'dom.get_attributes',
840
+ 'dom.wait_for',
841
+ 'dom.find_by_text',
842
+ 'dom.find_by_role',
843
+ 'dom.get_html',
844
+ 'dom.get_accessibility_tree',
845
+ 'layout.get_box_model',
846
+ 'layout.hit_test',
847
+ 'styles.get_computed',
848
+ 'styles.get_matched_rules',
849
+ 'network.intercept.list',
850
+ 'patch.list',
851
+ 'performance.get_metrics',
852
+ ]);
853
+
854
+ /**
855
+ * Classify a normalized or raw bridge call for parallel read-only execution.
856
+ * Parameter checks deliberately reject malformed truthy mutation flags before
857
+ * protocol normalization can coerce them.
858
+ *
859
+ * @param {import('./types.js').BridgeMethod} method
860
+ * @param {Record<string, unknown>} [params]
861
+ * @returns {boolean}
862
+ */
863
+ export function isBatchSafeBridgeCall(method, params = {}) {
864
+ if (!BATCH_SAFE_METHODS.has(method)) return false;
865
+ if (
866
+ (method === 'page.get_console' || method === 'page.get_network') &&
867
+ params.clear !== undefined &&
868
+ params.clear !== false
869
+ ) {
870
+ return false;
871
+ }
872
+ if (
873
+ method === 'page.get_network' &&
874
+ params.source === 'cdp' &&
875
+ params.capture !== undefined &&
876
+ params.capture !== 'read'
877
+ ) {
878
+ return false;
879
+ }
880
+ return true;
881
+ }
882
+
736
883
  /**
737
884
  * @returns {Record<string, import('./types.js').BridgeMethod[]>}
738
885
  */
@@ -751,6 +898,79 @@ export function createBridgeMethodGroups() {
751
898
  return groups;
752
899
  }
753
900
 
901
+ /**
902
+ * @typedef {{
903
+ * method: import('./types.js').BridgeMethod,
904
+ * group: string,
905
+ * tab: boolean,
906
+ * params: readonly string[],
907
+ * description: string,
908
+ * complexity: BridgeMethodComplexity
909
+ * }} BridgeMethodDescription
910
+ */
911
+
912
+ /**
913
+ * @param {import('./types.js').BridgeMethod} method
914
+ * @returns {BridgeMethodDescription}
915
+ */
916
+ function createBridgeMethodDescription(method) {
917
+ const entry = BRIDGE_METHOD_REGISTRY[method];
918
+ return {
919
+ method,
920
+ group: entry.group,
921
+ tab: entry.tab,
922
+ params: entry.params,
923
+ description: entry.description,
924
+ complexity: entry.complexity,
925
+ };
926
+ }
927
+
928
+ /**
929
+ * Return a compact registry index or detailed descriptions for one method or group.
930
+ *
931
+ * @param {{ method?: unknown, group?: unknown }} [options]
932
+ * @returns {{ groups: Array<{ group: string, count: number }> } | BridgeMethodDescription | { group: string, methods: BridgeMethodDescription[] }}
933
+ */
934
+ export function describeBridgeMethods(options = {}) {
935
+ const { method, group } = options;
936
+ if (method !== undefined && group !== undefined) {
937
+ throw new BridgeError(
938
+ ERROR_CODES.INVALID_REQUEST,
939
+ 'protocol.describe accepts either method or group, not both.'
940
+ );
941
+ }
942
+ if (method !== undefined) {
943
+ if (typeof method !== 'string' || !isBridgeMethod(method)) {
944
+ throw new BridgeError(
945
+ ERROR_CODES.INVALID_REQUEST,
946
+ `Unknown bridge method "${String(method)}".`
947
+ );
948
+ }
949
+ return createBridgeMethodDescription(method);
950
+ }
951
+
952
+ const groups = createBridgeMethodGroups();
953
+ if (group !== undefined) {
954
+ if (typeof group !== 'string' || !Object.hasOwn(groups, group)) {
955
+ throw new BridgeError(
956
+ ERROR_CODES.INVALID_REQUEST,
957
+ `Unknown bridge method group "${String(group)}".`
958
+ );
959
+ }
960
+ return {
961
+ group,
962
+ methods: groups[group].map(createBridgeMethodDescription),
963
+ };
964
+ }
965
+
966
+ return {
967
+ groups: Object.entries(groups).map(([groupName, methods]) => ({
968
+ group: groupName,
969
+ count: methods.length,
970
+ })),
971
+ };
972
+ }
973
+
754
974
  /**
755
975
  * @param {string} method
756
976
  * @returns {method is import('./types.js').BridgeMethod}