theus-cli-linux-arm64 1.2.1 → 1.2.2

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.
@@ -0,0 +1,672 @@
1
+ // Theus en Chrome — fase 2: control total vía chrome.debugger (CDP).
2
+ //
3
+ // Este archivo se carga con importScripts('cdp-core.js', 'cdp.js') desde
4
+ // background.js. Aporta:
5
+ // - Gestor de sesiones CDP por pestaña: attach perezoso (la primera tool
6
+ // que lo exige), detach al cerrar la pestaña, reattach transparente si
7
+ // Chrome suelta el debugger (con aviso honesto de que el buffer se cortó).
8
+ // - Buffers circulares de consola y red que arrancan en el attach.
9
+ // - Handlers de: computer, read_page, form_input, resize_window,
10
+ // read_console_messages y read_network_requests.
11
+ //
12
+ // Nota inevitable: mientras una pestaña está instrumentada, Chrome muestra la
13
+ // barra «Theus en Chrome ha empezado a depurar este navegador». Es el precio
14
+ // de chrome.debugger y no se puede ocultar.
15
+
16
+ const CDP_VERSION = '1.3'
17
+ const MAX_ENTRADAS_CONSOLA = 1000
18
+ const MAX_ENTRADAS_RED = 1000
19
+ const MAX_ELEMENTOS_PAGINA = 150
20
+ const MAX_ELEMENTOS_CON_BOUNDS = 100
21
+ const MAX_ESPERA_S = 15
22
+ // Debe casar con MAX_CHANNEL_MESSAGE_BYTES del CLI
23
+ // (vendor/theus-packages/theus-for-chrome-mcp/constants.ts).
24
+ const LIMITE_CANAL_BYTES = 16 * 1024 * 1024
25
+ // Margen para el JSON envolvente: si el base64 excede esto, se recomprime.
26
+ const MAX_CAPTURA_BASE64 = 12 * 1024 * 1024
27
+ const MAX_ANCHO_CAPTURA = 1600
28
+
29
+ const Core = globalThis.TheusCdpCore
30
+
31
+ // tabId → sesión
32
+ const sesionesCdp = new Map()
33
+
34
+ function crearSesion(tabId) {
35
+ return {
36
+ tabId,
37
+ adjunta: false,
38
+ adjuntadaDesdeMs: 0,
39
+ cortes: 0, // reattaches: el buffer tuvo huecos
40
+ consola: [],
41
+ red: Core.crearEstadoRed(),
42
+ refs: new Map(), // ref (string) → backendDOMNodeId, de la última read_page
43
+ }
44
+ }
45
+
46
+ function enviarComando(tabId, metodo, parametros) {
47
+ return new Promise((resolve, reject) => {
48
+ chrome.debugger.sendCommand({ tabId }, metodo, parametros || {}, resultado => {
49
+ if (chrome.runtime.lastError) {
50
+ reject(new Error(chrome.runtime.lastError.message))
51
+ } else {
52
+ resolve(resultado)
53
+ }
54
+ })
55
+ })
56
+ }
57
+
58
+ function adjuntarDebugger(tabId) {
59
+ return new Promise((resolve, reject) => {
60
+ chrome.debugger.attach({ tabId }, CDP_VERSION, () => {
61
+ if (chrome.runtime.lastError) {
62
+ reject(new Error(chrome.runtime.lastError.message))
63
+ } else {
64
+ resolve()
65
+ }
66
+ })
67
+ })
68
+ }
69
+
70
+ function soltarDebugger(tabId) {
71
+ return new Promise(resolve => {
72
+ chrome.debugger.detach({ tabId }, () => {
73
+ // Ignorar lastError: puede no estar adjunto.
74
+ void chrome.runtime.lastError
75
+ resolve()
76
+ })
77
+ })
78
+ }
79
+
80
+ /**
81
+ * Garantiza una sesión CDP viva sobre la pestaña (attach perezoso + reattach
82
+ * transparente). Devuelve la sesión.
83
+ */
84
+ async function asegurarSesion(tabId) {
85
+ let sesion = sesionesCdp.get(tabId)
86
+ if (sesion && sesion.adjunta) {
87
+ return sesion
88
+ }
89
+ if (!sesion) {
90
+ sesion = crearSesion(tabId)
91
+ sesionesCdp.set(tabId, sesion)
92
+ }
93
+ try {
94
+ await adjuntarDebugger(tabId)
95
+ } catch (e) {
96
+ const msg = String(e && e.message ? e.message : e)
97
+ if (/already attached/i.test(msg)) {
98
+ // El service worker se reinició dejando el debugger adjunto sin estado:
99
+ // soltar y volver a adjuntar para reinstrumentar limpio.
100
+ await soltarDebugger(tabId)
101
+ await adjuntarDebugger(tabId)
102
+ } else {
103
+ throw new Error(
104
+ `No se pudo instrumentar la pestaña ${tabId}: ${msg}. ` +
105
+ 'Las páginas del navegador (chrome://…) y la Web Store no se pueden depurar.',
106
+ )
107
+ }
108
+ }
109
+ sesion.adjunta = true
110
+ if (sesion.adjuntadaDesdeMs === 0) {
111
+ sesion.adjuntadaDesdeMs = Date.now()
112
+ }
113
+ // Instrumentación: los buffers de consola/red graban desde este momento.
114
+ await enviarComando(tabId, 'Runtime.enable')
115
+ await enviarComando(tabId, 'Log.enable')
116
+ await enviarComando(tabId, 'Network.enable')
117
+ await enviarComando(tabId, 'Page.enable')
118
+ await enviarComando(tabId, 'DOM.enable')
119
+ await enviarComando(tabId, 'Accessibility.enable').catch(() => {})
120
+ // Que el agente DOM conozca el documento (necesario para backendNodeIds).
121
+ await enviarComando(tabId, 'DOM.getDocument', { depth: 0 }).catch(() => {})
122
+ return sesion
123
+ }
124
+
125
+ chrome.debugger.onEvent.addListener((origen, metodo, parametros) => {
126
+ const sesion = origen && typeof origen.tabId === 'number' ? sesionesCdp.get(origen.tabId) : null
127
+ if (!sesion) return
128
+ const params = parametros || {}
129
+ // Kuti fase 1: navegaciones del frame principal → paso `navegar`. CDP
130
+ // complementa al content script, que muere al cambiar de página y no puede
131
+ // reportar la navegación por sí mismo.
132
+ if (metodo === 'Page.frameNavigated') {
133
+ const frame = params.frame
134
+ const grab = globalThis.__kuti
135
+ if (
136
+ frame &&
137
+ !frame.parentId &&
138
+ grab &&
139
+ grab.grabando &&
140
+ origen.tabId === grab.tabGrabada &&
141
+ typeof globalThis.kutiEmitirNavegacion === 'function'
142
+ ) {
143
+ const url = frame.url || ''
144
+ if (grab.capturas) {
145
+ capturarBase64(sesion).then(
146
+ b64 => globalThis.kutiEmitirNavegacion(origen.tabId, url, b64),
147
+ () => globalThis.kutiEmitirNavegacion(origen.tabId, url),
148
+ )
149
+ } else {
150
+ globalThis.kutiEmitirNavegacion(origen.tabId, url)
151
+ }
152
+ }
153
+ return
154
+ }
155
+ if (
156
+ metodo === 'Runtime.consoleAPICalled' ||
157
+ metodo === 'Runtime.exceptionThrown' ||
158
+ metodo === 'Log.entryAdded'
159
+ ) {
160
+ const entrada = Core.formatearEventoConsola(metodo, params)
161
+ if (entrada) Core.pushCircular(sesion.consola, entrada, MAX_ENTRADAS_CONSOLA)
162
+ return
163
+ }
164
+ if (metodo.startsWith('Network.')) {
165
+ Core.plegarEventoRed(sesion.red, metodo, params, MAX_ENTRADAS_RED)
166
+ }
167
+ })
168
+
169
+ chrome.debugger.onDetach.addListener(origen => {
170
+ const sesion = origen && typeof origen.tabId === 'number' ? sesionesCdp.get(origen.tabId) : null
171
+ if (!sesion) return
172
+ // Chrome soltó el debugger (usuario pulsó «Cancelar», DevTools, etc.):
173
+ // marcamos el corte; el próximo tool re-adjunta y lo cuenta honesto.
174
+ sesion.adjunta = false
175
+ sesion.cortes += 1
176
+ })
177
+
178
+ chrome.tabs.onRemoved.addListener(tabId => {
179
+ sesionesCdp.delete(tabId)
180
+ })
181
+
182
+ function notaBuffer(sesion) {
183
+ let nota = `Buffer grabando desde las ${Core.horaLocal(sesion.adjuntadaDesdeMs)} (la pestaña no estaba instrumentada antes).`
184
+ if (sesion.cortes > 0) {
185
+ nota += ` Ojo: la instrumentación se cortó ${sesion.cortes} ${sesion.cortes === 1 ? 'vez' : 'veces'}; hay huecos.`
186
+ }
187
+ return nota
188
+ }
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // computer
192
+ // ---------------------------------------------------------------------------
193
+
194
+ async function resolverPunto(sesion, args) {
195
+ if (Array.isArray(args.coordinate) && args.coordinate.length === 2) {
196
+ return { x: Number(args.coordinate[0]), y: Number(args.coordinate[1]) }
197
+ }
198
+ if (typeof args.ref === 'string') {
199
+ return centroDeRef(sesion, args.ref)
200
+ }
201
+ throw new Error('Indica coordinate [x, y] o un ref de read_page.')
202
+ }
203
+
204
+ async function centroDeRef(sesion, ref) {
205
+ const backendNodeId = sesion.refs.get(ref)
206
+ if (!backendNodeId) {
207
+ throw new Error(
208
+ `Ref desconocido: ${ref}. Llama a read_page para obtener refs vigentes (se invalidan al navegar).`,
209
+ )
210
+ }
211
+ await enviarComando(sesion.tabId, 'DOM.scrollIntoViewIfNeeded', { backendNodeId })
212
+ const { quads } = await enviarComando(sesion.tabId, 'DOM.getContentQuads', {
213
+ backendNodeId,
214
+ })
215
+ if (!quads || quads.length === 0) {
216
+ throw new Error(`El elemento ${ref} no tiene geometría visible (¿oculto?).`)
217
+ }
218
+ const q = quads[0] // [x1,y1, x2,y2, x3,y3, x4,y4]
219
+ return {
220
+ x: (q[0] + q[2] + q[4] + q[6]) / 4,
221
+ y: (q[1] + q[3] + q[5] + q[7]) / 4,
222
+ }
223
+ }
224
+
225
+ function eventoRaton(tabId, tipo, x, y, boton, clicks) {
226
+ return enviarComando(tabId, 'Input.dispatchMouseEvent', {
227
+ type: tipo,
228
+ x,
229
+ y,
230
+ button: boton,
231
+ clickCount: clicks || 0,
232
+ })
233
+ }
234
+
235
+ async function clic(sesion, args, boton, dobles) {
236
+ const { x, y } = await resolverPunto(sesion, args)
237
+ await eventoRaton(sesion.tabId, 'mouseMoved', x, y, 'none', 0)
238
+ await eventoRaton(sesion.tabId, 'mousePressed', x, y, boton, 1)
239
+ await eventoRaton(sesion.tabId, 'mouseReleased', x, y, boton, 1)
240
+ if (dobles) {
241
+ await eventoRaton(sesion.tabId, 'mousePressed', x, y, boton, 2)
242
+ await eventoRaton(sesion.tabId, 'mouseReleased', x, y, boton, 2)
243
+ }
244
+ return { x, y }
245
+ }
246
+
247
+ async function centroViewport(tabId) {
248
+ const metricas = await enviarComando(tabId, 'Page.getLayoutMetrics')
249
+ const vv = metricas.cssVisualViewport || metricas.visualViewport || {}
250
+ return {
251
+ x: (vv.clientWidth || 800) / 2,
252
+ y: (vv.clientHeight || 600) / 2,
253
+ }
254
+ }
255
+
256
+ async function capturarPantalla(sesion) {
257
+ const res = await enviarComando(sesion.tabId, 'Page.captureScreenshot', {
258
+ format: 'jpeg',
259
+ quality: 80,
260
+ })
261
+ let base64 = res.data
262
+ // Recompresión/escala si la captura amenaza el límite del canal o es
263
+ // más ancha que MAX_ANCHO_CAPTURA px.
264
+ try {
265
+ base64 = await recomprimirCaptura(base64)
266
+ } catch (_e) {
267
+ // Si la recompresión falla y aun así cabe en el canal, se envía tal cual.
268
+ if (base64.length > MAX_CAPTURA_BASE64) {
269
+ throw new Error(
270
+ 'La captura excede el límite del canal y no se pudo recomprimir.',
271
+ )
272
+ }
273
+ }
274
+ return {
275
+ content: [{ type: 'image', data: base64, mimeType: 'image/jpeg' }],
276
+ }
277
+ }
278
+
279
+ // Kuti: base64 JPEG (recomprimido/escalado) de la pestaña, para la captura
280
+ // por paso opcional del grabador. Devuelve solo la cadena base64.
281
+ async function capturarBase64(sesion) {
282
+ const res = await enviarComando(sesion.tabId, 'Page.captureScreenshot', {
283
+ format: 'jpeg',
284
+ quality: 60,
285
+ })
286
+ let base64 = res.data
287
+ try {
288
+ base64 = await recomprimirCaptura(base64)
289
+ } catch (_e) {
290
+ // Si no se pudo recomprimir se devuelve tal cual (el llamador acota tamaño).
291
+ }
292
+ return base64
293
+ }
294
+
295
+ async function recomprimirCaptura(base64) {
296
+ const respuesta = await fetch(`data:image/jpeg;base64,${base64}`)
297
+ const blob = await respuesta.blob()
298
+ const bitmap = await createImageBitmap(blob)
299
+ const plan = Core.planRecompresionCaptura(base64.length, bitmap.width, {
300
+ maxAncho: MAX_ANCHO_CAPTURA,
301
+ maxBase64: MAX_CAPTURA_BASE64,
302
+ })
303
+ if (!plan.reprocesar) {
304
+ bitmap.close()
305
+ return base64
306
+ }
307
+ const ancho = Math.max(1, Math.round(bitmap.width * plan.escala))
308
+ const alto = Math.max(1, Math.round(bitmap.height * plan.escala))
309
+ const lienzo = new OffscreenCanvas(ancho, alto)
310
+ const ctx = lienzo.getContext('2d')
311
+ ctx.drawImage(bitmap, 0, 0, ancho, alto)
312
+ bitmap.close()
313
+ const salida = await lienzo.convertToBlob({
314
+ type: 'image/jpeg',
315
+ quality: plan.calidad,
316
+ })
317
+ const bytes = new Uint8Array(await salida.arrayBuffer())
318
+ let binario = ''
319
+ const TROZO = 0x8000
320
+ for (let i = 0; i < bytes.length; i += TROZO) {
321
+ binario += String.fromCharCode.apply(null, bytes.subarray(i, i + TROZO))
322
+ }
323
+ return btoa(binario)
324
+ }
325
+
326
+ async function accionComputer(args) {
327
+ const tabId = requireTabId(args)
328
+ const accion = args.action
329
+ if (typeof accion !== 'string') {
330
+ throw new Error('Falta el parámetro action.')
331
+ }
332
+ const sesion = await asegurarSesion(tabId)
333
+
334
+ switch (accion) {
335
+ case 'screenshot':
336
+ return capturarPantalla(sesion)
337
+
338
+ case 'left_click': {
339
+ const p = await clic(sesion, args, 'left', false)
340
+ return textResult(`Clic izquierdo en (${Math.round(p.x)}, ${Math.round(p.y)}).`)
341
+ }
342
+ case 'right_click': {
343
+ const p = await clic(sesion, args, 'right', false)
344
+ return textResult(`Clic derecho en (${Math.round(p.x)}, ${Math.round(p.y)}).`)
345
+ }
346
+ case 'middle_click': {
347
+ const p = await clic(sesion, args, 'middle', false)
348
+ return textResult(`Clic central en (${Math.round(p.x)}, ${Math.round(p.y)}).`)
349
+ }
350
+ case 'double_click': {
351
+ const p = await clic(sesion, args, 'left', true)
352
+ return textResult(`Doble clic en (${Math.round(p.x)}, ${Math.round(p.y)}).`)
353
+ }
354
+
355
+ case 'left_click_drag': {
356
+ if (!Array.isArray(args.coordinate) || args.coordinate.length !== 2) {
357
+ throw new Error('left_click_drag exige coordinate [x, y] como destino.')
358
+ }
359
+ let inicio
360
+ if (Array.isArray(args.start_coordinate) && args.start_coordinate.length === 2) {
361
+ inicio = {
362
+ x: Number(args.start_coordinate[0]),
363
+ y: Number(args.start_coordinate[1]),
364
+ }
365
+ } else if (typeof args.ref === 'string') {
366
+ inicio = await centroDeRef(sesion, args.ref)
367
+ } else {
368
+ throw new Error(
369
+ 'left_click_drag exige el origen: start_coordinate [x, y] o un ref.',
370
+ )
371
+ }
372
+ const fin = { x: Number(args.coordinate[0]), y: Number(args.coordinate[1]) }
373
+ await eventoRaton(tabId, 'mouseMoved', inicio.x, inicio.y, 'none', 0)
374
+ await eventoRaton(tabId, 'mousePressed', inicio.x, inicio.y, 'left', 1)
375
+ const PASOS = 8
376
+ for (let i = 1; i <= PASOS; i++) {
377
+ const x = inicio.x + ((fin.x - inicio.x) * i) / PASOS
378
+ const y = inicio.y + ((fin.y - inicio.y) * i) / PASOS
379
+ await eventoRaton(tabId, 'mouseMoved', x, y, 'left', 0)
380
+ }
381
+ await eventoRaton(tabId, 'mouseReleased', fin.x, fin.y, 'left', 1)
382
+ return textResult(
383
+ `Arrastre de (${Math.round(inicio.x)}, ${Math.round(inicio.y)}) a (${Math.round(fin.x)}, ${Math.round(fin.y)}).`,
384
+ )
385
+ }
386
+
387
+ case 'type': {
388
+ if (typeof args.text !== 'string' || args.text.length === 0) {
389
+ throw new Error('type exige el parámetro text.')
390
+ }
391
+ await enviarComando(tabId, 'Input.insertText', { text: args.text })
392
+ return textResult(
393
+ `Texto tecleado (${args.text.length} caracteres) en el elemento con foco.`,
394
+ )
395
+ }
396
+
397
+ case 'key': {
398
+ if (typeof args.text !== 'string' || args.text.length === 0) {
399
+ throw new Error('key exige el parámetro text (p. ej. "ctrl+a" o "Enter").')
400
+ }
401
+ const def = Core.parsearCombinacionTeclas(args.text)
402
+ if (!def) {
403
+ throw new Error(`Combinación de teclas no reconocida: «${args.text}».`)
404
+ }
405
+ const comun = {
406
+ modifiers: def.modifiers,
407
+ key: def.key,
408
+ code: def.code,
409
+ windowsVirtualKeyCode: def.keyCode,
410
+ nativeVirtualKeyCode: def.keyCode,
411
+ }
412
+ await enviarComando(tabId, 'Input.dispatchKeyEvent', {
413
+ type: def.text ? 'keyDown' : 'rawKeyDown',
414
+ ...comun,
415
+ ...(def.text ? { text: def.text } : {}),
416
+ })
417
+ await enviarComando(tabId, 'Input.dispatchKeyEvent', {
418
+ type: 'keyUp',
419
+ ...comun,
420
+ })
421
+ return textResult(`Pulsado: ${args.text}.`)
422
+ }
423
+
424
+ case 'scroll': {
425
+ const direccion = args.scroll_direction
426
+ if (!['up', 'down', 'left', 'right'].includes(direccion)) {
427
+ throw new Error('scroll exige scroll_direction: up, down, left o right.')
428
+ }
429
+ const punto =
430
+ Array.isArray(args.coordinate) && args.coordinate.length === 2
431
+ ? { x: Number(args.coordinate[0]), y: Number(args.coordinate[1]) }
432
+ : typeof args.ref === 'string'
433
+ ? await centroDeRef(sesion, args.ref)
434
+ : await centroViewport(tabId)
435
+ const PASO = 600
436
+ const deltaX = direccion === 'left' ? -PASO : direccion === 'right' ? PASO : 0
437
+ const deltaY = direccion === 'up' ? -PASO : direccion === 'down' ? PASO : 0
438
+ await enviarComando(tabId, 'Input.dispatchMouseEvent', {
439
+ type: 'mouseWheel',
440
+ x: punto.x,
441
+ y: punto.y,
442
+ deltaX,
443
+ deltaY,
444
+ })
445
+ return textResult(`Scroll ${direccion} (${PASO}px).`)
446
+ }
447
+
448
+ case 'wait': {
449
+ const segundos = Math.min(
450
+ Math.max(typeof args.duration === 'number' ? args.duration : 1, 0),
451
+ MAX_ESPERA_S,
452
+ )
453
+ await new Promise(resolve => setTimeout(resolve, segundos * 1000))
454
+ return textResult(`Esperados ${segundos} s.`)
455
+ }
456
+
457
+ default:
458
+ throw new Error(`Acción de computer no soportada: ${accion}.`)
459
+ }
460
+ }
461
+
462
+ // ---------------------------------------------------------------------------
463
+ // read_page
464
+ // ---------------------------------------------------------------------------
465
+
466
+ async function leerPagina(args) {
467
+ const tabId = requireTabId(args)
468
+ const sesion = await asegurarSesion(tabId)
469
+ const { nodes } = await enviarComando(tabId, 'Accessibility.getFullAXTree')
470
+ const resumen = Core.resumirArbolAX(nodes, args.filter, MAX_ELEMENTOS_PAGINA)
471
+
472
+ // Refs nuevos: los anteriores dejan de valer (lectura nueva = mapa nuevo).
473
+ sesion.refs = new Map()
474
+ const filas = []
475
+ for (const el of resumen.elementos) {
476
+ sesion.refs.set(el.ref, el.backendDOMNodeId)
477
+ const fila = { ref: el.ref, rol: el.rol, nombre: el.nombre }
478
+ if (el.valor) fila.valor = el.valor
479
+ filas.push(fila)
480
+ }
481
+
482
+ // Bounds (px CSS del viewport) para los primeros MAX_ELEMENTOS_CON_BOUNDS.
483
+ for (let i = 0; i < filas.length && i < MAX_ELEMENTOS_CON_BOUNDS; i++) {
484
+ try {
485
+ const { quads } = await enviarComando(tabId, 'DOM.getContentQuads', {
486
+ backendNodeId: resumen.elementos[i].backendDOMNodeId,
487
+ })
488
+ if (quads && quads.length > 0) {
489
+ const q = quads[0]
490
+ const xs = [q[0], q[2], q[4], q[6]]
491
+ const ys = [q[1], q[3], q[5], q[7]]
492
+ const x = Math.min(...xs)
493
+ const y = Math.min(...ys)
494
+ filas[i].bounds = [
495
+ Math.round(x),
496
+ Math.round(y),
497
+ Math.round(Math.max(...xs) - x),
498
+ Math.round(Math.max(...ys) - y),
499
+ ]
500
+ }
501
+ } catch (_e) {
502
+ // Sin geometría (oculto/desprendido): la fila va sin bounds.
503
+ }
504
+ }
505
+
506
+ const cabecera = [
507
+ `Elementos: ${filas.length}` +
508
+ (resumen.truncado
509
+ ? ` (truncado: había ${resumen.totalCandidatos} candidatos; usa filter o find para acotar)`
510
+ : ''),
511
+ 'Los refs sirven para computer (clic por ref) y form_input; se invalidan al navegar o al volver a llamar a read_page.',
512
+ ].join('\n')
513
+ return textResult(
514
+ truncate(`${cabecera}\n${JSON.stringify(filas, null, 1)}`, MAX_TEXT_CHARS),
515
+ )
516
+ }
517
+
518
+ // ---------------------------------------------------------------------------
519
+ // form_input
520
+ // ---------------------------------------------------------------------------
521
+
522
+ async function fijarCampo(args) {
523
+ const tabId = requireTabId(args)
524
+ if (typeof args.ref !== 'string' || args.ref.length === 0) {
525
+ throw new Error('Falta el parámetro ref (de read_page).')
526
+ }
527
+ if (typeof args.value !== 'string') {
528
+ throw new Error('Falta el parámetro value (string).')
529
+ }
530
+ const sesion = await asegurarSesion(tabId)
531
+ const backendNodeId = sesion.refs.get(args.ref)
532
+ if (!backendNodeId) {
533
+ throw new Error(
534
+ `Ref desconocido: ${args.ref}. Llama a read_page para obtener refs vigentes.`,
535
+ )
536
+ }
537
+ const { object } = await enviarComando(tabId, 'DOM.resolveNode', { backendNodeId })
538
+ const resultado = await enviarComando(tabId, 'Runtime.callFunctionOn', {
539
+ objectId: object.objectId,
540
+ functionDeclaration: `function (valor) {
541
+ const el = this
542
+ const tag = (el.tagName || '').toLowerCase()
543
+ const disparar = () => {
544
+ el.dispatchEvent(new Event('input', { bubbles: true }))
545
+ el.dispatchEvent(new Event('change', { bubbles: true }))
546
+ }
547
+ if (tag === 'select') {
548
+ let encontrada = false
549
+ for (const opt of el.options) {
550
+ if (opt.value === valor || opt.textContent.trim() === valor.trim()) {
551
+ el.value = opt.value
552
+ encontrada = true
553
+ break
554
+ }
555
+ }
556
+ if (!encontrada) return { ok: false, error: 'Opción no encontrada en el select: ' + valor }
557
+ disparar()
558
+ return { ok: true, tipo: 'select', valor: el.value }
559
+ }
560
+ if (tag === 'input' && (el.type === 'checkbox' || el.type === 'radio')) {
561
+ const marcado = valor === 'true' || valor === '1' || valor === 'on'
562
+ // Setter nativo para que React/Vue vean el cambio.
563
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'checked').set
564
+ setter.call(el, marcado)
565
+ el.dispatchEvent(new Event('click', { bubbles: true }))
566
+ disparar()
567
+ return { ok: true, tipo: el.type, valor: String(el.checked) }
568
+ }
569
+ if (tag === 'input' || tag === 'textarea') {
570
+ const proto = tag === 'input' ? HTMLInputElement.prototype : HTMLTextAreaElement.prototype
571
+ const setter = Object.getOwnPropertyDescriptor(proto, 'value').set
572
+ el.focus()
573
+ setter.call(el, valor)
574
+ disparar()
575
+ return { ok: true, tipo: tag, valor: el.value }
576
+ }
577
+ if (el.isContentEditable) {
578
+ el.focus()
579
+ el.textContent = valor
580
+ disparar()
581
+ return { ok: true, tipo: 'contenteditable', valor: el.textContent }
582
+ }
583
+ return { ok: false, error: 'El elemento no es un campo de formulario (es <' + tag + '>).' }
584
+ }`,
585
+ arguments: [{ value: args.value }],
586
+ returnByValue: true,
587
+ })
588
+ const salida = resultado && resultado.result ? resultado.result.value : null
589
+ if (!salida || !salida.ok) {
590
+ throw new Error(salida && salida.error ? salida.error : 'No se pudo fijar el valor.')
591
+ }
592
+ return textResult(
593
+ `Campo ${args.ref} (${salida.tipo}) fijado a «${truncate(String(salida.valor), 200)}» con eventos input/change disparados.`,
594
+ )
595
+ }
596
+
597
+ // ---------------------------------------------------------------------------
598
+ // resize_window · lectores de consola y red
599
+ // ---------------------------------------------------------------------------
600
+
601
+ async function redimensionarVentana(args) {
602
+ const tabId = requireTabId(args)
603
+ const width = args.width
604
+ const height = args.height
605
+ if (typeof width !== 'number' || typeof height !== 'number' || width <= 0 || height <= 0) {
606
+ throw new Error('resize_window exige width y height positivos (píxeles).')
607
+ }
608
+ const pestana = await chrome.tabs.get(tabId)
609
+ await chrome.windows.update(pestana.windowId, {
610
+ width: Math.round(width),
611
+ height: Math.round(height),
612
+ state: 'normal',
613
+ })
614
+ return jsonResult({ tabId, windowId: pestana.windowId, width, height })
615
+ }
616
+
617
+ async function leerConsola(args) {
618
+ const tabId = requireTabId(args)
619
+ const sesion = await asegurarSesion(tabId)
620
+ const entradas = Core.filtrarEntradas(sesion.consola, {
621
+ pattern: typeof args.pattern === 'string' ? args.pattern : undefined,
622
+ limit: typeof args.limit === 'number' ? args.limit : 100,
623
+ onlyErrors: args.onlyErrors === true,
624
+ campoTexto: 'texto',
625
+ })
626
+ const lineas = entradas.map(e => {
627
+ const url = e.url ? ` (${e.url})` : ''
628
+ return `[${Core.horaLocal(e.ts)}] [${e.nivel}] ${e.texto}${url}`
629
+ })
630
+ const cuerpo =
631
+ lineas.length > 0 ? lineas.join('\n') : 'Sin mensajes que casen con el filtro.'
632
+ return textResult(
633
+ truncate(`${notaBuffer(sesion)}\nMensajes: ${lineas.length}\n${cuerpo}`, MAX_TEXT_CHARS),
634
+ )
635
+ }
636
+
637
+ async function leerRed(args) {
638
+ const tabId = requireTabId(args)
639
+ const sesion = await asegurarSesion(tabId)
640
+ const entradas = Core.filtrarEntradas(sesion.red.entradas, {
641
+ pattern: typeof args.pattern === 'string' ? args.pattern : undefined,
642
+ limit: typeof args.limit === 'number' ? args.limit : 100,
643
+ campoTexto: 'url',
644
+ })
645
+ const filas = entradas.map(e => ({
646
+ metodo: e.metodo,
647
+ url: e.url,
648
+ estado: e.error ? `ERROR: ${e.error}` : (e.estado ?? 'pendiente'),
649
+ tipo: e.tipo,
650
+ ...(e.mime ? { mime: e.mime } : {}),
651
+ ...(e.tam !== null ? { bytes: e.tam } : {}),
652
+ ...(e.duracionMs !== null ? { ms: Math.round(e.duracionMs) } : {}),
653
+ }))
654
+ return textResult(
655
+ truncate(
656
+ `${notaBuffer(sesion)}\nPeticiones: ${filas.length}\n${JSON.stringify(filas, null, 1)}`,
657
+ MAX_TEXT_CHARS,
658
+ ),
659
+ )
660
+ }
661
+
662
+ // Handlers de fase 2 que background.js fusiona en TOOL_HANDLERS.
663
+ // requireTabId, textResult, jsonResult, truncate y MAX_TEXT_CHARS los define
664
+ // background.js — importScripts comparte el ámbito global del service worker.
665
+ const CDP_TOOL_HANDLERS = {
666
+ computer: accionComputer,
667
+ read_page: leerPagina,
668
+ form_input: fijarCampo,
669
+ resize_window: redimensionarVentana,
670
+ read_console_messages: leerConsola,
671
+ read_network_requests: leerRed,
672
+ }