@browserbridge/bbx 1.7.2 → 1.7.4

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.
@@ -1,18 +1,18 @@
1
1
  // @ts-check
2
2
 
3
3
  import {
4
- dispatchToolAction,
4
+ createToolResult,
5
5
  getToolTokenBudget,
6
6
  REQUEST_SOURCE,
7
7
  requestBridgeWithRetry,
8
8
  resolveToolRef,
9
- resolveRef,
10
9
  summarizeToolError,
11
10
  summarizeToolResponse,
12
11
  withToolClient,
13
12
  } from './handlers-utils.js';
14
13
 
15
14
  /** @typedef {import('../../protocol/src/types.js').BridgeMethod} BridgeMethod */
15
+ /** @typedef {import('../../protocol/src/types.js').BridgeResponse} BridgeResponse */
16
16
  /** @typedef {import('./handlers-utils.js').ToolAction} ToolAction */
17
17
  /** @typedef {import('./handlers-utils.js').ToolResult} ToolResult */
18
18
 
@@ -97,7 +97,170 @@ export async function handleCaptureTool(args) {
97
97
  return summarizeToolError('nodeId must be a finite number.');
98
98
  }
99
99
 
100
- return dispatchToolAction(CAPTURE_ACTIONS, args, 'capture');
100
+ const entry = CAPTURE_ACTIONS[args.action];
101
+ if (!entry) {
102
+ return summarizeToolError(`Unsupported capture action "${args.action}".`);
103
+ }
104
+
105
+ return withToolClient(
106
+ async (client) => {
107
+ const requestedTabId = typeof args.tabId === 'number' ? args.tabId : null;
108
+ const ref = entry.ref ? await resolveToolRef(client, args, requestedTabId) : undefined;
109
+ const response = await requestBridgeWithRetry(client, entry.method, entry.params(args, ref), {
110
+ tabId: requestedTabId,
111
+ source: REQUEST_SOURCE,
112
+ tokenBudget: getToolTokenBudget(args),
113
+ });
114
+ if (!response.ok) {
115
+ return summarizeToolResponse(response, entry.method);
116
+ }
117
+ return entry.method.startsWith('screenshot.')
118
+ ? createScreenshotResult(response, entry.method)
119
+ : createCdpCaptureResult(response, entry.method);
120
+ },
121
+ { destinationId: args.destinationId ?? null }
122
+ );
123
+ }
124
+
125
+ /**
126
+ * @param {BridgeResponse & { ok: true }} response
127
+ * @param {BridgeMethod} method
128
+ * @returns {ToolResult}
129
+ */
130
+ function createScreenshotResult(response, method) {
131
+ const result = toRecord(response.result);
132
+ if (typeof result.image !== 'string') {
133
+ return summarizeToolError(`${method} returned no image data.`);
134
+ }
135
+
136
+ const image = normalizeBase64Image(result.image);
137
+ if (!image) {
138
+ return summarizeToolError(`${method} returned invalid base64 image data.`);
139
+ }
140
+
141
+ const rect = boundedRect(result.rect);
142
+ return createToolResult(
143
+ `Captured ${image.mimeType} screenshot (${image.byteLength} bytes).`,
144
+ {
145
+ ok: true,
146
+ method,
147
+ mimeType: image.mimeType,
148
+ byteLength: image.byteLength,
149
+ ...(rect ? { rect } : {}),
150
+ },
151
+ false,
152
+ [{ type: 'image', data: image.data, mimeType: image.mimeType }]
153
+ );
154
+ }
155
+
156
+ /**
157
+ * @param {BridgeResponse & { ok: true }} response
158
+ * @param {BridgeMethod} method
159
+ * @returns {ToolResult}
160
+ */
161
+ function createCdpCaptureResult(response, method) {
162
+ const bounded = boundStructuredValue(response.result);
163
+ return createToolResult(`Captured bounded structured data from ${method}.`, {
164
+ ok: true,
165
+ method,
166
+ data: bounded.value,
167
+ truncated: bounded.truncated,
168
+ });
169
+ }
170
+
171
+ /**
172
+ * @param {string} value
173
+ * @returns {{ data: string, mimeType: string, byteLength: number } | null}
174
+ */
175
+ function normalizeBase64Image(value) {
176
+ const dataUrl = /^data:([^;,]+);base64,(.*)$/su.exec(value);
177
+ const mimeType = dataUrl?.[1] ?? 'image/png';
178
+ if (!mimeType.startsWith('image/')) return null;
179
+ const data = (dataUrl?.[2] ?? value).replace(/\s+/gu, '');
180
+ if (!data || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(data)) {
181
+ return null;
182
+ }
183
+ const bytes = Buffer.from(data, 'base64');
184
+ if (bytes.toString('base64') !== data) return null;
185
+ return { data, mimeType, byteLength: bytes.length };
186
+ }
187
+
188
+ /**
189
+ * @param {unknown} value
190
+ * @returns {Record<string, number> | null}
191
+ */
192
+ function boundedRect(value) {
193
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
194
+ const source = /** @type {Record<string, unknown>} */ (value);
195
+ /** @type {Record<string, number>} */
196
+ const rect = {};
197
+ for (const key of ['x', 'y', 'width', 'height', 'scale']) {
198
+ if (isFiniteNumber(source[key])) rect[key] = source[key];
199
+ }
200
+ return Object.keys(rect).length > 0 ? rect : null;
201
+ }
202
+
203
+ /**
204
+ * Bound debugger payloads by depth, entries, and string length while retaining
205
+ * their actual values rather than reducing them to field names.
206
+ *
207
+ * @param {unknown} input
208
+ * @returns {{ value: unknown, truncated: boolean }}
209
+ */
210
+ function boundStructuredValue(input) {
211
+ const state = { entries: 0, characters: 0, truncated: false };
212
+ const maxEntries = 500;
213
+ const maxCharacters = 50_000;
214
+ /** @type {(value: unknown, depth: number) => unknown} */
215
+ const visit = (value, depth) => {
216
+ if (state.entries >= maxEntries || state.characters >= maxCharacters || depth > 8) {
217
+ state.truncated = true;
218
+ return '[truncated]';
219
+ }
220
+ state.entries += 1;
221
+ if (typeof value === 'string') {
222
+ const remaining = maxCharacters - state.characters;
223
+ const limit = Math.min(value.length, remaining, 4000);
224
+ state.characters += limit;
225
+ if (limit === value.length) return value;
226
+ state.truncated = true;
227
+ return `${value.slice(0, limit)}[truncated]`;
228
+ }
229
+ if (Array.isArray(value)) {
230
+ const remaining = maxEntries - state.entries;
231
+ const limit = Math.min(value.length, remaining, 100);
232
+ if (limit < value.length) state.truncated = true;
233
+ return value.slice(0, limit).map((entry) => visit(entry, depth + 1));
234
+ }
235
+ if (value && typeof value === 'object') {
236
+ /** @type {Record<string, unknown>} */
237
+ const output = {};
238
+ const entries = Object.entries(/** @type {Record<string, unknown>} */ (value));
239
+ const limit = Math.min(entries.length, 100);
240
+ if (limit < entries.length) state.truncated = true;
241
+ for (const [key, entry] of entries.slice(0, limit)) {
242
+ if (state.entries >= maxEntries || state.characters >= maxCharacters) {
243
+ state.truncated = true;
244
+ break;
245
+ }
246
+ state.characters += key.length;
247
+ output[key] = visit(entry, depth + 1);
248
+ }
249
+ return output;
250
+ }
251
+ return value;
252
+ };
253
+ return { value: visit(input, 0), truncated: state.truncated };
254
+ }
255
+
256
+ /**
257
+ * @param {unknown} value
258
+ * @returns {Record<string, unknown>}
259
+ */
260
+ function toRecord(value) {
261
+ return value && typeof value === 'object' && !Array.isArray(value)
262
+ ? /** @type {Record<string, unknown>} */ (value)
263
+ : {};
101
264
  }
102
265
 
103
266
  /** @type {Record<string, BridgeMethod>} */
@@ -105,6 +268,7 @@ export const INPUT_ACTION_METHODS = {
105
268
  click: 'input.click',
106
269
  focus: 'input.focus',
107
270
  type: 'input.type',
271
+ fill: 'input.fill',
108
272
  press_key: 'input.press_key',
109
273
  cdp_press_key: 'cdp.dispatch_key_event',
110
274
  set_checked: 'input.set_checked',
@@ -115,13 +279,30 @@ export const INPUT_ACTION_METHODS = {
115
279
  };
116
280
 
117
281
  /**
118
- * @param {{ action: string, elementRef?: string, selector?: string, button?: string, clickCount?: number, text?: string, clear?: boolean, submit?: boolean, key?: string, code?: string, modifiers?: string[], checked?: boolean, values?: string[], labels?: string[], indexes?: number[], duration?: number, sourceElementRef?: string, sourceSelector?: string, destinationElementRef?: string, destinationSelector?: string, offsetX?: number, offsetY?: number, tabId?: number, destinationId?: string, budgetPreset?: 'quick' | 'normal' | 'deep' }} args
282
+ * Keep selector-based actions atomic instead of resolving a ref in a separate
283
+ * bridge request that can race with a page rerender.
284
+ *
285
+ * @param {unknown} elementRef
286
+ * @param {unknown} selector
287
+ * @returns {import('../../protocol/src/types.js').InputTarget | null}
288
+ */
289
+ function createInputTarget(elementRef, selector) {
290
+ if (typeof elementRef === 'string' && elementRef) return { elementRef };
291
+ if (typeof selector === 'string' && selector) return { selector };
292
+ return null;
293
+ }
294
+
295
+ /**
296
+ * @param {{ action: string, elementRef?: string, selector?: string, button?: string, clickCount?: number, text?: string, value?: string, mode?: 'auto' | 'setter' | 'keystrokes', clear?: boolean, submit?: boolean, key?: string, code?: string, modifiers?: string[], checked?: boolean, values?: string[], labels?: string[], indexes?: number[], duration?: number, sourceElementRef?: string, sourceSelector?: string, destinationElementRef?: string, destinationSelector?: string, offsetX?: number, offsetY?: number, tabId?: number, destinationId?: string, budgetPreset?: 'quick' | 'normal' | 'deep' }} args
119
297
  * @returns {Promise<ToolResult>}
120
298
  */
121
299
  export async function handleInputTool(args) {
122
300
  if (args.action === 'type' && !hasText(args.text)) {
123
301
  return summarizeToolError('text is required for input.type.');
124
302
  }
303
+ if (args.action === 'fill' && typeof args.value !== 'string') {
304
+ return summarizeToolError('value is required for input.fill.');
305
+ }
125
306
  if ((args.action === 'press_key' || args.action === 'cdp_press_key') && !hasText(args.key)) {
126
307
  return summarizeToolError('key is required for key input actions.');
127
308
  }
@@ -137,9 +318,11 @@ export async function handleInputTool(args) {
137
318
  return withToolClient(
138
319
  async (client) => {
139
320
  const requestedTabId = typeof args.tabId === 'number' ? args.tabId : null;
140
- const elementTarget = async () => ({
141
- elementRef: await resolveToolRef(client, args, requestedTabId),
142
- });
321
+ const elementTarget = () => {
322
+ const target = createInputTarget(args.elementRef, args.selector);
323
+ if (!target) throw new Error('Provide either elementRef or selector.');
324
+ return target;
325
+ };
143
326
 
144
327
  switch (args.action) {
145
328
  case 'click': {
@@ -147,7 +330,7 @@ export async function handleInputTool(args) {
147
330
  client,
148
331
  'input.click',
149
332
  {
150
- target: await elementTarget(),
333
+ target: elementTarget(),
151
334
  button: args.button,
152
335
  clickCount: args.clickCount,
153
336
  modifiers: args.modifiers,
@@ -165,7 +348,7 @@ export async function handleInputTool(args) {
165
348
  client,
166
349
  'input.focus',
167
350
  {
168
- target: await elementTarget(),
351
+ target: elementTarget(),
169
352
  },
170
353
  {
171
354
  tabId: requestedTabId,
@@ -180,7 +363,7 @@ export async function handleInputTool(args) {
180
363
  client,
181
364
  'input.type',
182
365
  {
183
- target: await elementTarget(),
366
+ target: elementTarget(),
184
367
  text: args.text,
185
368
  clear: args.clear,
186
369
  submit: args.submit,
@@ -194,8 +377,25 @@ export async function handleInputTool(args) {
194
377
  );
195
378
  return summarizeToolResponse(response, 'input.type');
196
379
  }
380
+ case 'fill': {
381
+ const response = await requestBridgeWithRetry(
382
+ client,
383
+ 'input.fill',
384
+ {
385
+ target: elementTarget(),
386
+ value: args.value,
387
+ mode: args.mode,
388
+ },
389
+ {
390
+ tabId: requestedTabId,
391
+ source: REQUEST_SOURCE,
392
+ tokenBudget: getToolTokenBudget(args),
393
+ }
394
+ );
395
+ return summarizeToolResponse(response, 'input.fill');
396
+ }
197
397
  case 'press_key': {
198
- const target = args.elementRef || args.selector ? await elementTarget() : undefined;
398
+ const target = args.elementRef || args.selector ? elementTarget() : undefined;
199
399
  const response = await requestBridgeWithRetry(
200
400
  client,
201
401
  'input.press_key',
@@ -234,7 +434,7 @@ export async function handleInputTool(args) {
234
434
  client,
235
435
  'input.set_checked',
236
436
  {
237
- target: await elementTarget(),
437
+ target: elementTarget(),
238
438
  checked: args.checked,
239
439
  },
240
440
  {
@@ -250,7 +450,7 @@ export async function handleInputTool(args) {
250
450
  client,
251
451
  'input.select_option',
252
452
  {
253
- target: await elementTarget(),
453
+ target: elementTarget(),
254
454
  values: args.values,
255
455
  labels: args.labels,
256
456
  indexes: args.indexes,
@@ -268,7 +468,7 @@ export async function handleInputTool(args) {
268
468
  client,
269
469
  'input.hover',
270
470
  {
271
- target: await elementTarget(),
471
+ target: elementTarget(),
272
472
  duration: args.duration,
273
473
  modifiers: args.modifiers,
274
474
  },
@@ -281,21 +481,12 @@ export async function handleInputTool(args) {
281
481
  return summarizeToolResponse(response, 'input.hover');
282
482
  }
283
483
  case 'drag': {
284
- const source = {
285
- elementRef:
286
- args.sourceElementRef ||
287
- (args.sourceSelector
288
- ? await resolveRef(client, args.sourceSelector, requestedTabId, REQUEST_SOURCE)
289
- : ''),
290
- };
291
- const destination = {
292
- elementRef:
293
- args.destinationElementRef ||
294
- (args.destinationSelector
295
- ? await resolveRef(client, args.destinationSelector, requestedTabId, REQUEST_SOURCE)
296
- : ''),
297
- };
298
- if (!source.elementRef || !destination.elementRef) {
484
+ const source = createInputTarget(args.sourceElementRef, args.sourceSelector);
485
+ const destination = createInputTarget(
486
+ args.destinationElementRef,
487
+ args.destinationSelector
488
+ );
489
+ if (!source || !destination) {
299
490
  return summarizeToolError(
300
491
  'sourceElementRef/sourceSelector and destinationElementRef/destinationSelector are required for drag.'
301
492
  );
@@ -322,7 +513,7 @@ export async function handleInputTool(args) {
322
513
  client,
323
514
  'input.scroll_into_view',
324
515
  {
325
- target: await elementTarget(),
516
+ target: elementTarget(),
326
517
  },
327
518
  {
328
519
  tabId: requestedTabId,
@@ -96,6 +96,16 @@ export async function handleTabsTool(args) {
96
96
  { destinationId: args.destinationId ?? null }
97
97
  );
98
98
  }
99
+ if (args.action === 'activate') {
100
+ if (typeof args.tabId !== 'number') {
101
+ return summarizeToolError('tabId is required for tabs.activate.');
102
+ }
103
+ return callBridgeTool(
104
+ 'tabs.activate',
105
+ { tabId: args.tabId },
106
+ { destinationId: args.destinationId ?? null }
107
+ );
108
+ }
99
109
  return summarizeToolError(`Unsupported tabs action "${args.action}".`);
100
110
  }
101
111
 
@@ -24,8 +24,57 @@ import {
24
24
  } from './handlers-utils.js';
25
25
 
26
26
  /** @typedef {import('../../protocol/src/types.js').BridgeMethod} BridgeMethod */
27
+ /** @typedef {import('../../agent-client/src/client.js').BridgeClient} BridgeClient */
27
28
  /** @typedef {import('./handlers-utils.js').ToolResult} ToolResult */
28
29
 
30
+ export const MAX_BATCH_CALLS = 20;
31
+ export const MAX_BATCH_CONCURRENCY = 5;
32
+
33
+ /** @type {ReadonlySet<BridgeMethod>} */
34
+ const BATCH_SAFE_METHODS = new Set([
35
+ 'health.ping',
36
+ 'daemon.metrics',
37
+ 'tabs.list',
38
+ 'skill.get_runtime_context',
39
+ 'setup.get_status',
40
+ 'log.tail',
41
+ 'page.get_state',
42
+ 'page.get_console',
43
+ 'page.wait_for_load_state',
44
+ 'page.get_storage',
45
+ 'page.get_text',
46
+ 'page.get_network',
47
+ 'dom.query',
48
+ 'dom.describe',
49
+ 'dom.get_text',
50
+ 'dom.get_attributes',
51
+ 'dom.wait_for',
52
+ 'dom.find_by_text',
53
+ 'dom.find_by_role',
54
+ 'dom.get_html',
55
+ 'dom.get_accessibility_tree',
56
+ 'layout.get_box_model',
57
+ 'layout.hit_test',
58
+ 'styles.get_computed',
59
+ 'styles.get_matched_rules',
60
+ 'network.intercept.list',
61
+ 'patch.list',
62
+ 'performance.get_metrics',
63
+ ]);
64
+
65
+ /**
66
+ * @param {BridgeMethod} method
67
+ * @param {Record<string, unknown>} params
68
+ * @returns {boolean}
69
+ */
70
+ export function isBatchSafeBridgeCall(method, params) {
71
+ if (!BATCH_SAFE_METHODS.has(method)) return false;
72
+ if ((method === 'page.get_console' || method === 'page.get_network') && params.clear === true) {
73
+ return false;
74
+ }
75
+ return true;
76
+ }
77
+
29
78
  /** @type {Record<string, { method: BridgeMethod, params: (a: Record<string, unknown>) => Record<string, unknown> }>} */
30
79
  export const PAGE_ACTIONS = {
31
80
  state: { method: 'page.get_state', params: () => ({}) },
@@ -109,86 +158,90 @@ export async function handleBatchTool(args) {
109
158
  if (!Array.isArray(args.calls) || args.calls.length === 0) {
110
159
  return summarizeToolError('calls must be a non-empty array.');
111
160
  }
161
+ if (args.calls.length > MAX_BATCH_CALLS) {
162
+ return summarizeToolError(`calls must contain at most ${MAX_BATCH_CALLS} entries.`);
163
+ }
112
164
 
113
165
  const calls = args.calls;
114
- return withToolClient(async (client) => {
115
- const results = await Promise.all(
116
- calls.map(async (call) => {
117
- if (!call || typeof call !== 'object' || typeof call.method !== 'string') {
118
- return {
119
- method: '',
120
- tabId: null,
121
- ok: false,
122
- summary: 'INVALID_REQUEST: Each batch call needs a method.',
123
- evidence: null,
124
- error: {
125
- code: 'INVALID_REQUEST',
126
- message: 'Each batch call needs a method.',
127
- },
128
- response: null,
129
- };
130
- }
131
-
132
- if (!METHOD_SET.has(/** @type {BridgeMethod} */ (call.method))) {
133
- return {
134
- method: call.method,
135
- tabId: null,
136
- ok: false,
137
- summary: `INVALID_REQUEST: Unknown bridge method "${call.method}".`,
138
- evidence: null,
139
- error: {
140
- code: 'INVALID_REQUEST',
141
- message: `Unknown bridge method "${call.method}".`,
142
- },
143
- response: null,
144
- };
145
- }
166
+ for (const [index, call] of calls.entries()) {
167
+ if (!call || typeof call !== 'object' || typeof call.method !== 'string') {
168
+ return summarizeToolError(`calls[${index}] needs a method.`);
169
+ }
170
+ if (!METHOD_SET.has(/** @type {BridgeMethod} */ (call.method))) {
171
+ return summarizeToolError(`Unknown bridge method "${call.method}" at calls[${index}].`);
172
+ }
173
+ const method = /** @type {BridgeMethod} */ (call.method);
174
+ if (!isBatchSafeBridgeCall(method, call.params || {})) {
175
+ return summarizeToolError(
176
+ `${method} is not safe for parallel batch execution. Use its specialized tool or browser_call sequentially.`
177
+ );
178
+ }
179
+ }
146
180
 
147
- const method = /** @type {BridgeMethod} */ (call.method);
148
- const tabId = bridgeMethodNeedsTab(method)
149
- ? typeof call.tabId === 'number'
150
- ? call.tabId
151
- : null
152
- : null;
153
- const tokenBudget = getToolTokenBudget(call);
154
- const destinationId = typeof call.destinationId === 'string' ? call.destinationId : null;
181
+ /** @param {BridgeClient | null} client */
182
+ const executeBatch = async (client) => {
183
+ const results = await mapWithConcurrency(calls, MAX_BATCH_CONCURRENCY, async (call) => {
184
+ const method = /** @type {BridgeMethod} */ (call.method);
185
+ const tabId = bridgeMethodNeedsTab(method)
186
+ ? typeof call.tabId === 'number'
187
+ ? call.tabId
188
+ : null
189
+ : null;
190
+ const tokenBudget = getToolTokenBudget(call);
191
+ const destinationId = typeof call.destinationId === 'string' ? call.destinationId : null;
155
192
 
156
- const startTime = Date.now();
157
- const callClient = destinationId
158
- ? await createBridgeClientForDestination(destinationId)
159
- : client;
160
- try {
161
- if (destinationId) {
162
- await callClient.connect();
163
- }
164
- const response = await requestBridgeWithRetry(callClient, method, call.params || {}, {
193
+ const startTime = Date.now();
194
+ const callClient = destinationId
195
+ ? await createBridgeClientForDestination(destinationId)
196
+ : client;
197
+ if (!callClient) {
198
+ return summarizeBatchErrorItem(
199
+ {
200
+ method,
165
201
  tabId,
166
- source: REQUEST_SOURCE,
167
- tokenBudget,
168
- });
169
- return {
170
- destinationId,
171
- ...summarizeBatchResponseItem({
202
+ error: new Error('A local bridge client is required for calls without destinationId.'),
203
+ durationMs: Date.now() - startTime,
204
+ },
205
+ { compact: true }
206
+ );
207
+ }
208
+ try {
209
+ if (destinationId) {
210
+ await callClient.connect();
211
+ }
212
+ const response = await requestBridgeWithRetry(callClient, method, call.params || {}, {
213
+ tabId,
214
+ source: REQUEST_SOURCE,
215
+ tokenBudget,
216
+ });
217
+ return {
218
+ destinationId,
219
+ ...summarizeBatchResponseItem(
220
+ {
172
221
  method,
173
222
  tabId,
174
223
  response,
175
224
  durationMs: Date.now() - startTime,
176
- }),
177
- };
178
- } catch (error) {
179
- return summarizeBatchErrorItem({
225
+ },
226
+ { compact: true }
227
+ ),
228
+ };
229
+ } catch (error) {
230
+ return summarizeBatchErrorItem(
231
+ {
180
232
  method,
181
233
  tabId,
182
234
  error,
183
235
  durationMs: Date.now() - startTime,
184
- });
185
- } finally {
186
- if (destinationId) {
187
- await callClient.close();
188
- }
236
+ },
237
+ { compact: true }
238
+ );
239
+ } finally {
240
+ if (destinationId) {
241
+ await callClient.close();
189
242
  }
190
- })
191
- );
243
+ }
244
+ });
192
245
 
193
246
  const failureCount = results.filter((result) => !result.ok).length;
194
247
  const summary =
@@ -199,11 +252,42 @@ export async function handleBatchTool(args) {
199
252
  summary,
200
253
  {
201
254
  ok: failureCount === 0,
255
+ successCount: results.length - failureCount,
256
+ failureCount,
257
+ compactOutput: true,
202
258
  results,
203
259
  },
204
260
  failureCount > 0
205
261
  );
206
- });
262
+ };
263
+
264
+ return calls.every((call) => typeof call?.destinationId === 'string')
265
+ ? executeBatch(null)
266
+ : withToolClient(executeBatch);
267
+ }
268
+
269
+ /**
270
+ * @template T, R
271
+ * @param {T[]} values
272
+ * @param {number} concurrency
273
+ * @param {(value: T, index: number) => Promise<R>} callback
274
+ * @returns {Promise<R[]>}
275
+ */
276
+ async function mapWithConcurrency(values, concurrency, callback) {
277
+ /** @type {R[]} */
278
+ const results = new Array(values.length);
279
+ let nextIndex = 0;
280
+ const worker = async () => {
281
+ while (nextIndex < values.length) {
282
+ const index = nextIndex;
283
+ nextIndex += 1;
284
+ results[index] = await callback(values[index], index);
285
+ }
286
+ };
287
+ await Promise.all(
288
+ Array.from({ length: Math.min(concurrency, values.length) }, async () => worker())
289
+ );
290
+ return results;
207
291
  }
208
292
 
209
293
  /**
@@ -214,6 +298,9 @@ export async function handleRawCallTool(args) {
214
298
  if (!METHOD_SET.has(/** @type {BridgeMethod} */ (args.method))) {
215
299
  return summarizeToolError(`Unknown bridge method "${args.method}".`);
216
300
  }
301
+ if (args.method === 'setup.install') {
302
+ return summarizeToolError('Use browser_setup for setup changes.');
303
+ }
217
304
 
218
305
  return withToolClient(
219
306
  async (client) => {