@browserbridge/bbx 1.11.3 → 1.12.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@browserbridge/bbx",
3
- "version": "1.11.3",
3
+ "version": "1.12.0",
4
4
  "private": false,
5
5
  "keywords": [
6
6
  "agent-tools",
@@ -56,6 +56,8 @@
56
56
  "format": "oxfmt .",
57
57
  "format:check": "oxfmt --check .",
58
58
  "fixture:browser": "node tests/fixtures/browser-reliability/server.mjs",
59
+ "fixture:interaction": "node tests/fixtures/agent-interaction/server.mjs",
60
+ "bench:interaction": "node tests/fixtures/agent-interaction/bench.mjs",
59
61
  "test:runtime": "node --import tsx --test packages/*/test/*.test.ts packages/integration-tests/*.test.ts tests/fixtures/*/*.test.ts",
60
62
  "test": "c8 node --import tsx --test packages/*/test/*.test.ts packages/integration-tests/*.test.ts tests/fixtures/*/*.test.ts",
61
63
  "coverage": "c8 report --reporter=text --reporter=text-summary",
@@ -73,25 +75,29 @@
73
75
  "install-manifest": "node packages/native-host/bin/install-manifest.js"
74
76
  },
75
77
  "dependencies": {
76
- "@modelcontextprotocol/server": "^2.0.0",
78
+ "@modelcontextprotocol/server": "^2.3.1",
77
79
  "@mozilla/readability": "^0.6.0",
78
80
  "linkedom": "^0.18.13",
79
81
  "zod": "^4.6.5"
80
82
  },
81
83
  "devDependencies": {
82
- "@modelcontextprotocol/client": "^2.0.0",
83
- "@types/chrome": "^0.3.0",
84
- "@types/node": "^26.6.1",
84
+ "@modelcontextprotocol/client": "^2.3.1",
85
+ "@types/chrome": "^0.3.4",
86
+ "@types/node": "^26.6.4",
85
87
  "ajv": "^8.20.0",
86
88
  "ajv-formats": "^3.0.1",
87
89
  "c8": "^12.0.0",
88
90
  "har-schema": "^2.0.0",
89
- "oxfmt": "^0.68.0",
90
- "oxlint": "^1.83.0",
91
- "tsx": "^4.23.13",
91
+ "oxfmt": "^0.72.0",
92
+ "oxlint": "^1.87.0",
93
+ "tsx": "^4.23.15",
92
94
  "typescript": "^7.0.2"
93
95
  },
94
96
  "engines": {
95
97
  "node": ">=20"
98
+ },
99
+ "allowScripts": {
100
+ "esbuild@0.28.1": true,
101
+ "fsevents@2.3.3": true
96
102
  }
97
103
  }
@@ -139,6 +139,18 @@ async function writeSocketLine(socket, line, signal) {
139
139
  }
140
140
  }
141
141
 
142
+ /**
143
+ * Agent session that keeps tab routing sticky across separate CLI processes.
144
+ * Set `BBX_SESSION` to give one agent its own working tab; unset callers share
145
+ * the extension's default session.
146
+ *
147
+ * @returns {string | null}
148
+ */
149
+ export function getDefaultAgentSession() {
150
+ const value = process.env.BBX_SESSION?.trim();
151
+ return value ? value : null;
152
+ }
153
+
142
154
  export class BridgeClient extends EventEmitter {
143
155
  /**
144
156
  * @param {BridgeClientOptions} [options={}]
@@ -156,6 +168,7 @@ export class BridgeClient extends EventEmitter {
156
168
  exitProcessOnNpmUpdate = false,
157
169
  updateCompatibleNpmPackageFn = updateCompatibleNpmPackage,
158
170
  authToken = undefined,
171
+ agentSession = getDefaultAgentSession(),
159
172
  } = {}) {
160
173
  super();
161
174
  this.transport = socketPath ? createSocketBridgeTransport(socketPath) : transport;
@@ -171,6 +184,7 @@ export class BridgeClient extends EventEmitter {
171
184
  this.exitProcessOnNpmUpdate = exitProcessOnNpmUpdate;
172
185
  this.updateCompatibleNpmPackageFn = updateCompatibleNpmPackageFn;
173
186
  this.authToken = authToken;
187
+ this.agentSession = agentSession;
174
188
  this.socket = null;
175
189
  this.connected = false;
176
190
  this.protocolCompatibility = null;
@@ -488,7 +502,10 @@ export class BridgeClient extends EventEmitter {
488
502
  method,
489
503
  params,
490
504
  tabId,
491
- meta,
505
+ meta:
506
+ this.agentSession && meta.agent_session === undefined
507
+ ? { ...meta, agent_session: this.agentSession }
508
+ : meta,
492
509
  });
493
510
 
494
511
  if (method === 'page.extract_content') {
@@ -124,6 +124,8 @@ export interface BridgeClientOptions {
124
124
  supportedVersions: readonly string[];
125
125
  }) => Promise<NpmUpdateResult>;
126
126
  authToken?: string | null;
127
+ /** Stable agent session id used for sticky working-tab routing. */
128
+ agentSession?: string | null;
127
129
  }
128
130
 
129
131
  export interface ShortcutCommand {
@@ -326,10 +326,17 @@ export const INPUT_ACTION_METHODS = {
326
326
  *
327
327
  * @param {unknown} elementRef
328
328
  * @param {unknown} selector
329
+ * @param {unknown} [locator]
329
330
  * @returns {import('../../protocol/src/types.js').InputTarget | null}
330
331
  */
331
- function createInputTarget(elementRef, selector) {
332
+ function createInputTarget(elementRef, selector, locator) {
332
333
  if (typeof elementRef === 'string' && elementRef) return { elementRef };
334
+ if (locator && typeof locator === 'object' && Object.keys(locator).length) {
335
+ return {
336
+ ...(typeof selector === 'string' && selector ? { selector } : {}),
337
+ .../** @type {Record<string, unknown>} */ (locator),
338
+ };
339
+ }
333
340
  if (typeof selector === 'string' && selector) return { selector };
334
341
  return null;
335
342
  }
@@ -337,18 +344,20 @@ function createInputTarget(elementRef, selector) {
337
344
  /**
338
345
  * Avoid changing legacy handler payloads when new options are omitted.
339
346
  *
340
- * @param {{ executionMode?: 'dom' | 'cdp', recoverStale?: boolean }} args
341
- * @returns {{ executionMode?: 'dom' | 'cdp', recoverStale?: boolean }}
347
+ * @param {{ executionMode?: 'dom' | 'cdp', recoverStale?: boolean, timeoutMs?: number, observe?: boolean | { settleMs?: number } }} args
348
+ * @returns {{ executionMode?: 'dom' | 'cdp', recoverStale?: boolean, timeoutMs?: number, observe?: boolean | { settleMs?: number } }}
342
349
  */
343
350
  function createInputOptions(args) {
344
351
  return {
345
352
  ...(args.executionMode ? { executionMode: args.executionMode } : {}),
346
353
  ...(typeof args.recoverStale === 'boolean' ? { recoverStale: args.recoverStale } : {}),
354
+ ...(typeof args.timeoutMs === 'number' ? { timeoutMs: args.timeoutMs } : {}),
355
+ ...(args.observe !== undefined ? { observe: args.observe } : {}),
347
356
  };
348
357
  }
349
358
 
350
359
  /**
351
- * @param {{ action: string, elementRef?: string, selector?: string, button?: string, clickCount?: number, text?: string, value?: string, mode?: 'auto' | 'setter' | 'keystrokes', executionMode?: 'dom' | 'cdp', recoverStale?: boolean, 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
360
+ * @param {{ action: string, elementRef?: string, selector?: string, button?: string, clickCount?: number, holdMs?: number, text?: string, value?: string, mode?: 'auto' | 'setter' | 'keystrokes', executionMode?: 'dom' | 'cdp', recoverStale?: boolean, 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', locator?: Record<string, unknown>, timeoutMs?: number, observe?: boolean | { settleMs?: number } }} args
352
361
  * @returns {Promise<ToolResult>}
353
362
  */
354
363
  export async function handleInputTool(args) {
@@ -374,9 +383,9 @@ export async function handleInputTool(args) {
374
383
  async (client) => {
375
384
  const requestedTabId = typeof args.tabId === 'number' ? args.tabId : null;
376
385
  const elementTarget = () => {
377
- const target = createInputTarget(args.elementRef, args.selector);
386
+ const target = createInputTarget(args.elementRef, args.selector, args.locator);
378
387
  if (!target) {
379
- throw new BridgeError('INVALID_REQUEST', 'Provide either elementRef or selector.');
388
+ throw new BridgeError('INVALID_REQUEST', 'Provide elementRef, selector, or locator.');
380
389
  }
381
390
  return target;
382
391
  };
@@ -391,6 +400,7 @@ export async function handleInputTool(args) {
391
400
  button: args.button,
392
401
  clickCount: args.clickCount,
393
402
  modifiers: args.modifiers,
403
+ ...(typeof args.holdMs === 'number' ? { holdMs: args.holdMs } : {}),
394
404
  ...createInputOptions(args),
395
405
  },
396
406
  {
@@ -456,7 +466,8 @@ export async function handleInputTool(args) {
456
466
  return summarizeToolResponse(response, 'input.fill');
457
467
  }
458
468
  case 'press_key': {
459
- const target = args.elementRef || args.selector ? elementTarget() : undefined;
469
+ const target =
470
+ args.elementRef || args.selector || args.locator ? elementTarget() : undefined;
460
471
  const response = await requestBridgeWithRetry(
461
472
  client,
462
473
  'input.press_key',
@@ -464,6 +475,7 @@ export async function handleInputTool(args) {
464
475
  target,
465
476
  key: args.key,
466
477
  modifiers: args.modifiers,
478
+ ...(typeof args.holdMs === 'number' ? { holdMs: args.holdMs } : {}),
467
479
  ...createInputOptions(args),
468
480
  },
469
481
  {
@@ -87,6 +87,7 @@ export const DOM_ACTIONS = {
87
87
  exact: a.exact,
88
88
  selector: a.selector,
89
89
  maxResults: a.maxResults,
90
+ ...(a.includeHidden !== undefined ? { includeHidden: a.includeHidden } : {}),
90
91
  }),
91
92
  },
92
93
  find_role: {
@@ -95,8 +96,10 @@ export const DOM_ACTIONS = {
95
96
  params: (a) => ({
96
97
  role: a.role,
97
98
  name: a.name,
99
+ ...(a.exact !== undefined ? { exact: a.exact } : {}),
98
100
  selector: a.selector,
99
101
  maxResults: a.maxResults,
102
+ ...(a.includeHidden !== undefined ? { includeHidden: a.includeHidden } : {}),
100
103
  }),
101
104
  },
102
105
  html: {
@@ -117,12 +120,14 @@ export const DOM_ACTIONS = {
117
120
  maxDepth: a.maxDepth,
118
121
  compact: a.compact,
119
122
  interactiveOnly: a.interactiveOnly,
123
+ ...(a.source !== undefined ? { source: a.source } : {}),
124
+ ...(a.format !== undefined ? { format: a.format } : {}),
120
125
  }),
121
126
  },
122
127
  };
123
128
 
124
129
  /**
125
- * @param {{ action: string, baselineId?: string, maxChanges?: number, selector?: string, elementRef?: string, withinRef?: string, maxNodes?: number, maxDepth?: number, compact?: boolean, interactiveOnly?: boolean, textBudget?: number, includeBbox?: boolean, attributeAllowlist?: string[], attributes?: string[], text?: string, exact?: boolean, maxResults?: number, role?: string, name?: string, state?: string, timeoutMs?: number, outer?: boolean, maxLength?: number, tabId?: number, destinationId?: string, budgetPreset?: 'quick' | 'normal' | 'deep' }} args
130
+ * @param {{ action: string, baselineId?: string, maxChanges?: number, selector?: string, elementRef?: string, withinRef?: string, maxNodes?: number, maxDepth?: number, compact?: boolean, interactiveOnly?: boolean, source?: 'cdp' | 'dom', format?: 'tree' | 'outline', includeHidden?: boolean, textBudget?: number, includeBbox?: boolean, attributeAllowlist?: string[], attributes?: string[], text?: string, exact?: boolean, maxResults?: number, role?: string, name?: string, state?: string, timeoutMs?: number, outer?: boolean, maxLength?: number, tabId?: number, destinationId?: string, budgetPreset?: 'quick' | 'normal' | 'deep' }} args
126
131
  * @returns {Promise<ToolResult>}
127
132
  */
128
133
  export async function handleDomTool(args) {
@@ -47,6 +47,8 @@ import { annotateBridgeSummary, summarizeBridgeResponse } from '../../agent-clie
47
47
 
48
48
  export const REQUEST_SOURCE = 'mcp';
49
49
  const MCP_CLIENT_ID = `mcp_${randomUUID()}`;
50
+ // One MCP server process serves one agent conversation, so it owns one working tab.
51
+ const MCP_AGENT_SESSION = process.env.BBX_SESSION?.trim() || MCP_CLIENT_ID;
50
52
  /** @type {AsyncLocalStorage<{ era: import('../../protocol/src/types.js').McpProtocolEra, signal?: AbortSignal }>} */
51
53
  const MCP_REQUEST_ERA = new AsyncLocalStorage();
52
54
 
@@ -346,6 +348,7 @@ export async function withMcpRequestClient(callback, options = {}) {
346
348
  const client = await createBridgeClientForDestination(options.destinationId, {
347
349
  checkProtocolOnConnect: false,
348
350
  clientId: MCP_CLIENT_ID,
351
+ agentSession: MCP_AGENT_SESSION,
349
352
  });
350
353
  let completed = false;
351
354
  try {
@@ -284,7 +284,7 @@ export function createBridgeMcpServer(options = {}) {
284
284
  {
285
285
  title: 'Browser DOM',
286
286
  description:
287
- 'Query, describe, read, search, or wait for DOM elements. Reuse elementRef from prior results. For full-page text, use browser_page action "text". accessibility_tree is debugger-backed and depth-limited - use query/find first.',
287
+ 'Query, describe, read, search, or wait for DOM elements. Reuse elementRef from prior results. For a page overview to act on, use accessibility_tree with source "dom": a compact outline (`- role "name" [ref]`) whose refs work directly as input targets, with no debugger. find_text/find_role search shadow DOM, use real accessible names (labels, aria-*), and skip hidden matches. For full-page text, use browser_page action "text".',
288
288
  inputSchema: z.object({
289
289
  action: z
290
290
  .enum([
@@ -350,6 +350,16 @@ export function createBridgeMcpServer(options = {}) {
350
350
  .describe(
351
351
  'Return semantically interactive AX nodes; actionable state is reported separately'
352
352
  ),
353
+ source: z
354
+ .enum(['dom', 'cdp'])
355
+ .optional()
356
+ .describe(
357
+ 'accessibility_tree source: dom = actionable outline with refs, no debugger (recommended); cdp = Chrome AX tree (default)'
358
+ ),
359
+ format: z
360
+ .enum(['outline', 'tree'])
361
+ .optional()
362
+ .describe('accessibility_tree format (outline is default for source dom)'),
353
363
  textBudget: z
354
364
  .number()
355
365
  .int()
@@ -381,6 +391,10 @@ export function createBridgeMcpServer(options = {}) {
381
391
  .describe('Maximum search results (default: 10)'),
382
392
  role: z.string().optional().describe('ARIA role to search for (for find_role action)'),
383
393
  name: z.string().optional().describe('Accessible name to match with role'),
394
+ includeHidden: z
395
+ .boolean()
396
+ .optional()
397
+ .describe('Include hidden matches in find_text/find_role (default: false; counted)'),
384
398
  state: z
385
399
  .enum(['attached', 'detached', 'visible', 'hidden'])
386
400
  .optional()
@@ -658,7 +672,7 @@ export function createBridgeMcpServer(options = {}) {
658
672
  {
659
673
  title: 'Browser Input',
660
674
  description:
661
- 'Dispatch browser input. Targeted click, focus, type, fill, press_key, set_checked, select_option, hover, and drag actions perform actionability checks and report resolution/execution metadata. cdp_press_key and scroll_into_view use separate contracts. Reuse elementRef values and verify application state after mutations.',
675
+ 'Dispatch browser input. Target by elementRef, selector, or a semantic locator ({role,name} / text / label / placeholder / testId) in one call. Targeted actions auto-wait for the element to become actionable (timeoutMs) and click/press_key/set_checked/select_option/submits return observed effects (URL change, dialogs, alerts, focus, DOM churn, target state), so a follow-up read is usually unnecessary. cdp_press_key and scroll_into_view use separate contracts.',
662
676
  inputSchema: z.object({
663
677
  action: z
664
678
  .enum([
@@ -707,7 +721,7 @@ export function createBridgeMcpServer(options = {}) {
707
721
  .enum(['dom', 'cdp'])
708
722
  .optional()
709
723
  .describe(
710
- 'Input execution path (default: dom; CDP supports click, hover, drag, type, fill)'
724
+ 'Input execution path (default: dom; CDP supports click, hover, drag, type, fill, press_key). Use cdp when the page needs trusted input, e.g. to start audio or media.'
711
725
  ),
712
726
  recoverStale: z
713
727
  .boolean()
@@ -729,6 +743,15 @@ export function createBridgeMcpServer(options = {}) {
729
743
  .array(z.enum(['Alt', 'Control', 'Meta', 'Shift']))
730
744
  .optional()
731
745
  .describe('Modifier keys'),
746
+ holdMs: z
747
+ .number()
748
+ .int()
749
+ .min(0)
750
+ .max(10000)
751
+ .optional()
752
+ .describe(
753
+ 'Keep the mouse button or key pressed this long (click, press_key; default: 0)'
754
+ ),
732
755
  checked: z.boolean().optional().describe('Checked state (for set_checked action)'),
733
756
  values: z.array(z.string()).optional().describe('Option values to select'),
734
757
  labels: z
@@ -760,6 +783,39 @@ export function createBridgeMcpServer(options = {}) {
760
783
  .describe('Drag destination selector (alternative to destinationElementRef)'),
761
784
  offsetX: z.number().optional().describe('Drag drop offset X (default: 0)'),
762
785
  offsetY: z.number().optional().describe('Drag drop offset Y (default: 0)'),
786
+ locator: z
787
+ .object({
788
+ role: z.string().max(500).optional().describe('ARIA role, e.g. button, textbox, link'),
789
+ name: z.string().max(500).optional().describe('Accessible name (labels, aria-*, text)'),
790
+ text: z.string().max(500).optional().describe('Visible text of the element'),
791
+ label: z.string().max(500).optional().describe('Form control label text'),
792
+ placeholder: z.string().max(500).optional(),
793
+ testId: z.string().max(500).optional().describe('data-testid / data-test / data-qa'),
794
+ exact: z.boolean().optional().describe('Exact instead of substring match'),
795
+ nth: z.number().int().min(0).max(49).optional().describe('Pick the nth visible match'),
796
+ })
797
+ .optional()
798
+ .describe(
799
+ 'Find the target semantically in the same call (instead of elementRef/selector); selector narrows candidates'
800
+ ),
801
+ timeoutMs: z
802
+ .number()
803
+ .int()
804
+ .min(0)
805
+ .max(15000)
806
+ .optional()
807
+ .describe(
808
+ 'Wait for the target to appear and become actionable (default: 2500; 0 = no wait)'
809
+ ),
810
+ observe: z
811
+ .union([
812
+ z.boolean(),
813
+ z.object({ settleMs: z.number().int().min(0).max(5000).optional() }),
814
+ ])
815
+ .optional()
816
+ .describe(
817
+ 'Report effects (URL, dialogs, alerts, focus, DOM churn, target state) after the action; default on for click, press_key, set_checked, select_option, and submits'
818
+ ),
763
819
  }),
764
820
  },
765
821
  handleInputTool
@@ -45,6 +45,22 @@ export const MAX_BATCH_CALLS = 20;
45
45
 
46
46
  /** Maximum read-only batch calls executing concurrently. */
47
47
  export const MAX_BATCH_CONCURRENCY = 5;
48
+
49
+ /** Maximum press-and-hold time for one pointer, key, or touch action (ms). */
50
+ export const MAX_INPUT_HOLD_MS = 10_000;
51
+
52
+ /** Default contact time for an input.touch gesture without explicit holdMs (ms). */
53
+ export const DEFAULT_TOUCH_HOLD_MS = 50;
54
+
55
+ /** Maximum simultaneous touch points in one input.touch gesture. */
56
+ export const MAX_TOUCH_POINTS = 10;
57
+
58
+ /** Maximum steps accepted by one input.perform sequence. */
59
+ export const MAX_PERFORM_STEPS = 200;
60
+
61
+ /** Default and maximum total budget for one input.perform sequence (ms). */
62
+ export const DEFAULT_PERFORM_TIMEOUT_MS = 30_000;
63
+ export const MAX_PERFORM_TIMEOUT_MS = 120_000;
48
64
  export const MAX_INTERCEPT_RULES_PER_TAB = 32;
49
65
  export const MAX_INTERCEPT_URL_PATTERN_LENGTH = 2_048;
50
66
  export const MAX_INTERCEPT_BODY_BYTES = 262_144;