chromeflow 0.8.0 → 0.9.9

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,436 +0,0 @@
1
- import { z } from "zod";
2
- function registerFlowTools(server, bridge) {
3
- server.tool(
4
- "scroll_page",
5
- "Scroll the page or the focused panel up or down. Use this when the target location is unknown. If you know which field or element you need, use scroll_to_element instead \u2014 it scrolls precisely without guessing. After scrolling, call get_page_text to read the new content \u2014 NEVER call take_screenshot after scrolling.",
6
- {
7
- direction: z.enum(["down", "up"]).describe("Scroll direction"),
8
- amount: z.number().optional().describe("Pixels to scroll (default 400)")
9
- },
10
- async ({ direction, amount = 400 }) => {
11
- await bridge.request({ type: "scroll_page", direction, amount });
12
- return { content: [{ type: "text", text: `Scrolled ${direction} ${amount}px.` }] };
13
- }
14
- );
15
- server.tool(
16
- "click_element",
17
- `Click a button, link, or interactive element on the page by its visible text or aria-label.
18
- Use this whenever Claude can press a button without needing user input \u2014 e.g. "Save", "Continue", "Create product", "Add pricing", "Confirm", "Next".
19
- After clicking, use get_page_text to check the result \u2014 only use take_screenshot if you need pixel positions.
20
- Do NOT use for: elements that require the user to make a personal choice, consent to terms, or enter sensitive data.
21
- When multiple elements share the same label (e.g. many "Remove" buttons), use nth to target a specific one (1 = first/topmost, 2 = second, etc.).
22
-
23
- Verifying the click took effect: on React-heavy sites the synthetic click sometimes returns success but the handler never ran. Pass an "until" condition that should hold AFTER the click \u2014 click_element will then poll for it and return success only if the page actually changed:
24
- - until_selector: a CSS selector that should appear (e.g. ".success-toast", "#confirm-modal")
25
- - until_url_contains: a substring that should appear in the URL (e.g. "/listing-published")
26
- - until_text_contains: a substring that should appear anywhere in page text (e.g. "Listing created")
27
- If the until-condition is not met within until_timeout_ms (default 5000ms), click_element returns success=false with a clear message so the caller can retry or take a different path.`,
28
- {
29
- textHint: z.string().describe(
30
- "The visible label of the button or link (e.g. 'Save product', 'Continue', 'Add a product', 'Create')"
31
- ),
32
- nth: z.number().int().min(1).optional().describe("Which match to click when multiple elements share the same label (1 = first/topmost, default 1)"),
33
- until_selector: z.string().optional().describe('Wait until this CSS selector appears on the page after the click (e.g. ".success-toast"). Returns success=false if it does not appear within until_timeout_ms.'),
34
- until_url_contains: z.string().optional().describe('Wait until the URL contains this substring after the click (e.g. "/checkout/complete"). Returns success=false if it does not.'),
35
- until_text_contains: z.string().optional().describe('Wait until the visible page text contains this substring after the click (e.g. "Listing published"). Returns success=false if it does not.'),
36
- until_timeout_ms: z.number().int().min(500).optional().describe("How long to wait for the until-condition, in milliseconds (default 5000). Only used if one of until_* is set.")
37
- },
38
- async ({ textHint, nth, until_selector, until_url_contains, until_text_contains, until_timeout_ms }) => {
39
- const wsTimeout = Math.max(3e4, (until_timeout_ms ?? 0) + 1e4);
40
- let response;
41
- try {
42
- response = await bridge.request(
43
- { type: "click_element", textHint, nth, until_selector, until_url_contains, until_text_contains, until_timeout_ms },
44
- wsTimeout
45
- );
46
- } catch (err) {
47
- const errMsg = err instanceof Error ? err.message : String(err);
48
- if (errMsg.includes("timed out")) {
49
- return {
50
- content: [
51
- {
52
- type: "text",
53
- text: `Could not confirm click on "${textHint}": ${errMsg}. The click MAY have already fired \u2014 the page just took longer than ${wsTimeout}ms to respond. Verify with get_page_text or wait_for_selector before retrying. Re-clicking can toggle the wrong way on React-controlled radios.`
54
- }
55
- ]
56
- };
57
- }
58
- return {
59
- content: [
60
- { type: "text", text: `Could not click "${textHint}": ${errMsg}` }
61
- ]
62
- };
63
- }
64
- const r = response;
65
- if (!r.success) {
66
- return {
67
- content: [
68
- {
69
- type: "text",
70
- text: `Could not click "${textHint}": ${r.message}`
71
- }
72
- ]
73
- };
74
- }
75
- return {
76
- content: [{ type: "text", text: r.message }]
77
- };
78
- }
79
- );
80
- server.tool(
81
- "wait_for_click",
82
- `Wait for the user to click (or interact with) the currently highlighted element, then return.
83
- Use this after highlighting a step so the flow advances automatically without the user returning to the chat.
84
- After this resolves, highlight the next step immediately.
85
- If the click causes page navigation, this resolves when the new page finishes loading.`,
86
- {
87
- timeout: z.number().optional().describe("Max seconds to wait for the click (default 120)")
88
- },
89
- async ({ timeout = 120 }) => {
90
- const response = await bridge.request({
91
- type: "start_click_watch",
92
- timeout: timeout * 1e3
93
- });
94
- if (response.type === "navigation_complete") {
95
- return {
96
- content: [
97
- {
98
- type: "text",
99
- text: `User clicked. Page navigated to: ${response.url}`
100
- }
101
- ]
102
- };
103
- }
104
- return {
105
- content: [{ type: "text", text: "User clicked the highlighted element." }]
106
- };
107
- }
108
- );
109
- server.tool(
110
- "wait_for_selector",
111
- `Wait for a CSS selector to appear on the page. Use this instead of polling with take_screenshot.
112
- Examples: wait for a build to finish, a success/error message to appear, a modal to open.
113
- After it resolves, use get_page_text to read the result rather than taking a screenshot.
114
- For long-running server-side processes (e.g. a query job that may take minutes), set poll_interval
115
- to 15 seconds so the page is checked gently rather than hammered every 500ms.
116
-
117
- Pierces open shadow roots automatically \u2014 selectors for elements inside web components
118
- (Outlier task UI, Lit/Stencil widgets) match without needing a shadow-DOM-aware caller.
119
-
120
- Pass \`shadow_root: true\` when the matched element is itself a shadow host whose tree
121
- hasn't attached yet \u2014 common after SPA route transitions where the host element appears
122
- seconds before its shadow content hydrates. Without this, wait_for_selector("the-host")
123
- resolves on the empty host and the next execute_script(host.shadowRoot) returns null.`,
124
- {
125
- selector: z.string().describe(
126
- `CSS selector to wait for (e.g. '.deploy-ready', '[data-status="error"]', '.toast-error')`
127
- ),
128
- timeout: z.number().optional().describe("Max seconds to wait (default 30)"),
129
- poll_interval: z.number().optional().describe(
130
- "How often to check for the selector, in seconds (default 0.5). Set to 15 when waiting for a slow server-side process."
131
- ),
132
- shadow_root: z.boolean().optional().describe(
133
- "If true, also require the matched element to have an attached shadowRoot (not null). Use after SPA navigations where the shadow host appears before its tree hydrates. Default false."
134
- )
135
- },
136
- async ({ selector, timeout = 30, poll_interval, shadow_root }) => {
137
- const timeoutMs = timeout * 1e3;
138
- const pollMs = poll_interval ? poll_interval * 1e3 : void 0;
139
- await bridge.request(
140
- { type: "wait_for_selector", selector, timeout: timeoutMs, refresh: pollMs, shadow_root },
141
- timeoutMs + 5e3
142
- );
143
- const suffix = shadow_root ? " (with attached shadowRoot)" : "";
144
- return {
145
- content: [{ type: "text", text: `Selector "${selector}" found on page${suffix}.` }]
146
- };
147
- }
148
- );
149
- server.tool(
150
- "wait_for_change",
151
- `Block until the element matching \`selector\` mutates, then return its text content.
152
- Uses a MutationObserver \u2014 no polling, no screenshots. Ideal after an action where you expect
153
- a specific UI region to update: click Save, then wait_for_change(".toast") to capture the
154
- confirmation. wait_for_change(".chat-messages") after sending a message to get the reply.
155
-
156
- The element must exist at call time (use wait_for_selector first if needed). After the first
157
- mutation fires, waits a brief settle window (default 150ms) for the update to batch, then
158
- returns the element's current text with secrets redacted.
159
-
160
- Only observes changes within the matched element's subtree. Mutations in deeper shadow roots
161
- or in sibling elements are not detected. For form inputs whose \`value\` changes without a
162
- DOM mutation, this won't fire \u2014 use execute_script to read the value directly.`,
163
- {
164
- selector: z.string().describe(
165
- `CSS selector of the element whose changes you want to observe (e.g. '.toast', '.chat-messages', '[role="alert"]')`
166
- ),
167
- timeout: z.number().optional().describe("Max seconds to wait for a mutation (default 30)"),
168
- settle: z.number().optional().describe(
169
- "Milliseconds to wait AFTER the first mutation for subsequent mutations to batch (default 150). Increase to 500-1000 if the page renders in multiple rapid steps."
170
- )
171
- },
172
- async ({ selector, timeout = 30, settle }) => {
173
- const timeoutMs = timeout * 1e3;
174
- const settleMs = settle ?? 150;
175
- const response = await bridge.request(
176
- { type: "wait_for_change", selector, timeout: timeoutMs, settle: settleMs },
177
- timeoutMs + 5e3
178
- );
179
- const r = response;
180
- if (!r.ok) {
181
- return { content: [{ type: "text", text: r.message ?? `wait_for_change timed out on "${selector}"` }] };
182
- }
183
- const preview = (r.text ?? "").slice(0, 5e3);
184
- return {
185
- content: [
186
- {
187
- type: "text",
188
- text: `Element "${selector}" changed.
189
-
190
- ${preview}`
191
- }
192
- ]
193
- };
194
- }
195
- );
196
- server.tool(
197
- "scroll_to_element",
198
- `Scroll an element into view by CSS selector or label/text match.
199
- Use this instead of guessing scroll amounts when you know which field or section you need to reach.
200
- Examples: scroll_to_element("#submit-btn"), scroll_to_element("Billing address"), scroll_to_element(".cm-editor")`,
201
- {
202
- query: z.string().describe("CSS selector (e.g. '#my-input', '.section-header') or visible text / label to search for")
203
- },
204
- async ({ query }) => {
205
- const response = await bridge.request({ type: "scroll_to_element", query });
206
- const msg = response.message ?? `Scrolled to element matching "${query}".`;
207
- return { content: [{ type: "text", text: msg }] };
208
- }
209
- );
210
- server.tool(
211
- "find_text",
212
- `Search the page for text and get back actionable matches without dumping the whole DOM. Use this instead of get_page_text when you only need to know "is X on the page?" or "where is the Save button?".
213
-
214
- For each match, returns the surrounding context, the nearest meaningful element (button/link/heading/role/label/etc.), a best-effort CSS selector, and a clickable flag. If a match is clickable, pipe the matched text into click_element to act on it.
215
-
216
- Use when:
217
- - Checking whether a toast / error message / heading appeared after an action
218
- - Locating one of multiple buttons by text
219
- - Finding all instances of a phrase to count or inspect them
220
-
221
- Do NOT use for: reading large blocks of body text \u2014 use get_page_text(selector=...) for that. find_text returns one short snippet per match, not the full content.
222
-
223
- Pierces open shadow roots. Pass frame="iframe.selector" to search inside a same-origin iframe.`,
224
- {
225
- query: z.string().describe(
226
- "Text to search for. Substring match by default; pass regex=true to interpret as a case-insensitive regex."
227
- ),
228
- max: z.number().int().min(1).optional().describe("Maximum matches to return (default 10). total_matches is reported even when truncated."),
229
- scope_selector: z.string().optional().describe('Limit search to descendants of this CSS selector (e.g. ".main-panel", "#dialog"). Default searches the whole body.'),
230
- regex: z.boolean().optional().describe("Treat query as a regex (case-insensitive). Default false."),
231
- visible_only: z.boolean().optional().describe("Skip matches inside display:none / visibility:hidden / aria-hidden=true ancestors. Default true."),
232
- context_chars: z.number().int().min(0).optional().describe("Characters of surrounding context to include before/after each match. Default 60."),
233
- frame: z.string().optional().describe('Same-origin iframe CSS selector (e.g. "iframe.editor") to search inside. Cross-origin iframes are not supported.')
234
- },
235
- async ({ query, max, scope_selector, regex, visible_only, context_chars, frame }) => {
236
- const response = await bridge.request({
237
- type: "find_text",
238
- query,
239
- max,
240
- scope_selector,
241
- regex,
242
- visible_only,
243
- context_chars,
244
- frame
245
- });
246
- const r = response;
247
- if (r.frame_error) {
248
- return { content: [{ type: "text", text: r.frame_error }] };
249
- }
250
- if (r.scope_missed) {
251
- return {
252
- content: [
253
- { type: "text", text: `Scope selector "${scope_selector}" did not match any element \u2014 no search performed.` }
254
- ]
255
- };
256
- }
257
- if (r.matches.length === 0) {
258
- return {
259
- content: [
260
- { type: "text", text: `No matches found for "${query}".` }
261
- ]
262
- };
263
- }
264
- const lines = r.matches.map((m, i) => {
265
- const role = m.role ? `, role=${m.role}` : "";
266
- const click = m.clickable ? " \u2014 clickable" : "";
267
- const pos = m.position ? ` at (${m.position.x}, ${m.position.y}, ${m.position.width}\xD7${m.position.height})` : "";
268
- return ` ${i + 1}. [${m.tag}${role}]${click}${pos} \u2014 selector: ${m.selector}
269
- "${m.text}"
270
- context: ${m.context}`;
271
- });
272
- const header = r.truncated ? `Found ${r.matches.length} of ${r.total_matches} matches for "${query}":` : `Found ${r.matches.length} match${r.matches.length === 1 ? "" : "es"} for "${query}":`;
273
- return {
274
- content: [{ type: "text", text: `${header}
275
- ${lines.join("\n")}` }]
276
- };
277
- }
278
- );
279
- server.tool(
280
- "find_input",
281
- `Locate form inputs whose label / placeholder / aria-label / name / id matches a hint, returning the top N with their section heading. Use this instead of get_form_fields when you only need a couple of fields \u2014 it's the targeted lookup, not the full inventory.
282
-
283
- Match strength is reported as match_kind: aria-eq / placeholder-eq / label-text-eq / name-eq / id-eq are exact matches; *-includes are partial matches; fuzzy-text-walk is the lowest-confidence fallback.
284
-
285
- Returned labels are designed to be piped straight into fill_input(label, value), which uses the same fuzzy ranks to find the same field again. No CSS selector is returned \u2014 fill_input matches by label text, not by selector.
286
-
287
- Use when:
288
- - "Is the Email field on this page?"
289
- - "Find the price input below the fold"
290
- - "Which input has placeholder 'you@example.com'?"
291
-
292
- Do NOT use for: filling fields (use fill_input / fill_form). For the full form inventory (every field including hidden ones), use get_form_fields.
293
-
294
- Pierces open shadow roots. Pass frame="iframe.selector" to search inside a same-origin iframe. Pass exact=true to refuse fuzzy text-walk and *-includes matches when the hint is short and could collide with neighbours.`,
295
- {
296
- query: z.string().describe(
297
- "Hint to match against the field's label, placeholder, aria-label, name, or id (e.g. 'Email', 'price', 'Card number')"
298
- ),
299
- type_filter: z.string().optional().describe(
300
- 'Restrict to a specific input type \u2014 "email", "checkbox", "file", "textarea", "select", "number", etc. Default "any".'
301
- ),
302
- max: z.number().int().min(1).optional().describe("Maximum fields to return (default 5). total_matches is reported even when truncated."),
303
- exact: z.boolean().optional().describe(
304
- "If true, return only exact equality matches (aria-eq / placeholder-eq / label-text-eq / name-eq / id-eq). Skips fuzzy text-walk and *-includes. Default false."
305
- ),
306
- frame: z.string().optional().describe("Same-origin iframe CSS selector to search inside. Cross-origin iframes are not supported.")
307
- },
308
- async ({ query, type_filter, max, exact, frame }) => {
309
- const response = await bridge.request({
310
- type: "find_input",
311
- query,
312
- type_filter,
313
- max,
314
- exact,
315
- frame
316
- });
317
- const r = response;
318
- if (r.frame_error) {
319
- return { content: [{ type: "text", text: r.frame_error }] };
320
- }
321
- if (r.fields.length === 0) {
322
- return {
323
- content: [{ type: "text", text: `No input fields found matching "${query}".` }]
324
- };
325
- }
326
- const lines = r.fields.map((f, i) => {
327
- const placeholderPart = f.placeholder ? ` placeholder="${f.placeholder}"` : "";
328
- const valuePart = f.value ? ` value="${f.value}"` : "";
329
- const underPart = f.under ? ` [under: "${f.under}"]` : "";
330
- const posPart = f.position ? ` at y=${f.position.y}` : "";
331
- return ` ${i + 1}. "${f.label}" type=${f.type}${placeholderPart}${valuePart}${underPart} \u2014 match: ${f.match_kind}${posPart}`;
332
- });
333
- const header = r.truncated ? `Found ${r.fields.length} of ${r.total_matches} input(s) for "${query}":` : `Found ${r.fields.length} input${r.fields.length === 1 ? "" : "s"} for "${query}":`;
334
- return {
335
- content: [{ type: "text", text: `${header}
336
- ${lines.join("\n")}
337
-
338
- To fill: fill_input("${r.fields[0].label}", "<value>")` }]
339
- };
340
- }
341
- );
342
- server.tool(
343
- "wait_for_text",
344
- `Wait for text to appear in the DOM. Complement to wait_for_selector for the case where you only know the message text \u2014 no selector required. Uses a MutationObserver under the hood, no polling.
345
-
346
- Resolves on the first match (or if the text is already present). Returns the elapsed time, the matched text, and the surrounding context.
347
-
348
- Use when:
349
- - "Click Save, then wait for 'Saved successfully' to show"
350
- - "Wait until the deploy log says 'Build complete'"
351
- - Any case where the post-action signal is a phrase, not a known selector
352
-
353
- Do NOT use for: waiting on a known CSS selector (use wait_for_selector \u2014 slightly cheaper).
354
-
355
- Pierces open shadow roots. Pass frame="iframe.selector" to wait for text inside a same-origin iframe.`,
356
- {
357
- query: z.string().describe("Text to wait for (substring by default; pass regex=true for a regex)"),
358
- timeout_ms: z.number().int().min(100).optional().describe("Maximum milliseconds to wait (default 10000)"),
359
- scope_selector: z.string().optional().describe("Limit the observation to a CSS selector's subtree (e.g. '.toast-region')"),
360
- regex: z.boolean().optional().describe("Treat query as a regex (case-insensitive). Default false."),
361
- frame: z.string().optional().describe("Same-origin iframe CSS selector to wait inside. Cross-origin iframes are not supported.")
362
- },
363
- async ({ query, timeout_ms, scope_selector, regex, frame }) => {
364
- const wsTimeout = Math.max(15e3, (timeout_ms ?? 1e4) + 5e3);
365
- const response = await bridge.request(
366
- {
367
- type: "wait_for_text",
368
- query,
369
- timeout_ms,
370
- scope_selector,
371
- regex,
372
- frame
373
- },
374
- wsTimeout
375
- );
376
- const r = response;
377
- if (r.frame_error) {
378
- return { content: [{ type: "text", text: r.frame_error }] };
379
- }
380
- if (!r.found) {
381
- return {
382
- content: [
383
- { type: "text", text: `Timed out after ${r.elapsed_ms}ms waiting for "${query}".` }
384
- ]
385
- };
386
- }
387
- const ctx = r.context ? `
388
- context: ${r.context}` : "";
389
- const sel = r.selector ? `
390
- selector: ${r.selector}` : "";
391
- return {
392
- content: [
393
- {
394
- type: "text",
395
- text: `Found "${r.text}" after ${r.elapsed_ms}ms.${sel}${ctx}`
396
- }
397
- ]
398
- };
399
- }
400
- );
401
- server.tool(
402
- "fill_form",
403
- `Fill multiple form fields in a single call by targeting each field by its label text.
404
- Use this instead of calling fill_input repeatedly \u2014 it fills all fields in one round trip and returns a per-field success report.
405
- Ideal for forms with many textareas or inputs where each fill would otherwise require a separate tool call.
406
- fields is an array of {label, value} pairs. label should match the field's visible label, placeholder, or aria-label.
407
-
408
- Each per-field result includes the matched element description (e.g. \`<input name="title" id="..." placeholder="...">\`) so Claude can spot when fill_form picked the wrong field.
409
-
410
- Pass \`exact: true\` for forms with short generic labels (like "Rate" or "Amount") that may collide with similarly-labeled neighbours \u2014 fields without an exact aria-label/placeholder/name/id/label-text match will return success=false instead of silently filling the wrong field.`,
411
- {
412
- fields: z.array(
413
- z.object({
414
- label: z.string().describe("Visible label, placeholder, or aria-label of the field"),
415
- value: z.string().describe("Value to fill in")
416
- })
417
- ).describe("List of fields to fill"),
418
- exact: z.boolean().optional().describe("If true, refuse fuzzy text-walk matches for every field. Default false.")
419
- },
420
- async ({ fields, exact }) => {
421
- const response = await bridge.request({ type: "fill_form", fields, exact });
422
- const r = response;
423
- const lines = r.results.map((f) => `${f.success ? "\u2713" : "\u2717"} "${f.label}": ${f.message}`);
424
- return {
425
- content: [{
426
- type: "text",
427
- text: `Filled ${r.succeeded}/${r.total} fields:
428
- ${lines.join("\n")}`
429
- }]
430
- };
431
- }
432
- );
433
- }
434
- export {
435
- registerFlowTools
436
- };
@@ -1,70 +0,0 @@
1
- import { z } from "zod";
2
- function registerHighlightTools(server, bridge) {
3
- server.tool(
4
- "find_and_highlight",
5
- "Find an element on the page by its visible text and highlight it with an instructional callout. Try this before using highlight_region. Returns whether the element was found.",
6
- {
7
- text: z.string().describe(
8
- "Visible text of the element or text near it (e.g. 'API Keys', 'Create account')"
9
- ),
10
- message: z.string().describe(
11
- "Instruction to show the user in the callout (e.g. 'Click here to create your API key'). When the user needs to type something, use a short instruction like 'Type this in the field:' and pass the text as valueToType."
12
- ),
13
- valueToType: z.string().optional().describe(
14
- "Only use when the user must personally type the value (password, email, personal data). Do NOT use when Claude will auto-fill after the click \u2014 in that case, omit this and use message: 'Click here \u2014 I'll fill it in'."
15
- )
16
- },
17
- async ({ text, message, valueToType }) => {
18
- const response = await bridge.request({
19
- type: "find_highlight",
20
- text,
21
- message,
22
- valueToType
23
- });
24
- if (response.type !== "find_highlight_response") {
25
- throw new Error("Unexpected response from extension");
26
- }
27
- return {
28
- content: [
29
- {
30
- type: "text",
31
- text: response.found ? `Element containing "${text}" highlighted.` : `Element containing "${text}" not found. Try get_elements() to get exact DOM coordinates, or take_screenshot() only if you need to see the visual layout.`
32
- }
33
- ]
34
- };
35
- }
36
- );
37
- server.tool(
38
- "highlight_region",
39
- `Highlight a region on the page with an instructional callout.
40
- Prefer passing a CSS selector \u2014 the extension will find the element, scroll it into view, and highlight its exact bounds automatically. This is more robust than pixel coordinates, which go stale if the user scrolls.
41
- Only pass x/y/width/height when you have no selector and already have fresh coordinates from get_elements.`,
42
- {
43
- selector: z.string().optional().describe("CSS selector of the element to highlight (e.g. '#upload-zone', '.drop-area'). Preferred over raw coordinates."),
44
- x: z.number().optional().describe("Left edge in CSS pixels \u2014 only needed if no selector"),
45
- y: z.number().optional().describe("Top edge in CSS pixels \u2014 only needed if no selector"),
46
- width: z.number().optional().describe("Width in CSS pixels \u2014 only needed if no selector"),
47
- height: z.number().optional().describe("Height in CSS pixels \u2014 only needed if no selector"),
48
- message: z.string().describe(
49
- "Instruction to show the user in the callout. When the user needs to type something, use a short instruction like 'Type this in the field:' and pass the text as valueToType."
50
- ),
51
- valueToType: z.string().optional().describe(
52
- `Only use when the user must personally type the value (password, email, personal data). Do NOT use when Claude will auto-fill after the click \u2014 in that case, omit this and use message: "Click here \u2014 I'll fill it in".`
53
- )
54
- },
55
- async ({ selector, x, y, width, height, message, valueToType }) => {
56
- await bridge.request({ type: "highlight_region", selector, x, y, width, height, message, valueToType });
57
- return {
58
- content: [
59
- {
60
- type: "text",
61
- text: selector ? `Highlighted element matching "${selector}".` : `Region highlighted at (${x ?? 0}, ${y ?? 0}) ${width ?? 0}\xD7${height ?? 0}.`
62
- }
63
- ]
64
- };
65
- }
66
- );
67
- }
68
- export {
69
- registerHighlightTools
70
- };
package/dist/types.js DELETED
File without changes
package/dist/ws-bridge.js DELETED
@@ -1,116 +0,0 @@
1
- import { WebSocketServer, WebSocket } from "ws";
2
- import path from "path";
3
- const WS_PORT_BASE = 7878;
4
- const WS_PORT_MAX = 7888;
5
- const REQUEST_TIMEOUT_MS = 3e4;
6
- class WsBridge {
7
- wss;
8
- client = null;
9
- pending = /* @__PURE__ */ new Map();
10
- port = WS_PORT_BASE;
11
- constructor() {
12
- this.bind(WS_PORT_BASE);
13
- }
14
- bind(tryPort) {
15
- if (tryPort > WS_PORT_MAX) {
16
- console.error(
17
- `[chromeflow] All ports ${WS_PORT_BASE}-${WS_PORT_MAX} are in use. Cannot start MCP server.`
18
- );
19
- return;
20
- }
21
- const wss = new WebSocketServer({ port: tryPort });
22
- wss.on("error", (err) => {
23
- if (err.code === "EADDRINUSE") {
24
- console.error(`[chromeflow] Port ${tryPort} in use, trying ${tryPort + 1}...`);
25
- this.bind(tryPort + 1);
26
- } else {
27
- console.error("[chromeflow] WS server error:", err);
28
- }
29
- });
30
- wss.on("listening", () => {
31
- this.wss = wss;
32
- this.port = tryPort;
33
- console.error(`[chromeflow] WS bridge listening on ws://localhost:${tryPort}`);
34
- });
35
- wss.on("connection", (ws) => {
36
- if (this.client) {
37
- this.client.terminate();
38
- }
39
- this.client = ws;
40
- console.error("[chromeflow] Extension connected");
41
- ws.on("message", (data) => {
42
- let msg;
43
- try {
44
- msg = JSON.parse(data.toString());
45
- } catch {
46
- return;
47
- }
48
- if (msg.type === "ready") {
49
- console.error("[chromeflow] Extension ready");
50
- const cwd = process.cwd();
51
- ws.send(JSON.stringify({
52
- type: "identity",
53
- cwd,
54
- label: path.basename(cwd),
55
- port: this.port
56
- }));
57
- return;
58
- }
59
- const pending = this.pending.get(msg.requestId);
60
- if (pending) {
61
- clearTimeout(pending.timer);
62
- this.pending.delete(msg.requestId);
63
- if (msg.type === "error") {
64
- pending.reject(new Error(msg.message));
65
- } else {
66
- pending.resolve(msg);
67
- }
68
- }
69
- });
70
- ws.on("close", () => {
71
- console.error("[chromeflow] Extension disconnected");
72
- this.client = null;
73
- for (const [id, pending] of this.pending) {
74
- clearTimeout(pending.timer);
75
- pending.reject(new Error(
76
- "Chrome extension disconnected. Reload the chromeflow extension in Chrome and try again."
77
- ));
78
- this.pending.delete(id);
79
- }
80
- });
81
- });
82
- }
83
- isConnected() {
84
- return this.client !== null && this.client.readyState === WebSocket.OPEN;
85
- }
86
- /** Send a message and wait for a response from the extension. */
87
- request(message, timeoutMs = REQUEST_TIMEOUT_MS) {
88
- if (!this.isConnected()) {
89
- return Promise.reject(
90
- new Error(
91
- "Chromeflow extension is not connected. Open Chrome and ensure the extension is installed."
92
- )
93
- );
94
- }
95
- const requestId = crypto.randomUUID();
96
- return new Promise((resolve, reject) => {
97
- const timer = setTimeout(() => {
98
- this.pending.delete(requestId);
99
- reject(new Error(`Request timed out after ${timeoutMs}ms`));
100
- }, timeoutMs);
101
- this.pending.set(requestId, { resolve, reject, timer });
102
- this.client.send(JSON.stringify({ ...message, requestId }));
103
- });
104
- }
105
- /** Send a fire-and-forget message (no response expected). */
106
- send(message) {
107
- if (!this.isConnected()) {
108
- throw new Error("Chromeflow extension is not connected.");
109
- }
110
- const requestId = crypto.randomUUID();
111
- this.client.send(JSON.stringify({ ...message, requestId }));
112
- }
113
- }
114
- export {
115
- WsBridge
116
- };