@browserbridge/bbx 1.7.8 → 1.8.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 (32) hide show
  1. package/PRIVACY.md +30 -11
  2. package/README.md +9 -5
  3. package/package.json +9 -6
  4. package/packages/agent-client/src/cli-args.js +24 -0
  5. package/packages/agent-client/src/cli.js +46 -52
  6. package/packages/agent-client/src/client.js +140 -5
  7. package/packages/agent-client/src/command-registry.js +56 -53
  8. package/packages/agent-client/src/content-extract.js +198 -0
  9. package/packages/agent-client/src/remotes.js +1 -1
  10. package/packages/agent-client/src/runtime.js +768 -35
  11. package/packages/agent-client/src/types.ts +131 -6
  12. package/packages/mcp-server/src/guidance.js +1 -1
  13. package/packages/mcp-server/src/handlers-capture.js +41 -5
  14. package/packages/mcp-server/src/handlers-dom.js +8 -2
  15. package/packages/mcp-server/src/handlers-navigation.js +2 -1
  16. package/packages/mcp-server/src/handlers-page.js +41 -4
  17. package/packages/mcp-server/src/handlers-utils.js +67 -7
  18. package/packages/mcp-server/src/handlers.js +4 -4
  19. package/packages/mcp-server/src/server.js +102 -6
  20. package/packages/native-host/src/daemon.js +158 -6
  21. package/packages/native-host/src/native-host.js +3 -1
  22. package/packages/protocol/src/defaults.js +3 -0
  23. package/packages/protocol/src/errors.js +54 -1
  24. package/packages/protocol/src/protocol.js +297 -1
  25. package/packages/protocol/src/registry.js +66 -35
  26. package/packages/protocol/src/summary.js +87 -5
  27. package/packages/protocol/src/types.ts +234 -0
  28. package/skills/browser-bridge/SKILL.md +34 -13
  29. package/skills/browser-bridge/references/interaction.md +55 -11
  30. package/skills/browser-bridge/references/patch-workflow.md +2 -0
  31. package/skills/browser-bridge/references/protocol.md +157 -82
  32. package/skills/browser-bridge/references/token-efficiency.md +19 -13
@@ -30,103 +30,106 @@ function createShortcutCommand(method, usage, build, options = {}) {
30
30
  };
31
31
  }
32
32
 
33
+ /**
34
+ * Preserve explicit refs and pass selectors directly to methods that support
35
+ * atomic target resolution.
36
+ *
37
+ * @param {string | undefined} value
38
+ * @returns {{ elementRef: string } | { selector: string }}
39
+ */
40
+ function createAtomicTarget(value) {
41
+ if (!value) throw new Error('A ref or selector is required.');
42
+ return value.startsWith('el_') ? { elementRef: value } : { selector: value };
43
+ }
44
+
33
45
  /** @type {Record<string, ShortcutCommand>} */
34
46
  export const SHORTCUT_COMMANDS = {
35
- 'access-request': createShortcutCommand('access.request', 'bbx access-request', () => ({})),
47
+ 'access-request': createShortcutCommand('access.request', 'bbx access-request [intent]', (r) =>
48
+ r[0] ? { intent: r[0] } : {}
49
+ ),
36
50
  'dom-query': createShortcutCommand('dom.query', 'bbx dom-query [selector]', (r) => ({
37
51
  selector: r[0] || 'body',
38
52
  })),
39
53
  describe: createShortcutCommand(
40
54
  'dom.describe',
41
55
  'bbx describe <ref|selector>',
42
- (_r, ref) => ({ elementRef: ref }),
43
- { resolve: true, printMethod: 'dom.describe' }
56
+ (r) => ({ target: createAtomicTarget(r[0]) }),
57
+ { printMethod: 'dom.describe' }
44
58
  ),
45
59
  text: createShortcutCommand(
46
60
  'dom.get_text',
47
61
  'bbx text <ref|selector> [budget]',
48
- (r, ref) => ({
49
- elementRef: ref,
62
+ (r) => ({
63
+ target: createAtomicTarget(r[0]),
50
64
  textBudget: r[1] ? parseIntArg(r[1], 'budget') : undefined,
51
65
  }),
52
- { resolve: true, printMethod: 'dom.get_text' }
66
+ { printMethod: 'dom.get_text' }
53
67
  ),
54
68
  styles: createShortcutCommand(
55
69
  'styles.get_computed',
56
70
  'bbx styles <ref|selector> [props]',
57
- (r, ref) => ({ elementRef: ref, properties: parseCommaList(r[1]) }),
58
- { resolve: true, printMethod: 'styles.get_computed' }
71
+ (r) => ({ target: createAtomicTarget(r[0]), properties: parseCommaList(r[1]) }),
72
+ { printMethod: 'styles.get_computed' }
59
73
  ),
60
74
  box: createShortcutCommand(
61
75
  'layout.get_box_model',
62
76
  'bbx box <ref|selector>',
63
- (_r, ref) => ({ elementRef: ref }),
64
- { resolve: true, printMethod: 'layout.get_box_model' }
65
- ),
66
- click: createShortcutCommand(
67
- 'input.click',
68
- 'bbx click <ref|selector> [button]',
69
- (r, ref) => ({ target: { elementRef: ref }, button: r[1] }),
70
- { resolve: true }
71
- ),
72
- focus: createShortcutCommand(
73
- 'input.focus',
74
- 'bbx focus <ref|selector>',
75
- (_r, ref) => ({ target: { elementRef: ref } }),
76
- { resolve: true }
77
- ),
78
- type: createShortcutCommand(
79
- 'input.type',
80
- 'bbx type <ref|selector> <text...>',
81
- (r, ref) => ({ target: { elementRef: ref }, text: r.slice(1).join(' ') }),
82
- { resolve: true }
77
+ (r) => ({ target: createAtomicTarget(r[0]) }),
78
+ { printMethod: 'layout.get_box_model' }
83
79
  ),
80
+ click: createShortcutCommand('input.click', 'bbx click <ref|selector> [button]', (r) => ({
81
+ target: createAtomicTarget(r[0]),
82
+ button: r[1],
83
+ })),
84
+ focus: createShortcutCommand('input.focus', 'bbx focus <ref|selector>', (r) => ({
85
+ target: createAtomicTarget(r[0]),
86
+ })),
87
+ type: createShortcutCommand('input.type', 'bbx type <ref|selector> <text...>', (r) => ({
88
+ target: createAtomicTarget(r[0]),
89
+ text: r.slice(1).join(' '),
90
+ })),
84
91
  fill: createShortcutCommand(
85
92
  'input.fill',
86
93
  'bbx fill <ref|selector> <value>',
87
- (r, ref) => ({
88
- target: { elementRef: ref },
94
+ (r) => ({
95
+ target: createAtomicTarget(r[0]),
89
96
  value: r.slice(1).join(' '),
90
97
  mode: 'auto',
91
98
  }),
92
99
  {
93
- resolve: true,
94
100
  description: 'Set input/textarea value (React/Vue/Angular-safe, auto fallback to keystrokes)',
95
101
  }
96
102
  ),
97
- hover: createShortcutCommand(
98
- 'input.hover',
99
- 'bbx hover <ref|selector>',
100
- (_r, ref) => ({ target: { elementRef: ref } }),
101
- { resolve: true }
102
- ),
103
+ hover: createShortcutCommand('input.hover', 'bbx hover <ref|selector>', (r) => ({
104
+ target: createAtomicTarget(r[0]),
105
+ })),
103
106
  html: createShortcutCommand(
104
107
  'dom.get_html',
105
108
  'bbx html <ref|selector> [maxLen]',
106
- (r, ref) => ({
107
- elementRef: ref,
109
+ (r) => ({
110
+ target: createAtomicTarget(r[0]),
108
111
  maxLength: r[1] ? parseIntArg(r[1], 'maxLen') : undefined,
109
112
  }),
110
- { resolve: true }
113
+ {}
111
114
  ),
112
115
  'patch-style': createShortcutCommand(
113
116
  'patch.apply_styles',
114
117
  'bbx patch-style <ref|sel> prop=val',
115
- (r, ref) => ({
116
- target: { elementRef: ref },
118
+ (r) => ({
119
+ target: createAtomicTarget(r[0]),
117
120
  declarations: parsePropertyAssignments(r.slice(1)),
118
121
  }),
119
- { resolve: true }
122
+ {}
120
123
  ),
121
124
  'patch-text': createShortcutCommand(
122
125
  'patch.apply_dom',
123
126
  'bbx patch-text <ref|sel> <text...>',
124
- (r, ref) => ({
125
- target: { elementRef: ref },
127
+ (r) => ({
128
+ target: createAtomicTarget(r[0]),
126
129
  operation: 'set_text',
127
130
  value: r.slice(1).join(' '),
128
131
  }),
129
- { resolve: true, description: 'Apply a reversible DOM text patch' }
132
+ { description: 'Apply a reversible DOM text patch' }
130
133
  ),
131
134
  patches: createShortcutCommand('patch.list', 'bbx patches', () => ({}), {
132
135
  printMethod: 'patch.list',
@@ -222,14 +225,14 @@ export const SHORTCUT_COMMANDS = {
222
225
  attrs: createShortcutCommand(
223
226
  'dom.get_attributes',
224
227
  'bbx attrs <ref|selector> [attr1,...]',
225
- (r, ref) => ({ elementRef: ref, attributes: parseCommaList(r[1]) }),
226
- { resolve: true, printMethod: 'dom.get_attributes' }
228
+ (r) => ({ target: createAtomicTarget(r[0]), attributes: parseCommaList(r[1]) }),
229
+ { printMethod: 'dom.get_attributes' }
227
230
  ),
228
231
  'matched-rules': createShortcutCommand(
229
232
  'styles.get_matched_rules',
230
233
  'bbx matched-rules <ref|selector>',
231
- (_r, ref) => ({ elementRef: ref }),
232
- { resolve: true, printMethod: 'styles.get_matched_rules' }
234
+ (r) => ({ target: createAtomicTarget(r[0]) }),
235
+ { printMethod: 'styles.get_matched_rules' }
233
236
  ),
234
237
  };
235
238
 
@@ -260,7 +263,7 @@ export const CLI_HELP_SECTIONS = Object.freeze([
260
263
  'bbx install-skill [targets|all] [--global] [--project <path>] Install/update the managed Browser Bridge CLI skill',
261
264
  'bbx install-mcp [client|all] [--local] Write MCP config for codex|claude|cursor|copilot|opencode|antigravity|windsurf|agents',
262
265
  'bbx status Check bridge connection',
263
- 'bbx doctor Diagnose install, daemon, extension, and access readiness',
266
+ 'bbx doctor Consolidated local runtime, setup, protocol, and readiness diagnostics',
264
267
  'bbx restart Restart the local daemon and running MCP servers',
265
268
  'bbx proxy enable [--port 9223] [--bind-host 127.0.0.1] [--rotate-token] Enable loopback TCP proxy mode for SSH tunneling',
266
269
  ' Non-loopback binds require --unsafe-plaintext because raw TCP is unencrypted.',
@@ -270,7 +273,7 @@ export const CLI_HELP_SECTIONS = Object.freeze([
270
273
  'bbx remote list List remote destinations',
271
274
  'bbx remote test <name> Ping a remote destination',
272
275
  'bbx remote remove <name> Remove a remote destination',
273
- 'bbx access-request Request Browser Bridge access for the focused window',
276
+ 'bbx access-request [intent] Request access with inspect|interact|capture|navigate|debugger|general intent',
274
277
  'bbx logs Recent bridge logs',
275
278
  'bbx tabs List available tabs',
276
279
  'bbx tab-create [url] Create a new tab',
@@ -372,7 +375,7 @@ export const CLI_HELP_SECTIONS = Object.freeze([
372
375
  {
373
376
  title: 'Capture',
374
377
  lines: [
375
- 'bbx screenshot [--tab <tabId>] <ref|selector> [path] Capture partial element screenshot',
378
+ 'bbx screenshot [--tab <tabId>] [--format png|jpeg|webp] [--quality 0-100] <ref|selector> [path]',
376
379
  ],
377
380
  },
378
381
  ]);
@@ -0,0 +1,198 @@
1
+ // @ts-check
2
+
3
+ import { Readability } from '@mozilla/readability';
4
+ import { parseHTML } from 'linkedom';
5
+
6
+ /** @typedef {import('../../protocol/src/types.js').ExtractContentResult} ExtractContentResult */
7
+ /** @typedef {import('../../protocol/src/types.js').NormalizedExtractContentParams} ExtractContentParams */
8
+
9
+ const METADATA_LIMITS = Object.freeze({ title: 300, byline: 200, excerpt: 500, siteName: 200 });
10
+ const EXCLUDED_SELECTOR =
11
+ 'script,style,noscript,template,input,textarea,select,option,button,[hidden],[aria-hidden="true"]';
12
+
13
+ /** @param {string | null | undefined} value @param {number} limit @returns {string | undefined} */
14
+ function boundedText(value, limit) {
15
+ const normalized = String(value ?? '')
16
+ .replace(/\s+/g, ' ')
17
+ .trim();
18
+ return normalized ? normalized.slice(0, limit) : undefined;
19
+ }
20
+
21
+ /** @param {ParentNode} root @returns {void} */
22
+ function sanitize(root) {
23
+ for (const element of root.querySelectorAll(EXCLUDED_SELECTOR)) element.remove();
24
+ for (const element of root.querySelectorAll('[style]')) {
25
+ const style = element.getAttribute('style')?.toLowerCase() ?? '';
26
+ if (/display\s*:\s*none|visibility\s*:\s*hidden/.test(style)) element.remove();
27
+ }
28
+ }
29
+
30
+ /** @param {Element} element @returns {number} */
31
+ function meaningfulLength(element) {
32
+ return (element.textContent ?? '').replace(/\s+/g, ' ').trim().length;
33
+ }
34
+
35
+ /** @param {ParentNode} root @returns {Element | null} */
36
+ function largestSemanticRoot(root) {
37
+ let best = null;
38
+ let bestLength = 0;
39
+ const candidates = [];
40
+ if (
41
+ 'matches' in root &&
42
+ typeof (/** @type {{ matches?: unknown }} */ (root).matches) === 'function' &&
43
+ /** @type {Element} */ (root).matches('article,main,[role="main"]')
44
+ ) {
45
+ candidates.push(/** @type {Element} */ (root));
46
+ }
47
+ candidates.push(...root.querySelectorAll('article,main,[role="main"]'));
48
+ for (const candidate of candidates) {
49
+ const length = meaningfulLength(candidate);
50
+ if (length > bestLength) {
51
+ best = candidate;
52
+ bestLength = length;
53
+ }
54
+ }
55
+ return best;
56
+ }
57
+
58
+ /** @param {Node} node @returns {string} */
59
+ function markdownForNode(node) {
60
+ if (node.nodeType === 3) return (node.textContent ?? '').replace(/\s+/g, ' ');
61
+ if (node.nodeType !== 1) return '';
62
+ const element = /** @type {Element} */ (node);
63
+ const tag = element.tagName.toLowerCase();
64
+ const children = () => Array.from(element.childNodes, markdownForNode).join('');
65
+ if (/^h[1-6]$/.test(tag)) return `\n${'#'.repeat(Number(tag[1]))} ${children().trim()}\n\n`;
66
+ if (['p', 'div', 'section', 'article', 'main'].includes(tag)) {
67
+ return `\n${children().trim()}\n\n`;
68
+ }
69
+ if (tag === 'br') return '\n';
70
+ if (tag === 'hr') return '\n---\n';
71
+ if (tag === 'strong' || tag === 'b') return `**${children().trim()}**`;
72
+ if (tag === 'em' || tag === 'i') return `*${children().trim()}*`;
73
+ if (tag === 'code' && element.parentElement?.tagName.toLowerCase() !== 'pre') {
74
+ return `\`${(element.textContent ?? '').replace(/`/g, '\\`')}\``;
75
+ }
76
+ if (tag === 'pre') return `\n\`\`\`\n${element.textContent ?? ''}\n\`\`\`\n\n`;
77
+ if (tag === 'blockquote') {
78
+ return `\n${children()
79
+ .trim()
80
+ .split('\n')
81
+ .map((line) => `> ${line}`)
82
+ .join('\n')}\n\n`;
83
+ }
84
+ if (tag === 'li') {
85
+ const marker =
86
+ element.parentElement?.tagName.toLowerCase() === 'ol'
87
+ ? `${Array.from(element.parentElement.children).indexOf(element) + 1}.`
88
+ : '-';
89
+ return `\n${marker} ${children().trim()}`;
90
+ }
91
+ if (tag === 'ul' || tag === 'ol') return `${children()}\n\n`;
92
+ if (tag === 'a') {
93
+ const label = children().trim();
94
+ const href = element.getAttribute('href')?.trim();
95
+ return href && !/^(?:javascript|data):/i.test(href) ? `[${label}](${href})` : label;
96
+ }
97
+ return children();
98
+ }
99
+
100
+ /** @param {Element} root @param {'text' | 'markdown'} format @returns {string} */
101
+ function formatRoot(root, format) {
102
+ if (format === 'markdown') {
103
+ return markdownForNode(root)
104
+ .replace(/[ \t]+\n/g, '\n')
105
+ .replace(/\n{3,}/g, '\n\n')
106
+ .trim();
107
+ }
108
+ return (root.textContent ?? '')
109
+ .replace(/[ \t]+/g, ' ')
110
+ .replace(/\n{3,}/g, '\n\n')
111
+ .trim();
112
+ }
113
+
114
+ /** @param {string} content @param {number} budget @param {'text' | 'markdown'} format */
115
+ function truncateContent(content, budget, format) {
116
+ if (content.length <= budget) return { content, truncated: false, omitted: 0 };
117
+ const boundary = Math.max(content.lastIndexOf('\n\n', budget), content.lastIndexOf(' ', budget));
118
+ const end = boundary >= Math.floor(budget * 0.7) ? boundary : budget;
119
+ let value = content.slice(0, end).trimEnd();
120
+ if (format === 'markdown' && (value.match(/```/g)?.length ?? 0) % 2 === 1) {
121
+ value = `${value.slice(0, Math.max(0, budget - 4)).trimEnd()}\n\`\`\``;
122
+ }
123
+ return { content: value, truncated: true, omitted: content.length - value.length };
124
+ }
125
+
126
+ /**
127
+ * Convert one bounded browser HTML snapshot into semantic content in Node.
128
+ * Raw HTML is consumed here and never included in the returned result.
129
+ *
130
+ * @param {string} html
131
+ * @param {ExtractContentParams} params
132
+ * @param {{ title?: string, settlement?: ExtractContentResult['settlement'] }} [context]
133
+ * @returns {ExtractContentResult}
134
+ */
135
+ export function extractContentFromHtml(html, params, context = {}) {
136
+ const escapedTitle = String(context.title ?? '').replace(/[&<>]/g, (character) => {
137
+ if (character === '&') return '&amp;';
138
+ if (character === '<') return '&lt;';
139
+ return '&gt;';
140
+ });
141
+ const { document } = parseHTML(
142
+ `<!doctype html><html><head><title>${escapedTitle}</title></head><body>${html}</body></html>`
143
+ );
144
+ sanitize(document);
145
+ const semanticRoot = largestSemanticRoot(document.body);
146
+ const semanticLength = semanticRoot ? meaningfulLength(semanticRoot) : 0;
147
+ let readability = null;
148
+ try {
149
+ readability = new Readability(/** @type {Document} */ (document.cloneNode(true))).parse();
150
+ } catch {
151
+ readability = null;
152
+ }
153
+
154
+ /** @type {Element} */
155
+ let resultRoot;
156
+ /** @type {import('../../protocol/src/types.js').ExtractContentSource} */
157
+ let source;
158
+ if (readability && (readability.length ?? 0) >= Math.max(200, Math.floor(semanticLength * 0.2))) {
159
+ const template = document.createElement('template');
160
+ template.innerHTML = readability.content ?? '';
161
+ sanitize(template.content);
162
+ const wrapper = document.createElement('article');
163
+ wrapper.append(template.content.cloneNode(true));
164
+ resultRoot = wrapper;
165
+ source = 'readability';
166
+ } else if (semanticRoot && semanticLength > 0) {
167
+ resultRoot = semanticRoot;
168
+ source = 'semantic-root';
169
+ } else {
170
+ resultRoot = document.body;
171
+ source = 'body';
172
+ }
173
+
174
+ const formatted = formatRoot(resultRoot, params.format);
175
+ const bounded = truncateContent(formatted, params.textBudget, params.format);
176
+ const metadata = params.includeMetadata
177
+ ? {
178
+ title: boundedText(readability?.title ?? context.title, METADATA_LIMITS.title),
179
+ byline: boundedText(readability?.byline, METADATA_LIMITS.byline),
180
+ excerpt: boundedText(readability?.excerpt, METADATA_LIMITS.excerpt),
181
+ siteName: boundedText(readability?.siteName, METADATA_LIMITS.siteName),
182
+ }
183
+ : {};
184
+ return {
185
+ format: params.format,
186
+ content: bounded.content,
187
+ ...metadata,
188
+ source,
189
+ root: {
190
+ tag: resultRoot.tagName.toLowerCase(),
191
+ ...(params.selector ? { selector: params.selector } : {}),
192
+ },
193
+ length: formatted.length,
194
+ truncated: bounded.truncated,
195
+ omitted: bounded.omitted,
196
+ ...(context.settlement ? { settlement: context.settlement } : {}),
197
+ };
198
+ }
@@ -287,7 +287,7 @@ function validateAuthToken(token) {
287
287
 
288
288
  /**
289
289
  * @param {string | null | undefined} destinationId
290
- * @param {{ defaultTimeoutMs?: number }} [options={}]
290
+ * @param {{ defaultTimeoutMs?: number, checkProtocolOnConnect?: boolean }} [options={}]
291
291
  * @returns {Promise<BridgeClient>}
292
292
  */
293
293
  export async function createBridgeClientForDestination(destinationId, options = {}) {