chocolatito-code 1.0.0 → 1.2.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 (122) hide show
  1. package/README.md +155 -0
  2. package/dist/agent/compaction.d.ts +65 -0
  3. package/dist/agent/compaction.js +134 -0
  4. package/dist/agent/context.d.ts +3 -0
  5. package/dist/agent/context.js +35 -0
  6. package/dist/agent/loop.d.ts +91 -2
  7. package/dist/agent/loop.js +642 -91
  8. package/dist/agent/loopDetector.d.ts +57 -0
  9. package/dist/agent/loopDetector.js +0 -0
  10. package/dist/agent/mentions.d.ts +13 -0
  11. package/dist/agent/mentions.js +49 -0
  12. package/dist/agent/repoMap.d.ts +117 -0
  13. package/dist/agent/repoMap.js +560 -0
  14. package/dist/agent/result.d.ts +64 -0
  15. package/dist/agent/result.js +48 -0
  16. package/dist/agent/retry.d.ts +35 -0
  17. package/dist/agent/retry.js +123 -4
  18. package/dist/agent/salidaDelAgente.d.ts +43 -0
  19. package/dist/agent/salidaDelAgente.js +53 -0
  20. package/dist/agent/subagent.js +62 -15
  21. package/dist/agent/syntaxValidator.js +288 -52
  22. package/dist/agent/toolGate.d.ts +58 -0
  23. package/dist/agent/toolGate.js +111 -0
  24. package/dist/agent/tracker.js +4 -7
  25. package/dist/agent/undoManager.d.ts +44 -11
  26. package/dist/agent/undoManager.js +95 -24
  27. package/dist/agent/usageMeter.d.ts +56 -0
  28. package/dist/agent/usageMeter.js +51 -0
  29. package/dist/agent/verifier.d.ts +49 -0
  30. package/dist/agent/verifier.js +158 -0
  31. package/dist/config/constants.js +14 -0
  32. package/dist/config/engine.d.ts +19 -0
  33. package/dist/config/engine.js +35 -3
  34. package/dist/config/license.d.ts +5 -0
  35. package/dist/config/license.js +107 -9
  36. package/dist/config/limits.d.ts +28 -0
  37. package/dist/config/limits.js +51 -0
  38. package/dist/config/nodeVersion.d.ts +13 -0
  39. package/dist/config/nodeVersion.js +26 -0
  40. package/dist/config/permissions.d.ts +76 -1
  41. package/dist/config/permissions.js +379 -23
  42. package/dist/config/quota.js +24 -1
  43. package/dist/config/updater.d.ts +68 -0
  44. package/dist/config/updater.js +215 -0
  45. package/dist/hooks/manager.js +159 -14
  46. package/dist/index.js +240 -37
  47. package/dist/mcp/client.d.ts +50 -0
  48. package/dist/mcp/client.js +155 -19
  49. package/dist/mcp/ide.d.ts +76 -0
  50. package/dist/mcp/ide.js +160 -0
  51. package/dist/mcp/manager.js +9 -0
  52. package/dist/prompts/systemPrompt.js +12 -1
  53. package/dist/sessions/manager.d.ts +12 -0
  54. package/dist/sessions/manager.js +33 -5
  55. package/dist/sessions/resume.d.ts +36 -0
  56. package/dist/sessions/resume.js +43 -0
  57. package/dist/skills/manager.d.ts +1 -0
  58. package/dist/skills/manager.js +9 -0
  59. package/dist/tools/binary.d.ts +46 -0
  60. package/dist/tools/binary.js +100 -0
  61. package/dist/tools/browserCdp.js +13 -1
  62. package/dist/tools/browserExtension.d.ts +5 -0
  63. package/dist/tools/browserExtension.js +237 -22
  64. package/dist/tools/browserSession.d.ts +23 -0
  65. package/dist/tools/browserSession.js +28 -0
  66. package/dist/tools/computerUse.js +33 -1
  67. package/dist/tools/createImage.d.ts +11 -0
  68. package/dist/tools/createImage.js +13 -1
  69. package/dist/tools/definitions.js +17 -4
  70. package/dist/tools/editDiagnosis.d.ts +54 -0
  71. package/dist/tools/editDiagnosis.js +142 -0
  72. package/dist/tools/editFile.d.ts +1 -0
  73. package/dist/tools/editFile.js +118 -62
  74. package/dist/tools/findFiles.d.ts +21 -0
  75. package/dist/tools/findFiles.js +75 -6
  76. package/dist/tools/getSystemInfo.js +15 -7
  77. package/dist/tools/gitTools.d.ts +24 -0
  78. package/dist/tools/gitTools.js +87 -26
  79. package/dist/tools/grepSearch.js +111 -23
  80. package/dist/tools/moveCopyFile.d.ts +18 -1
  81. package/dist/tools/moveCopyFile.js +74 -4
  82. package/dist/tools/patchCascade.d.ts +57 -0
  83. package/dist/tools/patchCascade.js +338 -0
  84. package/dist/tools/runCommand.js +213 -25
  85. package/dist/tools/runner.js +58 -5
  86. package/dist/tools/toolDefsComputer.js +12 -2
  87. package/dist/tools/truncator.d.ts +27 -1
  88. package/dist/tools/truncator.js +117 -11
  89. package/dist/tools/viewFile.js +15 -11
  90. package/dist/tools/visionBridge.js +7 -0
  91. package/dist/tools/webSearch.d.ts +21 -0
  92. package/dist/tools/webSearch.js +125 -14
  93. package/dist/tools/writeFile.d.ts +16 -1
  94. package/dist/tools/writeFile.js +40 -2
  95. package/dist/ui/interrupt.d.ts +4 -1
  96. package/dist/ui/interrupt.js +59 -7
  97. package/dist/ui/keyboardGuard.d.ts +76 -0
  98. package/dist/ui/keyboardGuard.js +94 -0
  99. package/dist/ui/loopPrompt.d.ts +17 -0
  100. package/dist/ui/loopPrompt.js +59 -0
  101. package/dist/ui/pegados.d.ts +50 -0
  102. package/dist/ui/pegados.js +83 -0
  103. package/dist/ui/permissionPrompt.js +84 -2
  104. package/dist/ui/prompt.d.ts +40 -0
  105. package/dist/ui/prompt.js +436 -131
  106. package/dist/ui/reasoningStream.d.ts +8 -0
  107. package/dist/ui/reasoningStream.js +99 -7
  108. package/dist/ui/salida.d.ts +10 -0
  109. package/dist/ui/salida.js +50 -0
  110. package/extension/README.md +95 -74
  111. package/extension/background.js +772 -181
  112. package/extension/content.js +527 -46
  113. package/extension/fuentes/Chocolatito-Marca.ttf +0 -0
  114. package/extension/iconos/128.png +0 -0
  115. package/extension/iconos/16.png +0 -0
  116. package/extension/iconos/32.png +0 -0
  117. package/extension/iconos/48.png +0 -0
  118. package/extension/iconos/logotipo.png +0 -0
  119. package/extension/manifest.json +28 -4
  120. package/extension/popup.html +16 -6
  121. package/extension/popup.js +28 -12
  122. package/package.json +3 -2
@@ -13,18 +13,120 @@
13
13
  // todas pueden trabajar.
14
14
  const WS_PORTS = [8787, 8788, 8789, 8790];
15
15
  const GROUP_TITLE = "Chocolatito Code";
16
+ const VERSION = "1.2.0";
16
17
 
17
- /** puerto -> { socket, delay, timer } */
18
+ /**
19
+ * Tope de un comando dentro de la extension.
20
+ *
21
+ * La CLI ya tiene el suyo, pero el suyo solo hace que ella deje de esperar: la
22
+ * accion se queda corriendo aqui y puede llegar tarde, cuando el modelo ya ha
23
+ * pedido otra cosa. Cortando de este lado, lo que la CLI recibe es un error
24
+ * explicable en vez de un silencio.
25
+ */
26
+ const TIMEOUT_COMANDO_MS = 45000;
27
+
28
+ /** puerto -> { socket, delay, timer, state, sessionId, reconectando } */
18
29
  const links = new Map();
19
30
 
20
- /** ref -> { tabId, frameId }, para resolver despues los clics. */
21
- const refIndex = new Map();
22
- let workingTabId = null;
23
- let groupId = null;
31
+ /** Paginas donde Chrome no deja entrar a ninguna extension. */
32
+ const ESQUEMAS_VETADOS = /^(chrome|edge|about|devtools|view-source|chrome-untrusted|chrome-search|chrome-error):/i;
33
+ const TIENDAS_VETADAS = /^https:\/\/(chromewebstore\.google\.com|chrome\.google\.com\/webstore)/i;
34
+
35
+ /**
36
+ * Estado de UNA sesion de la CLI.
37
+ *
38
+ * Antes esto eran tres variables de modulo. Como la extension se conecta a los
39
+ * cuatro puertos desde el MISMO service worker, dos sesiones compartian indice
40
+ * de refs, pestana de trabajo y grupo: el snapshot de la sesion B borraba el
41
+ * indice de A, y el clic de A caia sobre la pestana de B. Un estado por sesion
42
+ * es lo que hace cierto lo que promete la CLI: que varias convivan.
43
+ *
44
+ * refIndex: ref -> { tabId, frameId }, para resolver despues los clics.
45
+ * propias: pestanas que ESTA sesion abrio o adopto con select. Es lo unico
46
+ * que puede cerrar; ver close_tab.
47
+ */
48
+ function nuevoEstado() {
49
+ return { refIndex: new Map(), workingTabId: null, groupId: null, propias: new Set() };
50
+ }
51
+
52
+ // ======================================================= estado persistente
53
+
54
+ /**
55
+ * En Manifest V3 el service worker se apaga cuando le parece. Cuando eso pasa,
56
+ * el estado en memoria —indice de refs, pestana de trabajo, grupo— desaparece
57
+ * entero y sin avisar: el siguiente clic responde "el ref 3 no existe" y el
58
+ * siguiente close_tab dice que no hay pestana de trabajo, aunque en pantalla
59
+ * siga todo donde estaba. La extension ya pedia el permiso `storage` en el
60
+ * manifiesto y no lo usaba para nada.
61
+ *
62
+ * Se guarda en `storage.session`, que vive en memoria y se borra al cerrar
63
+ * Chrome: es un apunte para sobrevivir a un reinicio del worker, no un
64
+ * historial de navegacion en el disco del usuario.
65
+ */
66
+ const almacen = (() => {
67
+ try {
68
+ return chrome.storage && chrome.storage.session ? chrome.storage.session : null;
69
+ } catch (_) {
70
+ return null;
71
+ }
72
+ })();
73
+
74
+ const claveDe = (sessionId) => "sesion:" + sessionId;
75
+
76
+ function serializar(st) {
77
+ return {
78
+ workingTabId: st.workingTabId,
79
+ groupId: st.groupId,
80
+ propias: [...st.propias],
81
+ refIndex: [...st.refIndex.entries()],
82
+ };
83
+ }
84
+
85
+ function deserializar(plano) {
86
+ const st = nuevoEstado();
87
+ if (!plano) return st;
88
+ st.workingTabId = plano.workingTabId ?? null;
89
+ st.groupId = plano.groupId ?? null;
90
+ st.propias = new Set(plano.propias || []);
91
+ st.refIndex = new Map(plano.refIndex || []);
92
+ return st;
93
+ }
94
+
95
+ async function guardarEstado(link) {
96
+ if (!almacen || !link || !link.sessionId) return;
97
+ try {
98
+ await almacen.set({ [claveDe(link.sessionId)]: serializar(link.state) });
99
+ } catch (_) {}
100
+ }
101
+
102
+ async function restaurarEstado(link, sessionId) {
103
+ link.sessionId = sessionId;
104
+ if (!almacen) return;
105
+ try {
106
+ const guardado = await almacen.get(claveDe(sessionId));
107
+ const plano = guardado && guardado[claveDe(sessionId)];
108
+ if (!plano) return;
109
+ link.state = deserializar(plano);
110
+ // La pestana pudo cerrarse mientras el worker estaba dormido.
111
+ if (link.state.workingTabId) {
112
+ try {
113
+ await chrome.tabs.get(link.state.workingTabId);
114
+ } catch (_) {
115
+ link.state.workingTabId = null;
116
+ }
117
+ }
118
+ } catch (_) {}
119
+ }
24
120
 
25
121
  // ============================================================ conexion
26
122
 
27
123
  function connect(port) {
124
+ const existente = links.get(port);
125
+ // Sin esta guarda, dos reintentos solapados dejaban dos sockets vivos contra
126
+ // el mismo puerto: la CLI se quedaba con el ultimo y el otro seguia
127
+ // consumiendo un keepalive para siempre.
128
+ if (existente && existente.socket && existente.socket.readyState === WebSocket.OPEN) return;
129
+
28
130
  let ws;
29
131
  try {
30
132
  ws = new WebSocket("ws://127.0.0.1:" + port);
@@ -33,16 +135,29 @@ function connect(port) {
33
135
  return;
34
136
  }
35
137
 
36
- const link = links.get(port) || { delay: 1000 };
138
+ const link = existente || { delay: 1000, state: nuevoEstado() };
139
+ if (!link.state) link.state = nuevoEstado();
37
140
  link.socket = ws;
141
+ link.reconectando = false;
38
142
  links.set(port, link);
39
143
 
40
144
  ws.onopen = () => {
41
145
  link.delay = 1000;
42
- sendTo(ws, { type: "hello", agent: "chocolatito-extension", version: "1.1.0", port });
146
+ // Que este puerto vuelva a aceptar conexion puede significar dos cosas muy
147
+ // distintas: otra sesion de la CLI (hay que empezar en limpio, heredar su
148
+ // pestana de trabajo es como acaban los comandos sin tabId actuando sobre
149
+ // una pestana ajena) o el mismo proceso de siempre despues de que Chrome
150
+ // apagara este worker (hay que RECUPERAR el estado, o el usuario ve como
151
+ // el agente se olvida de donde estaba trabajando a mitad de tarea).
152
+ //
153
+ // No se puede distinguir sin preguntar, asi que se arranca en limpio y se
154
+ // recupera solo si la CLI se identifica con el mismo sessionId de antes.
155
+ link.state = nuevoEstado();
156
+ link.sessionId = null;
157
+ sendTo(ws, { type: "hello", agent: "chocolatito-extension", version: VERSION, port, resumable: !!almacen });
43
158
  refreshBadge();
44
- // El service worker de MV3 se duerme al quedar inactivo. Un ping periodico
45
- // mantiene viva la conexion y con ella el indice de referencias.
159
+ // Un ping periodico mantiene viva la conexion y, con ella, el worker: la
160
+ // actividad de WebSocket reinicia el temporizador de inactividad de MV3.
46
161
  clearInterval(link.timer);
47
162
  link.timer = setInterval(() => sendTo(ws, { type: "keepalive" }), 20000);
48
163
  };
@@ -52,13 +167,27 @@ function connect(port) {
52
167
  try {
53
168
  msg = JSON.parse(ev.data);
54
169
  } catch (_) {
170
+ return; // sin id no hay a quien contestarle
171
+ }
172
+ if (!msg) return;
173
+
174
+ // La CLI se identifica al conectar. Si es la misma sesion que ya conocia
175
+ // este worker, se recupera su estado; si es otra, se queda el limpio.
176
+ if (msg.type === "session" && typeof msg.sessionId === "string") {
177
+ await restaurarEstado(link, msg.sessionId);
55
178
  return;
56
179
  }
57
- if (!msg || typeof msg.id !== "number") return;
180
+
181
+ if (typeof msg.id !== "number") return;
182
+
183
+ // Un comando sin nombre antes se caia por el `throw` de handle y se
184
+ // contestaba igual; se deja explicito para que el error diga que pasa.
58
185
  try {
59
- const data = await handle(msg);
186
+ const data = await conTimeout(handle(msg, link.state), TIMEOUT_COMANDO_MS, msg.cmd);
187
+ await guardarEstado(link);
60
188
  sendTo(ws, { type: "result", id: msg.id, ok: true, data });
61
189
  } catch (err) {
190
+ await guardarEstado(link);
62
191
  sendTo(ws, { type: "result", id: msg.id, ok: false, error: String((err && err.message) || err) });
63
192
  }
64
193
  };
@@ -66,6 +195,9 @@ function connect(port) {
66
195
  ws.onclose = () => {
67
196
  clearInterval(link.timer);
68
197
  if (link.socket === ws) link.socket = null;
198
+ const oldTab = link.state && link.state.workingTabId;
199
+ if (link.state) link.state.workingTabId = null;
200
+ if (oldTab) setTabOverlay(oldTab, false);
69
201
  refreshBadge();
70
202
  scheduleReconnect(port);
71
203
  };
@@ -78,7 +210,14 @@ function connect(port) {
78
210
  function scheduleReconnect(port) {
79
211
  const link = links.get(port) || { delay: 1000 };
80
212
  links.set(port, link);
81
- setTimeout(() => connect(port), link.delay);
213
+ // onerror cierra el socket y onclose vuelve a llamar aqui: sin esta marca se
214
+ // encadenaban dos temporizadores por cada caida.
215
+ if (link.reconectando) return;
216
+ link.reconectando = true;
217
+ setTimeout(() => {
218
+ link.reconectando = false;
219
+ connect(port);
220
+ }, link.delay);
82
221
  // Tope bajo a proposito: el servidor es local y reintentar cuesta nada.
83
222
  link.delay = Math.min((link.delay || 1000) * 1.5, 3000);
84
223
  }
@@ -109,13 +248,66 @@ function setBadge(text) {
109
248
 
110
249
  // ============================================================ utilidades
111
250
 
251
+ function sleep(ms) {
252
+ return new Promise((r) => setTimeout(r, ms));
253
+ }
254
+
255
+ /** Corta una operacion que no vuelve, con un error que dice cual era. */
256
+ function conTimeout(promesa, ms, etiqueta) {
257
+ return new Promise((resolve, reject) => {
258
+ const t = setTimeout(() => {
259
+ reject(new Error(`"${etiqueta || "el comando"}" no respondio en ${Math.round(ms / 1000)} s. ` +
260
+ "La pagina puede estar bloqueada o pidiendo algo en un dialogo del navegador."));
261
+ }, ms);
262
+ promesa.then(
263
+ (v) => { clearTimeout(t); resolve(v); },
264
+ (e) => { clearTimeout(t); reject(e); }
265
+ );
266
+ });
267
+ }
268
+
269
+ /**
270
+ * Por que no se puede trabajar en esta URL, o null si si se puede.
271
+ *
272
+ * Chrome prohibe inyectar scripts en sus propias paginas y en la tienda. Antes
273
+ * el intento fallaba en `chrome.scripting.executeScript` con un error de
274
+ * Chrome ("Cannot access contents of the page"), que no le dice al agente ni
275
+ * que es culpa de la URL ni que no lo reintente.
276
+ */
277
+ function motivoUrlVetada(url) {
278
+ const u = String(url || "");
279
+ if (ESQUEMAS_VETADOS.test(u)) {
280
+ return `Chrome no deja que ninguna extension actue en "${u.slice(0, 60)}". ` +
281
+ "Es una pagina interna del navegador: abre una pestana normal (https://...) para trabajar.";
282
+ }
283
+ if (TIENDAS_VETADAS.test(u)) {
284
+ return "Chrome bloquea las extensiones en la Chrome Web Store. No se puede leer ni pulsar ahi.";
285
+ }
286
+ return null;
287
+ }
288
+
289
+ async function comprobarUrl(tabId) {
290
+ let url;
291
+ try {
292
+ url = (await chrome.tabs.get(tabId)).url;
293
+ } catch (_) {
294
+ return; // la pestana ya no esta; el error de quien llama lo explica mejor
295
+ }
296
+ const motivo = motivoUrlVetada(url);
297
+ if (motivo) throw new Error(motivo);
298
+ }
299
+
112
300
  /** Manda un mensaje al content script, inyectandolo si aun no estaba. */
113
301
  async function tell(tabId, frameId, msg) {
114
302
  const opts = frameId === undefined || frameId === null ? {} : { frameId };
115
303
  try {
116
304
  return await chrome.tabs.sendMessage(tabId, msg, opts);
117
305
  } catch (_) {
118
- // Pagina cargada antes de instalar la extension: se inyecta al vuelo.
306
+ // Pagina cargada antes de instalar la extension: se inyecta al vuelo. Si la
307
+ // URL es de las que Chrome veta, se dice claramente en vez de devolver el
308
+ // "Cannot access contents of the page" de Chrome, que no explica ni que es
309
+ // culpa de la URL ni que no hay que reintentarlo.
310
+ await comprobarUrl(tabId);
119
311
  await chrome.scripting.executeScript({
120
312
  target: frameId ? { tabId, frameIds: [frameId] } : { tabId, allFrames: true },
121
313
  files: ["content.js"],
@@ -133,44 +325,186 @@ async function framesOf(tabId) {
133
325
  }
134
326
  }
135
327
 
136
- async function resolveTab(tabId) {
328
+ /** Pestana explicita, o la de trabajo de ESTA sesion. null si no hay ninguna. */
329
+ async function ownTab(tabId, st) {
137
330
  if (tabId) return tabId;
138
- if (workingTabId) {
331
+ if (st.workingTabId) {
139
332
  try {
140
- await chrome.tabs.get(workingTabId);
141
- return workingTabId;
333
+ await chrome.tabs.get(st.workingTabId);
334
+ return st.workingTabId;
142
335
  } catch (_) {
143
- workingTabId = null;
336
+ st.workingTabId = null;
337
+ }
338
+ }
339
+ return null;
340
+ }
341
+
342
+ /**
343
+ * Pestana para los comandos que ACTUAN (navigate, close_tab, eval, teclas).
344
+ *
345
+ * Aqui NO se cae a la pestana activa. Esa es la que el usuario tiene delante, y
346
+ * cuando no habia pestana de trabajo -arranque en frio, o despues de un
347
+ * `done`/`ungroup`- close_tab se la cerraba con el formulario a medio rellenar y
348
+ * navigate se la llevaba a otra URL. El dialogo de permiso solo dice la accion
349
+ * ("close_tab"), asi que el usuario ni siquiera podia ver cual moria. Fallar es
350
+ * lo correcto: que el agente abra o seleccione una pestana suya.
351
+ */
352
+ async function actTab(tabId, st) {
353
+ const id = await ownTab(tabId, st);
354
+ if (id) return id;
355
+ throw new Error(
356
+ "no hay pestana de trabajo: abrela con open, elige una con select, o pasa tabId. " +
357
+ "No se actua sobre la pestana que el usuario tiene delante."
358
+ );
359
+ }
360
+
361
+ /**
362
+ * La pestana que el usuario tiene delante, con varios planes B.
363
+ *
364
+ * `lastFocusedWindow` era el unico criterio, y es justo el que falla en el caso
365
+ * de uso que la extension anuncia: si el usuario esta en un juego a pantalla
366
+ * completa, Chrome no tiene ninguna ventana enfocada y la consulta vuelve
367
+ * vacia. El agente recibia entonces "no hay ninguna pestana disponible" con
368
+ * doce pestanas abiertas delante.
369
+ */
370
+ async function pestanaVisible() {
371
+ const intentos = [
372
+ { active: true, lastFocusedWindow: true },
373
+ { active: true, currentWindow: true },
374
+ { active: true },
375
+ ];
376
+ for (const q of intentos) {
377
+ try {
378
+ const [t] = await chrome.tabs.query(q);
379
+ if (t) return t;
380
+ } catch (_) {}
381
+ }
382
+ try {
383
+ const [t] = await chrome.tabs.query({});
384
+ if (t) return t;
385
+ } catch (_) {}
386
+ return null;
387
+ }
388
+
389
+ /**
390
+ * Pestana para los comandos de solo lectura (snapshot, getText, screenshot,
391
+ * waitFor, banner): mirar lo que el usuario tiene delante es aceptable.
392
+ *
393
+ * Devuelve own=false cuando es prestada, para que el que lee NO la adopte como
394
+ * pestana de trabajo: adoptarla convertiria el siguiente close_tab sin tabId en
395
+ * el cierre de la pestana del usuario, por la puerta de atras.
396
+ */
397
+ async function readTab(tabId, st) {
398
+ const id = await ownTab(tabId, st);
399
+ if (id) return { tabId: id, own: true };
400
+ const activa = await pestanaVisible();
401
+ if (!activa) throw new Error("no hay ninguna pestana disponible");
402
+ return { tabId: activa.id, own: false };
403
+ }
404
+
405
+ /**
406
+ * Comprueba si alguna sesión activa está usando la pestaña como pestaña de trabajo (B4).
407
+ * Permite que dos sesiones convivan sin que el cierre de una apague el borde
408
+ * de la pestaña si la otra aún la está utilizando.
409
+ */
410
+ function tabEnUso(tabId) {
411
+ if (!tabId) return false;
412
+ for (const link of links.values()) {
413
+ if (link.state && link.state.workingTabId === tabId) return true;
414
+ }
415
+ return false;
416
+ }
417
+
418
+ /**
419
+ * Pide al content script encender o apagar el borde naranja y la insignia (B1-B4).
420
+ * Si se pide apagar pero otra sesión aún usa la pestaña, se mantiene encendido.
421
+ * Si el content script no está inyectado todavía (páginas internas, cargando),
422
+ * se captura el error silenciosamente para no impedir la acción.
423
+ */
424
+ async function setTabOverlay(tabId, activo) {
425
+ if (!tabId) return;
426
+ if (!activo && tabEnUso(tabId)) return;
427
+ try {
428
+ await chrome.tabs.sendMessage(tabId, { cmd: "overlay", tipo: "overlay", activo: Boolean(activo) });
429
+ } catch (_) {
430
+ // Si la página aún no tiene content script o es una URL vetada, se ignora
431
+ }
432
+ }
433
+
434
+ // D8: Detección y reporte de descargas disparadas por acciones del agente
435
+ const descargasRecientes = [];
436
+ try {
437
+ if (typeof chrome !== "undefined" && chrome.downloads && chrome.downloads.onCreated) {
438
+ chrome.downloads.onCreated.addListener((item) => {
439
+ descargasRecientes.push({
440
+ id: item.id,
441
+ url: item.url,
442
+ filename: item.filename || "",
443
+ state: item.state || "in_progress",
444
+ timestamp: Date.now(),
445
+ });
446
+ if (descargasRecientes.length > 20) descargasRecientes.shift();
447
+ });
448
+ }
449
+ if (typeof chrome !== "undefined" && chrome.downloads && chrome.downloads.onChanged) {
450
+ chrome.downloads.onChanged.addListener((delta) => {
451
+ const item = descargasRecientes.find((d) => d.id === delta.id);
452
+ if (item) {
453
+ if (delta.filename && delta.filename.current) item.filename = delta.filename.current;
454
+ if (delta.state && delta.state.current) item.state = delta.state.current;
455
+ }
456
+ });
457
+ }
458
+ } catch (_) {}
459
+
460
+ async function detectarDescarga(tiempoInicio) {
461
+ try {
462
+ if (typeof chrome !== "undefined" && chrome.downloads && typeof chrome.downloads.search === "function") {
463
+ const items = await chrome.downloads.search({
464
+ startedAfter: new Date(tiempoInicio).toISOString(),
465
+ limit: 1,
466
+ });
467
+ if (items && items.length > 0) {
468
+ return {
469
+ id: items[0].id,
470
+ filename: items[0].filename || "",
471
+ state: items[0].state || "in_progress",
472
+ url: items[0].url,
473
+ };
474
+ }
475
+ }
476
+ } catch (_) {}
477
+ for (let i = descargasRecientes.length - 1; i >= 0; i--) {
478
+ if (descargasRecientes[i].timestamp >= tiempoInicio) {
479
+ return descargasRecientes[i];
144
480
  }
145
481
  }
146
- const [active] = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
147
- if (!active) throw new Error("no hay ninguna pestana disponible");
148
- return active.id;
482
+ return null;
149
483
  }
150
484
 
151
485
  /**
152
486
  * Mete la pestana en un grupo visible llamado "Chocolatito Code", para que el
153
487
  * usuario vea de un vistazo donde esta trabajando el agente.
154
488
  */
155
- async function groupTab(tabId) {
489
+ async function groupTab(tabId, st) {
156
490
  try {
157
- if (groupId !== null) {
491
+ if (st.groupId !== null) {
158
492
  try {
159
- await chrome.tabGroups.get(groupId);
493
+ await chrome.tabGroups.get(st.groupId);
160
494
  } catch (_) {
161
- groupId = null;
495
+ st.groupId = null;
162
496
  }
163
497
  }
164
498
  const newGroupId = await chrome.tabs.group(
165
- groupId === null ? { tabIds: [tabId] } : { tabIds: [tabId], groupId }
499
+ st.groupId === null ? { tabIds: [tabId] } : { tabIds: [tabId], groupId: st.groupId }
166
500
  );
167
- groupId = newGroupId;
168
- await chrome.tabGroups.update(groupId, {
501
+ st.groupId = newGroupId;
502
+ await chrome.tabGroups.update(st.groupId, {
169
503
  title: GROUP_TITLE,
170
504
  color: "orange",
171
505
  collapsed: false,
172
506
  });
173
- return groupId;
507
+ return st.groupId;
174
508
  } catch (err) {
175
509
  return null;
176
510
  }
@@ -182,33 +516,142 @@ async function banner(tabId, text) {
182
516
  } catch (_) {}
183
517
  }
184
518
 
519
+ /**
520
+ * Espera a que la pestana termine de cargar DE VERDAD.
521
+ *
522
+ * La version anterior solo miraba `status === "complete"`, y lo miraba justo
523
+ * despues de pedir la navegacion: la pestana todavia informaba del estado de la
524
+ * pagina ANTERIOR, asi que la espera volvia al instante y el snapshot siguiente
525
+ * leia la pagina vieja. Un fallo de los que no dan error: el agente cree que ya
526
+ * esta en la pagina nueva y describe la anterior.
527
+ *
528
+ * Ahora hay que ver primero la transicion (estado "loading" o cambio de URL) y
529
+ * solo despues aceptar el "complete". El evento onUpdated resuelve rapido; el
530
+ * sondeo esta de red por si la extension no puede suscribirse.
531
+ */
532
+ function esperarCarga(tabId, timeout, urlAnterior) {
533
+ return new Promise((resolve) => {
534
+ const deadline = Date.now() + (timeout || 30000);
535
+ let vioTransicion = urlAnterior === undefined || urlAnterior === null;
536
+ let terminado = false;
537
+ let oyente = null;
538
+
539
+ const acabar = (valor) => {
540
+ if (terminado) return;
541
+ terminado = true;
542
+ if (oyente) {
543
+ try { chrome.tabs.onUpdated.removeListener(oyente); } catch (_) {}
544
+ }
545
+ resolve(valor);
546
+ };
185
547
 
548
+ try {
549
+ if (chrome.tabs.onUpdated && chrome.tabs.onUpdated.addListener) {
550
+ oyente = (id, info) => {
551
+ if (id !== tabId) return;
552
+ if (info.status === "loading" || (info.url && info.url !== urlAnterior)) vioTransicion = true;
553
+ if (vioTransicion && info.status === "complete") acabar(true);
554
+ };
555
+ chrome.tabs.onUpdated.addListener(oyente);
556
+ }
557
+ } catch (_) {}
186
558
 
187
- /** Activa la pestana, ejecuta la accion por CDP y lo deja todo como estaba. */
188
- async function withTrustedTab(tabId, fn) {
189
- const target = { tabId };
190
- let previaId = null;
191
- let attached = false;
192
- try {
193
- const tab = await chrome.tabs.get(tabId);
194
- const [activa] = await chrome.tabs.query({ active: true, windowId: tab.windowId });
195
- if (activa && activa.id !== tabId) previaId = activa.id;
196
- if (!tab.active) await chrome.tabs.update(tabId, { active: true });
197
- await sleep(250);
198
- } catch (_) {}
559
+ const sondeo = async () => {
560
+ if (terminado) return;
561
+ try {
562
+ const t = await chrome.tabs.get(tabId);
563
+ if (!vioTransicion && (t.status === "loading" || (t.url && t.url !== urlAnterior))) {
564
+ vioTransicion = true;
565
+ }
566
+ if (vioTransicion && t.status === "complete") return acabar(true);
567
+ } catch (_) {
568
+ return acabar(false); // la pestana se fue
569
+ }
570
+ if (Date.now() > deadline) return acabar(false);
571
+ setTimeout(sondeo, 150);
572
+ };
573
+ sondeo();
574
+ });
575
+ }
199
576
 
200
- try {
201
- await chrome.debugger.attach(target, "1.3");
202
- attached = true;
203
- return await fn(target);
204
- } finally {
205
- if (attached) {
206
- try { await chrome.debugger.detach(target); } catch (_) {}
207
- }
208
- if (previaId !== null) {
209
- try { await chrome.tabs.update(previaId, { active: true }); } catch (_) {}
577
+ // ==================================================== acciones de confianza
578
+
579
+ /**
580
+ * Cola de adjuntos del depurador POR PESTANA.
581
+ *
582
+ * chrome.debugger.attach falla si ya hay otro depurador adjunto a esa pestana,
583
+ * y con cuatro sesiones hablando por el mismo worker eso pasa en cuanto dos
584
+ * escriben a la vez. Antes cada una lo intentaba a lo bruto y la segunda caia
585
+ * al camino del DOM sin que nadie supiera por que.
586
+ */
587
+ const colaPorPestana = new Map();
588
+
589
+ function enCola(tabId, fn) {
590
+ const anterior = colaPorPestana.get(tabId) || Promise.resolve();
591
+ const turno = anterior.then(fn, fn);
592
+ const marca = turno.catch(() => {});
593
+ colaPorPestana.set(tabId, marca);
594
+ marca.then(() => {
595
+ if (colaPorPestana.get(tabId) === marca) colaPorPestana.delete(tabId);
596
+ });
597
+ return turno;
598
+ }
599
+
600
+ function cdp(target, method, params) {
601
+ return chrome.debugger.sendCommand(target, method, params || {});
602
+ }
603
+
604
+ /**
605
+ * Activa la pestana, ejecuta la accion por CDP y lo deja todo como estaba.
606
+ *
607
+ * El renderizador de una pestana que no esta activa descarta los eventos de
608
+ * entrada: por eso escribir en segundo plano dejaba el campo vacio. Se activa
609
+ * solo lo que dura la accion y despues se devuelve la que estaba.
610
+ *
611
+ * Importante: esto NO toca el foco del sistema operativo. Solo cambia que
612
+ * pestana se ve dentro de Chrome. Si el usuario esta en un juego a pantalla
613
+ * completa o en otra aplicacion, no nota nada.
614
+ */
615
+ function withTrustedTab(tabId, fn, preparar) {
616
+ return enCola(tabId, async () => {
617
+ const target = { tabId };
618
+ let previaId = null;
619
+ let attached = false;
620
+ try {
621
+ const tab = await chrome.tabs.get(tabId);
622
+ const [activa] = await chrome.tabs.query({ active: true, windowId: tab.windowId });
623
+ if (activa && activa.id !== tabId) previaId = activa.id;
624
+ if (!tab.active) await chrome.tabs.update(tabId, { active: true });
625
+ await sleep(250);
626
+ } catch (_) {}
627
+
628
+ // `preparar` corre DENTRO de la cola y con la pestana ya activa. Ahi es
629
+ // donde se piden las coordenadas del elemento: pedirlas fuera —como se
630
+ // hacia— significa medirlas antes de que le toque el turno, y para entonces
631
+ // la accion anterior puede haber movido la pagina. El clic salia entonces
632
+ // sobre lo que hubiera quedado en ese punto, no sobre el elemento pedido.
633
+ const preparado = preparar ? await preparar() : undefined;
634
+
635
+ try {
636
+ await chrome.debugger.attach(target, "1.3");
637
+ attached = true;
638
+ return await fn(target, preparado);
639
+ } finally {
640
+ if (attached) {
641
+ try { await chrome.debugger.detach(target); } catch (_) {}
642
+ }
643
+ if (previaId !== null) {
644
+ try { await chrome.tabs.update(previaId, { active: true }); } catch (_) {}
645
+ }
210
646
  }
211
- }
647
+ });
648
+ }
649
+
650
+ /** Pide al content script donde esta el elemento, en coordenadas del viewport. */
651
+ async function situar(tabId, frameId, ref, queEs) {
652
+ const pos = await tell(tabId, frameId, { cmd: "rect", ref });
653
+ if (!pos || !pos.ok) throw new Error((pos && pos.error) || `no se pudo situar ${queEs}`);
654
+ return pos;
212
655
  }
213
656
 
214
657
  /**
@@ -219,26 +662,20 @@ async function withTrustedTab(tabId, fn) {
219
662
  * En Flow, el boton de enviar recibia el clic sintetico y no pasaba nada.
220
663
  */
221
664
  async function clickTrusted(tabId, frameId, ref) {
222
- const pos = await tell(tabId, frameId, { cmd: "rect", ref });
223
- if (!pos || !pos.ok) throw new Error((pos && pos.error) || "no se pudo situar el elemento");
224
-
225
- await withTrustedTab(tabId, async (target) => {
226
- const base = { x: pos.x, y: pos.y, button: "left", clickCount: 1 };
227
- await cdp(target, "Input.dispatchMouseEvent", { type: "mouseMoved", x: pos.x, y: pos.y });
228
- await sleep(60);
229
- await cdp(target, "Input.dispatchMouseEvent", { ...base, type: "mousePressed", buttons: 1 });
230
- await sleep(60);
231
- await cdp(target, "Input.dispatchMouseEvent", { ...base, type: "mouseReleased", buttons: 0 });
232
- await sleep(300);
233
- });
234
-
235
- return { label: pos.label };
236
- }
237
-
238
- // ==================================================== escritura de confianza
239
-
240
- function cdp(target, method, params) {
241
- return chrome.debugger.sendCommand(target, method, params || {});
665
+ return await withTrustedTab(
666
+ tabId,
667
+ async (target, pos) => {
668
+ const base = { x: pos.x, y: pos.y, button: "left", clickCount: 1 };
669
+ await cdp(target, "Input.dispatchMouseEvent", { type: "mouseMoved", x: pos.x, y: pos.y });
670
+ await sleep(60);
671
+ await cdp(target, "Input.dispatchMouseEvent", { ...base, type: "mousePressed", buttons: 1 });
672
+ await sleep(60);
673
+ await cdp(target, "Input.dispatchMouseEvent", { ...base, type: "mouseReleased", buttons: 0 });
674
+ await sleep(300);
675
+ return { label: pos.label };
676
+ },
677
+ () => situar(tabId, frameId, ref, "el elemento")
678
+ );
242
679
  }
243
680
 
244
681
  /**
@@ -250,36 +687,10 @@ function cdp(target, method, params) {
250
687
  * proporcionar una instruccion". Es exactamente lo que pasaba.
251
688
  *
252
689
  * Input.insertText y Input.dispatchKeyEvent los procesa el renderizador igual que
253
- * si los hubiera tecleado una persona, y funcionan con la pestana en segundo
254
- * plano: no hace falta foco ni traer la ventana al frente.
690
+ * si los hubiera tecleado una persona.
255
691
  */
256
692
  async function typeTrusted(tabId, frameId, ref, text, submit, replace) {
257
- const pos = await tell(tabId, frameId, { cmd: "rect", ref });
258
- if (!pos || !pos.ok) throw new Error((pos && pos.error) || "no se pudo situar el campo");
259
-
260
- const target = { tabId };
261
- let attached = false;
262
-
263
- // El renderizador de una pestana que no esta activa descarta los eventos de
264
- // texto: por eso escribir en segundo plano dejaba el campo vacio. Se activa la
265
- // pestana solo lo que dura la escritura y despues se devuelve la que estaba.
266
- //
267
- // Importante: esto NO toca el foco del sistema operativo. Solo cambia que
268
- // pestana se ve dentro de Chrome, y se deja como estaba. Si el usuario esta en
269
- // un juego a pantalla completa o en otra aplicacion, no nota nada.
270
- let previaId = null;
271
- try {
272
- const tab = await chrome.tabs.get(tabId);
273
- const [activa] = await chrome.tabs.query({ active: true, windowId: tab.windowId });
274
- if (activa && activa.id !== tabId) previaId = activa.id;
275
- if (!tab.active) await chrome.tabs.update(tabId, { active: true });
276
- await sleep(250);
277
- } catch (_) {}
278
-
279
- try {
280
- await chrome.debugger.attach(target, "1.3");
281
- attached = true;
282
-
693
+ const etiqueta = await withTrustedTab(tabId, async (target, pos) => {
283
694
  // Clic real para que el editor tome el foco de verdad.
284
695
  const click = { x: pos.x, y: pos.y, button: "left", clickCount: 1, buttons: 1 };
285
696
  await cdp(target, "Input.dispatchMouseEvent", { type: "mouseMoved", x: pos.x, y: pos.y });
@@ -316,51 +727,95 @@ async function typeTrusted(tabId, frameId, ref, text, submit, replace) {
316
727
  await cdp(target, "Input.dispatchKeyEvent", { ...enter, type: "keyUp" });
317
728
  await sleep(400);
318
729
  }
319
- } finally {
320
- if (attached) {
321
- try { await chrome.debugger.detach(target); } catch (_) {}
322
- }
323
- // Se devuelve la pestana que el usuario tenia delante.
324
- if (previaId !== null) {
325
- try { await chrome.tabs.update(previaId, { active: true }); } catch (_) {}
326
- }
327
- }
730
+ return pos.label;
731
+ }, () => situar(tabId, frameId, ref, "el campo"));
328
732
 
329
733
  const after = await tell(tabId, frameId, { cmd: "value", ref });
330
- return { label: pos.label, value: after && after.ok ? after.value : "" };
734
+ return { label: etiqueta, value: after && after.ok ? after.value : "" };
735
+ }
736
+
737
+ /**
738
+ * Captura la pestana.
739
+ *
740
+ * Se prefiere Page.captureScreenshot del protocolo DevTools porque funciona con
741
+ * la pestana en segundo plano. Pero si hay otro depurador adjunto —lo mas
742
+ * comun: el usuario tiene DevTools abierto— el adjunto falla, y antes eso
743
+ * dejaba el comando sin captura y sin explicacion, mientras que escribir y
744
+ * pulsar si tenian camino alternativo. Ahora tambien lo tiene: se activa la
745
+ * pestana un instante y se usa la captura normal de Chrome.
746
+ */
747
+ async function capturar(tabId) {
748
+ try {
749
+ return await withTrustedTab(tabId, async (target) => {
750
+ const res = await cdp(target, "Page.captureScreenshot", { format: "png", captureBeyondViewport: false });
751
+ return { data: res.data, via: "cdp" };
752
+ });
753
+ } catch (err) {
754
+ if (!chrome.tabs.captureVisibleTab) throw err;
755
+ return await enCola(tabId, async () => {
756
+ let previaId = null;
757
+ let windowId;
758
+ try {
759
+ const tab = await chrome.tabs.get(tabId);
760
+ windowId = tab.windowId;
761
+ const [activa] = await chrome.tabs.query({ active: true, windowId });
762
+ if (activa && activa.id !== tabId) previaId = activa.id;
763
+ if (!tab.active) await chrome.tabs.update(tabId, { active: true });
764
+ await sleep(250);
765
+ const dataUrl = await chrome.tabs.captureVisibleTab(windowId, { format: "png" });
766
+ return {
767
+ data: String(dataUrl).replace(/^data:image\/png;base64,/, ""),
768
+ via: "tabs",
769
+ warning: "el depurador no estaba disponible (¿DevTools abierto?): captura por la via normal",
770
+ };
771
+ } finally {
772
+ if (previaId !== null) {
773
+ try { await chrome.tabs.update(previaId, { active: true }); } catch (_) {}
774
+ }
775
+ }
776
+ });
777
+ }
331
778
  }
332
779
 
333
780
  // ============================================================ comandos
334
781
 
335
- async function handle(msg) {
782
+ async function handle(msg, st) {
336
783
  const cmd = msg.cmd;
784
+ if (typeof cmd !== "string" || !cmd) {
785
+ throw new Error("mensaje sin comando");
786
+ }
337
787
 
338
788
  if (cmd === "ping") {
339
- return { pong: true, version: "1.0.0" };
789
+ return { pong: true, version: VERSION };
340
790
  }
341
791
 
342
792
  if (cmd === "tabs") {
343
793
  const tabs = await chrome.tabs.query({});
344
794
  return {
345
- working: workingTabId,
795
+ working: st.workingTabId,
346
796
  tabs: tabs.map((t) => ({
347
797
  id: t.id,
348
798
  title: t.title || "",
349
799
  url: t.url || "",
350
800
  active: !!t.active,
351
- grouped: t.groupId !== undefined && t.groupId === groupId,
801
+ grouped: t.groupId !== undefined && t.groupId === st.groupId,
802
+ propia: st.propias.has(t.id),
352
803
  })),
353
804
  };
354
805
  }
355
806
 
356
807
  if (cmd === "open") {
808
+ const vetada = motivoUrlVetada(msg.url);
809
+ if (vetada) throw new Error(vetada);
357
810
  // active:false es la clave: la pestana se crea SIN robar el foco ni cambiar
358
811
  // lo que el usuario esta viendo.
359
812
  const tab = await chrome.tabs.create({ url: msg.url, active: false });
360
- workingTabId = tab.id;
361
- await waitForLoad(tab.id, msg.timeout || 30000);
362
- await groupTab(tab.id);
813
+ st.workingTabId = tab.id;
814
+ st.propias.add(tab.id);
815
+ await esperarCarga(tab.id, msg.timeout || 30000);
816
+ await groupTab(tab.id, st);
363
817
  await banner(tab.id, msg.note || "pestana abierta");
818
+ await setTabOverlay(tab.id, true);
364
819
  const info = await chrome.tabs.get(tab.id);
365
820
  return { tabId: tab.id, title: info.title || "", url: info.url || "" };
366
821
  }
@@ -372,42 +827,78 @@ async function handle(msg) {
372
827
  (t) => (t.url || "").toLowerCase().includes(needle) || (t.title || "").toLowerCase().includes(needle)
373
828
  );
374
829
  if (!hit) throw new Error('ninguna pestana contiene "' + msg.match + '"');
375
- workingTabId = hit.id;
376
- await groupTab(hit.id);
830
+ if (st.workingTabId && st.workingTabId !== hit.id) {
831
+ const vieja = st.workingTabId;
832
+ st.workingTabId = null;
833
+ await setTabOverlay(vieja, false);
834
+ }
835
+ st.workingTabId = hit.id;
836
+ // Elegirla es un acto explicito del agente: a partir de aqui es suya y la
837
+ // puede cerrar. Mirarla (readTab) nunca da ese derecho.
838
+ st.propias.add(hit.id);
839
+ await groupTab(hit.id, st);
377
840
  await banner(hit.id, "pestana seleccionada");
841
+ await setTabOverlay(hit.id, true);
378
842
  return { tabId: hit.id, title: hit.title || "", url: hit.url || "" };
379
843
  }
380
844
 
381
845
  if (cmd === "navigate") {
382
- const tabId = await resolveTab(msg.tabId);
846
+ const vetada = motivoUrlVetada(msg.url);
847
+ if (vetada) throw new Error(vetada);
848
+ const tabId = await actTab(msg.tabId, st);
849
+ let urlAnterior;
850
+ try {
851
+ urlAnterior = (await chrome.tabs.get(tabId)).url;
852
+ } catch (_) {}
853
+ // La espera se arma ANTES de pedir la navegacion: al reves se pierde el
854
+ // evento de las paginas que cargan rapido.
855
+ const cargando = esperarCarga(tabId, msg.timeout || 30000, urlAnterior);
383
856
  await chrome.tabs.update(tabId, { url: msg.url });
384
- await waitForLoad(tabId, msg.timeout || 30000);
385
- workingTabId = tabId;
386
- await groupTab(tabId);
857
+ await cargando;
858
+ st.workingTabId = tabId;
859
+ st.propias.add(tabId);
860
+ await groupTab(tabId, st);
387
861
  const info = await chrome.tabs.get(tabId);
388
862
  await banner(tabId, "navegando a " + (info.url || "").slice(0, 60));
863
+ await setTabOverlay(tabId, true);
389
864
  return { tabId, title: info.title || "", url: info.url || "" };
390
865
  }
391
866
 
392
867
  if (cmd === "snapshot") {
393
- const tabId = await resolveTab(msg.tabId);
394
- workingTabId = tabId;
868
+ const { tabId, own } = await readTab(msg.tabId, st);
869
+ // Solo se adopta como pestana de trabajo si ya era nuestra o vino con tabId.
870
+ if (own) {
871
+ st.workingTabId = tabId;
872
+ await setTabOverlay(tabId, true);
873
+ }
395
874
  await banner(tabId, "leyendo la pagina");
396
- refIndex.clear();
875
+ st.refIndex.clear();
397
876
 
398
877
  const frames = await framesOf(tabId);
399
878
  const rows = [];
400
879
  let counter = 0;
880
+ let fallos = 0;
401
881
  for (const frameId of frames) {
402
882
  let res;
403
883
  try {
404
- res = await tell(tabId, frameId, { cmd: "snapshot", startIndex: counter, filter: msg.filter || "" });
884
+ // `nuevo` va a TODOS los frames, no solo al primero: cada frame tiene su
885
+ // propio content script con su propia tabla de identidades, y antes solo
886
+ // se limpiaba la del frame con startIndex 0. Los subframes acumulaban
887
+ // identidades de snapshots viejos y resolvian un ref a un boton que ya
888
+ // no era ese.
889
+ res = await tell(tabId, frameId, {
890
+ cmd: "snapshot",
891
+ startIndex: counter,
892
+ filter: msg.filter || "",
893
+ nuevo: true,
894
+ });
405
895
  } catch (_) {
896
+ fallos++;
406
897
  continue;
407
898
  }
408
899
  if (!res || !res.ok) continue;
409
900
  for (const item of res.items) {
410
- refIndex.set(item.ref, { tabId, frameId });
901
+ st.refIndex.set(item.ref, { tabId, frameId });
411
902
  rows.push(item);
412
903
  }
413
904
  counter += res.items.length;
@@ -415,23 +906,36 @@ async function handle(msg) {
415
906
 
416
907
  const info = await chrome.tabs.get(tabId);
417
908
  const max = msg.max || 60;
909
+ if (rows.length === 0) {
910
+ // Cero elementos y todos los frames fallando casi siempre es una pagina
911
+ // donde Chrome no deja entrar. Decirlo aqui ahorra el viaje de intentar
912
+ // pulsar algo y recibir un error distinto.
913
+ const vetada = motivoUrlVetada(info.url);
914
+ if (vetada) throw new Error(vetada);
915
+ }
418
916
  return {
419
917
  tabId,
420
918
  title: info.title || "",
421
919
  url: info.url || "",
422
920
  truncated: rows.length > max,
921
+ framesSinRespuesta: fallos || undefined,
423
922
  elements: rows.slice(0, max),
424
923
  };
425
924
  }
426
925
 
427
926
  if (cmd === "click" || cmd === "type" || cmd === "press") {
428
- const loc = refIndex.get(msg.ref);
927
+ const loc = st.refIndex.get(msg.ref);
429
928
  if (msg.ref !== undefined && msg.ref !== null && !loc) {
430
929
  throw new Error("el ref " + msg.ref + " no existe; vuelve a hacer snapshot");
431
930
  }
432
- const tabId = loc ? loc.tabId : await resolveTab(msg.tabId);
931
+ // Sin ref (press suele venir asi) esto escribe teclas en la pagina: es
932
+ // actuacion, nunca sobre la pestana que el usuario esta mirando.
933
+ const tabId = loc ? loc.tabId : await actTab(msg.tabId, st);
433
934
  const frameId = loc ? loc.frameId : 0;
434
- workingTabId = tabId;
935
+ st.workingTabId = tabId;
936
+ await setTabOverlay(tabId, true);
937
+
938
+ const tiempoInicioAccion = Date.now() - 50;
435
939
 
436
940
  if (cmd === "click") await banner(tabId, "pulsando un elemento");
437
941
  if (cmd === "type") await banner(tabId, "escribiendo");
@@ -473,28 +977,63 @@ async function handle(msg) {
473
977
  if (!res || !res.ok) throw new Error((res && res.error) || "el content script no respondio");
474
978
 
475
979
  // Se deja respirar a la pagina para que reaccione antes del siguiente paso.
476
- await sleep(msg.settle || 600);
980
+ await sleep(msg.settle === undefined ? 600 : msg.settle);
477
981
  const info = await chrome.tabs.get(tabId);
478
- return { tabId, title: info.title || "", url: info.url || "", label: res.label, value: res.value, via: res.via, warning: res.warning };
982
+
983
+ // D8: Detectar si esta acción inició o completó alguna descarga de archivo
984
+ const descarga = await detectarDescarga(tiempoInicioAccion);
985
+ if (descarga) {
986
+ res.download = descarga;
987
+ res.label = (res.label ? res.label + " " : "") + `[Descarga: ${descarga.filename || descarga.url}]`;
988
+ }
989
+
990
+ return {
991
+ tabId,
992
+ title: info.title || "",
993
+ url: info.url || "",
994
+ label: res.label,
995
+ value: res.value,
996
+ via: res.via,
997
+ warning: res.warning,
998
+ download: res.download,
999
+ };
479
1000
  }
480
1001
 
481
1002
  if (cmd === "getText") {
482
- const tabId = await resolveTab(msg.tabId);
1003
+ const { tabId } = await readTab(msg.tabId, st);
483
1004
  const res = await tell(tabId, 0, { cmd: "getText" });
484
1005
  if (!res || !res.ok) throw new Error("no se pudo leer el texto");
485
1006
  return res;
486
1007
  }
487
1008
 
488
1009
  if (cmd === "eval") {
489
- const tabId = await resolveTab(msg.tabId);
1010
+ // eval ejecuta JavaScript arbitrario en la pagina: puede pulsar, enviar
1011
+ // formularios y leerlo todo. Es actuacion, no lectura.
1012
+ const tabId = await actTab(msg.tabId, st);
490
1013
  const res = await tell(tabId, 0, { cmd: "eval", script: msg.script });
491
- if (!res || !res.ok) throw new Error("no se pudo evaluar");
1014
+ if (!res || !res.ok) throw new Error((res && res.error) || "no se pudo evaluar");
492
1015
  return res;
493
1016
  }
494
1017
 
495
1018
  if (cmd === "waitFor") {
496
- const tabId = await resolveTab(msg.tabId);
497
- const deadline = Date.now() + (msg.timeout || 20000);
1019
+ const { tabId } = await readTab(msg.tabId, st);
1020
+ const timeout = msg.timeout || 20000;
1021
+ const deadline = Date.now() + timeout;
1022
+
1023
+ // D3: Espera hasta que aparezca un selector CSS en el DOM
1024
+ if (msg.selector || msg.css) {
1025
+ const sel = msg.selector || msg.css;
1026
+ while (Date.now() < deadline) {
1027
+ const res = await tell(tabId, 0, { cmd: "waitForCondition", selector: sel });
1028
+ if (res && res.ok && res.conditionMet) {
1029
+ return { ok: true, found: true, selector: sel };
1030
+ }
1031
+ await sleep(300);
1032
+ }
1033
+ return { ok: false, found: false, error: `Tiempo agotado esperando el selector "${sel}"` };
1034
+ }
1035
+
1036
+ // D3: Espera hasta que aparezca un texto concreto
498
1037
  if (msg.text) {
499
1038
  while (Date.now() < deadline) {
500
1039
  const res = await tell(tabId, 0, { cmd: "getText" });
@@ -503,73 +1042,125 @@ async function handle(msg) {
503
1042
  }
504
1043
  return { found: false };
505
1044
  }
506
- await sleep(msg.ms || 2000);
507
- return { waited: msg.ms || 2000 };
1045
+
1046
+ // D3: Espera hasta que la pantalla "esté quieta" (sin mutaciones de DOM durante quietMs)
1047
+ if (msg.stable || msg.quiet || msg.condition === "stable") {
1048
+ while (Date.now() < deadline) {
1049
+ const res = await tell(tabId, 0, { cmd: "waitForCondition", stable: true, quietMs: msg.quietMs || 400 });
1050
+ if (res && res.ok && res.conditionMet) {
1051
+ return { ok: true, stable: true };
1052
+ }
1053
+ await sleep(250);
1054
+ }
1055
+ return { ok: true, stable: true, warning: "Tiempo límite alcanzado esperando quietud" };
1056
+ }
1057
+
1058
+ const ms = msg.ms || 2000;
1059
+ await sleep(ms);
1060
+ return { waited: ms };
508
1061
  }
509
1062
 
510
1063
  if (cmd === "screenshot") {
511
- // Page.captureScreenshot por el protocolo DevTools funciona sobre pestanas
512
- // en segundo plano; chrome.tabs.captureVisibleTab exigiria que la pestana
513
- // estuviera activa y la ventana enfocada.
514
- const tabId = await resolveTab(msg.tabId);
515
- const target = { tabId };
516
- await chrome.debugger.attach(target, "1.3");
517
- try {
518
- const res = await chrome.debugger.sendCommand(target, "Page.captureScreenshot", {
519
- format: "png",
520
- captureBeyondViewport: false,
521
- });
522
- return { data: res.data };
523
- } finally {
524
- try { await chrome.debugger.detach(target); } catch (_) {}
525
- }
1064
+ const { tabId } = await readTab(msg.tabId, st);
1065
+ return await capturar(tabId);
526
1066
  }
527
1067
 
528
1068
  if (cmd === "banner") {
529
- const tabId = await resolveTab(msg.tabId);
1069
+ const { tabId } = await readTab(msg.tabId, st);
530
1070
  await banner(tabId, msg.text);
531
1071
  return { ok: true };
532
1072
  }
533
1073
 
534
1074
  if (cmd === "close_tab") {
535
1075
  // Cerrar lo que se abre es parte de dejar el navegador como se encontro.
536
- const ids = Array.isArray(msg.tabIds) ? msg.tabIds : [await resolveTab(msg.tabId)];
1076
+ // Lo que NO puede pasar es cerrar lo que no se abrio.
1077
+ //
1078
+ // Sin tabId y sin pestana de trabajo, actTab falla en vez de llevarse la del
1079
+ // usuario. Y con una lista explicita de tabIds -por donde antes se colaba
1080
+ // cualquier pestana, incluida la del usuario, sin comprobacion ninguna- se
1081
+ // exige que cada una sea de esta sesion.
1082
+ let ids;
1083
+ if (Array.isArray(msg.tabIds)) {
1084
+ const ajenas = msg.tabIds.filter((id) => !st.propias.has(id) && id !== st.workingTabId);
1085
+ if (ajenas.length > 0) {
1086
+ throw new Error(
1087
+ "esta sesion no abrio las pestanas " + ajenas.join(", ") + ", asi que no las cierra. " +
1088
+ "Solo se cierran las que abrio con open o eligio con select."
1089
+ );
1090
+ }
1091
+ ids = msg.tabIds;
1092
+ } else {
1093
+ ids = [await actTab(msg.tabId, st)];
1094
+ }
1095
+
1096
+ if (ids.length === 0) return { closed: 0 };
1097
+ if (ids.includes(st.workingTabId)) st.workingTabId = null;
1098
+ for (const id of ids) st.propias.delete(id);
537
1099
  await chrome.tabs.remove(ids);
538
- if (ids.includes(workingTabId)) workingTabId = null;
1100
+ for (const id of ids) {
1101
+ await setTabOverlay(id, false);
1102
+ }
539
1103
  return { closed: ids.length };
540
1104
  }
541
1105
 
542
1106
  if (cmd === "ungroup") {
543
- if (workingTabId) {
544
- try { await chrome.tabs.ungroup(workingTabId); } catch (_) {}
545
- await banner(workingTabId, null);
1107
+ // Antes solo se sacaba del grupo la pestana de trabajo: si la sesion habia
1108
+ // abierto tres, las otras dos se quedaban dentro de un grupo naranja que ya
1109
+ // no era de nadie, y el usuario tenia que deshacerlo a mano.
1110
+ const aSoltar = new Set(st.propias);
1111
+ if (st.workingTabId) aSoltar.add(st.workingTabId);
1112
+ st.groupId = null;
1113
+ st.workingTabId = null;
1114
+ st.propias.clear();
1115
+ st.refIndex.clear();
1116
+ for (const id of aSoltar) {
1117
+ try {
1118
+ await chrome.tabs.ungroup(id);
1119
+ } catch (_) {}
1120
+ await banner(id, null);
1121
+ await setTabOverlay(id, false);
546
1122
  }
547
- groupId = null;
548
- workingTabId = null;
549
- return { ok: true };
1123
+ return { ok: true, soltadas: aSoltar.size };
550
1124
  }
551
1125
 
552
1126
  throw new Error("comando desconocido: " + cmd);
553
1127
  }
554
1128
 
555
- function sleep(ms) {
556
- return new Promise((r) => setTimeout(r, ms));
557
- }
1129
+ // Una pestana cerrada por el usuario no puede seguir siendo la de trabajo de
1130
+ // nadie: sin esto, el siguiente comando fallaba con el error de Chrome ("No tab
1131
+ // with id") en vez de decir que ya no hay donde trabajar.
1132
+ try {
1133
+ chrome.tabs.onRemoved.addListener((tabId) => {
1134
+ setTabOverlay(tabId, false);
1135
+ for (const link of links.values()) {
1136
+ if (!link.state) continue;
1137
+ link.state.propias.delete(tabId);
1138
+ if (link.state.workingTabId === tabId) link.state.workingTabId = null;
1139
+ guardarEstado(link);
1140
+ }
1141
+ });
1142
+ } catch (_) {}
1143
+
1144
+ // Al navegar o recargar la página, se reinyecta el content script y el overlay
1145
+ // se apaga. Si la pestaña sigue siendo de trabajo para alguna sesión, se vuelve a activar (B4).
1146
+ try {
1147
+ if (typeof chrome !== "undefined" && chrome.webNavigation && chrome.webNavigation.onCompleted && typeof chrome.webNavigation.onCompleted.addListener === "function") {
1148
+ chrome.webNavigation.onCompleted.addListener((details) => {
1149
+ if (details && details.frameId === 0 && tabEnUso(details.tabId)) {
1150
+ setTabOverlay(details.tabId, true);
1151
+ }
1152
+ });
1153
+ }
1154
+ } catch (_) {}
558
1155
 
559
- function waitForLoad(tabId, timeout) {
560
- return new Promise((resolve) => {
561
- const deadline = Date.now() + timeout;
562
- const check = async () => {
563
- try {
564
- const t = await chrome.tabs.get(tabId);
565
- if (t.status === "complete" || Date.now() > deadline) return resolve();
566
- } catch (_) {
567
- return resolve();
1156
+ try {
1157
+ if (typeof chrome !== "undefined" && chrome.tabs && chrome.tabs.onUpdated && typeof chrome.tabs.onUpdated.addListener === "function") {
1158
+ chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
1159
+ if (changeInfo && changeInfo.status === "complete" && tabEnUso(tabId)) {
1160
+ setTabOverlay(tabId, true);
568
1161
  }
569
- setTimeout(check, 250);
570
- };
571
- check();
572
- });
573
- }
1162
+ });
1163
+ }
1164
+ } catch (_) {}
574
1165
 
575
1166
  for (const p of WS_PORTS) connect(p);