theus-cli-linux-x64 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,540 @@
1
+ // Theus en Chrome — service worker (MV3).
2
+ //
3
+ // Conecta con el host de mensajería nativa que sirve el CLI de Theus
4
+ // (`com.theus.theus_code_browser_extension` → wrapper que ejecuta
5
+ // `theus --chrome-native-host`) y atiende los `tool_request` que el CLI
6
+ // serializa por el socket del host. Contrato de mensajes (ver
7
+ // utils/theusInChrome/chromeNativeHost.ts en el repo del CLI):
8
+ //
9
+ // host → extensión: {type:'tool_request', method, params:{id,name,arguments}}
10
+ // {type:'pong'|'status_response'|'mcp_connected'|'mcp_disconnected'|'error'}
11
+ // extensión → host: {type:'tool_response', id, result|error}
12
+ // {type:'ping'} · {type:'get_status'}
13
+ //
14
+ // Límite del canal host↔CLI: 16 MB por mensaje (LIMITE_CANAL_BYTES en cdp.js;
15
+ // debe casar con MAX_CHANNEL_MESSAGE_BYTES del CLI). Los textos largos se
16
+ // truncan igualmente (MAX_TEXT_CHARS) y las capturas se recomprimen.
17
+ //
18
+ // Fase 2 (control total vía chrome.debugger): cdp-core.js (lógica pura) +
19
+ // cdp.js (sesiones CDP por pestaña, buffers y handlers). Se fusionan en
20
+ // TOOL_HANDLERS más abajo.
21
+ importScripts('cdp-core.js', 'cdp.js')
22
+
23
+ const NATIVE_HOST = 'com.theus.theus_code_browser_extension'
24
+ const PING_INTERVAL_MS = 20_000 // mantiene vivo el service worker y el canal
25
+ const NAVIGATE_TIMEOUT_MS = 45_000
26
+ const MAX_TEXT_CHARS = 180_000 // margen holgado bajo el límite de 1 MB
27
+ const MAX_FIND_RESULTS = 25
28
+
29
+ let port = null
30
+ let reconnectDelayMs = 1_000
31
+ let reconnectTimer = null
32
+ let pingTimer = null
33
+ let mcpClientCount = 0 // clientes MCP (sesiones del CLI) vistos por el host
34
+ let lastError = null
35
+
36
+ // Kuti fase 1 — estado del grabador. Expuesto en globalThis para que cdp.js
37
+ // (cargado por importScripts, mismo ámbito global) sepa si debe reportar las
38
+ // navegaciones del frame principal.
39
+ const RecorderState = { grabando: false, tabGrabada: null, capturas: false }
40
+ globalThis.__kuti = RecorderState
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Conexión con el host nativo
44
+ // ---------------------------------------------------------------------------
45
+
46
+ function connectToHost() {
47
+ if (port) return
48
+ try {
49
+ port = chrome.runtime.connectNative(NATIVE_HOST)
50
+ } catch (e) {
51
+ lastError = String(e)
52
+ scheduleReconnect()
53
+ return
54
+ }
55
+
56
+ port.onMessage.addListener(onHostMessage)
57
+ port.onDisconnect.addListener(() => {
58
+ lastError = chrome.runtime.lastError
59
+ ? chrome.runtime.lastError.message
60
+ : null
61
+ port = null
62
+ mcpClientCount = 0
63
+ stopPing()
64
+ scheduleReconnect()
65
+ })
66
+
67
+ reconnectDelayMs = 1_000
68
+ lastError = null
69
+ startPing()
70
+ safePost({ type: 'get_status' })
71
+ }
72
+
73
+ function scheduleReconnect() {
74
+ if (reconnectTimer) return
75
+ reconnectTimer = setTimeout(() => {
76
+ reconnectTimer = null
77
+ connectToHost()
78
+ }, reconnectDelayMs)
79
+ reconnectDelayMs = Math.min(reconnectDelayMs * 2, 60_000)
80
+ }
81
+
82
+ function startPing() {
83
+ stopPing()
84
+ pingTimer = setInterval(() => safePost({ type: 'ping' }), PING_INTERVAL_MS)
85
+ }
86
+
87
+ function stopPing() {
88
+ if (pingTimer) {
89
+ clearInterval(pingTimer)
90
+ pingTimer = null
91
+ }
92
+ }
93
+
94
+ function safePost(message) {
95
+ if (!port) return
96
+ try {
97
+ port.postMessage(message)
98
+ } catch (e) {
99
+ lastError = String(e)
100
+ }
101
+ }
102
+
103
+ function onHostMessage(message) {
104
+ if (!message || typeof message.type !== 'string') return
105
+ switch (message.type) {
106
+ case 'pong':
107
+ case 'status_response':
108
+ break
109
+ case 'mcp_connected':
110
+ mcpClientCount += 1
111
+ break
112
+ case 'mcp_disconnected':
113
+ mcpClientCount = Math.max(0, mcpClientCount - 1)
114
+ break
115
+ case 'tool_request':
116
+ void handleToolRequest(message)
117
+ break
118
+ case 'error':
119
+ lastError = String(message.error || 'error del host')
120
+ break
121
+ default:
122
+ break
123
+ }
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Despacho de tools
128
+ // ---------------------------------------------------------------------------
129
+
130
+ async function handleToolRequest(message) {
131
+ const params = message.params || {}
132
+ const id = typeof params.id === 'string' ? params.id : null
133
+ const name =
134
+ typeof params.name === 'string' ? params.name : String(message.method || '')
135
+ const args =
136
+ params.arguments && typeof params.arguments === 'object'
137
+ ? params.arguments
138
+ : {}
139
+
140
+ if (!id) {
141
+ // Sin id no hay a quién correlacionar la respuesta; no se puede atender.
142
+ return
143
+ }
144
+
145
+ try {
146
+ const handler = TOOL_HANDLERS[name]
147
+ if (!handler) {
148
+ respondError(id, `Tool no soportada por la extensión: ${name}`)
149
+ return
150
+ }
151
+ const result = await handler(args)
152
+ safePost({ type: 'tool_response', id, result })
153
+ } catch (e) {
154
+ respondError(id, e && e.message ? e.message : String(e))
155
+ }
156
+ }
157
+
158
+ function respondError(id, text) {
159
+ safePost({
160
+ type: 'tool_response',
161
+ id,
162
+ result: { content: [{ type: 'text', text }], isError: true },
163
+ })
164
+ }
165
+
166
+ function textResult(text) {
167
+ return { content: [{ type: 'text', text }] }
168
+ }
169
+
170
+ function jsonResult(data) {
171
+ return textResult(JSON.stringify(data, null, 2))
172
+ }
173
+
174
+ function requireTabId(args) {
175
+ const tabId = args.tabId
176
+ if (typeof tabId !== 'number' || !Number.isInteger(tabId)) {
177
+ throw new Error('Falta el parámetro tabId (número entero).')
178
+ }
179
+ return tabId
180
+ }
181
+
182
+ function truncate(text, max) {
183
+ if (typeof text !== 'string') return ''
184
+ if (text.length <= max) return text
185
+ return (
186
+ text.slice(0, max) +
187
+ `\n\n[… texto truncado: ${text.length - max} caracteres omitidos]`
188
+ )
189
+ }
190
+
191
+ const TOOL_HANDLERS = {
192
+ // Lista pestañas: id, url, title, active.
193
+ async tabs_context_mcp() {
194
+ const tabs = await chrome.tabs.query({})
195
+ const rows = tabs.map(t => ({
196
+ id: t.id,
197
+ url: t.url || '',
198
+ title: t.title || '',
199
+ active: Boolean(t.active),
200
+ windowId: t.windowId,
201
+ }))
202
+ return jsonResult({ tabs: rows })
203
+ },
204
+
205
+ // Abre una pestaña (url opcional) y devuelve su tabId.
206
+ async tabs_create_mcp(args) {
207
+ const createProps = {}
208
+ if (typeof args.url === 'string' && args.url.length > 0) {
209
+ createProps.url = args.url
210
+ }
211
+ const tab = await chrome.tabs.create(createProps)
212
+ return jsonResult({ tabId: tab.id, url: tab.pendingUrl || tab.url || '' })
213
+ },
214
+
215
+ // Navega la pestaña y espera al estado "complete".
216
+ async navigate(args) {
217
+ const tabId = requireTabId(args)
218
+ if (typeof args.url !== 'string' || args.url.length === 0) {
219
+ throw new Error('Falta el parámetro url.')
220
+ }
221
+ await chrome.tabs.update(tabId, { url: args.url })
222
+ await waitForTabComplete(tabId, NAVIGATE_TIMEOUT_MS)
223
+ const tab = await chrome.tabs.get(tabId)
224
+ return jsonResult({
225
+ tabId,
226
+ url: tab.url || '',
227
+ title: tab.title || '',
228
+ status: tab.status || 'complete',
229
+ })
230
+ },
231
+
232
+ // Texto visible del DOM.
233
+ async get_page_text(args) {
234
+ const tabId = requireTabId(args)
235
+ const frames = await chrome.scripting.executeScript({
236
+ target: { tabId },
237
+ func: () => (document.body ? document.body.innerText : ''),
238
+ })
239
+ const text = frames && frames[0] ? String(frames[0].result || '') : ''
240
+ return textResult(truncate(text, MAX_TEXT_CHARS))
241
+ },
242
+
243
+ // Ejecuta JS en el mundo MAIN de la página y devuelve el resultado serializado.
244
+ async javascript_tool(args) {
245
+ const tabId = requireTabId(args)
246
+ if (typeof args.code !== 'string') {
247
+ throw new Error('Falta el parámetro code.')
248
+ }
249
+ const frames = await chrome.scripting.executeScript({
250
+ target: { tabId },
251
+ world: 'MAIN',
252
+ func: code => {
253
+ try {
254
+ // eslint-disable-next-line no-eval
255
+ const value = (0, eval)(code)
256
+ let serialized
257
+ try {
258
+ serialized = JSON.stringify(value)
259
+ } catch (_e) {
260
+ serialized = undefined
261
+ }
262
+ if (serialized === undefined) {
263
+ serialized = String(value)
264
+ }
265
+ return { ok: true, value: serialized }
266
+ } catch (e) {
267
+ return { ok: false, error: e && e.message ? e.message : String(e) }
268
+ }
269
+ },
270
+ args: [args.code],
271
+ })
272
+ const outcome = frames && frames[0] ? frames[0].result : null
273
+ if (!outcome) {
274
+ throw new Error('La ejecución no devolvió resultado (¿pestaña protegida?).')
275
+ }
276
+ if (!outcome.ok) {
277
+ return {
278
+ content: [
279
+ { type: 'text', text: `Error de ejecución: ${outcome.error}` },
280
+ ],
281
+ isError: true,
282
+ }
283
+ }
284
+ return textResult(truncate(String(outcome.value), MAX_TEXT_CHARS))
285
+ },
286
+
287
+ // Busca elementos por texto visible o selector CSS y devuelve refs simples.
288
+ async find(args) {
289
+ const tabId = requireTabId(args)
290
+ if (typeof args.query !== 'string' || args.query.length === 0) {
291
+ throw new Error('Falta el parámetro query.')
292
+ }
293
+ const frames = await chrome.scripting.executeScript({
294
+ target: { tabId },
295
+ func: (query, maxResults) => {
296
+ function cssPath(el) {
297
+ const parts = []
298
+ let node = el
299
+ while (node && node.nodeType === 1 && parts.length < 6) {
300
+ let part = node.tagName.toLowerCase()
301
+ if (node.id) {
302
+ parts.unshift(`${part}#${node.id}`)
303
+ break
304
+ }
305
+ const parent = node.parentElement
306
+ if (parent) {
307
+ const siblings = Array.from(parent.children).filter(
308
+ c => c.tagName === node.tagName,
309
+ )
310
+ if (siblings.length > 1) {
311
+ part += `:nth-of-type(${siblings.indexOf(node) + 1})`
312
+ }
313
+ }
314
+ parts.unshift(part)
315
+ node = node.parentElement
316
+ }
317
+ return parts.join(' > ')
318
+ }
319
+ function describe(el, ref) {
320
+ const text = (el.innerText || el.value || '')
321
+ .trim()
322
+ .replace(/\s+/g, ' ')
323
+ .slice(0, 120)
324
+ return {
325
+ ref,
326
+ tag: el.tagName.toLowerCase(),
327
+ selector: cssPath(el),
328
+ role: el.getAttribute('role') || null,
329
+ text,
330
+ }
331
+ }
332
+ const found = []
333
+ // 1) Si la query es un selector CSS válido, úsalo tal cual.
334
+ try {
335
+ const bySelector = document.querySelectorAll(query)
336
+ for (const el of bySelector) {
337
+ if (found.length >= maxResults) break
338
+ found.push(describe(el, `sel-${found.length + 1}`))
339
+ }
340
+ } catch (_e) {
341
+ // No era un selector válido; seguimos con búsqueda por texto.
342
+ }
343
+ // 2) Búsqueda por texto visible en elementos interactivos y comunes.
344
+ if (found.length < maxResults) {
345
+ const lower = query.toLowerCase()
346
+ const candidates = document.querySelectorAll(
347
+ 'a, button, input, select, textarea, label, h1, h2, h3, h4, [role], summary, li, td, th, p, span',
348
+ )
349
+ for (const el of candidates) {
350
+ if (found.length >= maxResults) break
351
+ const text = (
352
+ el.innerText ||
353
+ el.value ||
354
+ el.getAttribute('aria-label') ||
355
+ ''
356
+ )
357
+ .trim()
358
+ .replace(/\s+/g, ' ')
359
+ if (!text) continue
360
+ if (text.toLowerCase().includes(lower) && text.length < 400) {
361
+ found.push(describe(el, `txt-${found.length + 1}`))
362
+ }
363
+ }
364
+ }
365
+ return found
366
+ },
367
+ args: [args.query, MAX_FIND_RESULTS],
368
+ })
369
+ const results = frames && frames[0] && frames[0].result ? frames[0].result : []
370
+ return jsonResult({ query: args.query, matches: results })
371
+ },
372
+ }
373
+
374
+ // Fase 2: computer, read_page, form_input, resize_window,
375
+ // read_console_messages y read_network_requests (definidos en cdp.js).
376
+ Object.assign(TOOL_HANDLERS, CDP_TOOL_HANDLERS)
377
+
378
+ // ---------------------------------------------------------------------------
379
+ // Kuti fase 1 — grabador (captura automática de input)
380
+ // ---------------------------------------------------------------------------
381
+
382
+ // Inyecta el content script recorder.js en todos los frames de la pestaña.
383
+ async function inyectarRecorder(tabId) {
384
+ await chrome.scripting.executeScript({
385
+ target: { tabId, allFrames: true },
386
+ files: ['recorder.js'],
387
+ })
388
+ }
389
+
390
+ // Empuja un evento del grabador al CLI por el MISMO canal nativo (host →
391
+ // `recorder_event`, sin id de correlación). El host lo reenvía a los clientes
392
+ // MCP; el socketClient del CLI lo reparte a sus oyentes.
393
+ function empujarEventoGrabacion(evento) {
394
+ safePost({ type: 'recorder_event', evento })
395
+ }
396
+
397
+ // Hook que cdp.js llama al detectar una navegación del frame principal.
398
+ globalThis.kutiEmitirNavegacion = function (tabId, url, capturaBase64) {
399
+ if (!RecorderState.grabando || tabId !== RecorderState.tabGrabada) return
400
+ const evento = { t: 'navigate', url: url || '', tabId, ts: Date.now() }
401
+ if (typeof capturaBase64 === 'string' && capturaBase64.length > 0) {
402
+ evento.captura = capturaBase64
403
+ }
404
+ empujarEventoGrabacion(evento)
405
+ }
406
+
407
+ Object.assign(TOOL_HANDLERS, {
408
+ async recorder_start(args) {
409
+ const tabId = requireTabId(args)
410
+ RecorderState.grabando = true
411
+ RecorderState.tabGrabada = tabId
412
+ RecorderState.capturas = args.capturas === true
413
+ try {
414
+ await inyectarRecorder(tabId)
415
+ } catch (e) {
416
+ RecorderState.grabando = false
417
+ RecorderState.tabGrabada = null
418
+ throw new Error(
419
+ `No se pudo inyectar el capturador en la pestaña ${tabId}: ${e && e.message ? e.message : e}. ` +
420
+ 'Las páginas del navegador (chrome://…) y la Web Store no admiten grabación.',
421
+ )
422
+ }
423
+ // Sesión CDP para oír las navegaciones (y capturar por paso si se pidió).
424
+ // Si la pestaña no se puede instrumentar, se graba igual sin el paso
425
+ // `navegar` automático.
426
+ try {
427
+ await asegurarSesion(tabId)
428
+ } catch (_e) {
429
+ // Sin CDP: el content script sigue capturando clics/cambios/teclas.
430
+ }
431
+ return textResult(
432
+ 'Grabación iniciada. Opera normalmente en la pestaña: tus clics, cambios de campo y teclas se capturan. ' +
433
+ 'Mientras grabas, Chrome muestra la barra «Theus en Chrome ha empezado a depurar este navegador».',
434
+ )
435
+ },
436
+
437
+ async recorder_stop() {
438
+ const tabId = RecorderState.tabGrabada
439
+ RecorderState.grabando = false
440
+ RecorderState.tabGrabada = null
441
+ RecorderState.capturas = false
442
+ if (typeof tabId === 'number') {
443
+ try {
444
+ await chrome.tabs.sendMessage(tabId, { type: 'kuti_recorder_stop' })
445
+ } catch (_e) {
446
+ // El content script pudo desaparecer con una navegación: no pasa nada.
447
+ }
448
+ }
449
+ return textResult('Grabación detenida.')
450
+ },
451
+ })
452
+
453
+ function waitForTabComplete(tabId, timeoutMs) {
454
+ return new Promise((resolve, reject) => {
455
+ let settled = false
456
+ const timer = setTimeout(() => {
457
+ if (settled) return
458
+ settled = true
459
+ chrome.tabs.onUpdated.removeListener(listener)
460
+ reject(new Error(`La página no terminó de cargar en ${Math.round(timeoutMs / 1000)} s.`))
461
+ }, timeoutMs)
462
+ function listener(updatedTabId, changeInfo) {
463
+ if (updatedTabId === tabId && changeInfo.status === 'complete') {
464
+ if (settled) return
465
+ settled = true
466
+ clearTimeout(timer)
467
+ chrome.tabs.onUpdated.removeListener(listener)
468
+ resolve()
469
+ }
470
+ }
471
+ chrome.tabs.onUpdated.addListener(listener)
472
+ // Por si la pestaña ya estaba completa cuando llegamos.
473
+ chrome.tabs.get(tabId).then(tab => {
474
+ if (tab.status === 'complete' && !settled) {
475
+ settled = true
476
+ clearTimeout(timer)
477
+ chrome.tabs.onUpdated.removeListener(listener)
478
+ resolve()
479
+ }
480
+ }, () => {})
481
+ })
482
+ }
483
+
484
+ // ---------------------------------------------------------------------------
485
+ // Estado para el popup
486
+ // ---------------------------------------------------------------------------
487
+
488
+ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
489
+ if (message && message.type === 'get_state') {
490
+ sendResponse({
491
+ hostConnected: Boolean(port),
492
+ cliConnected: mcpClientCount > 0,
493
+ cliClients: mcpClientCount,
494
+ recording: RecorderState.grabando,
495
+ lastError,
496
+ })
497
+ return false
498
+ }
499
+ // Evento del grabador (content script recorder.js). Solo se reenvía si la
500
+ // grabación está activa y viene de la pestaña que grabamos.
501
+ if (message && message.type === 'kuti_recorder_event') {
502
+ if (
503
+ RecorderState.grabando &&
504
+ sender &&
505
+ sender.tab &&
506
+ sender.tab.id === RecorderState.tabGrabada &&
507
+ message.evento &&
508
+ typeof message.evento === 'object'
509
+ ) {
510
+ const evento = {
511
+ ...message.evento,
512
+ tabId: sender.tab.id,
513
+ url: sender.url || (sender.tab && sender.tab.url) || '',
514
+ ts: Date.now(),
515
+ }
516
+ empujarEventoGrabacion(evento)
517
+ }
518
+ return false
519
+ }
520
+ return false
521
+ })
522
+
523
+ // Reinyecta el capturador tras cada navegación de la pestaña grabada: el
524
+ // content script muere al cambiar de página.
525
+ chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
526
+ if (
527
+ RecorderState.grabando &&
528
+ tabId === RecorderState.tabGrabada &&
529
+ changeInfo.status === 'complete'
530
+ ) {
531
+ inyectarRecorder(tabId).catch(() => {
532
+ // Página protegida o carrera con un cierre: se ignora.
533
+ })
534
+ }
535
+ })
536
+
537
+ // Arranque: conectar al host (también al instalarse o reiniciar el navegador).
538
+ chrome.runtime.onStartup.addListener(connectToHost)
539
+ chrome.runtime.onInstalled.addListener(connectToHost)
540
+ connectToHost()