@browserbridge/bbx 1.7.2 → 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.
@@ -24,8 +24,12 @@ 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
+
29
33
  /** @type {Record<string, { method: BridgeMethod, params: (a: Record<string, unknown>) => Record<string, unknown> }>} */
30
34
  export const PAGE_ACTIONS = {
31
35
  state: { method: 'page.get_state', params: () => ({}) },
@@ -109,86 +113,111 @@ export async function handleBatchTool(args) {
109
113
  if (!Array.isArray(args.calls) || args.calls.length === 0) {
110
114
  return summarizeToolError('calls must be a non-empty array.');
111
115
  }
116
+ if (args.calls.length > MAX_BATCH_CALLS) {
117
+ return summarizeToolError(`calls must contain at most ${MAX_BATCH_CALLS} entries.`);
118
+ }
112
119
 
113
120
  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
- }
121
+ /** @param {BridgeClient | null} client */
122
+ const executeBatch = async (client) => {
123
+ const results = await mapWithConcurrency(calls, MAX_BATCH_CONCURRENCY, async (call) => {
124
+ if (!call || typeof call !== 'object' || typeof call.method !== 'string') {
125
+ return {
126
+ method: '',
127
+ tabId: null,
128
+ ok: false,
129
+ summary: 'INVALID_REQUEST: Each batch call needs a method.',
130
+ evidence: null,
131
+ error: {
132
+ code: 'INVALID_REQUEST',
133
+ message: 'Each batch call needs a method.',
134
+ },
135
+ response: null,
136
+ };
137
+ }
131
138
 
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
- }
139
+ if (!METHOD_SET.has(/** @type {BridgeMethod} */ (call.method))) {
140
+ return {
141
+ method: call.method,
142
+ tabId: null,
143
+ ok: false,
144
+ summary: `INVALID_REQUEST: Unknown bridge method "${call.method}".`,
145
+ evidence: null,
146
+ error: {
147
+ code: 'INVALID_REQUEST',
148
+ message: `Unknown bridge method "${call.method}".`,
149
+ },
150
+ response: null,
151
+ };
152
+ }
146
153
 
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;
154
+ if (call.method === 'setup.install') {
155
+ return {
156
+ method: call.method,
157
+ tabId: null,
158
+ ok: false,
159
+ summary: 'INVALID_REQUEST: Use browser_setup for setup changes.',
160
+ evidence: null,
161
+ error: {
162
+ code: 'INVALID_REQUEST',
163
+ message: 'Use browser_setup for setup changes.',
164
+ },
165
+ response: null,
166
+ };
167
+ }
155
168
 
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 || {}, {
165
- tabId,
166
- source: REQUEST_SOURCE,
167
- tokenBudget,
168
- });
169
- return {
170
- destinationId,
171
- ...summarizeBatchResponseItem({
172
- method,
173
- tabId,
174
- response,
175
- durationMs: Date.now() - startTime,
176
- }),
177
- };
178
- } catch (error) {
179
- return summarizeBatchErrorItem({
169
+ const method = /** @type {BridgeMethod} */ (call.method);
170
+ const tabId = bridgeMethodNeedsTab(method)
171
+ ? typeof call.tabId === 'number'
172
+ ? call.tabId
173
+ : null
174
+ : null;
175
+ const tokenBudget = getToolTokenBudget(call);
176
+ const destinationId = typeof call.destinationId === 'string' ? call.destinationId : null;
177
+
178
+ const startTime = Date.now();
179
+ const callClient = destinationId
180
+ ? await createBridgeClientForDestination(destinationId)
181
+ : client;
182
+ if (!callClient) {
183
+ return summarizeBatchErrorItem({
184
+ method,
185
+ tabId,
186
+ error: new Error('A local bridge client is required for calls without destinationId.'),
187
+ durationMs: Date.now() - startTime,
188
+ });
189
+ }
190
+ try {
191
+ if (destinationId) {
192
+ await callClient.connect();
193
+ }
194
+ const response = await requestBridgeWithRetry(callClient, method, call.params || {}, {
195
+ tabId,
196
+ source: REQUEST_SOURCE,
197
+ tokenBudget,
198
+ });
199
+ return {
200
+ destinationId,
201
+ ...summarizeBatchResponseItem({
180
202
  method,
181
203
  tabId,
182
- error,
204
+ response,
183
205
  durationMs: Date.now() - startTime,
184
- });
185
- } finally {
186
- if (destinationId) {
187
- await callClient.close();
188
- }
206
+ }),
207
+ };
208
+ } catch (error) {
209
+ return summarizeBatchErrorItem({
210
+ method,
211
+ tabId,
212
+ error,
213
+ durationMs: Date.now() - startTime,
214
+ });
215
+ } finally {
216
+ if (destinationId) {
217
+ await callClient.close();
189
218
  }
190
- })
191
- );
219
+ }
220
+ });
192
221
 
193
222
  const failureCount = results.filter((result) => !result.ok).length;
194
223
  const summary =
@@ -203,7 +232,35 @@ export async function handleBatchTool(args) {
203
232
  },
204
233
  failureCount > 0
205
234
  );
206
- });
235
+ };
236
+
237
+ return calls.every((call) => typeof call?.destinationId === 'string')
238
+ ? executeBatch(null)
239
+ : withToolClient(executeBatch);
240
+ }
241
+
242
+ /**
243
+ * @template T, R
244
+ * @param {T[]} values
245
+ * @param {number} concurrency
246
+ * @param {(value: T, index: number) => Promise<R>} callback
247
+ * @returns {Promise<R[]>}
248
+ */
249
+ async function mapWithConcurrency(values, concurrency, callback) {
250
+ /** @type {R[]} */
251
+ const results = new Array(values.length);
252
+ let nextIndex = 0;
253
+ const worker = async () => {
254
+ while (nextIndex < values.length) {
255
+ const index = nextIndex;
256
+ nextIndex += 1;
257
+ results[index] = await callback(values[index], index);
258
+ }
259
+ };
260
+ await Promise.all(
261
+ Array.from({ length: Math.min(concurrency, values.length) }, async () => worker())
262
+ );
263
+ return results;
207
264
  }
208
265
 
209
266
  /**
@@ -214,6 +271,9 @@ export async function handleRawCallTool(args) {
214
271
  if (!METHOD_SET.has(/** @type {BridgeMethod} */ (args.method))) {
215
272
  return summarizeToolError(`Unknown bridge method "${args.method}".`);
216
273
  }
274
+ if (args.method === 'setup.install') {
275
+ return summarizeToolError('Use browser_setup for setup changes.');
276
+ }
217
277
 
218
278
  return withToolClient(
219
279
  async (client) => {
@@ -76,7 +76,10 @@ export function isRetrySafeBridgeMethod(method, params) {
76
76
 
77
77
  /**
78
78
  * @typedef {{
79
- * content: Array<{ type: 'text', text: string }>,
79
+ * content: [
80
+ * { type: 'text', text: string },
81
+ * ...Array<{ type: 'image', data: string, mimeType: string }>
82
+ * ],
80
83
  * structuredContent: Record<string, unknown>,
81
84
  * isError?: boolean
82
85
  * }} ToolResult
@@ -86,11 +89,20 @@ export function isRetrySafeBridgeMethod(method, params) {
86
89
  * @param {string} summary
87
90
  * @param {Record<string, unknown>} [structuredContent={}]
88
91
  * @param {boolean} [isError=false]
92
+ * @param {Array<{ type: 'image', data: string, mimeType: string }>} [additionalContent=[]]
89
93
  * @returns {ToolResult}
90
94
  */
91
- export function createToolResult(summary, structuredContent = {}, isError = false) {
95
+ export function createToolResult(
96
+ summary,
97
+ structuredContent = {},
98
+ isError = false,
99
+ additionalContent = []
100
+ ) {
92
101
  const toolResult = {
93
- content: [{ type: /** @type {'text'} */ ('text'), text: summary }],
102
+ content: /** @type {ToolResult['content']} */ ([
103
+ { type: 'text', text: summary },
104
+ ...additionalContent,
105
+ ]),
94
106
  structuredContent,
95
107
  ...(isError ? { isError: true } : {}),
96
108
  };
@@ -132,67 +144,6 @@ export function summarizeToolError(error) {
132
144
  );
133
145
  }
134
146
 
135
- /**
136
- * @param {ToolResult} result
137
- * @returns {boolean}
138
- */
139
- function shouldTryRemoteFallback(result) {
140
- if (!result.isError && result.structuredContent.ok !== false) {
141
- return false;
142
- }
143
- const text = result.content[0]?.text ?? '';
144
- return /\b(ACCESS_DENIED|DAEMON_OFFLINE|EXTENSION_DISCONNECTED|NATIVE_HOST_UNAVAILABLE|CONNECTION_LOST)\b|\b(ENOENT|ECONNREFUSED|ETIMEDOUT)\b|ERROR: connect\b/u.test(
145
- text
146
- );
147
- }
148
-
149
- /**
150
- * @param {ToolResult} result
151
- * @param {string} destinationId
152
- * @returns {ToolResult}
153
- */
154
- function annotateAutoDestination(result, destinationId) {
155
- return {
156
- ...result,
157
- structuredContent: {
158
- ...result.structuredContent,
159
- destinationId,
160
- autoSelectedDestinationId: destinationId,
161
- },
162
- };
163
- }
164
-
165
- /**
166
- * @param {(client: import('../../agent-client/src/client.js').BridgeClient) => Promise<ToolResult>} callback
167
- * @param {ToolResult} localResult
168
- * @returns {Promise<ToolResult>}
169
- */
170
- async function tryRemoteFallback(callback, localResult) {
171
- const remotes = (await listBridgeDestinations()).filter((destination) => !destination.local);
172
- if (remotes.length === 0) {
173
- return localResult;
174
- }
175
-
176
- let lastFallbackResult = localResult;
177
- for (const remote of remotes) {
178
- const client = await createBridgeClientForDestination(remote.id);
179
- try {
180
- await client.connect();
181
- const result = annotateAutoDestination(await callback(client), remote.id);
182
- lastFallbackResult = result;
183
- if (!shouldTryRemoteFallback(result)) {
184
- return result;
185
- }
186
- } catch (error) {
187
- lastFallbackResult = annotateAutoDestination(summarizeToolError(error), remote.id);
188
- } finally {
189
- await client.close();
190
- }
191
- }
192
-
193
- return lastFallbackResult;
194
- }
195
-
196
147
  /**
197
148
  * @param {(client: import('../../agent-client/src/client.js').BridgeClient) => Promise<ToolResult>} callback
198
149
  * @param {{ destinationId?: string | null }} [options]
@@ -201,8 +152,7 @@ async function tryRemoteFallback(callback, localResult) {
201
152
  export async function withToolClient(callback, options = {}) {
202
153
  try {
203
154
  if (!options.destinationId) {
204
- const result = await withBridgeClient(callback);
205
- return shouldTryRemoteFallback(result) ? await tryRemoteFallback(callback, result) : result;
155
+ return await withBridgeClient(callback);
206
156
  }
207
157
  const client = await createBridgeClientForDestination(options.destinationId);
208
158
  await client.connect();
@@ -54,6 +54,8 @@ export {
54
54
  } from './handlers-capture.js';
55
55
 
56
56
  export {
57
+ MAX_BATCH_CALLS,
58
+ MAX_BATCH_CONCURRENCY,
57
59
  PAGE_ACTIONS,
58
60
  handlePageTool,
59
61
  handleBatchTool,
@@ -27,6 +27,7 @@ import {
27
27
  handleStylesLayoutTool,
28
28
  handleTabsTool,
29
29
  handleInvestigateTool,
30
+ MAX_BATCH_CALLS,
30
31
  } from './handlers.js';
31
32
  import {
32
33
  BUDGET_PRESETS,
@@ -40,7 +41,7 @@ import {
40
41
  getMethodsByMaxComplexity,
41
42
  } from '../../protocol/src/index.js';
42
43
  import { applyWindowsTcpTransportDefaults } from '../../native-host/src/config.js';
43
- import { MCP_SERVER_INSTRUCTIONS } from './guidance.js';
44
+ import { MCP_SERVER_INSTRUCTIONS, registerBridgeMcpGuidance } from './guidance.js';
44
45
 
45
46
  export const BUDGET_PRESET_DESCRIPTION = `Budget preset: "quick", "normal", or "deep" (defaults: query ${BUDGET_PRESETS.normal.maxNodes} nodes / depth ${BUDGET_PRESETS.normal.maxDepth} / text ${BUDGET_PRESETS.normal.textBudget}). Numeric fields override the preset when both are provided.`;
46
47
  export const TAB_ID_DESCRIPTION =
@@ -105,6 +106,8 @@ export function createBridgeMcpServer() {
105
106
  }
106
107
  );
107
108
 
109
+ registerBridgeMcpGuidance(server);
110
+
108
111
  server.registerTool(
109
112
  'browser_status',
110
113
  {
@@ -661,6 +664,7 @@ export function createBridgeMcpServer() {
661
664
  })
662
665
  )
663
666
  .min(1)
667
+ .max(MAX_BATCH_CALLS)
664
668
  .describe('Calls to execute in parallel'),
665
669
  },
666
670
  },
@@ -249,11 +249,20 @@ export async function stopBridgeDaemon(options = {}) {
249
249
  resolvedTransport.type === 'socket' ? resolvedTransport.socketPath : '';
250
250
 
251
251
  const pidFromFile = await readPidFn(pidPath);
252
- let previousPid = pidFromFile;
253
- let previouslyRunning = previousPid !== null;
254
-
255
- if (previousPid === null && (await safePingDaemon(resolvedTransport, pingDaemonFn))) {
256
- previousPid = await findPidByTransportFn(resolvedTransport);
252
+ const reachable = await safePingDaemon(resolvedTransport, pingDaemonFn);
253
+ const endpointOwnerPid =
254
+ pidFromFile !== null || reachable ? await findPidByTransportFn(resolvedTransport) : null;
255
+ /** @type {number | null} */
256
+ let previousPid = null;
257
+ let previouslyRunning = false;
258
+
259
+ if (endpointOwnerPid !== null) {
260
+ previousPid = endpointOwnerPid;
261
+ previouslyRunning = true;
262
+ } else if (reachable) {
263
+ // TCP endpoints do not expose an owner pid. In that case daemon
264
+ // reachability is the evidence supporting the recorded pid.
265
+ previousPid = pidFromFile;
257
266
  previouslyRunning = true;
258
267
  }
259
268
 
@@ -275,9 +284,8 @@ export async function stopBridgeDaemon(options = {}) {
275
284
  sleepFn,
276
285
  });
277
286
  if (!stopped) {
278
- // The pid file can be stale (e.g. a root-owned leftover from a sudo
279
- // install that the daemon could not overwrite) while a daemon with a
280
- // different pid owns the socket. Target the socket owner before giving up.
287
+ // Endpoint ownership may become discoverable after a recorded daemon pid
288
+ // fails to stop. Only target the verified owner before giving up.
281
289
  const socketOwnerPid = await findPidByTransportFn(resolvedTransport);
282
290
  if (socketOwnerPid !== null && socketOwnerPid !== previousPid) {
283
291
  try {