assistme 0.3.1 → 0.3.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.
Files changed (44) hide show
  1. package/PLAN.md +14 -3
  2. package/dist/{chunk-UWE5WVQI.js → chunk-KX7ITO55.js} +20 -11
  3. package/dist/index.js +1889 -583
  4. package/dist/{job-runner-N4XAAWLJ.js → job-runner-P2L6MOOX.js} +1 -1
  5. package/package.json +5 -3
  6. package/src/agent/job-runner.ts +9 -13
  7. package/src/agent/mcp-servers.ts +6 -952
  8. package/src/agent/memory.ts +2 -11
  9. package/src/agent/processor.ts +25 -108
  10. package/src/agent/scheduler.ts +2 -3
  11. package/src/agent/session.ts +20 -36
  12. package/src/agent/skills.ts +167 -61
  13. package/src/agent/system-prompt.ts +126 -0
  14. package/src/browser/chrome-launcher.ts +557 -0
  15. package/src/browser/controller.ts +1448 -0
  16. package/src/browser/types.ts +76 -0
  17. package/src/commands/credential.ts +190 -0
  18. package/src/commands/job.ts +14 -45
  19. package/src/commands/memory.ts +16 -29
  20. package/src/commands/schedule.ts +15 -37
  21. package/src/commands/start.ts +11 -43
  22. package/src/credentials/credential-store.test.ts +162 -0
  23. package/src/credentials/credential-store.ts +266 -0
  24. package/src/credentials/encryption.test.ts +98 -0
  25. package/src/credentials/encryption.ts +82 -0
  26. package/src/credentials/index.ts +15 -0
  27. package/src/credentials/local-store.ts +89 -0
  28. package/src/db/action.ts +19 -0
  29. package/src/db/api-client.ts +3 -32
  30. package/src/db/auth-store.ts +41 -0
  31. package/src/db/auth.ts +38 -0
  32. package/src/db/conversation.ts +39 -0
  33. package/src/db/event.ts +52 -0
  34. package/src/db/job-poll.ts +18 -0
  35. package/src/db/session.ts +60 -0
  36. package/src/db/supabase.ts +40 -383
  37. package/src/db/task.ts +69 -0
  38. package/src/db/types.ts +54 -0
  39. package/src/index.ts +2 -0
  40. package/src/mcp/agent-tools-server.ts +1047 -0
  41. package/src/mcp/browser-server.ts +241 -0
  42. package/src/tools/browser.ts +29 -1208
  43. package/src/tools/index.ts +31 -265
  44. package/src/tools/web.ts +0 -73
@@ -0,0 +1,1448 @@
1
+ import { WebSocket } from "ws";
2
+ import { platform } from "node:os";
3
+ import type {
4
+ CDPTab,
5
+ CDPResponse,
6
+ CDPEvalResult,
7
+ CDPScreenshotResult,
8
+ BoundingBox,
9
+ RefEntry,
10
+ SnapshotResult,
11
+ ActionSpec,
12
+ ActionResult,
13
+ RefActionResult,
14
+ } from "./types.js";
15
+
16
+ export class BrowserController {
17
+ private ws: WebSocket | null = null;
18
+ private debugPort: number;
19
+ private messageId = 0;
20
+ private callbacks = new Map<number, (response: CDPResponse) => void>();
21
+ private connected = false;
22
+ private currentTabId: string | null = null;
23
+ private refCache: Map<number, RefEntry> = new Map();
24
+
25
+ constructor(port = 9222) {
26
+ this.debugPort = port;
27
+ }
28
+
29
+ // ── Connection ──────────────────────────────────────────────────
30
+
31
+ async isAvailable(): Promise<boolean> {
32
+ try {
33
+ const res = await fetch(`http://127.0.0.1:${this.debugPort}/json/version`, {
34
+ signal: AbortSignal.timeout(2000),
35
+ });
36
+ return res.ok;
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ async connect(tabIndex?: number): Promise<string> {
43
+ // Reuse existing connection if still open and targeting the same tab
44
+ if (this.connected && this.ws?.readyState === WebSocket.OPEN) {
45
+ if (tabIndex === undefined) {
46
+ return "Already connected to browser.";
47
+ }
48
+ // If a specific tab is requested, check if we're already on it
49
+ const tabs = await this.getTabs();
50
+ const pageTabs = tabs.filter((t) => t.type === "page");
51
+ const targetTab = pageTabs[tabIndex];
52
+ if (targetTab && targetTab.id === this.currentTabId) {
53
+ return `Already connected to tab: "${targetTab.title}"`;
54
+ }
55
+ // Need to switch — disconnect first
56
+ await this.disconnect();
57
+ }
58
+
59
+ const available = await this.isAvailable();
60
+ if (!available) {
61
+ throw new Error(
62
+ `Cannot connect to browser on port ${this.debugPort}. ` +
63
+ "Chrome remote debugging is not reachable. " +
64
+ "Please ensure Chrome is running with remote debugging enabled."
65
+ );
66
+ }
67
+
68
+ const tabs = await this.getTabs();
69
+ const pageTabs = tabs.filter((t) => t.type === "page");
70
+
71
+ if (pageTabs.length === 0) {
72
+ throw new Error("No browser tabs found. Please open at least one tab.");
73
+ }
74
+
75
+ const targetTab = pageTabs[tabIndex ?? 0];
76
+ if (!targetTab.webSocketDebuggerUrl) {
77
+ throw new Error("Tab does not expose a WebSocket debugger URL.");
78
+ }
79
+
80
+ this.currentTabId = targetTab.id;
81
+
82
+ return new Promise((resolve, reject) => {
83
+ let settled = false;
84
+ this.ws = new WebSocket(targetTab.webSocketDebuggerUrl!);
85
+
86
+ const connectTimeout = setTimeout(() => {
87
+ if (!settled) {
88
+ settled = true;
89
+ this.ws?.close();
90
+ reject(new Error("Connection timeout (5s)"));
91
+ }
92
+ }, 5000);
93
+
94
+ this.ws.on("open", () => {
95
+ if (settled) return;
96
+ settled = true;
97
+ clearTimeout(connectTimeout);
98
+ this.connected = true;
99
+ // Enable required domains
100
+ this.send("Page.enable").catch(() => {});
101
+ this.send("Runtime.enable").catch(() => {});
102
+ this.send("DOM.enable").catch(() => {});
103
+ resolve(`Connected to tab: "${targetTab.title}" (${targetTab.url})`);
104
+ });
105
+
106
+ this.ws.on("message", (data) => {
107
+ try {
108
+ const msg = JSON.parse(data.toString()) as CDPResponse;
109
+ if (msg.id !== undefined && this.callbacks.has(msg.id)) {
110
+ this.callbacks.get(msg.id)!(msg);
111
+ this.callbacks.delete(msg.id);
112
+ }
113
+ } catch {
114
+ // Ignore non-JSON messages (events)
115
+ }
116
+ });
117
+
118
+ this.ws.on("error", (err) => {
119
+ this.connected = false;
120
+ if (!settled) {
121
+ settled = true;
122
+ clearTimeout(connectTimeout);
123
+ reject(new Error(`WebSocket error: ${err.message}`));
124
+ }
125
+ });
126
+
127
+ this.ws.on("close", () => {
128
+ this.connected = false;
129
+ this.ws = null;
130
+ // Reject pending CDP commands so they don't hang forever
131
+ for (const [id, cb] of this.callbacks) {
132
+ cb({ id, error: { code: -1, message: "WebSocket closed" } });
133
+ }
134
+ this.callbacks.clear();
135
+ });
136
+ });
137
+ }
138
+
139
+ async disconnect(): Promise<string> {
140
+ if (this.ws) {
141
+ this.ws.close();
142
+ this.ws = null;
143
+ this.connected = false;
144
+ }
145
+ return "Disconnected from browser.";
146
+ }
147
+
148
+ // ── CDP Protocol ────────────────────────────────────────────────
149
+
150
+ private async getTabs(): Promise<CDPTab[]> {
151
+ const res = await fetch(`http://127.0.0.1:${this.debugPort}/json`, {
152
+ signal: AbortSignal.timeout(3000),
153
+ });
154
+ return (await res.json()) as CDPTab[];
155
+ }
156
+
157
+ private send(method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>> {
158
+ return new Promise((resolve, reject) => {
159
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
160
+ reject(new Error("Not connected to browser. Call browser_connect first."));
161
+ return;
162
+ }
163
+
164
+ const id = ++this.messageId;
165
+ const timeout = setTimeout(() => {
166
+ this.callbacks.delete(id);
167
+ reject(new Error(`CDP command timed out: ${method}`));
168
+ }, 15000);
169
+
170
+ this.callbacks.set(id, (response) => {
171
+ clearTimeout(timeout);
172
+ if (response.error) {
173
+ reject(new Error(`CDP error: ${response.error.message}`));
174
+ } else {
175
+ resolve(response.result || {});
176
+ }
177
+ });
178
+
179
+ this.ws.send(JSON.stringify({ id, method, params }));
180
+ });
181
+ }
182
+
183
+ private ensureConnected() {
184
+ if (!this.connected || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
185
+ throw new Error("Not connected to browser. Use browser_connect tool first.");
186
+ }
187
+ }
188
+
189
+ // ── Navigation ──────────────────────────────────────────────────
190
+
191
+ async navigate(url: string): Promise<string> {
192
+ this.ensureConnected();
193
+ await this.send("Page.navigate", { url });
194
+ // Wait for load
195
+ await this.waitForLoad();
196
+ const info = await this.getPageInfo();
197
+ return `Navigated to: ${info.title}\nURL: ${info.url}`;
198
+ }
199
+
200
+ async goBack(): Promise<string> {
201
+ this.ensureConnected();
202
+ try {
203
+ // Get navigation history and go to the previous entry
204
+ const history = (await this.send("Page.getNavigationHistory")) as {
205
+ currentIndex?: number;
206
+ entries?: Array<{ id: number }>;
207
+ };
208
+ const idx = history.currentIndex ?? 0;
209
+ const entries = history.entries ?? [];
210
+ if (idx > 0 && entries[idx - 1]) {
211
+ await this.send("Page.navigateToHistoryEntry", {
212
+ entryId: entries[idx - 1].id,
213
+ });
214
+ } else {
215
+ // No previous entry in CDP history — use JS fallback
216
+ await this.evaluate("window.history.back()");
217
+ }
218
+ } catch {
219
+ // CDP history API failed — use JS fallback
220
+ await this.evaluate("window.history.back()");
221
+ }
222
+ await this.waitForLoad();
223
+ const info = await this.getPageInfo();
224
+ return `Went back to: ${info.title}`;
225
+ }
226
+
227
+ async reload(): Promise<string> {
228
+ this.ensureConnected();
229
+ await this.send("Page.reload");
230
+ await this.waitForLoad();
231
+ return "Page reloaded.";
232
+ }
233
+
234
+ // ── Page Reading ────────────────────────────────────────────────
235
+
236
+ async readPage(): Promise<string> {
237
+ this.ensureConnected();
238
+ const result = await this.send("Runtime.evaluate", {
239
+ expression: `
240
+ (function() {
241
+ // Get page title and URL
242
+ let output = "Title: " + document.title + "\\n";
243
+ output += "URL: " + window.location.href + "\\n\\n";
244
+
245
+ // Get main text content, cleaned up
246
+ const body = document.body.cloneNode(true);
247
+ // Remove scripts, styles, navs that add noise
248
+ body.querySelectorAll('script, style, noscript, svg, iframe').forEach(el => el.remove());
249
+
250
+ const text = body.innerText
251
+ .split('\\n')
252
+ .map(line => line.trim())
253
+ .filter(line => line.length > 0)
254
+ .join('\\n');
255
+
256
+ output += text;
257
+ return output.slice(0, 30000);
258
+ })()
259
+ `,
260
+ returnByValue: true,
261
+ });
262
+
263
+ return ((result as CDPEvalResult).result?.value as string) || "Could not read page content.";
264
+ }
265
+
266
+ async readElement(selector: string): Promise<string> {
267
+ this.ensureConnected();
268
+ const selectorJS = JSON.stringify(selector);
269
+ const result = await this.send("Runtime.evaluate", {
270
+ expression: `
271
+ (function() {
272
+ const el = document.querySelector(${selectorJS});
273
+ if (!el) return 'Element not found: ' + ${selectorJS};
274
+ return el.innerText || el.textContent || el.value || '(empty)';
275
+ })()
276
+ `,
277
+ returnByValue: true,
278
+ });
279
+
280
+ return ((result as CDPEvalResult).result?.value as string) || "Element not found.";
281
+ }
282
+
283
+ async getPageInfo(): Promise<{ title: string; url: string }> {
284
+ const result = await this.send("Runtime.evaluate", {
285
+ expression: `JSON.stringify({ title: document.title, url: window.location.href })`,
286
+ returnByValue: true,
287
+ });
288
+ try {
289
+ return JSON.parse(((result as CDPEvalResult).result?.value as string) || "{}");
290
+ } catch {
291
+ return { title: "Unknown", url: "unknown" };
292
+ }
293
+ }
294
+
295
+ // ── Screenshots (for Claude vision) ─────────────────────────────
296
+
297
+ async screenshot(): Promise<string> {
298
+ this.ensureConnected();
299
+ const result = await this.send("Page.captureScreenshot", {
300
+ format: "png",
301
+ quality: 80,
302
+ captureBeyondViewport: false,
303
+ });
304
+ // Returns base64-encoded PNG
305
+ return (result as CDPScreenshotResult).data || "";
306
+ }
307
+
308
+ // ── Interactions ────────────────────────────────────────────────
309
+
310
+ async click(selector: string): Promise<string> {
311
+ this.ensureConnected();
312
+ const selectorJS = JSON.stringify(selector);
313
+
314
+ const result = await this.send("Runtime.evaluate", {
315
+ expression: `
316
+ (function() {
317
+ var sel = ${selectorJS};
318
+
319
+ // Support :contains('text') pseudo-selector (not native CSS)
320
+ var containsMatch = sel.match(/^(.+?)?:contains\\(['"](.+?)['"]\\)$/);
321
+ if (containsMatch) {
322
+ var baseTag = (containsMatch[1] || '*').toLowerCase();
323
+ var searchText = containsMatch[2];
324
+ var candidates = document.querySelectorAll(baseTag === '*' ? '*' : baseTag);
325
+ var found = null;
326
+ for (var i = 0; i < candidates.length; i++) {
327
+ var c = candidates[i];
328
+ // Prefer exact text match on direct text content (not children)
329
+ var directText = Array.from(c.childNodes)
330
+ .filter(function(n) { return n.nodeType === 3; })
331
+ .map(function(n) { return n.textContent.trim(); })
332
+ .join(' ');
333
+ if (directText === searchText || c.textContent.trim() === searchText) {
334
+ // Prefer the deepest (most specific) matching element
335
+ if (!found || found.contains(c)) found = c;
336
+ }
337
+ }
338
+ if (!found) return 'Element not found: ' + sel;
339
+ found.scrollIntoView({ block: 'center', behavior: 'instant' });
340
+ found.click();
341
+ return 'Clicked: ' + (found.tagName || '') + ' ' + (found.textContent || '').slice(0, 50).trim();
342
+ }
343
+
344
+ var el = document.querySelector(sel);
345
+ if (!el) return 'Element not found: ' + sel;
346
+
347
+ // Scroll into view
348
+ el.scrollIntoView({ block: 'center', behavior: 'instant' });
349
+
350
+ // Click
351
+ el.click();
352
+ return 'Clicked: ' + (el.tagName || '') + ' ' + (el.textContent || '').slice(0, 50).trim();
353
+ })()
354
+ `,
355
+ returnByValue: true,
356
+ });
357
+
358
+ // Small delay for any resulting navigation/animation
359
+ await new Promise((r) => setTimeout(r, 500));
360
+ return ((result as CDPEvalResult).result?.value as string) || "Click executed.";
361
+ }
362
+
363
+ async typeText(selector: string, text: string): Promise<string> {
364
+ this.ensureConnected();
365
+ // Use JSON.stringify for safe string interpolation into JS — handles all
366
+ // special characters (quotes, backslashes, newlines, unicode) correctly.
367
+ const selectorJS = JSON.stringify(selector);
368
+ const textJS = JSON.stringify(text);
369
+
370
+ // First clear and set value via JS, dispatching all relevant events
371
+ const result = await this.send("Runtime.evaluate", {
372
+ expression: `
373
+ (function() {
374
+ const el = document.querySelector(${selectorJS});
375
+ if (!el) return 'Element not found: ' + ${selectorJS};
376
+
377
+ el.focus();
378
+
379
+ // Clear existing value
380
+ const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
381
+ window.HTMLInputElement.prototype, 'value'
382
+ )?.set || Object.getOwnPropertyDescriptor(
383
+ window.HTMLTextAreaElement.prototype, 'value'
384
+ )?.set;
385
+ if (nativeInputValueSetter) {
386
+ nativeInputValueSetter.call(el, ${textJS});
387
+ } else {
388
+ el.value = ${textJS};
389
+ }
390
+
391
+ // Dispatch events that frameworks (React, Angular, Material) listen to
392
+ el.dispatchEvent(new Event('input', { bubbles: true, cancelable: true }));
393
+ el.dispatchEvent(new Event('change', { bubbles: true, cancelable: true }));
394
+ el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: ${textJS} }));
395
+ return 'Typed into: ' + (el.tagName || '') + ' [' + (el.name || el.id || '') + ']';
396
+ })()
397
+ `,
398
+ returnByValue: true,
399
+ });
400
+
401
+ return ((result as CDPEvalResult).result?.value as string) || "Text entered.";
402
+ }
403
+
404
+ async pressKey(key: string): Promise<string> {
405
+ this.ensureConnected();
406
+
407
+ // Map common key names to CDP key codes
408
+ const keyMap: Record<string, { keyCode: number; code: string }> = {
409
+ Enter: { keyCode: 13, code: "Enter" },
410
+ Tab: { keyCode: 9, code: "Tab" },
411
+ Escape: { keyCode: 27, code: "Escape" },
412
+ Backspace: { keyCode: 8, code: "Backspace" },
413
+ Delete: { keyCode: 46, code: "Delete" },
414
+ ArrowDown: { keyCode: 40, code: "ArrowDown" },
415
+ ArrowUp: { keyCode: 38, code: "ArrowUp" },
416
+ ArrowLeft: { keyCode: 37, code: "ArrowLeft" },
417
+ ArrowRight: { keyCode: 39, code: "ArrowRight" },
418
+ Home: { keyCode: 36, code: "Home" },
419
+ End: { keyCode: 35, code: "End" },
420
+ Space: { keyCode: 32, code: "Space" },
421
+ };
422
+
423
+ // CDP modifier bitmask values
424
+ const modifierMap: Record<string, number> = {
425
+ Alt: 1,
426
+ Control: 2,
427
+ Meta: 4,
428
+ Shift: 8,
429
+ };
430
+
431
+ // Parse modifier combos like "Control+a", "Meta+Shift+z"
432
+ const parts = key.split("+");
433
+ let modifiers = 0;
434
+ let actualKey = parts[parts.length - 1];
435
+ for (let i = 0; i < parts.length - 1; i++) {
436
+ const mod = modifierMap[parts[i]];
437
+ if (mod) modifiers |= mod;
438
+ }
439
+
440
+ const mapped = keyMap[actualKey];
441
+ if (mapped) {
442
+ await this.send("Input.dispatchKeyEvent", {
443
+ type: "keyDown",
444
+ key: actualKey,
445
+ code: mapped.code,
446
+ windowsVirtualKeyCode: mapped.keyCode,
447
+ nativeVirtualKeyCode: mapped.keyCode,
448
+ modifiers,
449
+ });
450
+ await this.send("Input.dispatchKeyEvent", {
451
+ type: "keyUp",
452
+ key: actualKey,
453
+ code: mapped.code,
454
+ windowsVirtualKeyCode: mapped.keyCode,
455
+ nativeVirtualKeyCode: mapped.keyCode,
456
+ modifiers,
457
+ });
458
+ } else if (actualKey.length === 1) {
459
+ // Single character key (e.g., "a", "z")
460
+ const code = `Key${actualKey.toUpperCase()}`;
461
+ const keyCode = actualKey.toUpperCase().charCodeAt(0);
462
+ await this.send("Input.dispatchKeyEvent", {
463
+ type: "keyDown",
464
+ key: actualKey,
465
+ code,
466
+ windowsVirtualKeyCode: keyCode,
467
+ nativeVirtualKeyCode: keyCode,
468
+ modifiers,
469
+ });
470
+ if (!modifiers) {
471
+ // Only insert text for unmodified single characters
472
+ await this.send("Input.dispatchKeyEvent", {
473
+ type: "char",
474
+ text: actualKey,
475
+ modifiers,
476
+ });
477
+ }
478
+ await this.send("Input.dispatchKeyEvent", {
479
+ type: "keyUp",
480
+ key: actualKey,
481
+ code,
482
+ modifiers,
483
+ });
484
+ } else {
485
+ // Unknown key name — try as-is
486
+ await this.send("Input.dispatchKeyEvent", {
487
+ type: "keyDown",
488
+ key: actualKey,
489
+ modifiers,
490
+ });
491
+ await this.send("Input.dispatchKeyEvent", {
492
+ type: "keyUp",
493
+ key: actualKey,
494
+ modifiers,
495
+ });
496
+ }
497
+
498
+ return `Pressed key: ${key}`;
499
+ }
500
+
501
+ async scrollDown(): Promise<string> {
502
+ this.ensureConnected();
503
+ await this.send("Runtime.evaluate", {
504
+ expression: "window.scrollBy(0, window.innerHeight * 0.8)",
505
+ });
506
+ await new Promise((r) => setTimeout(r, 300));
507
+ return "Scrolled down.";
508
+ }
509
+
510
+ async scrollUp(): Promise<string> {
511
+ this.ensureConnected();
512
+ await this.send("Runtime.evaluate", {
513
+ expression: "window.scrollBy(0, -window.innerHeight * 0.8)",
514
+ });
515
+ await new Promise((r) => setTimeout(r, 300));
516
+ return "Scrolled up.";
517
+ }
518
+
519
+ // ── Annotated Snapshot (ref system) ─────────────────────────────
520
+
521
+ /**
522
+ * Take a snapshot of all interactive elements on the page.
523
+ *
524
+ * Strategy (informed by research — arxiv:2511.19477):
525
+ * - **Text ref table is ALWAYS returned** — compact, low-token, works for
526
+ * all page complexities including dense layouts (date pickers, tables).
527
+ * - **Annotated screenshot is OPTIONAL** (annotate parameter):
528
+ * - true: overlay ref badges on screenshot (best for simple pages with
529
+ * few interactive elements — gives visual context)
530
+ * - false: plain screenshot without overlays (default — avoids label
531
+ * clutter on dense pages; model still sees the page visually)
532
+ * - Research shows text-based grounding outperforms visual annotations
533
+ * on complex pages, and the hybrid approach (a11y text primary +
534
+ * selective vision) achieves ~85% vs ~50% for pure vision.
535
+ */
536
+ async snapshot(annotate = false): Promise<SnapshotResult> {
537
+ this.ensureConnected();
538
+
539
+ // Wait for page to be ready (auto-wait like Playwright)
540
+ await this.waitForLoad(5000);
541
+
542
+ // 1. Find all interactive elements, assign ref IDs, get bounding boxes
543
+ const findResult = await this.send("Runtime.evaluate", {
544
+ expression: `
545
+ (function() {
546
+ // Clean up previous refs
547
+ document.querySelectorAll('[data-assistme-ref]').forEach(function(el) {
548
+ el.removeAttribute('data-assistme-ref');
549
+ });
550
+
551
+ var selectors = [
552
+ 'a[href]', 'button', 'input:not([type="hidden"])', 'select', 'textarea',
553
+ '[role="button"]', '[role="link"]', '[role="checkbox"]', '[role="radio"]',
554
+ '[role="combobox"]', '[role="listbox"]', '[role="menuitem"]', '[role="tab"]',
555
+ '[role="switch"]', '[role="slider"]', '[role="option"]', '[role="searchbox"]',
556
+ '[onclick]', '[tabindex]:not([tabindex="-1"])',
557
+ '[contenteditable="true"]'
558
+ ].join(', ');
559
+
560
+ // Collect elements from main document AND same-origin iframes
561
+ var all = Array.from(document.querySelectorAll(selectors));
562
+ try {
563
+ var iframes = document.querySelectorAll('iframe');
564
+ for (var fi = 0; fi < iframes.length; fi++) {
565
+ try {
566
+ var iframeDoc = iframes[fi].contentDocument;
567
+ if (iframeDoc) {
568
+ var iframeRect = iframes[fi].getBoundingClientRect();
569
+ var iframeEls = iframeDoc.querySelectorAll(selectors);
570
+ for (var fe = 0; fe < iframeEls.length; fe++) {
571
+ // Tag iframe elements with offset for coordinate correction
572
+ iframeEls[fe].__iframeOffset = { x: iframeRect.x, y: iframeRect.y };
573
+ all.push(iframeEls[fe]);
574
+ }
575
+ }
576
+ } catch(e) { /* cross-origin iframe, skip */ }
577
+ }
578
+ } catch(e) { /* iframe enumeration failed, continue */ }
579
+
580
+ var refs = [];
581
+ var vh = window.innerHeight;
582
+ var vw = window.innerWidth;
583
+
584
+ for (var i = 0; i < all.length && refs.length < 80; i++) {
585
+ var el = all[i];
586
+ var rect = el.getBoundingClientRect();
587
+
588
+ // Skip invisible / tiny elements
589
+ if (rect.width < 5 || rect.height < 5) continue;
590
+ var style = window.getComputedStyle(el);
591
+ if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') continue;
592
+
593
+ // Skip elements far outside viewport
594
+ if (rect.bottom < -50 || rect.top > vh + 50) continue;
595
+ if (rect.right < -50 || rect.left > vw + 50) continue;
596
+
597
+ // Determine role
598
+ var role = el.getAttribute('role') || '';
599
+ if (!role) {
600
+ var tag = el.tagName.toLowerCase();
601
+ if (tag === 'a') role = 'link';
602
+ else if (tag === 'button') role = 'button';
603
+ else if (tag === 'input') {
604
+ var t = (el.type || 'text').toLowerCase();
605
+ if (t === 'checkbox') role = 'checkbox';
606
+ else if (t === 'radio') role = 'radio';
607
+ else if (t === 'submit' || t === 'button') role = 'button';
608
+ else role = 'textbox';
609
+ }
610
+ else if (tag === 'select') role = 'combobox';
611
+ else if (tag === 'textarea') role = 'textbox';
612
+ else role = tag;
613
+ }
614
+
615
+ // Determine accessible name
616
+ var name = '';
617
+ var ariaLabel = el.getAttribute('aria-label');
618
+ var ariaLabelledBy = el.getAttribute('aria-labelledby');
619
+ if (ariaLabel) {
620
+ name = ariaLabel;
621
+ } else if (ariaLabelledBy) {
622
+ var labelEl = document.getElementById(ariaLabelledBy);
623
+ if (labelEl) name = labelEl.textContent.trim();
624
+ } else if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
625
+ if (el.id) {
626
+ var lbl = document.querySelector('label[for="' + CSS.escape(el.id) + '"]');
627
+ if (lbl) name = lbl.textContent.trim();
628
+ }
629
+ if (!name) name = el.getAttribute('placeholder') || el.getAttribute('name') || '';
630
+ } else {
631
+ name = (el.textContent || '').trim().slice(0, 60);
632
+ }
633
+
634
+ var refId = refs.length + 1;
635
+ el.setAttribute('data-assistme-ref', String(refId));
636
+
637
+ // Correct coordinates for elements inside iframes
638
+ var offsetX = el.__iframeOffset ? el.__iframeOffset.x : 0;
639
+ var offsetY = el.__iframeOffset ? el.__iframeOffset.y : 0;
640
+
641
+ refs.push({
642
+ id: refId,
643
+ role: role,
644
+ name: name,
645
+ tag: el.tagName.toLowerCase(),
646
+ type: el.getAttribute('type') || '',
647
+ box: {
648
+ x: Math.round(rect.x + offsetX),
649
+ y: Math.round(rect.y + offsetY),
650
+ width: Math.round(rect.width),
651
+ height: Math.round(rect.height)
652
+ }
653
+ });
654
+ }
655
+
656
+ return JSON.stringify(refs);
657
+ })()
658
+ `,
659
+ returnByValue: true,
660
+ });
661
+
662
+ const refs: RefEntry[] = JSON.parse(
663
+ ((findResult as CDPEvalResult).result?.value as string) || "[]"
664
+ ).map((r: Record<string, unknown>) => ({
665
+ id: r.id as number,
666
+ role: r.role as string,
667
+ name: r.name as string,
668
+ tag: r.tag as string,
669
+ inputType: (r.type as string) || "",
670
+ box: r.box as BoundingBox,
671
+ }));
672
+
673
+ // 2. Optionally inject visual overlay with ref labels
674
+ // (Skip for dense pages — labels would overlap and become unreadable)
675
+ if (annotate && refs.length <= 40) {
676
+ const refsJson = JSON.stringify(refs);
677
+ await this.send("Runtime.evaluate", {
678
+ expression: `
679
+ (function() {
680
+ var old = document.getElementById('__assistme_refs__');
681
+ if (old) old.remove();
682
+
683
+ var overlay = document.createElement('div');
684
+ overlay.id = '__assistme_refs__';
685
+ overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:2147483647;';
686
+
687
+ var refs = ${refsJson};
688
+ var vh = window.innerHeight;
689
+ var vw = window.innerWidth;
690
+
691
+ for (var i = 0; i < refs.length; i++) {
692
+ var b = refs[i].box;
693
+ if (b.y + b.height < 0 || b.y > vh || b.x + b.width < 0 || b.x > vw) continue;
694
+
695
+ // Red badge with ref number
696
+ var badge = document.createElement('div');
697
+ var badgeTop = Math.max(0, b.y - 14);
698
+ var badgeLeft = Math.max(0, b.x);
699
+ badge.style.cssText = 'position:fixed;background:#e8384f;color:#fff;font:bold 10px/1.2 monospace;padding:1px 3px;border-radius:2px;white-space:nowrap;'
700
+ + 'left:' + badgeLeft + 'px;top:' + badgeTop + 'px;';
701
+ badge.textContent = String(refs[i].id);
702
+ overlay.appendChild(badge);
703
+
704
+ // Border around element
705
+ var border = document.createElement('div');
706
+ border.style.cssText = 'position:fixed;border:1.5px solid #e8384f;border-radius:2px;'
707
+ + 'left:' + b.x + 'px;top:' + b.y + 'px;width:' + b.width + 'px;height:' + b.height + 'px;';
708
+ overlay.appendChild(border);
709
+ }
710
+
711
+ document.documentElement.appendChild(overlay);
712
+ })()
713
+ `,
714
+ });
715
+ }
716
+
717
+ // 3. Take screenshot (with or without overlay)
718
+ const image = await this.screenshot();
719
+
720
+ // 4. Remove overlay if injected (keep data-assistme-ref attributes for later resolution)
721
+ if (annotate) {
722
+ await this.send("Runtime.evaluate", {
723
+ expression: `(function() { var el = document.getElementById('__assistme_refs__'); if (el) el.remove(); })()`,
724
+ });
725
+ }
726
+
727
+ // 5. Cache refs for subsequent act() calls
728
+ this.refCache.clear();
729
+ for (const ref of refs) {
730
+ this.refCache.set(ref.id, ref);
731
+ }
732
+
733
+ // 6. Get page info
734
+ const pageInfo = await this.getPageInfo();
735
+
736
+ return { image, refs, url: pageInfo.url, title: pageInfo.title };
737
+ }
738
+
739
+ /**
740
+ * Build a compact text table of refs for the model.
741
+ */
742
+ static formatRefTable(result: SnapshotResult): string {
743
+ let table = `Page: ${result.title}\nURL: ${result.url}\n\nRefs:\n`;
744
+ for (const ref of result.refs) {
745
+ const extra = ref.inputType ? ` (${ref.inputType})` : "";
746
+ const nameStr = ref.name ? ` "${ref.name}"` : "";
747
+ table += `[${ref.id}] ${ref.role}${nameStr}${extra}\n`;
748
+ }
749
+ if (result.refs.length === 0) {
750
+ table += "(no interactive elements found)\n";
751
+ }
752
+ return table;
753
+ }
754
+
755
+ // ── Ref Resolution ────────────────────────────────────────────────
756
+
757
+ /**
758
+ * Resolve a ref ID to its current center coordinates in the viewport.
759
+ * Uses two strategies:
760
+ * 1. Fast: find by data-assistme-ref attribute (set during snapshot)
761
+ * 2. Stable: search by role + accessible name (survives DOM changes)
762
+ *
763
+ * Includes actionability checks (like Playwright):
764
+ * - Element must be visible (not display:none, not zero-size)
765
+ * - Element must be in viewport (scrolls into view if needed)
766
+ * - Element must not be covered by another element (checks elementFromPoint)
767
+ *
768
+ * Returns null if the element cannot be found or is not actionable.
769
+ * Returns { error: string } if found but not actionable (for diagnostics).
770
+ */
771
+ private async resolveRef(
772
+ refId: number
773
+ ): Promise<{ x: number; y: number; width: number; height: number; error?: string } | null> {
774
+ const cached = this.refCache.get(refId);
775
+ const role = cached?.role || "";
776
+ const name = cached?.name || "";
777
+ const roleJS = JSON.stringify(role);
778
+ const nameJS = JSON.stringify(name);
779
+
780
+ const result = await this.send("Runtime.evaluate", {
781
+ expression: `
782
+ (function() {
783
+ var refId = ${refId};
784
+ var role = ${roleJS};
785
+ var name = ${nameJS};
786
+
787
+ // Strategy 1: data attribute (fast, from last snapshot)
788
+ var el = document.querySelector('[data-assistme-ref="' + refId + '"]');
789
+
790
+ // Strategy 2: role + name search (stable, survives DOM changes)
791
+ if (!el && role && name) {
792
+ var selectorMap = {
793
+ textbox: 'input, textarea, [role="textbox"], [role="searchbox"]',
794
+ button: 'button, [role="button"], input[type="submit"], input[type="button"]',
795
+ link: 'a[href], [role="link"]',
796
+ combobox: 'select, [role="combobox"]',
797
+ checkbox: 'input[type="checkbox"], [role="checkbox"]',
798
+ radio: 'input[type="radio"], [role="radio"]',
799
+ tab: '[role="tab"]',
800
+ menuitem: '[role="menuitem"]',
801
+ option: '[role="option"], option',
802
+ };
803
+ var sel = selectorMap[role] || '*[role="' + role + '"]';
804
+ var candidates = document.querySelectorAll(sel);
805
+ for (var i = 0; i < candidates.length; i++) {
806
+ var c = candidates[i];
807
+ var cName = c.getAttribute('aria-label')
808
+ || c.getAttribute('placeholder')
809
+ || (c.textContent || '').trim().slice(0, 60);
810
+ if (cName === name) { el = c; break; }
811
+ }
812
+ }
813
+
814
+ if (!el) return 'null';
815
+
816
+ // ── Actionability checks (Playwright-style) ──────────────
817
+
818
+ // Check visibility
819
+ var style = window.getComputedStyle(el);
820
+ if (style.display === 'none')
821
+ return JSON.stringify({ error: 'Element is hidden (display:none)' });
822
+ if (style.visibility === 'hidden')
823
+ return JSON.stringify({ error: 'Element is hidden (visibility:hidden)' });
824
+ if (parseFloat(style.opacity) < 0.05)
825
+ return JSON.stringify({ error: 'Element is hidden (opacity:0)' });
826
+
827
+ // Check disabled
828
+ if (el.disabled || el.getAttribute('aria-disabled') === 'true')
829
+ return JSON.stringify({ error: 'Element is disabled' });
830
+
831
+ // Scroll into view
832
+ el.scrollIntoView({ block: 'center', behavior: 'instant' });
833
+ var r = el.getBoundingClientRect();
834
+
835
+ // Check non-zero size
836
+ if (r.width < 1 || r.height < 1)
837
+ return JSON.stringify({ error: 'Element has zero size (' + r.width + 'x' + r.height + ')' });
838
+
839
+ // Check element is in viewport
840
+ if (r.bottom < 0 || r.top > window.innerHeight || r.right < 0 || r.left > window.innerWidth)
841
+ return JSON.stringify({ error: 'Element is outside viewport after scroll' });
842
+
843
+ var cx = r.x + r.width / 2;
844
+ var cy = r.y + r.height / 2;
845
+
846
+ // Check not covered by another element (hit test)
847
+ var topEl = document.elementFromPoint(cx, cy);
848
+ if (topEl && topEl !== el && !el.contains(topEl) && !topEl.closest('[data-assistme-ref="' + refId + '"]')) {
849
+ // Check if the covering element is the overlay (ignore it)
850
+ if (!topEl.closest('#__assistme_refs__')) {
851
+ var coverTag = topEl.tagName.toLowerCase();
852
+ var coverText = (topEl.textContent || '').trim().slice(0, 30);
853
+ return JSON.stringify({
854
+ error: 'Element is covered by <' + coverTag + '>' + (coverText ? ' "' + coverText + '"' : ''),
855
+ x: cx, y: cy, width: r.width, height: r.height
856
+ });
857
+ }
858
+ }
859
+
860
+ return JSON.stringify({
861
+ x: cx,
862
+ y: cy,
863
+ width: r.width,
864
+ height: r.height
865
+ });
866
+ })()
867
+ `,
868
+ returnByValue: true,
869
+ });
870
+
871
+ const value = (result as CDPEvalResult).result?.value as string;
872
+ if (!value || value === "null") return null;
873
+ try {
874
+ return JSON.parse(value);
875
+ } catch {
876
+ return null;
877
+ }
878
+ }
879
+
880
+ // ── Ref-based Interactions (CDP Input Events) ─────────────────────
881
+
882
+ /**
883
+ * Click an element by ref using CDP Input.dispatchMouseEvent.
884
+ * This simulates a real mouse click through the browser's input pipeline,
885
+ * triggering hover states, focus management, and all native browser events
886
+ * — more reliable than el.click() for framework components.
887
+ *
888
+ * Includes auto-wait: retries up to 3 times (with 500ms intervals) if the
889
+ * element is not yet actionable (e.g., covered by a loading overlay, still
890
+ * animating into view). This matches Playwright's auto-waiting behavior.
891
+ */
892
+ async clickRef(refId: number): Promise<RefActionResult> {
893
+ this.ensureConnected();
894
+ const ref = this.refCache.get(refId);
895
+ const refLabel = `[${refId}] ${ref?.role || ""} "${ref?.name || ""}"`;
896
+
897
+ // Auto-wait: retry up to 3 times if element is not actionable yet
898
+ const maxRetries = 3;
899
+ let lastError = "";
900
+
901
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
902
+ const resolved = await this.resolveRef(refId);
903
+
904
+ if (!resolved) {
905
+ return {
906
+ success: false,
907
+ message: `Ref ${refLabel} not found. Take a new snapshot with browser_snapshot.`,
908
+ };
909
+ }
910
+
911
+ if (resolved.error) {
912
+ lastError = resolved.error;
913
+ // If element is covered or hidden, wait and retry (it might be animating)
914
+ if (attempt < maxRetries - 1) {
915
+ await new Promise((r) => setTimeout(r, 500));
916
+ continue;
917
+ }
918
+ return { success: false, message: `Cannot click ${refLabel}: ${lastError}` };
919
+ }
920
+
921
+ // Element is actionable — small delay after scroll for rendering
922
+ if (attempt === 0) {
923
+ await new Promise((r) => setTimeout(r, 50));
924
+ // Re-read position after scroll settled
925
+ const settled = await this.resolveRef(refId);
926
+ if (settled && !settled.error) {
927
+ resolved.x = settled.x;
928
+ resolved.y = settled.y;
929
+ }
930
+ }
931
+
932
+ // Full mouse event sequence: move → press → release
933
+ await this.send("Input.dispatchMouseEvent", {
934
+ type: "mouseMoved",
935
+ x: resolved.x,
936
+ y: resolved.y,
937
+ });
938
+ await this.send("Input.dispatchMouseEvent", {
939
+ type: "mousePressed",
940
+ x: resolved.x,
941
+ y: resolved.y,
942
+ button: "left",
943
+ clickCount: 1,
944
+ });
945
+ await this.send("Input.dispatchMouseEvent", {
946
+ type: "mouseReleased",
947
+ x: resolved.x,
948
+ y: resolved.y,
949
+ button: "left",
950
+ clickCount: 1,
951
+ });
952
+
953
+ await new Promise((r) => setTimeout(r, 300));
954
+ return { success: true, message: `Clicked ${refLabel}` };
955
+ }
956
+
957
+ return { success: false, message: `Cannot click ${refLabel}: ${lastError}` };
958
+ }
959
+
960
+ /**
961
+ * Type text into an element by ref using CDP Input events.
962
+ * Clicks to focus, selects all existing text (Ctrl/Cmd+A), then uses
963
+ * Input.insertText for reliable text insertion across all frameworks.
964
+ */
965
+ async typeRef(refId: number, text: string): Promise<RefActionResult> {
966
+ this.ensureConnected();
967
+ const ref = this.refCache.get(refId);
968
+ const refLabel = `[${refId}] ${ref?.role || ""} "${ref?.name || ""}"`;
969
+
970
+ // Click to focus the element
971
+ const clickResult = await this.clickRef(refId);
972
+ if (!clickResult.success) return clickResult;
973
+ await new Promise((r) => setTimeout(r, 100));
974
+
975
+ // Clear existing text using multiple strategies for reliability:
976
+ // 1. Try Ctrl/Cmd+A to select all, then Backspace to delete
977
+ const selectAllKey = platform() === "darwin" ? "Meta+a" : "Control+a";
978
+ await this.pressKey(selectAllKey);
979
+ await new Promise((r) => setTimeout(r, 50));
980
+ await this.pressKey("Backspace");
981
+ await new Promise((r) => setTimeout(r, 50));
982
+
983
+ // 2. Verify the field is empty; if not, fall back to JS-based clearing
984
+ const cleared = await this.send("Runtime.evaluate", {
985
+ expression: `
986
+ (function() {
987
+ var el = document.querySelector('[data-assistme-ref="${refId}"]');
988
+ if (!el) return 'no_element';
989
+ if (el.value !== undefined && el.value !== '') {
990
+ // Ctrl+A didn't work (some frameworks intercept it) — clear via JS
991
+ var setter = Object.getOwnPropertyDescriptor(
992
+ window.HTMLInputElement.prototype, 'value'
993
+ )?.set || Object.getOwnPropertyDescriptor(
994
+ window.HTMLTextAreaElement.prototype, 'value'
995
+ )?.set;
996
+ if (setter) setter.call(el, '');
997
+ else el.value = '';
998
+ el.dispatchEvent(new Event('input', { bubbles: true }));
999
+ el.dispatchEvent(new Event('change', { bubbles: true }));
1000
+ return 'js_cleared';
1001
+ }
1002
+ return 'ok';
1003
+ })()
1004
+ `,
1005
+ returnByValue: true,
1006
+ });
1007
+ const clearStatus = ((cleared as CDPEvalResult).result?.value as string) || "ok";
1008
+ if (clearStatus === "no_element") {
1009
+ return {
1010
+ success: false,
1011
+ message: `Ref ${refLabel} not found after click. Take a new snapshot.`,
1012
+ };
1013
+ }
1014
+
1015
+ // Insert text via CDP (goes through the browser's input pipeline)
1016
+ await this.send("Input.insertText", { text });
1017
+
1018
+ await new Promise((r) => setTimeout(r, 100));
1019
+ return { success: true, message: `Typed "${text}" into ${refLabel}` };
1020
+ }
1021
+
1022
+ /**
1023
+ * Select a dropdown option by ref. Delegates to selectOption with the
1024
+ * ref's data attribute as selector, handling both native <select> and
1025
+ * custom dropdown components.
1026
+ */
1027
+ async selectRef(refId: number, option: string): Promise<RefActionResult> {
1028
+ this.ensureConnected();
1029
+
1030
+ const cached = this.refCache.get(refId);
1031
+ if (!cached) {
1032
+ return {
1033
+ success: false,
1034
+ message: `Ref [${refId}] not found. Take a new snapshot with browser_snapshot.`,
1035
+ };
1036
+ }
1037
+
1038
+ const refLabel = `[${refId}] ${cached.role} "${cached.name}"`;
1039
+ const result = await this.selectOption(`[data-assistme-ref="${refId}"]`, option);
1040
+ const message = result.replace(/\[data-assistme-ref="\d+"\]/, refLabel);
1041
+ const success = !result.includes("not found");
1042
+ return { success, message };
1043
+ }
1044
+
1045
+ // ── Action Pipeline ───────────────────────────────────────────────
1046
+
1047
+ /**
1048
+ * Execute a batch of actions sequentially using refs.
1049
+ * Reduces round-trips: instead of one tool call per action, the model
1050
+ * can specify a sequence of actions that execute atomically.
1051
+ *
1052
+ * Optionally takes a screenshot after all actions complete.
1053
+ */
1054
+ async act(
1055
+ actions: ActionSpec[],
1056
+ takeScreenshot = false
1057
+ ): Promise<{ results: ActionResult[]; screenshot?: string }> {
1058
+ this.ensureConnected();
1059
+ const results: ActionResult[] = [];
1060
+
1061
+ for (const spec of actions) {
1062
+ let result: string;
1063
+ let success = true;
1064
+
1065
+ try {
1066
+ switch (spec.action) {
1067
+ case "click": {
1068
+ const r = await this.clickRef(spec.ref);
1069
+ result = r.message;
1070
+ success = r.success;
1071
+ break;
1072
+ }
1073
+ case "type": {
1074
+ const r = await this.typeRef(spec.ref, spec.text);
1075
+ result = r.message;
1076
+ success = r.success;
1077
+ break;
1078
+ }
1079
+ case "select": {
1080
+ const r = await this.selectRef(spec.ref, spec.option);
1081
+ result = r.message;
1082
+ success = r.success;
1083
+ break;
1084
+ }
1085
+ case "press":
1086
+ result = await this.pressKey(spec.key);
1087
+ break;
1088
+ case "scroll":
1089
+ result = spec.direction === "up" ? await this.scrollUp() : await this.scrollDown();
1090
+ break;
1091
+ case "wait":
1092
+ await new Promise((r) => setTimeout(r, Math.min(spec.ms, 5000)));
1093
+ result = `Waited ${spec.ms}ms`;
1094
+ break;
1095
+ default:
1096
+ result = `Unknown action: ${(spec as { action: string }).action}`;
1097
+ success = false;
1098
+ }
1099
+ } catch (err) {
1100
+ result = `Error: ${err instanceof Error ? err.message : String(err)}`;
1101
+ success = false;
1102
+ }
1103
+
1104
+ results.push({
1105
+ action: spec.action,
1106
+ ref: "ref" in spec ? (spec as { ref: number }).ref : undefined,
1107
+ result,
1108
+ success,
1109
+ });
1110
+
1111
+ // If an action failed, stop the batch (remaining refs may be stale)
1112
+ if (!success) break;
1113
+
1114
+ // Brief pause between actions for DOM to settle
1115
+ if (spec.action !== "wait") {
1116
+ await new Promise((r) => setTimeout(r, 200));
1117
+ }
1118
+ }
1119
+
1120
+ let screenshot: string | undefined;
1121
+ if (takeScreenshot) {
1122
+ // Wait a bit for final DOM changes to settle
1123
+ await new Promise((r) => setTimeout(r, 300));
1124
+ screenshot = await this.screenshot();
1125
+ }
1126
+
1127
+ return { results, screenshot };
1128
+ }
1129
+
1130
+ // ── Dropdown/Select ─────────────────────────────────────────────
1131
+
1132
+ /**
1133
+ * Select an option from a dropdown — handles both native <select> elements
1134
+ * and custom Material Design / React / Angular dropdown components.
1135
+ *
1136
+ * Strategy:
1137
+ * 1. Try native <select> first (by selector or label text)
1138
+ * 2. Fall back to custom dropdown: click to open, then click the option by text
1139
+ */
1140
+ async selectOption(selector: string, optionText: string): Promise<string> {
1141
+ this.ensureConnected();
1142
+ const selectorJS = JSON.stringify(selector);
1143
+ const optionJS = JSON.stringify(optionText);
1144
+
1145
+ const result = await this.send("Runtime.evaluate", {
1146
+ expression: `
1147
+ (function() {
1148
+ var sel = ${selectorJS};
1149
+ var optText = ${optionJS};
1150
+
1151
+ // Strategy 1: Native <select> element
1152
+ var selectEl = document.querySelector(sel);
1153
+ if (selectEl && selectEl.tagName === 'SELECT') {
1154
+ var options = selectEl.querySelectorAll('option');
1155
+ for (var i = 0; i < options.length; i++) {
1156
+ if (options[i].textContent.trim() === optText) {
1157
+ selectEl.value = options[i].value;
1158
+ selectEl.dispatchEvent(new Event('change', { bubbles: true }));
1159
+ selectEl.dispatchEvent(new Event('input', { bubbles: true }));
1160
+ return 'Selected "' + optText + '" in native select';
1161
+ }
1162
+ }
1163
+ return 'Option "' + optText + '" not found in select. Available: ' +
1164
+ Array.from(options).map(function(o) { return o.textContent.trim(); }).join(', ');
1165
+ }
1166
+
1167
+ // Strategy 2: Custom dropdown — find the trigger element
1168
+ var trigger = selectEl;
1169
+ if (!trigger) {
1170
+ // Try finding by aria-label first (fast, indexed)
1171
+ trigger = document.querySelector('[aria-label="' + sel.replace(/"/g, '\\"') + '"]');
1172
+ }
1173
+ if (!trigger) {
1174
+ // Try finding by label/placeholder text in likely dropdown elements
1175
+ var dropdownCandidates = document.querySelectorAll(
1176
+ 'button, [role="combobox"], [role="listbox"], [role="button"], ' +
1177
+ 'select, input, .MuiSelect-root, .MuiInput-root, ' +
1178
+ '[class*="select"], [class*="dropdown"], [class*="picker"]'
1179
+ );
1180
+ for (var j = 0; j < dropdownCandidates.length; j++) {
1181
+ var el = dropdownCandidates[j];
1182
+ var ownText = Array.from(el.childNodes)
1183
+ .filter(function(n) { return n.nodeType === 3; })
1184
+ .map(function(n) { return n.textContent.trim(); })
1185
+ .join('');
1186
+ if (ownText === sel || el.getAttribute('aria-label') === sel ||
1187
+ el.getAttribute('placeholder') === sel) {
1188
+ trigger = el;
1189
+ break;
1190
+ }
1191
+ }
1192
+ }
1193
+
1194
+ if (!trigger) return 'Dropdown not found: ' + sel;
1195
+
1196
+ // Click to open the dropdown
1197
+ trigger.scrollIntoView({ block: 'center', behavior: 'instant' });
1198
+ trigger.click();
1199
+
1200
+ // Wait a frame for the dropdown menu to render, then select the option
1201
+ return new Promise(function(resolve) {
1202
+ setTimeout(function() {
1203
+ // Look for the option in listbox/menu/dropdown overlays
1204
+ var optionContainers = document.querySelectorAll(
1205
+ '[role="listbox"], [role="menu"], [role="presentation"], .MuiMenu-list, .MuiList-root, ul.mdc-list, .VfPpkd-xl07Ob'
1206
+ );
1207
+
1208
+ // Also check all visible elements as fallback
1209
+ var searchIn = optionContainers.length > 0
1210
+ ? Array.from(optionContainers).flatMap(function(c) { return Array.from(c.querySelectorAll('*')); })
1211
+ : Array.from(document.querySelectorAll('li, [role="option"], [role="menuitem"], div[data-value]'));
1212
+
1213
+ for (var k = 0; k < searchIn.length; k++) {
1214
+ var opt = searchIn[k];
1215
+ var txt = opt.textContent ? opt.textContent.trim() : '';
1216
+ if (txt === optText) {
1217
+ opt.scrollIntoView({ block: 'center', behavior: 'instant' });
1218
+ opt.click();
1219
+ resolve('Selected "' + optText + '" from custom dropdown');
1220
+ return;
1221
+ }
1222
+ }
1223
+
1224
+ // Broader search: visible leaf elements in interactive containers
1225
+ var broadCandidates = document.querySelectorAll(
1226
+ 'li, span, div, a, button, label, [role="option"], [role="menuitem"], ' +
1227
+ '[role="menuitemradio"], [role="menuitemcheckbox"], [data-value]'
1228
+ );
1229
+ for (var m = 0; m < broadCandidates.length; m++) {
1230
+ var candidate = broadCandidates[m];
1231
+ if (candidate.textContent && candidate.textContent.trim() === optText &&
1232
+ candidate.offsetParent !== null && candidate.children.length === 0) {
1233
+ candidate.click();
1234
+ resolve('Selected "' + optText + '" (broad match)');
1235
+ return;
1236
+ }
1237
+ }
1238
+
1239
+ resolve('Option "' + optText + '" not found in dropdown');
1240
+ }, 300);
1241
+ });
1242
+ })()
1243
+ `,
1244
+ returnByValue: true,
1245
+ awaitPromise: true,
1246
+ });
1247
+
1248
+ await new Promise((r) => setTimeout(r, 500));
1249
+ return ((result as CDPEvalResult).result?.value as string) || "Selection attempted.";
1250
+ }
1251
+
1252
+ // ── JavaScript Evaluation ───────────────────────────────────────
1253
+
1254
+ async evaluate(expression: string): Promise<string> {
1255
+ this.ensureConnected();
1256
+ const result = await this.send("Runtime.evaluate", {
1257
+ expression,
1258
+ returnByValue: true,
1259
+ awaitPromise: true,
1260
+ });
1261
+
1262
+ const evalResult = (result as CDPEvalResult).result;
1263
+ const value = evalResult?.value;
1264
+ if (value === undefined) {
1265
+ const desc = evalResult?.description;
1266
+ return desc || "(undefined)";
1267
+ }
1268
+ return typeof value === "string" ? value : JSON.stringify(value, null, 2);
1269
+ }
1270
+
1271
+ // ── Tab Management ──────────────────────────────────────────────
1272
+
1273
+ async listTabs(): Promise<string> {
1274
+ const tabs = await this.getTabs();
1275
+ const pageTabs = tabs.filter((t) => t.type === "page");
1276
+
1277
+ if (pageTabs.length === 0) return "No tabs open.";
1278
+
1279
+ return pageTabs
1280
+ .map(
1281
+ (t, i) =>
1282
+ `[${i}] ${t.title.slice(0, 60)}${this.currentTabId === t.id ? " (active)" : ""}\n ${t.url}`
1283
+ )
1284
+ .join("\n\n");
1285
+ }
1286
+
1287
+ async switchTab(index: number): Promise<string> {
1288
+ const tabs = await this.getTabs();
1289
+ const pageTabs = tabs.filter((t) => t.type === "page");
1290
+
1291
+ if (index < 0 || index >= pageTabs.length) {
1292
+ return `Invalid tab index. Available: 0-${pageTabs.length - 1}`;
1293
+ }
1294
+
1295
+ // Disconnect from current tab
1296
+ await this.disconnect();
1297
+
1298
+ // Connect to new tab
1299
+ return this.connect(index);
1300
+ }
1301
+
1302
+ async openNewTab(url?: string): Promise<string> {
1303
+ const targetUrl = url || "about:blank";
1304
+ const res = await fetch(
1305
+ `http://127.0.0.1:${this.debugPort}/json/new?${encodeURIComponent(targetUrl)}`,
1306
+ { signal: AbortSignal.timeout(5000) }
1307
+ );
1308
+ const tab = (await res.json()) as CDPTab;
1309
+
1310
+ // Connect to the new tab
1311
+ await this.disconnect();
1312
+ const tabs = await this.getTabs();
1313
+ const idx = tabs.filter((t) => t.type === "page").findIndex((t) => t.id === tab.id);
1314
+ if (idx >= 0) {
1315
+ await this.connect(idx);
1316
+ }
1317
+
1318
+ return `Opened new tab: ${targetUrl}`;
1319
+ }
1320
+
1321
+ // ── Helpers ─────────────────────────────────────────────────────
1322
+
1323
+ private async waitForLoad(timeoutMs = 8000): Promise<void> {
1324
+ const start = Date.now();
1325
+ let sawInteractive = false;
1326
+ while (Date.now() - start < timeoutMs) {
1327
+ try {
1328
+ const result = await this.send("Runtime.evaluate", {
1329
+ expression: "document.readyState",
1330
+ returnByValue: true,
1331
+ });
1332
+ const state = (result as CDPEvalResult).result?.value;
1333
+ if (state === "complete") {
1334
+ // Fully loaded — brief wait for dynamic content
1335
+ await new Promise((r) => setTimeout(r, 300));
1336
+ return;
1337
+ }
1338
+ if (state === "interactive") {
1339
+ if (!sawInteractive) {
1340
+ sawInteractive = true;
1341
+ // DOM is ready but sub-resources still loading — give it more
1342
+ // time to reach "complete" before settling for "interactive"
1343
+ }
1344
+ }
1345
+ } catch {
1346
+ // Tab might be navigating
1347
+ }
1348
+ await new Promise((r) => setTimeout(r, 300));
1349
+ }
1350
+ // Timed out — if we at least saw "interactive", that's usually good enough
1351
+ if (sawInteractive) {
1352
+ await new Promise((r) => setTimeout(r, 300));
1353
+ }
1354
+ }
1355
+
1356
+ isConnected(): boolean {
1357
+ return this.connected && this.ws?.readyState === WebSocket.OPEN;
1358
+ }
1359
+
1360
+ // ── Login Detection ────────────────────────────────────────────
1361
+
1362
+ /**
1363
+ * Detect if the current page appears to be a login/authentication page.
1364
+ * Checks URL patterns, password input fields, and login form actions.
1365
+ */
1366
+ async detectLoginPage(): Promise<{ isLoginPage: boolean; reason: string }> {
1367
+ try {
1368
+ const result = await this.send("Runtime.evaluate", {
1369
+ expression: `
1370
+ (function() {
1371
+ var url = window.location.href.toLowerCase();
1372
+
1373
+ // Exclude signup/registration pages — these are NOT login pages
1374
+ var signupPatterns = [
1375
+ '/signup', '/sign-up', '/sign_up', '/register',
1376
+ '/registration', '/create-account', '/create_account',
1377
+ '/join', '/enroll',
1378
+ 'accounts.google.com/lifecycle/steps/signup',
1379
+ 'signup.live.com',
1380
+ ];
1381
+ for (var s = 0; s < signupPatterns.length; s++) {
1382
+ if (url.indexOf(signupPatterns[s]) !== -1) {
1383
+ return JSON.stringify({ isLoginPage: false, reason: '' });
1384
+ }
1385
+ }
1386
+
1387
+ // URL-based detection
1388
+ var loginPatterns = [
1389
+ '/login', '/signin', '/sign-in', '/sign_in',
1390
+ '/auth/', '/sso/', '/oauth/', '/session/new',
1391
+ '/accounts/login', '/users/sign_in',
1392
+ 'accounts.google.com/v3/signin',
1393
+ 'accounts.google.com/servicelogin',
1394
+ 'login.microsoftonline.com',
1395
+ 'github.com/login', 'github.com/session',
1396
+ 'login.live.com', 'appleid.apple.com'
1397
+ ];
1398
+ for (var i = 0; i < loginPatterns.length; i++) {
1399
+ if (url.indexOf(loginPatterns[i]) !== -1) {
1400
+ return JSON.stringify({
1401
+ isLoginPage: true,
1402
+ reason: 'URL contains login pattern: ' + loginPatterns[i]
1403
+ });
1404
+ }
1405
+ }
1406
+
1407
+ // Password input detection (visible only)
1408
+ var passwordInputs = document.querySelectorAll('input[type="password"]');
1409
+ for (var j = 0; j < passwordInputs.length; j++) {
1410
+ var input = passwordInputs[j];
1411
+ var rect = input.getBoundingClientRect();
1412
+ var style = window.getComputedStyle(input);
1413
+ if (rect.width > 0 && rect.height > 0 &&
1414
+ style.display !== 'none' && style.visibility !== 'hidden') {
1415
+ return JSON.stringify({
1416
+ isLoginPage: true,
1417
+ reason: 'Page contains visible password input field'
1418
+ });
1419
+ }
1420
+ }
1421
+
1422
+ // Login form action detection
1423
+ var formSelectors = [
1424
+ 'form[action*="login"]', 'form[action*="signin"]',
1425
+ 'form[action*="session"]', 'form[action*="auth"]',
1426
+ 'form[action*="authenticate"]'
1427
+ ];
1428
+ var loginForms = document.querySelectorAll(formSelectors.join(','));
1429
+ if (loginForms.length > 0) {
1430
+ return JSON.stringify({
1431
+ isLoginPage: true,
1432
+ reason: 'Page contains login form'
1433
+ });
1434
+ }
1435
+
1436
+ return JSON.stringify({ isLoginPage: false, reason: '' });
1437
+ })()
1438
+ `,
1439
+ returnByValue: true,
1440
+ });
1441
+
1442
+ const value = (result as CDPEvalResult).result?.value as string;
1443
+ return JSON.parse(value || '{"isLoginPage":false,"reason":""}');
1444
+ } catch {
1445
+ return { isLoginPage: false, reason: "" };
1446
+ }
1447
+ }
1448
+ }