yunti-browser-runtime 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +256 -0
  3. package/bin/yunti-browser-runtime.js +86 -0
  4. package/docs/EXECUTION_PLAN.md +1278 -0
  5. package/docs/INSTALL.md +205 -0
  6. package/docs/PROJECT_INTENT.md +44 -0
  7. package/docs/PROJECT_STATUS.md +263 -0
  8. package/docs/PUBLISHING_BLOCKERS.md +110 -0
  9. package/docs/RELEASE.md +148 -0
  10. package/docs/ROADMAP.md +42 -0
  11. package/docs/SECURITY.md +56 -0
  12. package/docs/TOOL_GUIDE.md +69 -0
  13. package/extension/background.js +55 -0
  14. package/extension/cdp.js +582 -0
  15. package/extension/content.css +9 -0
  16. package/extension/content.js +946 -0
  17. package/extension/manifest.json +35 -0
  18. package/extension/network-monitor.js +140 -0
  19. package/extension/popup.css +66 -0
  20. package/extension/popup.html +30 -0
  21. package/extension/popup.js +55 -0
  22. package/extension/session-manager.js +332 -0
  23. package/extension/settings.js +94 -0
  24. package/extension/tool-handlers.js +1158 -0
  25. package/lib/runtime-paths.js +39 -0
  26. package/mcp/bridge-hub.js +604 -0
  27. package/mcp/http-server.js +326 -0
  28. package/mcp/json-rpc.js +35 -0
  29. package/mcp/memory.js +126 -0
  30. package/mcp/redaction.js +94 -0
  31. package/mcp/server.js +269 -0
  32. package/mcp/tools.js +1092 -0
  33. package/package.json +60 -0
  34. package/scripts/check-package-metadata.js +131 -0
  35. package/scripts/check-published-package.js +113 -0
  36. package/scripts/doctor.js +163 -0
  37. package/scripts/package-extension.js +137 -0
  38. package/scripts/print-config.js +116 -0
  39. package/scripts/release-check.js +472 -0
  40. package/skills/yunti-browser-runtime/SKILL.md +77 -0
@@ -0,0 +1,1158 @@
1
+ export function createToolDispatcher({
2
+ sessionsByTab,
3
+ pollers,
4
+ postBridge,
5
+ startPolling,
6
+ cdp,
7
+ }) {
8
+ const {
9
+ chromeDebuggerSendCommand,
10
+ delayCdp,
11
+ detachCdpTab,
12
+ ensureCdpAttached,
13
+ getBrowserTarget,
14
+ listBrowserTargets,
15
+ sendCdpCommand,
16
+ startPerformanceTrace,
17
+ stopPerformanceTrace,
18
+ } = cdp
19
+
20
+ async function executeToolRequest(tabId, session, event) {
21
+ let result
22
+ let ok = true
23
+ let error = null
24
+ try {
25
+ if (event.tool === "yunti_capture_visible_tab") {
26
+ try {
27
+ const dataUrl = await chrome.tabs.captureVisibleTab(session.windowId, {
28
+ format: "png",
29
+ })
30
+ result = {
31
+ dataUrl,
32
+ mimeType: "image/png",
33
+ method: "tabs.captureVisibleTab",
34
+ browserSessionId: session.browserSessionId,
35
+ url: session.url,
36
+ title: session.title || "",
37
+ capturedAt: new Date().toISOString(),
38
+ }
39
+ } catch (permError) {
40
+ // Fall back to CDP Page.captureScreenshot when activeTab
41
+ // permission is not available (e.g. tool call via relay without
42
+ // direct user interaction on the extension)
43
+ await ensureCdpAttached(tabId, "1.3")
44
+ const shot = await chromeDebuggerSendCommand(
45
+ { tabId },
46
+ "Page.captureScreenshot",
47
+ { format: "png" }
48
+ )
49
+ const dataUrl = `data:image/png;base64,${shot?.data || ""}`
50
+ result = {
51
+ dataUrl,
52
+ mimeType: "image/png",
53
+ method: "cdp.Page.captureScreenshot",
54
+ browserSessionId: session.browserSessionId,
55
+ url: session.url,
56
+ title: session.title || "",
57
+ capturedAt: new Date().toISOString(),
58
+ }
59
+ }
60
+ } else if (event.tool === "yunti_cdp_send_command") {
61
+ result = await sendCdpCommand(tabId, session, event.arguments || {})
62
+ } else if (event.tool === "yunti_list_browser_targets" || event.tool === "yunti_list_pages") {
63
+ result = await listBrowserTargets(session)
64
+ } else if (event.tool === "yunti_get_browser_target") {
65
+ result = await getBrowserTarget(session, event.arguments || {})
66
+ } else if (event.tool === "yunti_cdp_detach") {
67
+ result = await detachCdpTab(tabId, session, "tool_request")
68
+ } else if (event.tool === "yunti_navigate_page") {
69
+ result = await navigatePage(tabId, session, event.arguments || {})
70
+ } else if (event.tool === "yunti_take_screenshot") {
71
+ result = await takeScreenshot(tabId, session, event.arguments || {})
72
+ } else if (event.tool === "yunti_evaluate_script") {
73
+ result = await evaluateScript(tabId, session, event.arguments || {})
74
+ } else if (event.tool === "yunti_take_snapshot") {
75
+ result = await takeSnapshot(tabId, session, event.arguments || {})
76
+ } else if (event.tool === "yunti_click") {
77
+ result = await clickByUid(tabId, session, event.arguments || {})
78
+ } else if (event.tool === "yunti_hover") {
79
+ result = await hoverByUid(tabId, session, event.arguments || {})
80
+ } else if (event.tool === "yunti_fill") {
81
+ result = await fillByUid(tabId, session, event.arguments || {})
82
+ } else if (event.tool === "yunti_fill_form") {
83
+ result = await fillForm(tabId, session, event.arguments || {})
84
+ } else if (event.tool === "yunti_wait_for") {
85
+ result = await waitForCondition(tabId, session, event.arguments || {})
86
+ } else if (event.tool === "yunti_handle_dialog") {
87
+ result = await handleDialog(tabId, session, event.arguments || {})
88
+ } else if (event.tool === "yunti_resize_page") {
89
+ result = await resizePage(tabId, session, event.arguments || {})
90
+ } else if (event.tool === "yunti_emulate") {
91
+ result = await emulateDevice(tabId, session, event.arguments || {})
92
+ } else if (event.tool === "yunti_performance_start_trace") {
93
+ result = await startPerformanceTrace(tabId, session, event.arguments || {})
94
+ } else if (event.tool === "yunti_performance_stop_trace") {
95
+ result = await stopPerformanceTrace(tabId, session)
96
+ } else if (event.tool === "yunti_new_page") {
97
+ result = await newPage(session, event.arguments || {})
98
+ } else if (event.tool === "yunti_close_page") {
99
+ result = await closePage(tabId, session, event.arguments || {})
100
+ } else if (event.tool === "yunti_drag") {
101
+ result = await performDrag(tabId, session, event.arguments || {})
102
+ } else if (event.tool === "yunti_upload_file") {
103
+ result = await uploadFile(tabId, session, event.arguments || {})
104
+ } else if (event.tool === "yunti_type_text") {
105
+ result = await typeTextByUid(tabId, session, event.arguments || {})
106
+ } else if (event.tool === "yunti_press_key") {
107
+ result = await pressKeyByUid(tabId, session, event.arguments || {})
108
+ } else {
109
+ result = await chrome.tabs.sendMessage(tabId, {
110
+ type: "yunti_execute_tool",
111
+ tool: event.tool,
112
+ arguments: event.arguments || {},
113
+ })
114
+ }
115
+ } catch (err) {
116
+ ok = false
117
+ error = err instanceof Error ? err.message : String(err)
118
+ }
119
+
120
+ await postBridge("/extension/result", {
121
+ browserSessionId: session.browserSessionId,
122
+ requestId: event.id,
123
+ ok,
124
+ result,
125
+ error,
126
+ }).catch(() => {})
127
+ }
128
+
129
+ async function navigatePage(tabId, session, args = {}) {
130
+ const action = String(args.action || "url").trim()
131
+ const url = String(args.url || "").trim()
132
+
133
+ if (action === "reload") {
134
+ await chrome.tabs.reload(tabId)
135
+ return { navigated: true, action: "reload", tabId, browserSessionId: session.browserSessionId }
136
+ }
137
+
138
+ if (action === "back" || action === "forward") {
139
+ await ensureCdpAttached(tabId, "1.3")
140
+ let history
141
+ try {
142
+ history = await chromeDebuggerSendCommand(
143
+ { tabId },
144
+ "Page.getNavigationHistory",
145
+ {}
146
+ )
147
+ } catch (error) {
148
+ return navigateHistoryViaRuntime(
149
+ tabId,
150
+ session,
151
+ action,
152
+ `Page.getNavigationHistory failed: ${error?.message || String(error)}`
153
+ )
154
+ }
155
+ const entries = history?.entries || []
156
+ const currentIndex = history?.currentIndex ?? -1
157
+ const targetIndex = action === "back" ? currentIndex - 1 : currentIndex + 1
158
+ if (targetIndex < 0 || targetIndex >= entries.length) {
159
+ return navigateHistoryViaRuntime(
160
+ tabId,
161
+ session,
162
+ action,
163
+ `no CDP history entry at index ${targetIndex}`
164
+ )
165
+ }
166
+ try {
167
+ await chromeDebuggerSendCommand(
168
+ { tabId },
169
+ "Page.navigateToHistoryEntry",
170
+ { entryId: entries[targetIndex].id }
171
+ )
172
+ return {
173
+ navigated: true,
174
+ action,
175
+ tabId,
176
+ url: entries[targetIndex].url,
177
+ browserSessionId: session.browserSessionId,
178
+ method: "Page.navigateToHistoryEntry",
179
+ }
180
+ } catch (error) {
181
+ return navigateHistoryViaRuntime(
182
+ tabId,
183
+ session,
184
+ action,
185
+ `Page.navigateToHistoryEntry failed: ${error?.message || String(error)}`
186
+ )
187
+ }
188
+ }
189
+
190
+ // action === "url" (default)
191
+ if (!url) throw new Error("url is required for navigate action 'url'")
192
+ await chrome.tabs.update(tabId, { url })
193
+ return { navigated: true, action: "url", url, tabId, browserSessionId: session.browserSessionId }
194
+ }
195
+
196
+ async function navigateHistoryViaRuntime(tabId, session, action, reason) {
197
+ const method = action === "back" ? "back" : "forward"
198
+ try {
199
+ await chromeDebuggerSendCommand(
200
+ { tabId },
201
+ "Runtime.evaluate",
202
+ {
203
+ expression: `window.history.${method}(); true`,
204
+ returnByValue: true,
205
+ }
206
+ )
207
+ } catch (error) {
208
+ throw new Error(
209
+ `Cannot navigate ${action}: ${reason}; Runtime.evaluate window.history.${method}() failed: ${error?.message || String(error)}`
210
+ )
211
+ }
212
+ return {
213
+ navigated: true,
214
+ action,
215
+ tabId,
216
+ browserSessionId: session.browserSessionId,
217
+ method: `Runtime.evaluate(window.history.${method})`,
218
+ fallback: true,
219
+ fallbackReason: reason,
220
+ }
221
+ }
222
+
223
+ async function takeScreenshot(tabId, session, args = {}) {
224
+ const format = String(args.format || "png").trim() === "jpeg" ? "jpeg" : "png"
225
+ const quality = format === "jpeg" ? clampQuality(args.quality) : undefined
226
+ const fullPage = Boolean(args.fullPage)
227
+ const clip = normalizeClip(args.clip)
228
+
229
+ await ensureCdpAttached(tabId, "1.3")
230
+
231
+ if (fullPage) {
232
+ // Get full page dimensions
233
+ const metrics = await chromeDebuggerSendCommand(
234
+ { tabId },
235
+ "Page.getLayoutMetrics",
236
+ {}
237
+ )
238
+ const cssContentSize = metrics?.cssContentSize || metrics?.contentSize || {}
239
+ const width = cssContentSize.width || 1280
240
+ const height = cssContentSize.height || 720
241
+
242
+ await chromeDebuggerSendCommand(
243
+ { tabId },
244
+ "Emulation.setDeviceMetricsOverride",
245
+ { width: Math.ceil(width), height: Math.ceil(height), deviceScaleFactor: 1, mobile: false }
246
+ )
247
+ }
248
+
249
+ const params = { format }
250
+ if (quality !== undefined) params.quality = quality
251
+ if (clip) {
252
+ params.clip = { x: clip.x, y: clip.y, width: clip.width, height: clip.height, scale: clip.scale ?? 1 }
253
+ }
254
+ if (fullPage) {
255
+ params.captureBeyondViewport = true
256
+ }
257
+
258
+ let shot
259
+ try {
260
+ shot = await chromeDebuggerSendCommand({ tabId }, "Page.captureScreenshot", params)
261
+ } finally {
262
+ if (fullPage) {
263
+ await chromeDebuggerSendCommand(
264
+ { tabId },
265
+ "Emulation.clearDeviceMetricsOverride",
266
+ {}
267
+ ).catch(() => {})
268
+ }
269
+ }
270
+
271
+ const mimeType = `image/${format}`
272
+ const dataUrl = `data:${mimeType};base64,${shot?.data || ""}`
273
+ return {
274
+ dataUrl,
275
+ mimeType,
276
+ browserSessionId: session.browserSessionId,
277
+ format,
278
+ fullPage,
279
+ capturedAt: new Date().toISOString(),
280
+ }
281
+ }
282
+
283
+ function clampQuality(value) {
284
+ const num = Number(value)
285
+ return Number.isFinite(num) ? Math.max(0, Math.min(100, Math.round(num))) : 80
286
+ }
287
+
288
+ function normalizeClip(value) {
289
+ if (!value || typeof value !== "object") return null
290
+ const x = Number(value.x)
291
+ const y = Number(value.y)
292
+ const width = Number(value.width)
293
+ const height = Number(value.height)
294
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(width) || !Number.isFinite(height)) return null
295
+ const scale = Number.isFinite(Number(value.scale)) && Number(value.scale) > 0 ? Number(value.scale) : 1
296
+ return { x, y, width, height, scale }
297
+ }
298
+
299
+ async function evaluateScript(tabId, session, args = {}) {
300
+ const expression = String(args.expression || "").trim()
301
+ if (!expression) throw new Error("expression is required")
302
+ const awaitPromise = Boolean(args.awaitPromise)
303
+ const maxLength = Number.isFinite(Number(args.maxLength)) && Number(args.maxLength) > 0
304
+ ? Math.min(Number(args.maxLength), 100000)
305
+ : 10000
306
+
307
+ await ensureCdpAttached(tabId, "1.3")
308
+
309
+ const result = await chromeDebuggerSendCommand(
310
+ { tabId },
311
+ "Runtime.evaluate",
312
+ { expression, returnByValue: true, awaitPromise }
313
+ )
314
+
315
+ if (result?.exceptionDetails) {
316
+ const text = result.exceptionDetails.text || result.exceptionDetails.exception?.description || "Script error"
317
+ throw new Error(text)
318
+ }
319
+
320
+ const value = result?.result?.value
321
+ const type = result?.result?.type || typeof value
322
+ const serialized = serializeResult(value, maxLength)
323
+
324
+ return {
325
+ value: serialized,
326
+ type,
327
+ truncated: typeof value === "string" && value.length > maxLength,
328
+ browserSessionId: session.browserSessionId,
329
+ }
330
+ }
331
+
332
+ function serializeResult(value, maxLength) {
333
+ if (value === undefined || value === null) return value
334
+ if (typeof value === "string") {
335
+ return value.length > maxLength ? value.slice(0, maxLength) + "..." : value
336
+ }
337
+ if (typeof value === "number" || typeof value === "boolean") return value
338
+ try {
339
+ const json = JSON.stringify(value)
340
+ return json.length > maxLength ? json.slice(0, maxLength) + "..." : json
341
+ } catch {
342
+ return String(value).slice(0, maxLength)
343
+ }
344
+ }
345
+
346
+ // Snapshot state — maps uid to element info for click/fill/hover
347
+ const snapshotStore = new Map()
348
+
349
+ async function takeSnapshot(tabId, session, args = {}) {
350
+ const maxElements = Number.isFinite(Number(args.maxElements))
351
+ ? Math.max(1, Math.min(500, Number(args.maxElements)))
352
+ : 200
353
+ const includeHidden = Boolean(args.includeHidden)
354
+
355
+ await ensureCdpAttached(tabId, "1.3")
356
+
357
+ let elements = []
358
+ try {
359
+ const axTree = await chromeDebuggerSendCommand(
360
+ { tabId },
361
+ "Accessibility.getFullAXTree",
362
+ {}
363
+ )
364
+ elements = flattenAXTree(axTree?.nodes || [], maxElements, includeHidden)
365
+ } catch {
366
+ // Accessibility tree unavailable, fall back to DOM
367
+ elements = await domFallbackSnapshot(tabId, maxElements)
368
+ }
369
+
370
+ // Build uid mapping
371
+ const uidMap = {}
372
+ for (const el of elements) {
373
+ uidMap[el.uid] = { ...el }
374
+ }
375
+ snapshotStore.set(session.browserSessionId, uidMap)
376
+
377
+ return {
378
+ browserSessionId: session.browserSessionId,
379
+ url: session.url || "",
380
+ title: session.title || "",
381
+ elements,
382
+ elementCount: elements.length,
383
+ snapshotId: `snap-${Date.now()}`,
384
+ }
385
+ }
386
+
387
+ function flattenAXTree(nodes, maxElements, includeHidden) {
388
+ const elements = []
389
+ let uidCounter = 0
390
+
391
+ function walk(nodeList) {
392
+ for (const node of nodeList) {
393
+ if (elements.length >= maxElements) return
394
+ const el = axNodeToElement(node, ++uidCounter, includeHidden)
395
+ if (el) elements.push(el)
396
+ if (node.children) walk(node.children)
397
+ if (node.childIds) {
398
+ // childIds reference other nodes in the tree by backendDOMNodeId
399
+ }
400
+ }
401
+ }
402
+
403
+ walk(Array.isArray(nodes) ? nodes : [])
404
+ return elements
405
+ }
406
+
407
+ function axNodeToElement(node, uid, includeHidden) {
408
+ const role = String(node.role?.value || "").toLowerCase()
409
+ if (!role) return null
410
+
411
+ const name = String(node.name?.value || "").trim()
412
+ const description = String(node.description?.value || "").trim()
413
+
414
+ // Skip non-interactive and hidden elements
415
+ const isInteractive = /button|link|textbox|combobox|listbox|checkbox|radio|menuitem|tab|switch|slider|spinbutton|option|treeitem|gridcell|rowheader|columnheader/i.test(role)
416
+ const isStructural = /heading|list|listitem|table|row|cell|group|region|banner|main|navigation|article|section|status|alert/i.test(role)
417
+ const hasName = name.length > 0
418
+
419
+ if (!isInteractive && !isStructural && !hasName) return null
420
+ if (!includeHidden) {
421
+ const hidden = node.properties?.find(p => p.name === "hidden")?.value?.value
422
+ if (hidden === true) return null
423
+ }
424
+
425
+ return {
426
+ uid: `yunti-${uid}`,
427
+ role,
428
+ name,
429
+ description: description || undefined,
430
+ backendNodeId: node.backendDOMNodeId,
431
+ nodeId: node.nodeId,
432
+ childCount: (node.children?.length || 0),
433
+ properties: summarizeAXProperties(node.properties),
434
+ }
435
+ }
436
+
437
+ function summarizeAXProperties(properties) {
438
+ if (!Array.isArray(properties)) return undefined
439
+ const out = {}
440
+ for (const prop of properties) {
441
+ if (prop.name === "hidden" || prop.name === "focusable" || prop.name === "focused" ||
442
+ prop.name === "disabled" || prop.name === "checked" || prop.name === "selected" ||
443
+ prop.name === "expanded" || prop.name === "haspopup" || prop.name === "required" ||
444
+ prop.name === "invalid" || prop.name === "level" || prop.name === "valuemin" ||
445
+ prop.name === "valuemax" || prop.name === "valuenow" || prop.name === "valuetext" ||
446
+ prop.name === "multiselectable" || prop.name === "readonly") {
447
+ out[prop.name] = prop.value?.value ?? prop.value ?? true
448
+ }
449
+ }
450
+ return Object.keys(out).length ? out : undefined
451
+ }
452
+
453
+ async function domFallbackSnapshot(tabId, maxElements) {
454
+ const result = await chromeDebuggerSendCommand(
455
+ { tabId },
456
+ "Runtime.evaluate",
457
+ {
458
+ expression: `(() => {
459
+ const elements = [];
460
+ const interactive = 'a,button,input,select,textarea,[role="button"],[role="link"],[role="textbox"],[role="combobox"],[role="checkbox"],[role="radio"],[onclick]';
461
+ const all = document.querySelectorAll(interactive);
462
+ for (let i = 0; i < Math.min(all.length, ${maxElements}); i++) {
463
+ const el = all[i];
464
+ const rect = el.getBoundingClientRect();
465
+ if (rect.width === 0 && rect.height === 0) continue;
466
+ if (rect.bottom < 0 || rect.top > window.innerHeight) continue;
467
+ elements.push({
468
+ tag: el.tagName.toLowerCase(),
469
+ id: el.id || undefined,
470
+ name: (el.getAttribute('name') || '').slice(0, 100) || undefined,
471
+ text: (el.textContent || '').trim().slice(0, 200) || undefined,
472
+ type: el.type || undefined,
473
+ placeholder: (el.placeholder || '').slice(0, 100) || undefined,
474
+ href: el.href || undefined,
475
+ role: el.getAttribute('role') || undefined,
476
+ ariaLabel: (el.getAttribute('aria-label') || '').slice(0, 200) || undefined,
477
+ rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
478
+ disabled: el.disabled || el.getAttribute('aria-disabled') === 'true',
479
+ });
480
+ }
481
+ return elements;
482
+ })()`,
483
+ returnByValue: true,
484
+ }
485
+ )
486
+ const rawElements = result?.result?.value || []
487
+ return rawElements.map((el, i) => ({
488
+ uid: `yunti-${i + 1}`,
489
+ role: el.role || el.tag || "element",
490
+ name: el.ariaLabel || el.text || el.name || el.placeholder || el.id || "",
491
+ tag: el.tag,
492
+ id: el.id,
493
+ type: el.type,
494
+ placeholder: el.placeholder,
495
+ href: el.href,
496
+ rect: el.rect,
497
+ disabled: el.disabled,
498
+ }))
499
+ }
500
+
501
+ async function clickByUid(tabId, session, args = {}) {
502
+ const uid = String(args.uid || "").trim()
503
+ if (uid) {
504
+ return clickViaSnapshotUid(tabId, session, uid)
505
+ }
506
+ const x = Number(args.x)
507
+ const y = Number(args.y)
508
+ if (Number.isFinite(x) || Number.isFinite(y)) {
509
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
510
+ throw new Error("yunti_click coordinate mode requires both x and y. Use yunti_click_at with x/y, or call yunti_take_snapshot and pass uid.")
511
+ }
512
+ return clickAtCoordinate(tabId, session, x, y)
513
+ }
514
+ if (!String(args.selector || "").trim()) {
515
+ throw new Error("yunti_click requires uid, selector, or both x and y. Call yunti_take_snapshot to get uid, pass a CSS selector, or use yunti_click_at for coordinate-only clicks.")
516
+ }
517
+ // Fall back to content script for selector-based click
518
+ return chrome.tabs.sendMessage(tabId, {
519
+ type: "yunti_execute_tool",
520
+ tool: "yunti_click",
521
+ arguments: args,
522
+ })
523
+ }
524
+
525
+ async function hoverByUid(tabId, session, args = {}) {
526
+ const uid = String(args.uid || "").trim()
527
+ if (uid) {
528
+ return hoverViaSnapshotUid(tabId, session, uid)
529
+ }
530
+ const x = Number(args.x)
531
+ const y = Number(args.y)
532
+ if (Number.isFinite(x) || Number.isFinite(y)) {
533
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
534
+ throw new Error("yunti_hover coordinate mode requires both x and y. Call yunti_take_snapshot to get uid, pass selector, or provide both coordinates.")
535
+ }
536
+ await mouseMove(tabId, x, y)
537
+ return { hovered: true, x: Math.round(x), y: Math.round(y), browserSessionId: session.browserSessionId, method: "coordinate" }
538
+ }
539
+ const selector = String(args.selector || "").trim()
540
+ if (!selector) {
541
+ throw new Error("yunti_hover requires uid, selector, or both x and y. Call yunti_take_snapshot to get uid or pass a CSS selector.")
542
+ }
543
+ const point = await resolveSelectorCenter(tabId, selector)
544
+ await mouseMove(tabId, point.x, point.y)
545
+ return {
546
+ hovered: true,
547
+ selector,
548
+ x: Math.round(point.x),
549
+ y: Math.round(point.y),
550
+ browserSessionId: session.browserSessionId,
551
+ method: "selector",
552
+ }
553
+ }
554
+
555
+ async function fillByUid(tabId, session, args = {}) {
556
+ validateFillArgs(args)
557
+ const uid = String(args.uid || "").trim()
558
+ const value = String(args.value)
559
+ if (uid) {
560
+ const { x, y } = await resolveUidCenter(tabId, session, uid)
561
+ await mouseClick(tabId, x, y, 1)
562
+ await delayCdp(100)
563
+ // Clear existing value
564
+ const elements = await chromeDebuggerSendCommand(
565
+ { tabId },
566
+ "Runtime.evaluate",
567
+ { expression: `(() => {
568
+ const el = document.elementFromPoint(${x}, ${y});
569
+ if (!el) return 'not found';
570
+ const tag = el.tagName.toLowerCase();
571
+ if (tag === 'select') {
572
+ const opts = Array.from(el.options);
573
+ return { tag: 'select', options: opts.map(o => ({ value: o.value, text: o.text.slice(0, 80) })), selectedIndex: el.selectedIndex };
574
+ }
575
+ if (el.isContentEditable) {
576
+ el.textContent = '';
577
+ } else if (tag === 'input' || tag === 'textarea') {
578
+ el.focus();
579
+ el.select();
580
+ }
581
+ return { tag, type: el.type, contentEditable: el.isContentEditable };
582
+ })()`,
583
+ returnByValue: true,
584
+ }
585
+ )
586
+ const elInfo = elements?.result?.value
587
+
588
+ if (elInfo === "not found") throw new Error(`No element found at uid ${uid} coordinates`)
589
+
590
+ // Handle select element
591
+ if (elInfo?.tag === "select") {
592
+ const targetOption = elInfo.options?.find(
593
+ o => o.value === value || o.text === value
594
+ )
595
+ if (targetOption) {
596
+ await chromeDebuggerSendCommand(
597
+ { tabId },
598
+ "Runtime.evaluate",
599
+ {
600
+ expression: `(() => {
601
+ const el = document.elementFromPoint(${x}, ${y});
602
+ if (el) el.value = ${JSON.stringify(targetOption.value)};
603
+ el.dispatchEvent(new Event('change', { bubbles: true }));
604
+ el.dispatchEvent(new Event('input', { bubbles: true }));
605
+ })()`,
606
+ }
607
+ )
608
+ return { filled: true, uid, method: "select", value: targetOption.value, browserSessionId: session.browserSessionId }
609
+ }
610
+ throw new Error(`Option '${value}' not found in select at uid ${uid}. Use yunti_take_snapshot to inspect the select element or pass an available option value/text.`)
611
+ }
612
+
613
+ // Type text via CDP Input.dispatchKeyEvent
614
+ const text = value
615
+ for (const char of text) {
616
+ await chromeDebuggerSendCommand(
617
+ { tabId },
618
+ "Input.dispatchKeyEvent",
619
+ { type: "char", text: char, unmodifiedText: char }
620
+ )
621
+ }
622
+ return { filled: true, uid, method: "keyboard", value: text, browserSessionId: session.browserSessionId }
623
+ }
624
+
625
+ return chrome.tabs.sendMessage(tabId, {
626
+ type: "yunti_execute_tool",
627
+ tool: "yunti_fill",
628
+ arguments: args,
629
+ })
630
+ }
631
+
632
+ function validateFillArgs(args = {}) {
633
+ if (!Object.prototype.hasOwnProperty.call(args, "value") || args.value === undefined || args.value === null) {
634
+ throw new Error("yunti_fill requires value. Pass { value: \"...\", uid: \"yunti-...\" } after yunti_take_snapshot, or pass value with a CSS selector.")
635
+ }
636
+ const uid = String(args.uid || "").trim()
637
+ const selector = String(args.selector || "").trim()
638
+ const hasX = Number.isFinite(Number(args.x))
639
+ const hasY = Number.isFinite(Number(args.y))
640
+ if (hasX || hasY) {
641
+ throw new Error("yunti_fill does not support coordinate-only targeting. Call yunti_take_snapshot and pass uid, or pass selector with value.")
642
+ }
643
+ if (!uid && !selector) {
644
+ throw new Error("yunti_fill requires uid or selector with value. Call yunti_take_snapshot to get uid, or pass a CSS selector.")
645
+ }
646
+ }
647
+
648
+ async function clickViaSnapshotUid(tabId, session, uid) {
649
+ const { x, y } = await resolveUidCenter(tabId, session, uid)
650
+ await mouseClick(tabId, x, y, 1)
651
+ return { clicked: true, uid, x: Math.round(x), y: Math.round(y), browserSessionId: session.browserSessionId }
652
+ }
653
+
654
+ async function hoverViaSnapshotUid(tabId, session, uid) {
655
+ const { x, y } = await resolveUidCenter(tabId, session, uid)
656
+ await mouseMove(tabId, x, y)
657
+ return { hovered: true, uid, x: Math.round(x), y: Math.round(y), browserSessionId: session.browserSessionId }
658
+ }
659
+
660
+ async function clickAtCoordinate(tabId, session, x, y) {
661
+ await mouseClick(tabId, x, y, 1)
662
+ return { clicked: true, x: Math.round(x), y: Math.round(y), browserSessionId: session.browserSessionId, method: "coordinate" }
663
+ }
664
+
665
+ async function resolveSelectorCenter(tabId, selector) {
666
+ await ensureCdpAttached(tabId, "1.3")
667
+ const escapedSelector = JSON.stringify(selector)
668
+ const result = await chromeDebuggerSendCommand(
669
+ { tabId },
670
+ "Runtime.evaluate",
671
+ {
672
+ expression: `(() => {
673
+ const element = document.querySelector(${escapedSelector});
674
+ if (!element) return { ok: false, error: "Element not found: " + ${escapedSelector} };
675
+ const rect = element.getBoundingClientRect();
676
+ if (!rect || rect.width <= 0 || rect.height <= 0) {
677
+ return { ok: false, error: "Element is hidden or has no size: " + ${escapedSelector} };
678
+ }
679
+ element.scrollIntoView({ block: "center", inline: "center" });
680
+ const next = element.getBoundingClientRect();
681
+ return {
682
+ ok: true,
683
+ x: next.left + next.width / 2,
684
+ y: next.top + next.height / 2,
685
+ };
686
+ })()`,
687
+ returnByValue: true,
688
+ }
689
+ )
690
+ const value = result?.result?.value
691
+ if (!value?.ok) {
692
+ throw new Error(value?.error || `Element not found: ${selector}`)
693
+ }
694
+ return { x: Number(value.x), y: Number(value.y) }
695
+ }
696
+
697
+ async function resolveUidCenter(tabId, session, uid) {
698
+ const uidMap = snapshotStore.get(session.browserSessionId)
699
+ if (!uidMap || !uidMap[uid]) {
700
+ throw new Error(`uid ${uid} not found. Run yunti_take_snapshot first.`)
701
+ }
702
+ const el = uidMap[uid]
703
+
704
+ if (el.rect && el.rect.width > 0 && el.rect.height > 0) {
705
+ return {
706
+ x: el.rect.x + el.rect.width / 2,
707
+ y: el.rect.y + el.rect.height / 2,
708
+ }
709
+ }
710
+
711
+ // Resolve coordinates via CDP DOM.getBoxModel using backendNodeId
712
+ if (el.backendNodeId) {
713
+ await ensureCdpAttached(tabId, "1.3")
714
+ const boxModel = await chromeDebuggerSendCommand(
715
+ { tabId },
716
+ "DOM.getBoxModel",
717
+ { backendNodeId: el.backendNodeId }
718
+ )
719
+ const content = boxModel?.model?.content
720
+ if (content && content.length >= 4) {
721
+ const x = (content[0] + content[4]) / 2
722
+ const y = (content[1] + content[5]) / 2
723
+ return { x, y }
724
+ }
725
+ }
726
+
727
+ throw new Error(`Cannot resolve coordinates for uid ${uid}. Element may be off-screen or hidden.`)
728
+ }
729
+
730
+ async function mouseClick(tabId, x, y, clickCount = 1) {
731
+ await ensureCdpAttached(tabId, "1.3")
732
+ const button = "left"
733
+ await chromeDebuggerSendCommand({ tabId }, "Input.dispatchMouseEvent", {
734
+ type: "mousePressed", x, y, button, clickCount,
735
+ })
736
+ await chromeDebuggerSendCommand({ tabId }, "Input.dispatchMouseEvent", {
737
+ type: "mouseReleased", x, y, button, clickCount,
738
+ })
739
+ }
740
+
741
+ async function mouseMove(tabId, x, y) {
742
+ await ensureCdpAttached(tabId, "1.3")
743
+ await chromeDebuggerSendCommand({ tabId }, "Input.dispatchMouseEvent", {
744
+ type: "mouseMoved", x, y,
745
+ })
746
+ }
747
+
748
+ async function fillForm(tabId, session, args = {}) {
749
+ const fields = Array.isArray(args.fields) ? args.fields : []
750
+ if (!fields.length) throw new Error("fields array is required")
751
+
752
+ let filled = 0
753
+ let failed = 0
754
+ const results = []
755
+
756
+ for (const field of fields) {
757
+ try {
758
+ const uid = String(field.uid || "").trim()
759
+ if (uid) {
760
+ await fillByUid(tabId, session, { uid, value: String(field.value || "") })
761
+ } else {
762
+ await chrome.tabs.sendMessage(tabId, {
763
+ type: "yunti_execute_tool",
764
+ tool: "yunti_fill",
765
+ arguments: { selector: field.selector, value: field.value },
766
+ })
767
+ }
768
+ filled++
769
+ results.push({ uid: field.uid, selector: field.selector, ok: true })
770
+ } catch (err) {
771
+ failed++
772
+ results.push({ uid: field.uid, selector: field.selector, ok: false, error: err?.message || String(err) })
773
+ }
774
+ }
775
+
776
+ return { filled, failed, results, browserSessionId: session.browserSessionId }
777
+ }
778
+
779
+ async function waitForCondition(tabId, session, args = {}) {
780
+ const timeoutMs = Number.isFinite(Number(args.timeoutMs))
781
+ ? Math.max(100, Math.min(30000, Number(args.timeoutMs)))
782
+ : 5000
783
+ const text = String(args.text || "").trim()
784
+ const selector = String(args.selector || "").trim()
785
+ const urlContains = String(args.urlContains || "").trim()
786
+
787
+ if (!text && !selector && !urlContains) {
788
+ throw new Error("At least one of text, selector, or urlContains is required")
789
+ }
790
+
791
+ await ensureCdpAttached(tabId, "1.3")
792
+ const startTime = Date.now()
793
+
794
+ while (Date.now() - startTime < timeoutMs) {
795
+ if (urlContains) {
796
+ const tab = await chrome.tabs.get(tabId)
797
+ if (tab.url && tab.url.includes(urlContains)) {
798
+ return { found: true, condition: "urlContains", value: urlContains, waitedMs: Date.now() - startTime, browserSessionId: session.browserSessionId }
799
+ }
800
+ }
801
+
802
+ if (text || selector) {
803
+ const expression = `(() => {
804
+ ${selector ? `const el = document.querySelector(${JSON.stringify(selector)}); if (el) { const rect = el.getBoundingClientRect(); if (rect.width > 0 && rect.height > 0) return { found: 'selector', selector: ${JSON.stringify(selector)} }; }` : ""}
805
+ ${text ? `if (document.body && document.body.innerText && document.body.innerText.includes(${JSON.stringify(text)})) return { found: 'text', text: ${JSON.stringify(text)} };` : ""}
806
+ return null;
807
+ })()`
808
+
809
+ const result = await chromeDebuggerSendCommand(
810
+ { tabId },
811
+ "Runtime.evaluate",
812
+ { expression, returnByValue: true }
813
+ )
814
+
815
+ if (result?.result?.value) {
816
+ return { found: true, ...result.result.value, waitedMs: Date.now() - startTime, browserSessionId: session.browserSessionId }
817
+ }
818
+ }
819
+
820
+ await delayCdp(200)
821
+ }
822
+
823
+ return { found: false, waitedMs: timeoutMs, browserSessionId: session.browserSessionId }
824
+ }
825
+
826
+ async function handleDialog(tabId, session, args = {}) {
827
+ const action = String(args.action || "accept").trim()
828
+ if (action !== "accept" && action !== "dismiss") throw new Error("action must be 'accept' or 'dismiss'")
829
+ const promptText = action === "accept" ? String(args.promptText || "") : undefined
830
+
831
+ await ensureCdpAttached(tabId, "1.3")
832
+ await chromeDebuggerSendCommand(
833
+ { tabId },
834
+ "Page.handleJavaScriptDialog",
835
+ { accept: action === "accept", promptText }
836
+ )
837
+ return { handled: true, action, browserSessionId: session.browserSessionId }
838
+ }
839
+
840
+ async function resizePage(tabId, session, args = {}) {
841
+ const width = Number.isFinite(Number(args.width)) ? Math.round(Number(args.width)) : null
842
+ const height = Number.isFinite(Number(args.height)) ? Math.round(Number(args.height)) : null
843
+ const deviceScaleFactor = Number.isFinite(Number(args.deviceScaleFactor)) && Number(args.deviceScaleFactor) > 0
844
+ ? Number(args.deviceScaleFactor) : 1
845
+
846
+ if (!width && !height) throw new Error("width and/or height is required")
847
+
848
+ await ensureCdpAttached(tabId, "1.3")
849
+ const params = { deviceScaleFactor, mobile: false }
850
+ if (width) params.width = width
851
+ if (height) params.height = height
852
+ await chromeDebuggerSendCommand({ tabId }, "Emulation.setDeviceMetricsOverride", params)
853
+ return { resized: true, width, height, deviceScaleFactor, browserSessionId: session.browserSessionId }
854
+ }
855
+
856
+ async function emulateDevice(tabId, session, args = {}) {
857
+ await ensureCdpAttached(tabId, "1.3")
858
+
859
+ if (args.clear) {
860
+ await chromeDebuggerSendCommand({ tabId }, "Emulation.clearDeviceMetricsOverride", {})
861
+ // Use userAgent: "" to clear UA override — Chrome requires explicit param
862
+ await chromeDebuggerSendCommand({ tabId }, "Emulation.setUserAgentOverride", { userAgent: "" })
863
+ // Use -1 for unlimited throughput (0 = zero bandwidth)
864
+ await chromeDebuggerSendCommand({ tabId }, "Network.emulateNetworkConditions", {
865
+ offline: false, latency: 0, downloadThroughput: -1, uploadThroughput: -1, connectionType: "none",
866
+ })
867
+ // Also clear CPU throttling
868
+ await chromeDebuggerSendCommand({ tabId }, "Emulation.setCPUThrottlingRate", { rate: 1 })
869
+ return { emulated: false, cleared: true, browserSessionId: session.browserSessionId }
870
+ }
871
+
872
+ const deviceOverrides = resolveDeviceOverrides(args.deviceName)
873
+
874
+ // Viewport
875
+ if (args.viewport || deviceOverrides) {
876
+ const vp = args.viewport || deviceOverrides || {}
877
+ const w = Number.isFinite(Number(vp.width)) ? Math.round(Number(vp.width)) : 375
878
+ const h = Number.isFinite(Number(vp.height)) ? Math.round(Number(vp.height)) : 812
879
+ const dsf = Number.isFinite(Number(vp.deviceScaleFactor)) && Number(vp.deviceScaleFactor) > 0
880
+ ? Number(vp.deviceScaleFactor) : 2
881
+ await chromeDebuggerSendCommand({ tabId }, "Emulation.setDeviceMetricsOverride", {
882
+ width: w, height: h, deviceScaleFactor: dsf, mobile: true,
883
+ })
884
+ }
885
+
886
+ // User agent
887
+ if (args.userAgent || deviceOverrides?.userAgent) {
888
+ await chromeDebuggerSendCommand({ tabId }, "Emulation.setUserAgentOverride", {
889
+ userAgent: args.userAgent || deviceOverrides.userAgent,
890
+ })
891
+ }
892
+
893
+ // CPU throttle
894
+ if (Number.isFinite(Number(args.cpuThrottleRate)) && Number(args.cpuThrottleRate) > 1) {
895
+ await chromeDebuggerSendCommand({ tabId }, "Emulation.setCPUThrottlingRate", {
896
+ rate: Number(args.cpuThrottleRate),
897
+ })
898
+ }
899
+
900
+ // Network conditions
901
+ if (args.networkConditions && args.networkConditions !== "none") {
902
+ const conditions = NETWORK_CONDITIONS[args.networkConditions] || NETWORK_CONDITIONS.slow3g
903
+ await chromeDebuggerSendCommand({ tabId }, "Network.emulateNetworkConditions", {
904
+ offline: false, ...conditions,
905
+ })
906
+ }
907
+
908
+ return { emulated: true, deviceName: args.deviceName || null, browserSessionId: session.browserSessionId }
909
+ }
910
+
911
+ const DEVICE_PRESETS = {
912
+ "iPhone 12": { width: 390, height: 844, deviceScaleFactor: 3, mobile: true, userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15" },
913
+ "iPhone SE": { width: 375, height: 667, deviceScaleFactor: 2, mobile: true },
914
+ "Pixel 5": { width: 393, height: 851, deviceScaleFactor: 2.75, mobile: true, userAgent: "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36" },
915
+ "iPad": { width: 810, height: 1080, deviceScaleFactor: 2, mobile: true },
916
+ "iPad Pro": { width: 1024, height: 1366, deviceScaleFactor: 2, mobile: true },
917
+ }
918
+
919
+ const NETWORK_CONDITIONS = {
920
+ offline: { offline: true, latency: 0, downloadThroughput: -1, uploadThroughput: -1, connectionType: "none" },
921
+ slow3g: { offline: false, latency: 2000, downloadThroughput: 50000, uploadThroughput: 20000, connectionType: "cellular3g" },
922
+ fast3g: { offline: false, latency: 563, downloadThroughput: 180000, uploadThroughput: 45000, connectionType: "cellular3g" },
923
+ slow4g: { offline: false, latency: 100, downloadThroughput: 1750000, uploadThroughput: 500000, connectionType: "cellular4g" },
924
+ }
925
+
926
+ function resolveDeviceOverrides(deviceName) {
927
+ if (!deviceName) return null
928
+ const key = String(deviceName).trim()
929
+ return DEVICE_PRESETS[key] || DEVICE_PRESETS[Object.keys(DEVICE_PRESETS).find(k => k.toLowerCase() === key.toLowerCase())] || null
930
+ }
931
+
932
+ async function newPage(parentSession = null, args = {}) {
933
+ const url = String(args.url || "").trim()
934
+ const active = args.active !== false
935
+ const tab = await chrome.tabs.create({ url: url || undefined, active })
936
+ const browserSessionId = `yunti-${tab.id}-${crypto.randomUUID ? crypto.randomUUID() : Date.now()}`
937
+ const session = {
938
+ browserSessionId,
939
+ userId: parentSession?.userId || "",
940
+ displayName: parentSession?.displayName || "",
941
+ tabId: tab.id,
942
+ windowId: tab.windowId,
943
+ url: tab.url || url,
944
+ title: tab.title || "",
945
+ registeredAt: new Date().toISOString(),
946
+ }
947
+ sessionsByTab.set(tab.id, session)
948
+ await postBridge("/sessions/register", session).catch(() => null)
949
+ startPolling(tab.id)
950
+ return { created: true, browserSessionId, tabId: tab.id, windowId: tab.windowId, url: tab.url || url, title: tab.title || "" }
951
+ }
952
+
953
+ async function closePage(tabId, session, args = {}) {
954
+ if (args.tabId !== undefined || args.targetId !== undefined) {
955
+ throw new Error("yunti_close_page only closes the registered page identified by browserSessionId. To close by tabId or targetId, call yunti_cdp_send_command with method Target.closeTarget and top-level tabId or targetId.")
956
+ }
957
+ const connectedTabs = [...sessionsByTab.keys()]
958
+ if (connectedTabs.length <= 1) {
959
+ throw new Error("Cannot close the last connected tab")
960
+ }
961
+ await detachCdpTab(tabId, session, "page_closed").catch(() => {})
962
+ await chrome.tabs.remove(tabId)
963
+ sessionsByTab.delete(tabId)
964
+ pollers.get(tabId)?.abort()
965
+ pollers.delete(tabId)
966
+ return { closed: true, browserSessionId: session.browserSessionId, tabId }
967
+ }
968
+
969
+ async function performDrag(tabId, session, args = {}) {
970
+ const fromX = Number(args.fromX)
971
+ const fromY = Number(args.fromY)
972
+ const toX = Number(args.toX)
973
+ const toY = Number(args.toY)
974
+ if (!Number.isFinite(fromX) || !Number.isFinite(fromY) || !Number.isFinite(toX) || !Number.isFinite(toY)) {
975
+ throw new Error("fromX, fromY, toX, toY are required and must be numbers")
976
+ }
977
+ const steps = Number.isFinite(Number(args.steps)) ? Math.max(1, Math.min(50, Math.round(Number(args.steps)))) : 10
978
+
979
+ await ensureCdpAttached(tabId, "1.3")
980
+ const button = "left"
981
+
982
+ // Mouse press at start
983
+ await chromeDebuggerSendCommand({ tabId }, "Input.dispatchMouseEvent", {
984
+ type: "mousePressed", x: fromX, y: fromY, button, clickCount: 1,
985
+ })
986
+
987
+ // Move through intermediate points
988
+ for (let i = 1; i <= steps; i++) {
989
+ const t = i / (steps + 1)
990
+ const x = fromX + (toX - fromX) * t
991
+ const y = fromY + (toY - fromY) * t
992
+ await chromeDebuggerSendCommand({ tabId }, "Input.dispatchMouseEvent", {
993
+ type: "mouseMoved", x, y, button, buttons: 1,
994
+ })
995
+ await delayCdp(16) // ~60fps
996
+ }
997
+
998
+ // Mouse release at end
999
+ await chromeDebuggerSendCommand({ tabId }, "Input.dispatchMouseEvent", {
1000
+ type: "mouseReleased", x: toX, y: toY, button, clickCount: 1,
1001
+ })
1002
+
1003
+ return { dragged: true, from: { x: Math.round(fromX), y: Math.round(fromY) }, to: { x: Math.round(toX), y: Math.round(toY) }, steps, browserSessionId: session.browserSessionId }
1004
+ }
1005
+
1006
+ async function uploadFile(tabId, session, args = {}) {
1007
+ const filePaths = Array.isArray(args.filePaths) ? args.filePaths.filter(p => typeof p === "string") : []
1008
+ if (!filePaths.length) throw new Error("filePaths array is required")
1009
+
1010
+ const uid = String(args.uid || "").trim()
1011
+ const selector = String(args.selector || "").trim()
1012
+
1013
+ await ensureCdpAttached(tabId, "1.3")
1014
+
1015
+ let backendNodeId = null
1016
+ if (uid) {
1017
+ const { x, y } = await resolveUidCenter(tabId, session, uid)
1018
+ const nodeResult = await chromeDebuggerSendCommand({ tabId }, "DOM.getNodeForLocation", { x: Math.round(x), y: Math.round(y) })
1019
+ backendNodeId = nodeResult?.backendNodeId
1020
+ } else if (selector) {
1021
+ const docResult = await chromeDebuggerSendCommand({ tabId }, "DOM.getDocument", {})
1022
+ const rootNodeId = docResult?.root?.nodeId
1023
+ if (rootNodeId) {
1024
+ const queryResult = await chromeDebuggerSendCommand({ tabId }, "DOM.querySelector", { nodeId: rootNodeId, selector })
1025
+ if (queryResult?.nodeId) {
1026
+ const nodeResult = await chromeDebuggerSendCommand({ tabId }, "DOM.describeNode", { nodeId: queryResult.nodeId })
1027
+ backendNodeId = nodeResult?.node?.backendNodeId
1028
+ }
1029
+ }
1030
+ }
1031
+
1032
+ if (!backendNodeId) throw new Error("Cannot resolve file input element. Provide a valid uid from yunti_take_snapshot or a CSS selector.")
1033
+
1034
+ await chromeDebuggerSendCommand({ tabId }, "DOM.setFileInputFiles", {
1035
+ files: filePaths,
1036
+ backendNodeId,
1037
+ })
1038
+
1039
+ return { uploaded: true, fileCount: filePaths.length, filePaths, browserSessionId: session.browserSessionId }
1040
+ }
1041
+
1042
+ async function typeTextByUid(tabId, session, args = {}) {
1043
+ const uid = String(args.uid || "").trim()
1044
+ if (!uid) {
1045
+ // Fall back to content script for selector/coordinate-based typing
1046
+ return chrome.tabs.sendMessage(tabId, {
1047
+ type: "yunti_execute_tool",
1048
+ tool: "yunti_type_text",
1049
+ arguments: args,
1050
+ })
1051
+ }
1052
+
1053
+ const text = String(args.text || "")
1054
+ if (!text) throw new Error("text is required")
1055
+
1056
+ // Click the uid element to focus it
1057
+ const { x, y } = await resolveUidCenter(tabId, session, uid)
1058
+ await mouseClick(tabId, x, y, 1)
1059
+ await delayCdp(100)
1060
+
1061
+ // Type each character via CDP
1062
+ for (const char of text) {
1063
+ await chromeDebuggerSendCommand(
1064
+ { tabId },
1065
+ "Input.dispatchKeyEvent",
1066
+ { type: "char", text: char, unmodifiedText: char }
1067
+ )
1068
+ }
1069
+
1070
+ return { typed: true, uid, text, method: "cdp.keyboard", browserSessionId: session.browserSessionId }
1071
+ }
1072
+
1073
+ async function pressKeyByUid(tabId, session, args = {}) {
1074
+ const uid = String(args.uid || "").trim()
1075
+ if (!uid) {
1076
+ return chrome.tabs.sendMessage(tabId, {
1077
+ type: "yunti_execute_tool",
1078
+ tool: "yunti_press_key",
1079
+ arguments: args,
1080
+ })
1081
+ }
1082
+
1083
+ const key = String(args.key || "").trim()
1084
+ if (!key) throw new Error("key is required")
1085
+
1086
+ // Focus the uid element
1087
+ const { x, y } = await resolveUidCenter(tabId, session, uid)
1088
+ await mouseClick(tabId, x, y, 1)
1089
+ await delayCdp(50)
1090
+
1091
+ // Map common key names to CDP key events
1092
+ const keyDef = normalizeKey(key)
1093
+
1094
+ await chromeDebuggerSendCommand({ tabId }, "Input.dispatchKeyEvent", {
1095
+ type: "keyDown",
1096
+ key: keyDef.key,
1097
+ code: keyDef.code,
1098
+ windowsVirtualKeyCode: keyDef.keyCode,
1099
+ nativeVirtualKeyCode: keyDef.keyCode,
1100
+ })
1101
+ await chromeDebuggerSendCommand({ tabId }, "Input.dispatchKeyEvent", {
1102
+ type: "keyUp",
1103
+ key: keyDef.key,
1104
+ code: keyDef.code,
1105
+ windowsVirtualKeyCode: keyDef.keyCode,
1106
+ nativeVirtualKeyCode: keyDef.keyCode,
1107
+ })
1108
+
1109
+ return { pressed: true, uid, key, browserSessionId: session.browserSessionId }
1110
+ }
1111
+
1112
+ const KEY_MAP = {
1113
+ Enter: { key: "Enter", code: "Enter", keyCode: 13 },
1114
+ Escape: { key: "Escape", code: "Escape", keyCode: 27 },
1115
+ Tab: { key: "Tab", code: "Tab", keyCode: 9 },
1116
+ Backspace: { key: "Backspace", code: "Backspace", keyCode: 8 },
1117
+ Delete: { key: "Delete", code: "Delete", keyCode: 46 },
1118
+ ArrowUp: { key: "ArrowUp", code: "ArrowUp", keyCode: 38 },
1119
+ ArrowDown: { key: "ArrowDown", code: "ArrowDown", keyCode: 40 },
1120
+ ArrowLeft: { key: "ArrowLeft", code: "ArrowLeft", keyCode: 37 },
1121
+ ArrowRight: { key: "ArrowRight", code: "ArrowRight", keyCode: 39 },
1122
+ Home: { key: "Home", code: "Home", keyCode: 36 },
1123
+ End: { key: "End", code: "End", keyCode: 35 },
1124
+ PageUp: { key: "PageUp", code: "PageUp", keyCode: 33 },
1125
+ PageDown: { key: "PageDown", code: "PageDown", keyCode: 34 },
1126
+ Space: { key: " ", code: "Space", keyCode: 32 },
1127
+ F1: { key: "F1", code: "F1", keyCode: 112 },
1128
+ F2: { key: "F2", code: "F2", keyCode: 113 },
1129
+ F3: { key: "F3", code: "F3", keyCode: 114 },
1130
+ F4: { key: "F4", code: "F4", keyCode: 115 },
1131
+ F5: { key: "F5", code: "F5", keyCode: 116 },
1132
+ F6: { key: "F6", code: "F6", keyCode: 117 },
1133
+ F7: { key: "F7", code: "F7", keyCode: 118 },
1134
+ F8: { key: "F8", code: "F8", keyCode: 119 },
1135
+ F9: { key: "F9", code: "F9", keyCode: 120 },
1136
+ F10: { key: "F10", code: "F10", keyCode: 121 },
1137
+ F11: { key: "F11", code: "F11", keyCode: 122 },
1138
+ F12: { key: "F12", code: "F12", keyCode: 123 },
1139
+ Control: { key: "Control", code: "ControlLeft", keyCode: 17 },
1140
+ Shift: { key: "Shift", code: "ShiftLeft", keyCode: 16 },
1141
+ Alt: { key: "Alt", code: "AltLeft", keyCode: 18 },
1142
+ Meta: { key: "Meta", code: "MetaLeft", keyCode: 91 },
1143
+ }
1144
+
1145
+ function normalizeKey(key) {
1146
+ const upper = key.slice(0, 1).toUpperCase() + key.slice(1).toLowerCase()
1147
+ if (KEY_MAP[upper]) return KEY_MAP[upper]
1148
+ if (KEY_MAP[key]) return KEY_MAP[key]
1149
+ // Fallback for single characters
1150
+ if (key.length === 1) {
1151
+ const code = key.toUpperCase().charCodeAt(0)
1152
+ return { key, code: `Key${key.toUpperCase()}`, keyCode: code }
1153
+ }
1154
+ return { key, code: key, keyCode: 0 }
1155
+ }
1156
+
1157
+ return { executeToolRequest }
1158
+ }