@browserbridge/bbx 1.1.0 → 1.2.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.1.0",
3
+ "version": "1.2.0",
4
4
  "private": false,
5
5
  "keywords": [
6
6
  "agent-tools",
@@ -613,16 +613,18 @@ async function main() {
613
613
  }
614
614
 
615
615
  if (command === 'screenshot') {
616
- const [refOrSelector, outputPath] = rest;
617
- if (!refOrSelector) throw new Error('Usage: screenshot <ref|selector> [path]');
618
- const elementRef = await resolveRef(client, refOrSelector, null, REQUEST_SOURCE);
616
+ const parsed = extractTabFlag(rest);
617
+ const [refOrSelector, outputPath] = parsed.rest;
618
+ if (!refOrSelector)
619
+ throw new Error('Usage: screenshot [--tab <tabId>] <ref|selector> [path]');
620
+ const elementRef = await resolveRef(client, refOrSelector, parsed.tabId, REQUEST_SOURCE);
619
621
  const response = await requestBridge(
620
622
  client,
621
623
  'screenshot.capture_element',
622
624
  {
623
625
  elementRef,
624
626
  },
625
- { source: REQUEST_SOURCE }
627
+ { tabId: parsed.tabId, source: REQUEST_SOURCE }
626
628
  );
627
629
  if (!response.ok) {
628
630
  await printSummary(response);
@@ -15,6 +15,7 @@ import {
15
15
  getBridgeTransport,
16
16
  getSocketPath,
17
17
  } from '../../native-host/src/config.js';
18
+ import { restartBridgeDaemon } from '../../native-host/src/daemon-process.js';
18
19
 
19
20
  /** @typedef {import('../../native-host/src/config.js').BridgeTransport} BridgeTransport */
20
21
 
@@ -23,7 +24,9 @@ import {
23
24
  /** @typedef {import('../../protocol/src/types.js').BridgeMethod} BridgeMethod */
24
25
  /**
25
26
  * @typedef {{
27
+ * extensionConnected?: boolean,
26
28
  * supported_versions?: string[],
29
+ * daemon_supported_versions?: string[],
27
30
  * deprecated_since?: string,
28
31
  * migration_hint?: string
29
32
  * }} ProtocolHealthResult
@@ -55,9 +58,29 @@ import {
55
58
  * clientId?: string,
56
59
  * defaultTimeoutMs?: number,
57
60
  * autoReconnect?: boolean,
61
+ * restartDaemonOnVersionMismatch?: boolean,
62
+ * restartDaemonFn?: typeof restartBridgeDaemon,
58
63
  * }} BridgeClientOptions
59
64
  */
60
65
 
66
+ /**
67
+ * @param {string} left
68
+ * @param {string} right
69
+ * @returns {number}
70
+ */
71
+ function compareProtocolVersions(left, right) {
72
+ const leftParts = left.split('.').map((part) => Number(part) || 0);
73
+ const rightParts = right.split('.').map((part) => Number(part) || 0);
74
+ const length = Math.max(leftParts.length, rightParts.length);
75
+ for (let index = 0; index < length; index += 1) {
76
+ const delta = (leftParts[index] || 0) - (rightParts[index] || 0);
77
+ if (delta !== 0) {
78
+ return delta > 0 ? 1 : -1;
79
+ }
80
+ }
81
+ return 0;
82
+ }
83
+
61
84
  /**
62
85
  * @param {string} method
63
86
  * @param {number} timeoutMs
@@ -81,6 +104,8 @@ export class BridgeClient extends EventEmitter {
81
104
  clientId = `agent_${randomUUID()}`,
82
105
  defaultTimeoutMs = DEFAULT_CLIENT_REQUEST_TIMEOUT_MS,
83
106
  autoReconnect = false,
107
+ restartDaemonOnVersionMismatch = true,
108
+ restartDaemonFn = restartBridgeDaemon,
84
109
  } = {}) {
85
110
  super();
86
111
  this.transport = socketPath ? createSocketBridgeTransport(socketPath) : transport;
@@ -89,6 +114,8 @@ export class BridgeClient extends EventEmitter {
89
114
  this.clientId = clientId;
90
115
  this.defaultTimeoutMs = defaultTimeoutMs;
91
116
  this.autoReconnect = autoReconnect;
117
+ this.restartDaemonOnVersionMismatch = restartDaemonOnVersionMismatch;
118
+ this.restartDaemonFn = restartDaemonFn;
92
119
  this.socket = null;
93
120
  this.connected = false;
94
121
  this.protocolCompatibility = null;
@@ -96,6 +123,7 @@ export class BridgeClient extends EventEmitter {
96
123
  /** @type {Map<string, PendingRequest>} */
97
124
  this.waiting = new Map();
98
125
  this._reconnecting = false;
126
+ this._attemptedVersionMismatchRestart = false;
99
127
  }
100
128
 
101
129
  /**
@@ -173,19 +201,38 @@ export class BridgeClient extends EventEmitter {
173
201
  });
174
202
  });
175
203
 
204
+ this.protocolCompatibility = null;
205
+ this.protocolWarning = null;
206
+
207
+ /** @type {ProtocolHealthResult | null} */
208
+ let healthResult = null;
176
209
  try {
177
210
  const healthResponse = await this.request({
178
211
  method: 'health.ping',
179
212
  });
180
213
  if (healthResponse.ok) {
181
- this.protocolCompatibility = BridgeClient.checkProtocolVersion(
182
- /** @type {ProtocolHealthResult} */ (healthResponse.result)
183
- );
184
- this.protocolWarning = this.protocolCompatibility.warning ?? null;
214
+ healthResult = /** @type {ProtocolHealthResult} */ (healthResponse.result);
185
215
  }
186
216
  } catch {
187
217
  this.protocolCompatibility = null;
188
218
  this.protocolWarning = null;
219
+ return;
220
+ }
221
+
222
+ if (!healthResult) {
223
+ return;
224
+ }
225
+
226
+ this.protocolCompatibility = BridgeClient.checkProtocolVersion(healthResult);
227
+ this.protocolWarning = this.protocolCompatibility.warning ?? null;
228
+ if (this.protocolCompatibility.compatible) {
229
+ this._attemptedVersionMismatchRestart = false;
230
+ }
231
+ if (this.shouldRestartDaemonForProtocolMismatch(healthResult)) {
232
+ this._attemptedVersionMismatchRestart = true;
233
+ await this.disconnectForDaemonRestart();
234
+ await this.restartDaemonFn({ transport: this.transport });
235
+ await this.connect();
189
236
  }
190
237
  }
191
238
 
@@ -305,6 +352,60 @@ export class BridgeClient extends EventEmitter {
305
352
  this._reconnecting = false;
306
353
  }
307
354
 
355
+ /**
356
+ * @param {ProtocolHealthResult} healthResult
357
+ * @returns {boolean}
358
+ */
359
+ shouldRestartDaemonForProtocolMismatch(healthResult) {
360
+ if (
361
+ !this.restartDaemonOnVersionMismatch ||
362
+ this._attemptedVersionMismatchRestart ||
363
+ !this.protocolCompatibility ||
364
+ this.protocolCompatibility.compatible
365
+ ) {
366
+ return false;
367
+ }
368
+
369
+ const remoteVersions = Array.isArray(healthResult?.daemon_supported_versions)
370
+ ? healthResult.daemon_supported_versions
371
+ : healthResult?.extensionConnected === true
372
+ ? []
373
+ : Array.isArray(healthResult?.supported_versions)
374
+ ? healthResult.supported_versions
375
+ : [];
376
+ const latestRemote = remoteVersions[0];
377
+ return (
378
+ typeof latestRemote === 'string' &&
379
+ compareProtocolVersions(latestRemote, PROTOCOL_VERSION) < 0
380
+ );
381
+ }
382
+
383
+ /**
384
+ * Drop the current socket before forcing a daemon restart so the next
385
+ * connect() call observes a fresh local process rather than the existing one.
386
+ *
387
+ * @returns {Promise<void>}
388
+ */
389
+ async disconnectForDaemonRestart() {
390
+ const socket = this.socket;
391
+ if (!socket) {
392
+ return;
393
+ }
394
+
395
+ const previousAutoReconnect = this.autoReconnect;
396
+ this.autoReconnect = false;
397
+ this.connected = false;
398
+ this.socket = null;
399
+
400
+ if (!socket.destroyed) {
401
+ const closed = once(socket, 'close').catch(() => {});
402
+ socket.destroy();
403
+ await closed;
404
+ }
405
+
406
+ this.autoReconnect = previousAutoReconnect;
407
+ }
408
+
308
409
  /**
309
410
  * Check whether the remote side supports our protocol version.
310
411
  * Call after a successful health.ping to get early warnings about version drift.
@@ -347,7 +347,7 @@ export const CLI_HELP_SECTIONS = Object.freeze([
347
347
  {
348
348
  title: 'Capture',
349
349
  lines: [
350
- 'bbx screenshot <ref|selector> [path] Capture partial element screenshot',
350
+ 'bbx screenshot [--tab <tabId>] <ref|selector> [path] Capture partial element screenshot',
351
351
  ],
352
352
  },
353
353
  ]);
@@ -0,0 +1,279 @@
1
+ // @ts-check
2
+
3
+ import {
4
+ dispatchToolAction,
5
+ getToolTokenBudget,
6
+ REQUEST_SOURCE,
7
+ requestBridge,
8
+ resolveToolRef,
9
+ resolveRef,
10
+ summarizeToolError,
11
+ summarizeToolResponse,
12
+ withToolClient,
13
+ } from './handlers-utils.js';
14
+
15
+ /** @typedef {import('../../protocol/src/types.js').BridgeMethod} BridgeMethod */
16
+ /** @typedef {import('./handlers-utils.js').ToolAction} ToolAction */
17
+ /** @typedef {import('./handlers-utils.js').ToolResult} ToolResult */
18
+
19
+ /** @type {Record<string, ToolAction>} */
20
+ export const CAPTURE_ACTIONS = {
21
+ element: {
22
+ ref: true,
23
+ method: 'screenshot.capture_element',
24
+ params: (_, r) => ({ elementRef: r }),
25
+ },
26
+ region: {
27
+ ref: false,
28
+ method: 'screenshot.capture_region',
29
+ params: (a) => /** @type {Record<string, unknown>} */ (a.rect || {}),
30
+ },
31
+ full_page: {
32
+ ref: false,
33
+ method: 'screenshot.capture_full_page',
34
+ params: () => ({}),
35
+ },
36
+ cdp_document: { ref: false, method: 'cdp.get_document', params: () => ({}) },
37
+ cdp_dom_snapshot: {
38
+ ref: false,
39
+ method: 'cdp.get_dom_snapshot',
40
+ params: () => ({}),
41
+ },
42
+ cdp_box_model: {
43
+ ref: true,
44
+ method: 'cdp.get_box_model',
45
+ params: (_, r) => ({ elementRef: r }),
46
+ },
47
+ cdp_computed_styles: {
48
+ ref: true,
49
+ method: 'cdp.get_computed_styles_for_node',
50
+ params: (_, r) => ({ elementRef: r }),
51
+ },
52
+ };
53
+
54
+ /**
55
+ * @param {{ action: string, elementRef?: string, selector?: string, rect?: Record<string, unknown>, tabId?: number, budgetPreset?: 'quick' | 'normal' | 'deep' }} args
56
+ * @returns {Promise<ToolResult>}
57
+ */
58
+ export async function handleCaptureTool(args) {
59
+ return dispatchToolAction(CAPTURE_ACTIONS, args, 'capture');
60
+ }
61
+
62
+ /** @type {Record<string, BridgeMethod>} */
63
+ export const INPUT_ACTION_METHODS = {
64
+ click: 'input.click',
65
+ focus: 'input.focus',
66
+ type: 'input.type',
67
+ press_key: 'input.press_key',
68
+ cdp_press_key: 'cdp.dispatch_key_event',
69
+ set_checked: 'input.set_checked',
70
+ select_option: 'input.select_option',
71
+ hover: 'input.hover',
72
+ drag: 'input.drag',
73
+ scroll_into_view: 'input.scroll_into_view',
74
+ };
75
+
76
+ /**
77
+ * @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, budgetPreset?: 'quick' | 'normal' | 'deep' }} args
78
+ * @returns {Promise<ToolResult>}
79
+ */
80
+ export async function handleInputTool(args) {
81
+ return withToolClient(async (client) => {
82
+ const requestedTabId = typeof args.tabId === 'number' ? args.tabId : null;
83
+ const elementTarget = async () => ({
84
+ elementRef: await resolveToolRef(client, args, requestedTabId),
85
+ });
86
+
87
+ switch (args.action) {
88
+ case 'click': {
89
+ const response = await requestBridge(
90
+ client,
91
+ 'input.click',
92
+ {
93
+ target: await elementTarget(),
94
+ button: args.button,
95
+ clickCount: args.clickCount,
96
+ },
97
+ {
98
+ tabId: requestedTabId,
99
+ source: REQUEST_SOURCE,
100
+ tokenBudget: getToolTokenBudget(args),
101
+ }
102
+ );
103
+ return summarizeToolResponse(response, 'input.click');
104
+ }
105
+ case 'focus': {
106
+ const response = await requestBridge(
107
+ client,
108
+ 'input.focus',
109
+ {
110
+ target: await elementTarget(),
111
+ },
112
+ {
113
+ tabId: requestedTabId,
114
+ source: REQUEST_SOURCE,
115
+ tokenBudget: getToolTokenBudget(args),
116
+ }
117
+ );
118
+ return summarizeToolResponse(response, 'input.focus');
119
+ }
120
+ case 'type': {
121
+ const response = await requestBridge(
122
+ client,
123
+ 'input.type',
124
+ {
125
+ target: await elementTarget(),
126
+ text: args.text,
127
+ clear: args.clear,
128
+ submit: args.submit,
129
+ },
130
+ {
131
+ tabId: requestedTabId,
132
+ source: REQUEST_SOURCE,
133
+ tokenBudget: getToolTokenBudget(args),
134
+ }
135
+ );
136
+ return summarizeToolResponse(response, 'input.type');
137
+ }
138
+ case 'press_key': {
139
+ const target = args.elementRef || args.selector ? await elementTarget() : undefined;
140
+ const response = await requestBridge(
141
+ client,
142
+ 'input.press_key',
143
+ {
144
+ target,
145
+ key: args.key,
146
+ modifiers: args.modifiers,
147
+ },
148
+ {
149
+ tabId: requestedTabId,
150
+ source: REQUEST_SOURCE,
151
+ tokenBudget: getToolTokenBudget(args),
152
+ }
153
+ );
154
+ return summarizeToolResponse(response, 'input.press_key');
155
+ }
156
+ case 'cdp_press_key': {
157
+ const response = await requestBridge(
158
+ client,
159
+ 'cdp.dispatch_key_event',
160
+ {
161
+ key: args.key,
162
+ code: args.code,
163
+ modifiers: args.modifiers,
164
+ },
165
+ {
166
+ tabId: requestedTabId,
167
+ source: REQUEST_SOURCE,
168
+ tokenBudget: getToolTokenBudget(args),
169
+ }
170
+ );
171
+ return summarizeToolResponse(response, 'cdp.dispatch_key_event');
172
+ }
173
+ case 'set_checked': {
174
+ const response = await requestBridge(
175
+ client,
176
+ 'input.set_checked',
177
+ {
178
+ target: await elementTarget(),
179
+ checked: args.checked,
180
+ },
181
+ {
182
+ tabId: requestedTabId,
183
+ source: REQUEST_SOURCE,
184
+ tokenBudget: getToolTokenBudget(args),
185
+ }
186
+ );
187
+ return summarizeToolResponse(response, 'input.set_checked');
188
+ }
189
+ case 'select_option': {
190
+ const response = await requestBridge(
191
+ client,
192
+ 'input.select_option',
193
+ {
194
+ target: await elementTarget(),
195
+ values: args.values,
196
+ labels: args.labels,
197
+ indexes: args.indexes,
198
+ },
199
+ {
200
+ tabId: requestedTabId,
201
+ source: REQUEST_SOURCE,
202
+ tokenBudget: getToolTokenBudget(args),
203
+ }
204
+ );
205
+ return summarizeToolResponse(response, 'input.select_option');
206
+ }
207
+ case 'hover': {
208
+ const response = await requestBridge(
209
+ client,
210
+ 'input.hover',
211
+ {
212
+ target: await elementTarget(),
213
+ duration: args.duration,
214
+ },
215
+ {
216
+ tabId: requestedTabId,
217
+ source: REQUEST_SOURCE,
218
+ tokenBudget: getToolTokenBudget(args),
219
+ }
220
+ );
221
+ return summarizeToolResponse(response, 'input.hover');
222
+ }
223
+ case 'drag': {
224
+ const source = {
225
+ elementRef:
226
+ args.sourceElementRef ||
227
+ (args.sourceSelector
228
+ ? await resolveRef(client, args.sourceSelector, requestedTabId, REQUEST_SOURCE)
229
+ : ''),
230
+ };
231
+ const destination = {
232
+ elementRef:
233
+ args.destinationElementRef ||
234
+ (args.destinationSelector
235
+ ? await resolveRef(client, args.destinationSelector, requestedTabId, REQUEST_SOURCE)
236
+ : ''),
237
+ };
238
+ if (!source.elementRef || !destination.elementRef) {
239
+ return summarizeToolError(
240
+ 'sourceElementRef/sourceSelector and destinationElementRef/destinationSelector are required for drag.'
241
+ );
242
+ }
243
+ const response = await requestBridge(
244
+ client,
245
+ 'input.drag',
246
+ {
247
+ source,
248
+ destination,
249
+ offsetX: args.offsetX,
250
+ offsetY: args.offsetY,
251
+ },
252
+ {
253
+ tabId: requestedTabId,
254
+ source: REQUEST_SOURCE,
255
+ tokenBudget: getToolTokenBudget(args),
256
+ }
257
+ );
258
+ return summarizeToolResponse(response, 'input.drag');
259
+ }
260
+ case 'scroll_into_view': {
261
+ const response = await requestBridge(
262
+ client,
263
+ 'input.scroll_into_view',
264
+ {
265
+ target: await elementTarget(),
266
+ },
267
+ {
268
+ tabId: requestedTabId,
269
+ source: REQUEST_SOURCE,
270
+ tokenBudget: getToolTokenBudget(args),
271
+ }
272
+ );
273
+ return summarizeToolResponse(response, 'input.scroll_into_view');
274
+ }
275
+ default:
276
+ return summarizeToolError(`Unsupported input action "${args.action}".`);
277
+ }
278
+ });
279
+ }
@@ -0,0 +1,196 @@
1
+ // @ts-check
2
+
3
+ import {
4
+ applyHtmlBudgetPreset,
5
+ applyTextBudgetPreset,
6
+ applyTreeBudgetPreset,
7
+ dispatchToolAction,
8
+ inferBudgetFromSelector,
9
+ } from './handlers-utils.js';
10
+
11
+ /** @typedef {import('../../protocol/src/types.js').BridgeMethod} BridgeMethod */
12
+ /** @typedef {import('./handlers-utils.js').ToolAction} ToolAction */
13
+ /** @typedef {import('./handlers-utils.js').ToolResult} ToolResult */
14
+
15
+ /** @type {Record<string, ToolAction>} */
16
+ export const DOM_ACTIONS = {
17
+ query: {
18
+ ref: false,
19
+ method: 'dom.query',
20
+ params: (a) => ({
21
+ selector: a.selector || 'body',
22
+ withinRef: a.withinRef,
23
+ maxNodes: a.maxNodes,
24
+ maxDepth: a.maxDepth,
25
+ textBudget: a.textBudget,
26
+ includeBbox: a.includeBbox,
27
+ attributeAllowlist: a.attributeAllowlist,
28
+ }),
29
+ },
30
+ describe: {
31
+ ref: true,
32
+ method: 'dom.describe',
33
+ params: (_, r) => ({ elementRef: r }),
34
+ },
35
+ text: {
36
+ ref: true,
37
+ method: 'dom.get_text',
38
+ params: (a, r) => ({ elementRef: r, textBudget: a.textBudget }),
39
+ },
40
+ attributes: {
41
+ ref: true,
42
+ method: 'dom.get_attributes',
43
+ params: (a, r) => ({ elementRef: r, attributes: a.attributes || [] }),
44
+ },
45
+ wait: {
46
+ ref: false,
47
+ method: 'dom.wait_for',
48
+ params: (a) => ({
49
+ selector: a.selector,
50
+ text: a.text,
51
+ state: a.state,
52
+ timeoutMs: a.timeoutMs,
53
+ }),
54
+ },
55
+ find_text: {
56
+ ref: false,
57
+ method: 'dom.find_by_text',
58
+ params: (a) => ({
59
+ text: a.text,
60
+ exact: a.exact,
61
+ selector: a.selector,
62
+ maxResults: a.maxResults,
63
+ }),
64
+ },
65
+ find_role: {
66
+ ref: false,
67
+ method: 'dom.find_by_role',
68
+ params: (a) => ({
69
+ role: a.role,
70
+ name: a.name,
71
+ selector: a.selector,
72
+ maxResults: a.maxResults,
73
+ }),
74
+ },
75
+ html: {
76
+ ref: true,
77
+ method: 'dom.get_html',
78
+ params: (a, r) => ({
79
+ elementRef: r,
80
+ outer: a.outer,
81
+ maxLength: a.maxLength,
82
+ }),
83
+ },
84
+ accessibility_tree: {
85
+ ref: false,
86
+ method: 'dom.get_accessibility_tree',
87
+ params: (a) => ({ maxNodes: a.maxNodes, maxDepth: a.maxDepth }),
88
+ },
89
+ };
90
+
91
+ /**
92
+ * @param {{ action: string, selector?: string, elementRef?: string, withinRef?: string, maxNodes?: number, maxDepth?: number, 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, budgetPreset?: 'quick' | 'normal' | 'deep' }} args
93
+ * @returns {Promise<ToolResult>}
94
+ */
95
+ export async function handleDomTool(args) {
96
+ if (args.action === 'query' || args.action === 'accessibility_tree') {
97
+ const inferred = inferBudgetFromSelector(args);
98
+ const withBudget = inferred ? { ...args, budgetPreset: args.budgetPreset ?? inferred } : args;
99
+ return dispatchToolAction(DOM_ACTIONS, applyTreeBudgetPreset(withBudget), 'DOM');
100
+ }
101
+ if (args.action === 'text') {
102
+ return dispatchToolAction(DOM_ACTIONS, applyTextBudgetPreset(args), 'DOM');
103
+ }
104
+ if (args.action === 'html') {
105
+ return dispatchToolAction(DOM_ACTIONS, applyHtmlBudgetPreset(args), 'DOM');
106
+ }
107
+ return dispatchToolAction(DOM_ACTIONS, args, 'DOM');
108
+ }
109
+
110
+ /** @type {Record<string, ToolAction>} */
111
+ export const STYLES_LAYOUT_ACTIONS = {
112
+ computed: {
113
+ ref: true,
114
+ method: 'styles.get_computed',
115
+ params: (a, r) => ({ elementRef: r, properties: a.properties }),
116
+ },
117
+ matched_rules: {
118
+ ref: true,
119
+ method: 'styles.get_matched_rules',
120
+ params: (_, r) => ({ elementRef: r }),
121
+ },
122
+ box_model: {
123
+ ref: true,
124
+ method: 'layout.get_box_model',
125
+ params: (_, r) => ({ elementRef: r }),
126
+ },
127
+ hit_test: {
128
+ ref: false,
129
+ method: 'layout.hit_test',
130
+ params: (a) => ({ x: a.x, y: a.y }),
131
+ },
132
+ };
133
+
134
+ /**
135
+ * @param {{ action: string, elementRef?: string, selector?: string, properties?: string[], x?: number, y?: number, tabId?: number, budgetPreset?: 'quick' | 'normal' | 'deep' }} args
136
+ * @returns {Promise<ToolResult>}
137
+ */
138
+ export async function handleStylesLayoutTool(args) {
139
+ return dispatchToolAction(STYLES_LAYOUT_ACTIONS, args, 'styles/layout');
140
+ }
141
+
142
+ /** @type {Record<string, ToolAction>} */
143
+ export const PATCH_ACTIONS = {
144
+ apply_styles: {
145
+ ref: true,
146
+ method: 'patch.apply_styles',
147
+ params: (a, r) => ({
148
+ target: { elementRef: r },
149
+ declarations: a.declarations,
150
+ important: a.important,
151
+ verify: a.verify,
152
+ }),
153
+ },
154
+ apply_dom: {
155
+ ref: true,
156
+ method: 'patch.apply_dom',
157
+ params: (a, r) => {
158
+ const operation = typeof a.operation === 'string' ? a.operation : '';
159
+ /** @type {Record<string, string>} */
160
+ const opMap = {
161
+ setAttribute: 'set_attribute',
162
+ removeAttribute: 'remove_attribute',
163
+ addClass: 'toggle_class',
164
+ removeClass: 'toggle_class',
165
+ setTextContent: 'set_text',
166
+ setProperty: 'set_attribute',
167
+ };
168
+ return {
169
+ target: { elementRef: r },
170
+ operation: opMap[operation] || operation,
171
+ value: a.value,
172
+ name: a.name,
173
+ verify: a.verify,
174
+ };
175
+ },
176
+ },
177
+ list: { ref: false, method: 'patch.list', params: () => ({}) },
178
+ rollback: {
179
+ ref: false,
180
+ method: 'patch.rollback',
181
+ params: (a) => ({ patchId: a.patchId }),
182
+ },
183
+ commit_baseline: {
184
+ ref: false,
185
+ method: 'patch.commit_session_baseline',
186
+ params: () => ({}),
187
+ },
188
+ };
189
+
190
+ /**
191
+ * @param {{ action: string, elementRef?: string, selector?: string, declarations?: Record<string, string>, important?: boolean, operation?: string, value?: unknown, name?: string, patchId?: string, verify?: boolean, tabId?: number, budgetPreset?: 'quick' | 'normal' | 'deep' }} args
192
+ * @returns {Promise<ToolResult>}
193
+ */
194
+ export async function handlePatchTool(args) {
195
+ return dispatchToolAction(PATCH_ACTIONS, args, 'patch');
196
+ }