chocolatito-code 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +89 -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 +536 -78
  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/subagent.js +62 -15
  19. package/dist/agent/syntaxValidator.js +288 -52
  20. package/dist/agent/toolGate.d.ts +58 -0
  21. package/dist/agent/toolGate.js +111 -0
  22. package/dist/agent/tracker.js +4 -7
  23. package/dist/agent/undoManager.d.ts +44 -11
  24. package/dist/agent/undoManager.js +95 -24
  25. package/dist/agent/usageMeter.d.ts +56 -0
  26. package/dist/agent/usageMeter.js +51 -0
  27. package/dist/agent/verifier.d.ts +49 -0
  28. package/dist/agent/verifier.js +158 -0
  29. package/dist/config/constants.js +14 -0
  30. package/dist/config/engine.d.ts +19 -0
  31. package/dist/config/engine.js +35 -3
  32. package/dist/config/license.d.ts +5 -0
  33. package/dist/config/license.js +107 -9
  34. package/dist/config/limits.d.ts +28 -0
  35. package/dist/config/limits.js +51 -0
  36. package/dist/config/permissions.d.ts +76 -1
  37. package/dist/config/permissions.js +379 -23
  38. package/dist/config/quota.js +24 -1
  39. package/dist/config/updater.d.ts +68 -0
  40. package/dist/config/updater.js +215 -0
  41. package/dist/hooks/manager.js +159 -14
  42. package/dist/index.js +222 -34
  43. package/dist/mcp/client.d.ts +22 -0
  44. package/dist/mcp/client.js +93 -11
  45. package/dist/prompts/systemPrompt.js +12 -1
  46. package/dist/sessions/manager.d.ts +12 -0
  47. package/dist/sessions/manager.js +33 -5
  48. package/dist/sessions/resume.d.ts +36 -0
  49. package/dist/sessions/resume.js +43 -0
  50. package/dist/skills/manager.d.ts +1 -0
  51. package/dist/skills/manager.js +9 -0
  52. package/dist/tools/binary.d.ts +46 -0
  53. package/dist/tools/binary.js +100 -0
  54. package/dist/tools/browserCdp.js +13 -1
  55. package/dist/tools/browserExtension.d.ts +5 -0
  56. package/dist/tools/browserExtension.js +202 -18
  57. package/dist/tools/browserSession.d.ts +23 -0
  58. package/dist/tools/browserSession.js +28 -0
  59. package/dist/tools/computerUse.js +33 -1
  60. package/dist/tools/createImage.d.ts +11 -0
  61. package/dist/tools/createImage.js +13 -1
  62. package/dist/tools/definitions.js +9 -3
  63. package/dist/tools/editDiagnosis.d.ts +54 -0
  64. package/dist/tools/editDiagnosis.js +142 -0
  65. package/dist/tools/editFile.d.ts +1 -0
  66. package/dist/tools/editFile.js +118 -62
  67. package/dist/tools/findFiles.d.ts +21 -0
  68. package/dist/tools/findFiles.js +75 -6
  69. package/dist/tools/getSystemInfo.js +15 -7
  70. package/dist/tools/gitTools.d.ts +24 -0
  71. package/dist/tools/gitTools.js +87 -26
  72. package/dist/tools/moveCopyFile.d.ts +18 -1
  73. package/dist/tools/moveCopyFile.js +74 -4
  74. package/dist/tools/patchCascade.d.ts +57 -0
  75. package/dist/tools/patchCascade.js +338 -0
  76. package/dist/tools/runCommand.js +213 -25
  77. package/dist/tools/runner.js +57 -4
  78. package/dist/tools/toolDefsComputer.js +12 -2
  79. package/dist/tools/truncator.d.ts +27 -1
  80. package/dist/tools/truncator.js +117 -11
  81. package/dist/tools/viewFile.js +15 -11
  82. package/dist/tools/visionBridge.js +7 -0
  83. package/dist/tools/webSearch.d.ts +21 -0
  84. package/dist/tools/webSearch.js +125 -14
  85. package/dist/tools/writeFile.js +18 -1
  86. package/dist/ui/interrupt.d.ts +4 -1
  87. package/dist/ui/interrupt.js +56 -5
  88. package/dist/ui/keyboardGuard.d.ts +55 -0
  89. package/dist/ui/keyboardGuard.js +65 -0
  90. package/dist/ui/loopPrompt.d.ts +17 -0
  91. package/dist/ui/loopPrompt.js +59 -0
  92. package/dist/ui/permissionPrompt.js +84 -2
  93. package/dist/ui/prompt.d.ts +35 -0
  94. package/dist/ui/prompt.js +345 -125
  95. package/dist/ui/reasoningStream.d.ts +8 -0
  96. package/dist/ui/reasoningStream.js +99 -7
  97. package/extension/README.md +95 -74
  98. package/extension/background.js +598 -178
  99. package/extension/content.js +92 -9
  100. package/extension/manifest.json +2 -1
  101. package/extension/popup.js +28 -12
  102. package/package.json +1 -1
@@ -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
  };
@@ -78,7 +207,14 @@ function connect(port) {
78
207
  function scheduleReconnect(port) {
79
208
  const link = links.get(port) || { delay: 1000 };
80
209
  links.set(port, link);
81
- setTimeout(() => connect(port), link.delay);
210
+ // onerror cierra el socket y onclose vuelve a llamar aqui: sin esta marca se
211
+ // encadenaban dos temporizadores por cada caida.
212
+ if (link.reconectando) return;
213
+ link.reconectando = true;
214
+ setTimeout(() => {
215
+ link.reconectando = false;
216
+ connect(port);
217
+ }, link.delay);
82
218
  // Tope bajo a proposito: el servidor es local y reintentar cuesta nada.
83
219
  link.delay = Math.min((link.delay || 1000) * 1.5, 3000);
84
220
  }
@@ -109,13 +245,66 @@ function setBadge(text) {
109
245
 
110
246
  // ============================================================ utilidades
111
247
 
248
+ function sleep(ms) {
249
+ return new Promise((r) => setTimeout(r, ms));
250
+ }
251
+
252
+ /** Corta una operacion que no vuelve, con un error que dice cual era. */
253
+ function conTimeout(promesa, ms, etiqueta) {
254
+ return new Promise((resolve, reject) => {
255
+ const t = setTimeout(() => {
256
+ reject(new Error(`"${etiqueta || "el comando"}" no respondio en ${Math.round(ms / 1000)} s. ` +
257
+ "La pagina puede estar bloqueada o pidiendo algo en un dialogo del navegador."));
258
+ }, ms);
259
+ promesa.then(
260
+ (v) => { clearTimeout(t); resolve(v); },
261
+ (e) => { clearTimeout(t); reject(e); }
262
+ );
263
+ });
264
+ }
265
+
266
+ /**
267
+ * Por que no se puede trabajar en esta URL, o null si si se puede.
268
+ *
269
+ * Chrome prohibe inyectar scripts en sus propias paginas y en la tienda. Antes
270
+ * el intento fallaba en `chrome.scripting.executeScript` con un error de
271
+ * Chrome ("Cannot access contents of the page"), que no le dice al agente ni
272
+ * que es culpa de la URL ni que no lo reintente.
273
+ */
274
+ function motivoUrlVetada(url) {
275
+ const u = String(url || "");
276
+ if (ESQUEMAS_VETADOS.test(u)) {
277
+ return `Chrome no deja que ninguna extension actue en "${u.slice(0, 60)}". ` +
278
+ "Es una pagina interna del navegador: abre una pestana normal (https://...) para trabajar.";
279
+ }
280
+ if (TIENDAS_VETADAS.test(u)) {
281
+ return "Chrome bloquea las extensiones en la Chrome Web Store. No se puede leer ni pulsar ahi.";
282
+ }
283
+ return null;
284
+ }
285
+
286
+ async function comprobarUrl(tabId) {
287
+ let url;
288
+ try {
289
+ url = (await chrome.tabs.get(tabId)).url;
290
+ } catch (_) {
291
+ return; // la pestana ya no esta; el error de quien llama lo explica mejor
292
+ }
293
+ const motivo = motivoUrlVetada(url);
294
+ if (motivo) throw new Error(motivo);
295
+ }
296
+
112
297
  /** Manda un mensaje al content script, inyectandolo si aun no estaba. */
113
298
  async function tell(tabId, frameId, msg) {
114
299
  const opts = frameId === undefined || frameId === null ? {} : { frameId };
115
300
  try {
116
301
  return await chrome.tabs.sendMessage(tabId, msg, opts);
117
302
  } catch (_) {
118
- // Pagina cargada antes de instalar la extension: se inyecta al vuelo.
303
+ // Pagina cargada antes de instalar la extension: se inyecta al vuelo. Si la
304
+ // URL es de las que Chrome veta, se dice claramente en vez de devolver el
305
+ // "Cannot access contents of the page" de Chrome, que no explica ni que es
306
+ // culpa de la URL ni que no hay que reintentarlo.
307
+ await comprobarUrl(tabId);
119
308
  await chrome.scripting.executeScript({
120
309
  target: frameId ? { tabId, frameIds: [frameId] } : { tabId, allFrames: true },
121
310
  files: ["content.js"],
@@ -133,44 +322,106 @@ async function framesOf(tabId) {
133
322
  }
134
323
  }
135
324
 
136
- async function resolveTab(tabId) {
325
+ /** Pestana explicita, o la de trabajo de ESTA sesion. null si no hay ninguna. */
326
+ async function ownTab(tabId, st) {
137
327
  if (tabId) return tabId;
138
- if (workingTabId) {
328
+ if (st.workingTabId) {
139
329
  try {
140
- await chrome.tabs.get(workingTabId);
141
- return workingTabId;
330
+ await chrome.tabs.get(st.workingTabId);
331
+ return st.workingTabId;
142
332
  } catch (_) {
143
- workingTabId = null;
333
+ st.workingTabId = null;
144
334
  }
145
335
  }
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;
336
+ return null;
337
+ }
338
+
339
+ /**
340
+ * Pestana para los comandos que ACTUAN (navigate, close_tab, eval, teclas).
341
+ *
342
+ * Aqui NO se cae a la pestana activa. Esa es la que el usuario tiene delante, y
343
+ * cuando no habia pestana de trabajo -arranque en frio, o despues de un
344
+ * `done`/`ungroup`- close_tab se la cerraba con el formulario a medio rellenar y
345
+ * navigate se la llevaba a otra URL. El dialogo de permiso solo dice la accion
346
+ * ("close_tab"), asi que el usuario ni siquiera podia ver cual moria. Fallar es
347
+ * lo correcto: que el agente abra o seleccione una pestana suya.
348
+ */
349
+ async function actTab(tabId, st) {
350
+ const id = await ownTab(tabId, st);
351
+ if (id) return id;
352
+ throw new Error(
353
+ "no hay pestana de trabajo: abrela con open, elige una con select, o pasa tabId. " +
354
+ "No se actua sobre la pestana que el usuario tiene delante."
355
+ );
356
+ }
357
+
358
+ /**
359
+ * La pestana que el usuario tiene delante, con varios planes B.
360
+ *
361
+ * `lastFocusedWindow` era el unico criterio, y es justo el que falla en el caso
362
+ * de uso que la extension anuncia: si el usuario esta en un juego a pantalla
363
+ * completa, Chrome no tiene ninguna ventana enfocada y la consulta vuelve
364
+ * vacia. El agente recibia entonces "no hay ninguna pestana disponible" con
365
+ * doce pestanas abiertas delante.
366
+ */
367
+ async function pestanaVisible() {
368
+ const intentos = [
369
+ { active: true, lastFocusedWindow: true },
370
+ { active: true, currentWindow: true },
371
+ { active: true },
372
+ ];
373
+ for (const q of intentos) {
374
+ try {
375
+ const [t] = await chrome.tabs.query(q);
376
+ if (t) return t;
377
+ } catch (_) {}
378
+ }
379
+ try {
380
+ const [t] = await chrome.tabs.query({});
381
+ if (t) return t;
382
+ } catch (_) {}
383
+ return null;
384
+ }
385
+
386
+ /**
387
+ * Pestana para los comandos de solo lectura (snapshot, getText, screenshot,
388
+ * waitFor, banner): mirar lo que el usuario tiene delante es aceptable.
389
+ *
390
+ * Devuelve own=false cuando es prestada, para que el que lee NO la adopte como
391
+ * pestana de trabajo: adoptarla convertiria el siguiente close_tab sin tabId en
392
+ * el cierre de la pestana del usuario, por la puerta de atras.
393
+ */
394
+ async function readTab(tabId, st) {
395
+ const id = await ownTab(tabId, st);
396
+ if (id) return { tabId: id, own: true };
397
+ const activa = await pestanaVisible();
398
+ if (!activa) throw new Error("no hay ninguna pestana disponible");
399
+ return { tabId: activa.id, own: false };
149
400
  }
150
401
 
151
402
  /**
152
403
  * Mete la pestana en un grupo visible llamado "Chocolatito Code", para que el
153
404
  * usuario vea de un vistazo donde esta trabajando el agente.
154
405
  */
155
- async function groupTab(tabId) {
406
+ async function groupTab(tabId, st) {
156
407
  try {
157
- if (groupId !== null) {
408
+ if (st.groupId !== null) {
158
409
  try {
159
- await chrome.tabGroups.get(groupId);
410
+ await chrome.tabGroups.get(st.groupId);
160
411
  } catch (_) {
161
- groupId = null;
412
+ st.groupId = null;
162
413
  }
163
414
  }
164
415
  const newGroupId = await chrome.tabs.group(
165
- groupId === null ? { tabIds: [tabId] } : { tabIds: [tabId], groupId }
416
+ st.groupId === null ? { tabIds: [tabId] } : { tabIds: [tabId], groupId: st.groupId }
166
417
  );
167
- groupId = newGroupId;
168
- await chrome.tabGroups.update(groupId, {
418
+ st.groupId = newGroupId;
419
+ await chrome.tabGroups.update(st.groupId, {
169
420
  title: GROUP_TITLE,
170
421
  color: "orange",
171
422
  collapsed: false,
172
423
  });
173
- return groupId;
424
+ return st.groupId;
174
425
  } catch (err) {
175
426
  return null;
176
427
  }
@@ -182,33 +433,142 @@ async function banner(tabId, text) {
182
433
  } catch (_) {}
183
434
  }
184
435
 
436
+ /**
437
+ * Espera a que la pestana termine de cargar DE VERDAD.
438
+ *
439
+ * La version anterior solo miraba `status === "complete"`, y lo miraba justo
440
+ * despues de pedir la navegacion: la pestana todavia informaba del estado de la
441
+ * pagina ANTERIOR, asi que la espera volvia al instante y el snapshot siguiente
442
+ * leia la pagina vieja. Un fallo de los que no dan error: el agente cree que ya
443
+ * esta en la pagina nueva y describe la anterior.
444
+ *
445
+ * Ahora hay que ver primero la transicion (estado "loading" o cambio de URL) y
446
+ * solo despues aceptar el "complete". El evento onUpdated resuelve rapido; el
447
+ * sondeo esta de red por si la extension no puede suscribirse.
448
+ */
449
+ function esperarCarga(tabId, timeout, urlAnterior) {
450
+ return new Promise((resolve) => {
451
+ const deadline = Date.now() + (timeout || 30000);
452
+ let vioTransicion = urlAnterior === undefined || urlAnterior === null;
453
+ let terminado = false;
454
+ let oyente = null;
455
+
456
+ const acabar = (valor) => {
457
+ if (terminado) return;
458
+ terminado = true;
459
+ if (oyente) {
460
+ try { chrome.tabs.onUpdated.removeListener(oyente); } catch (_) {}
461
+ }
462
+ resolve(valor);
463
+ };
185
464
 
465
+ try {
466
+ if (chrome.tabs.onUpdated && chrome.tabs.onUpdated.addListener) {
467
+ oyente = (id, info) => {
468
+ if (id !== tabId) return;
469
+ if (info.status === "loading" || (info.url && info.url !== urlAnterior)) vioTransicion = true;
470
+ if (vioTransicion && info.status === "complete") acabar(true);
471
+ };
472
+ chrome.tabs.onUpdated.addListener(oyente);
473
+ }
474
+ } catch (_) {}
186
475
 
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 (_) {}
476
+ const sondeo = async () => {
477
+ if (terminado) return;
478
+ try {
479
+ const t = await chrome.tabs.get(tabId);
480
+ if (!vioTransicion && (t.status === "loading" || (t.url && t.url !== urlAnterior))) {
481
+ vioTransicion = true;
482
+ }
483
+ if (vioTransicion && t.status === "complete") return acabar(true);
484
+ } catch (_) {
485
+ return acabar(false); // la pestana se fue
486
+ }
487
+ if (Date.now() > deadline) return acabar(false);
488
+ setTimeout(sondeo, 150);
489
+ };
490
+ sondeo();
491
+ });
492
+ }
199
493
 
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 (_) {}
494
+ // ==================================================== acciones de confianza
495
+
496
+ /**
497
+ * Cola de adjuntos del depurador POR PESTANA.
498
+ *
499
+ * chrome.debugger.attach falla si ya hay otro depurador adjunto a esa pestana,
500
+ * y con cuatro sesiones hablando por el mismo worker eso pasa en cuanto dos
501
+ * escriben a la vez. Antes cada una lo intentaba a lo bruto y la segunda caia
502
+ * al camino del DOM sin que nadie supiera por que.
503
+ */
504
+ const colaPorPestana = new Map();
505
+
506
+ function enCola(tabId, fn) {
507
+ const anterior = colaPorPestana.get(tabId) || Promise.resolve();
508
+ const turno = anterior.then(fn, fn);
509
+ const marca = turno.catch(() => {});
510
+ colaPorPestana.set(tabId, marca);
511
+ marca.then(() => {
512
+ if (colaPorPestana.get(tabId) === marca) colaPorPestana.delete(tabId);
513
+ });
514
+ return turno;
515
+ }
516
+
517
+ function cdp(target, method, params) {
518
+ return chrome.debugger.sendCommand(target, method, params || {});
519
+ }
520
+
521
+ /**
522
+ * Activa la pestana, ejecuta la accion por CDP y lo deja todo como estaba.
523
+ *
524
+ * El renderizador de una pestana que no esta activa descarta los eventos de
525
+ * entrada: por eso escribir en segundo plano dejaba el campo vacio. Se activa
526
+ * solo lo que dura la accion y despues se devuelve la que estaba.
527
+ *
528
+ * Importante: esto NO toca el foco del sistema operativo. Solo cambia que
529
+ * pestana se ve dentro de Chrome. Si el usuario esta en un juego a pantalla
530
+ * completa o en otra aplicacion, no nota nada.
531
+ */
532
+ function withTrustedTab(tabId, fn, preparar) {
533
+ return enCola(tabId, async () => {
534
+ const target = { tabId };
535
+ let previaId = null;
536
+ let attached = false;
537
+ try {
538
+ const tab = await chrome.tabs.get(tabId);
539
+ const [activa] = await chrome.tabs.query({ active: true, windowId: tab.windowId });
540
+ if (activa && activa.id !== tabId) previaId = activa.id;
541
+ if (!tab.active) await chrome.tabs.update(tabId, { active: true });
542
+ await sleep(250);
543
+ } catch (_) {}
544
+
545
+ // `preparar` corre DENTRO de la cola y con la pestana ya activa. Ahi es
546
+ // donde se piden las coordenadas del elemento: pedirlas fuera —como se
547
+ // hacia— significa medirlas antes de que le toque el turno, y para entonces
548
+ // la accion anterior puede haber movido la pagina. El clic salia entonces
549
+ // sobre lo que hubiera quedado en ese punto, no sobre el elemento pedido.
550
+ const preparado = preparar ? await preparar() : undefined;
551
+
552
+ try {
553
+ await chrome.debugger.attach(target, "1.3");
554
+ attached = true;
555
+ return await fn(target, preparado);
556
+ } finally {
557
+ if (attached) {
558
+ try { await chrome.debugger.detach(target); } catch (_) {}
559
+ }
560
+ if (previaId !== null) {
561
+ try { await chrome.tabs.update(previaId, { active: true }); } catch (_) {}
562
+ }
210
563
  }
211
- }
564
+ });
565
+ }
566
+
567
+ /** Pide al content script donde esta el elemento, en coordenadas del viewport. */
568
+ async function situar(tabId, frameId, ref, queEs) {
569
+ const pos = await tell(tabId, frameId, { cmd: "rect", ref });
570
+ if (!pos || !pos.ok) throw new Error((pos && pos.error) || `no se pudo situar ${queEs}`);
571
+ return pos;
212
572
  }
213
573
 
214
574
  /**
@@ -219,26 +579,20 @@ async function withTrustedTab(tabId, fn) {
219
579
  * En Flow, el boton de enviar recibia el clic sintetico y no pasaba nada.
220
580
  */
221
581
  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 || {});
582
+ return await withTrustedTab(
583
+ tabId,
584
+ async (target, pos) => {
585
+ const base = { x: pos.x, y: pos.y, button: "left", clickCount: 1 };
586
+ await cdp(target, "Input.dispatchMouseEvent", { type: "mouseMoved", x: pos.x, y: pos.y });
587
+ await sleep(60);
588
+ await cdp(target, "Input.dispatchMouseEvent", { ...base, type: "mousePressed", buttons: 1 });
589
+ await sleep(60);
590
+ await cdp(target, "Input.dispatchMouseEvent", { ...base, type: "mouseReleased", buttons: 0 });
591
+ await sleep(300);
592
+ return { label: pos.label };
593
+ },
594
+ () => situar(tabId, frameId, ref, "el elemento")
595
+ );
242
596
  }
243
597
 
244
598
  /**
@@ -250,36 +604,10 @@ function cdp(target, method, params) {
250
604
  * proporcionar una instruccion". Es exactamente lo que pasaba.
251
605
  *
252
606
  * 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.
607
+ * si los hubiera tecleado una persona.
255
608
  */
256
609
  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
-
610
+ const etiqueta = await withTrustedTab(tabId, async (target, pos) => {
283
611
  // Clic real para que el editor tome el foco de verdad.
284
612
  const click = { x: pos.x, y: pos.y, button: "left", clickCount: 1, buttons: 1 };
285
613
  await cdp(target, "Input.dispatchMouseEvent", { type: "mouseMoved", x: pos.x, y: pos.y });
@@ -316,50 +644,93 @@ async function typeTrusted(tabId, frameId, ref, text, submit, replace) {
316
644
  await cdp(target, "Input.dispatchKeyEvent", { ...enter, type: "keyUp" });
317
645
  await sleep(400);
318
646
  }
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
- }
647
+ return pos.label;
648
+ }, () => situar(tabId, frameId, ref, "el campo"));
328
649
 
329
650
  const after = await tell(tabId, frameId, { cmd: "value", ref });
330
- return { label: pos.label, value: after && after.ok ? after.value : "" };
651
+ return { label: etiqueta, value: after && after.ok ? after.value : "" };
652
+ }
653
+
654
+ /**
655
+ * Captura la pestana.
656
+ *
657
+ * Se prefiere Page.captureScreenshot del protocolo DevTools porque funciona con
658
+ * la pestana en segundo plano. Pero si hay otro depurador adjunto —lo mas
659
+ * comun: el usuario tiene DevTools abierto— el adjunto falla, y antes eso
660
+ * dejaba el comando sin captura y sin explicacion, mientras que escribir y
661
+ * pulsar si tenian camino alternativo. Ahora tambien lo tiene: se activa la
662
+ * pestana un instante y se usa la captura normal de Chrome.
663
+ */
664
+ async function capturar(tabId) {
665
+ try {
666
+ return await withTrustedTab(tabId, async (target) => {
667
+ const res = await cdp(target, "Page.captureScreenshot", { format: "png", captureBeyondViewport: false });
668
+ return { data: res.data, via: "cdp" };
669
+ });
670
+ } catch (err) {
671
+ if (!chrome.tabs.captureVisibleTab) throw err;
672
+ return await enCola(tabId, async () => {
673
+ let previaId = null;
674
+ let windowId;
675
+ try {
676
+ const tab = await chrome.tabs.get(tabId);
677
+ windowId = tab.windowId;
678
+ const [activa] = await chrome.tabs.query({ active: true, windowId });
679
+ if (activa && activa.id !== tabId) previaId = activa.id;
680
+ if (!tab.active) await chrome.tabs.update(tabId, { active: true });
681
+ await sleep(250);
682
+ const dataUrl = await chrome.tabs.captureVisibleTab(windowId, { format: "png" });
683
+ return {
684
+ data: String(dataUrl).replace(/^data:image\/png;base64,/, ""),
685
+ via: "tabs",
686
+ warning: "el depurador no estaba disponible (¿DevTools abierto?): captura por la via normal",
687
+ };
688
+ } finally {
689
+ if (previaId !== null) {
690
+ try { await chrome.tabs.update(previaId, { active: true }); } catch (_) {}
691
+ }
692
+ }
693
+ });
694
+ }
331
695
  }
332
696
 
333
697
  // ============================================================ comandos
334
698
 
335
- async function handle(msg) {
699
+ async function handle(msg, st) {
336
700
  const cmd = msg.cmd;
701
+ if (typeof cmd !== "string" || !cmd) {
702
+ throw new Error("mensaje sin comando");
703
+ }
337
704
 
338
705
  if (cmd === "ping") {
339
- return { pong: true, version: "1.0.0" };
706
+ return { pong: true, version: VERSION };
340
707
  }
341
708
 
342
709
  if (cmd === "tabs") {
343
710
  const tabs = await chrome.tabs.query({});
344
711
  return {
345
- working: workingTabId,
712
+ working: st.workingTabId,
346
713
  tabs: tabs.map((t) => ({
347
714
  id: t.id,
348
715
  title: t.title || "",
349
716
  url: t.url || "",
350
717
  active: !!t.active,
351
- grouped: t.groupId !== undefined && t.groupId === groupId,
718
+ grouped: t.groupId !== undefined && t.groupId === st.groupId,
719
+ propia: st.propias.has(t.id),
352
720
  })),
353
721
  };
354
722
  }
355
723
 
356
724
  if (cmd === "open") {
725
+ const vetada = motivoUrlVetada(msg.url);
726
+ if (vetada) throw new Error(vetada);
357
727
  // active:false es la clave: la pestana se crea SIN robar el foco ni cambiar
358
728
  // lo que el usuario esta viendo.
359
729
  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);
730
+ st.workingTabId = tab.id;
731
+ st.propias.add(tab.id);
732
+ await esperarCarga(tab.id, msg.timeout || 30000);
733
+ await groupTab(tab.id, st);
363
734
  await banner(tab.id, msg.note || "pestana abierta");
364
735
  const info = await chrome.tabs.get(tab.id);
365
736
  return { tabId: tab.id, title: info.title || "", url: info.url || "" };
@@ -372,42 +743,68 @@ async function handle(msg) {
372
743
  (t) => (t.url || "").toLowerCase().includes(needle) || (t.title || "").toLowerCase().includes(needle)
373
744
  );
374
745
  if (!hit) throw new Error('ninguna pestana contiene "' + msg.match + '"');
375
- workingTabId = hit.id;
376
- await groupTab(hit.id);
746
+ st.workingTabId = hit.id;
747
+ // Elegirla es un acto explicito del agente: a partir de aqui es suya y la
748
+ // puede cerrar. Mirarla (readTab) nunca da ese derecho.
749
+ st.propias.add(hit.id);
750
+ await groupTab(hit.id, st);
377
751
  await banner(hit.id, "pestana seleccionada");
378
752
  return { tabId: hit.id, title: hit.title || "", url: hit.url || "" };
379
753
  }
380
754
 
381
755
  if (cmd === "navigate") {
382
- const tabId = await resolveTab(msg.tabId);
756
+ const vetada = motivoUrlVetada(msg.url);
757
+ if (vetada) throw new Error(vetada);
758
+ const tabId = await actTab(msg.tabId, st);
759
+ let urlAnterior;
760
+ try {
761
+ urlAnterior = (await chrome.tabs.get(tabId)).url;
762
+ } catch (_) {}
763
+ // La espera se arma ANTES de pedir la navegacion: al reves se pierde el
764
+ // evento de las paginas que cargan rapido.
765
+ const cargando = esperarCarga(tabId, msg.timeout || 30000, urlAnterior);
383
766
  await chrome.tabs.update(tabId, { url: msg.url });
384
- await waitForLoad(tabId, msg.timeout || 30000);
385
- workingTabId = tabId;
386
- await groupTab(tabId);
767
+ await cargando;
768
+ st.workingTabId = tabId;
769
+ st.propias.add(tabId);
770
+ await groupTab(tabId, st);
387
771
  const info = await chrome.tabs.get(tabId);
388
772
  await banner(tabId, "navegando a " + (info.url || "").slice(0, 60));
389
773
  return { tabId, title: info.title || "", url: info.url || "" };
390
774
  }
391
775
 
392
776
  if (cmd === "snapshot") {
393
- const tabId = await resolveTab(msg.tabId);
394
- workingTabId = tabId;
777
+ const { tabId, own } = await readTab(msg.tabId, st);
778
+ // Solo se adopta como pestana de trabajo si ya era nuestra o vino con tabId.
779
+ if (own) st.workingTabId = tabId;
395
780
  await banner(tabId, "leyendo la pagina");
396
- refIndex.clear();
781
+ st.refIndex.clear();
397
782
 
398
783
  const frames = await framesOf(tabId);
399
784
  const rows = [];
400
785
  let counter = 0;
786
+ let fallos = 0;
401
787
  for (const frameId of frames) {
402
788
  let res;
403
789
  try {
404
- res = await tell(tabId, frameId, { cmd: "snapshot", startIndex: counter, filter: msg.filter || "" });
790
+ // `nuevo` va a TODOS los frames, no solo al primero: cada frame tiene su
791
+ // propio content script con su propia tabla de identidades, y antes solo
792
+ // se limpiaba la del frame con startIndex 0. Los subframes acumulaban
793
+ // identidades de snapshots viejos y resolvian un ref a un boton que ya
794
+ // no era ese.
795
+ res = await tell(tabId, frameId, {
796
+ cmd: "snapshot",
797
+ startIndex: counter,
798
+ filter: msg.filter || "",
799
+ nuevo: true,
800
+ });
405
801
  } catch (_) {
802
+ fallos++;
406
803
  continue;
407
804
  }
408
805
  if (!res || !res.ok) continue;
409
806
  for (const item of res.items) {
410
- refIndex.set(item.ref, { tabId, frameId });
807
+ st.refIndex.set(item.ref, { tabId, frameId });
411
808
  rows.push(item);
412
809
  }
413
810
  counter += res.items.length;
@@ -415,23 +812,33 @@ async function handle(msg) {
415
812
 
416
813
  const info = await chrome.tabs.get(tabId);
417
814
  const max = msg.max || 60;
815
+ if (rows.length === 0) {
816
+ // Cero elementos y todos los frames fallando casi siempre es una pagina
817
+ // donde Chrome no deja entrar. Decirlo aqui ahorra el viaje de intentar
818
+ // pulsar algo y recibir un error distinto.
819
+ const vetada = motivoUrlVetada(info.url);
820
+ if (vetada) throw new Error(vetada);
821
+ }
418
822
  return {
419
823
  tabId,
420
824
  title: info.title || "",
421
825
  url: info.url || "",
422
826
  truncated: rows.length > max,
827
+ framesSinRespuesta: fallos || undefined,
423
828
  elements: rows.slice(0, max),
424
829
  };
425
830
  }
426
831
 
427
832
  if (cmd === "click" || cmd === "type" || cmd === "press") {
428
- const loc = refIndex.get(msg.ref);
833
+ const loc = st.refIndex.get(msg.ref);
429
834
  if (msg.ref !== undefined && msg.ref !== null && !loc) {
430
835
  throw new Error("el ref " + msg.ref + " no existe; vuelve a hacer snapshot");
431
836
  }
432
- const tabId = loc ? loc.tabId : await resolveTab(msg.tabId);
837
+ // Sin ref (press suele venir asi) esto escribe teclas en la pagina: es
838
+ // actuacion, nunca sobre la pestana que el usuario esta mirando.
839
+ const tabId = loc ? loc.tabId : await actTab(msg.tabId, st);
433
840
  const frameId = loc ? loc.frameId : 0;
434
- workingTabId = tabId;
841
+ st.workingTabId = tabId;
435
842
 
436
843
  if (cmd === "click") await banner(tabId, "pulsando un elemento");
437
844
  if (cmd === "type") await banner(tabId, "escribiendo");
@@ -473,27 +880,29 @@ async function handle(msg) {
473
880
  if (!res || !res.ok) throw new Error((res && res.error) || "el content script no respondio");
474
881
 
475
882
  // Se deja respirar a la pagina para que reaccione antes del siguiente paso.
476
- await sleep(msg.settle || 600);
883
+ await sleep(msg.settle === undefined ? 600 : msg.settle);
477
884
  const info = await chrome.tabs.get(tabId);
478
885
  return { tabId, title: info.title || "", url: info.url || "", label: res.label, value: res.value, via: res.via, warning: res.warning };
479
886
  }
480
887
 
481
888
  if (cmd === "getText") {
482
- const tabId = await resolveTab(msg.tabId);
889
+ const { tabId } = await readTab(msg.tabId, st);
483
890
  const res = await tell(tabId, 0, { cmd: "getText" });
484
891
  if (!res || !res.ok) throw new Error("no se pudo leer el texto");
485
892
  return res;
486
893
  }
487
894
 
488
895
  if (cmd === "eval") {
489
- const tabId = await resolveTab(msg.tabId);
896
+ // eval ejecuta JavaScript arbitrario en la pagina: puede pulsar, enviar
897
+ // formularios y leerlo todo. Es actuacion, no lectura.
898
+ const tabId = await actTab(msg.tabId, st);
490
899
  const res = await tell(tabId, 0, { cmd: "eval", script: msg.script });
491
- if (!res || !res.ok) throw new Error("no se pudo evaluar");
900
+ if (!res || !res.ok) throw new Error((res && res.error) || "no se pudo evaluar");
492
901
  return res;
493
902
  }
494
903
 
495
904
  if (cmd === "waitFor") {
496
- const tabId = await resolveTab(msg.tabId);
905
+ const { tabId } = await readTab(msg.tabId, st);
497
906
  const deadline = Date.now() + (msg.timeout || 20000);
498
907
  if (msg.text) {
499
908
  while (Date.now() < deadline) {
@@ -508,68 +917,79 @@ async function handle(msg) {
508
917
  }
509
918
 
510
919
  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
- }
920
+ const { tabId } = await readTab(msg.tabId, st);
921
+ return await capturar(tabId);
526
922
  }
527
923
 
528
924
  if (cmd === "banner") {
529
- const tabId = await resolveTab(msg.tabId);
925
+ const { tabId } = await readTab(msg.tabId, st);
530
926
  await banner(tabId, msg.text);
531
927
  return { ok: true };
532
928
  }
533
929
 
534
930
  if (cmd === "close_tab") {
535
931
  // 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)];
932
+ // Lo que NO puede pasar es cerrar lo que no se abrio.
933
+ //
934
+ // Sin tabId y sin pestana de trabajo, actTab falla en vez de llevarse la del
935
+ // usuario. Y con una lista explicita de tabIds -por donde antes se colaba
936
+ // cualquier pestana, incluida la del usuario, sin comprobacion ninguna- se
937
+ // exige que cada una sea de esta sesion.
938
+ let ids;
939
+ if (Array.isArray(msg.tabIds)) {
940
+ const ajenas = msg.tabIds.filter((id) => !st.propias.has(id) && id !== st.workingTabId);
941
+ if (ajenas.length > 0) {
942
+ throw new Error(
943
+ "esta sesion no abrio las pestanas " + ajenas.join(", ") + ", asi que no las cierra. " +
944
+ "Solo se cierran las que abrio con open o eligio con select."
945
+ );
946
+ }
947
+ ids = msg.tabIds;
948
+ } else {
949
+ ids = [await actTab(msg.tabId, st)];
950
+ }
951
+
952
+ if (ids.length === 0) return { closed: 0 };
537
953
  await chrome.tabs.remove(ids);
538
- if (ids.includes(workingTabId)) workingTabId = null;
954
+ for (const id of ids) st.propias.delete(id);
955
+ if (ids.includes(st.workingTabId)) st.workingTabId = null;
539
956
  return { closed: ids.length };
540
957
  }
541
958
 
542
959
  if (cmd === "ungroup") {
543
- if (workingTabId) {
544
- try { await chrome.tabs.ungroup(workingTabId); } catch (_) {}
545
- await banner(workingTabId, null);
960
+ // Antes solo se sacaba del grupo la pestana de trabajo: si la sesion habia
961
+ // abierto tres, las otras dos se quedaban dentro de un grupo naranja que ya
962
+ // no era de nadie, y el usuario tenia que deshacerlo a mano.
963
+ const aSoltar = new Set(st.propias);
964
+ if (st.workingTabId) aSoltar.add(st.workingTabId);
965
+ for (const id of aSoltar) {
966
+ try {
967
+ await chrome.tabs.ungroup(id);
968
+ } catch (_) {}
969
+ await banner(id, null);
546
970
  }
547
- groupId = null;
548
- workingTabId = null;
549
- return { ok: true };
971
+ st.groupId = null;
972
+ st.workingTabId = null;
973
+ st.propias.clear();
974
+ st.refIndex.clear();
975
+ return { ok: true, soltadas: aSoltar.size };
550
976
  }
551
977
 
552
978
  throw new Error("comando desconocido: " + cmd);
553
979
  }
554
980
 
555
- function sleep(ms) {
556
- return new Promise((r) => setTimeout(r, ms));
557
- }
558
-
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();
568
- }
569
- setTimeout(check, 250);
570
- };
571
- check();
981
+ // Una pestana cerrada por el usuario no puede seguir siendo la de trabajo de
982
+ // nadie: sin esto, el siguiente comando fallaba con el error de Chrome ("No tab
983
+ // with id") en vez de decir que ya no hay donde trabajar.
984
+ try {
985
+ chrome.tabs.onRemoved.addListener((tabId) => {
986
+ for (const link of links.values()) {
987
+ if (!link.state) continue;
988
+ link.state.propias.delete(tabId);
989
+ if (link.state.workingTabId === tabId) link.state.workingTabId = null;
990
+ guardarEstado(link);
991
+ }
572
992
  });
573
- }
993
+ } catch (_) {}
574
994
 
575
995
  for (const p of WS_PORTS) connect(p);