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
@@ -0,0 +1,338 @@
1
+ /**
2
+ * Cascada de estrategias para localizar el fragmento que el modelo quiere
3
+ * reemplazar.
4
+ *
5
+ * El problema real no es que el modelo invente el codigo: es que copia bien las
6
+ * lineas y se come la sangria, o se deja una linea en blanco delante, o resume
7
+ * el interior de un bloque largo. Con una unica busqueda exacta, todos esos
8
+ * casos son un fallo y una iteracion perdida; Aider y opencode los resuelven
9
+ * probando estrategias cada vez mas flexibles y parando en la primera que casa.
10
+ *
11
+ * Reglas que se mantienen en TODAS las estrategias, porque son lo que separa
12
+ * "tolerante" de "escribe donde le parece":
13
+ *
14
+ * - Si una estrategia encuentra mas de un sitio posible, no elige: falla. La
15
+ * ambiguedad se resuelve pidiendo mas contexto, no adivinando.
16
+ * - Una coincidencia desproporcionada (mucho mas grande que lo buscado) se
17
+ * rechaza aunque sea unica. Es el fallo mas caro: un anclaje laxo que se
18
+ * come medio archivo y responde "editado exitosamente".
19
+ */
20
+ export function esRechazo(r) {
21
+ return !!r && r.ambiguo === true;
22
+ }
23
+ /**
24
+ * Normalizacion de puntuacion Unicode a ASCII.
25
+ *
26
+ * Es el cuarto nivel de la escalera de Codex CLI, y es el que mas rinde en
27
+ * castellano. Cuando el modelo devuelve un fragmento, muy a menudo lo reescribe
28
+ * con la puntuacion "bonita": el guion largo del archivo vuelve como guion
29
+ * normal, las comillas rectas vuelven curvas, y un espacio duro se convierte en
30
+ * un espacio corriente. Ninguna de esas diferencias se ve al leer el diff, pero
31
+ * las cuatro estrategias anteriores comparan texto y fallan todas.
32
+ *
33
+ * La tabla esta copiada de `seek_sequence.rs`: siete guiones tipograficos, las
34
+ * comillas simples y dobles curvas, y once espacios raros.
35
+ */
36
+ const EQUIVALENCIAS = [
37
+ [/[‐‑‒–—―−]/g, "-"],
38
+ [/[‘’‚‛]/g, "'"],
39
+ [/[“”„‟]/g, '"'],
40
+ [/[             ]/g, " "],
41
+ ];
42
+ export function normalizarPuntuacion(texto) {
43
+ let salida = texto;
44
+ for (const [patron, ascii] of EQUIVALENCIAS)
45
+ salida = salida.replace(patron, ascii);
46
+ return salida;
47
+ }
48
+ /** true si el texto tiene algo que normalizar; evita recorrer el archivo en balde. */
49
+ export function tienePuntuacionRara(texto) {
50
+ return EQUIVALENCIAS.some(([patron]) => {
51
+ patron.lastIndex = 0;
52
+ return patron.test(texto);
53
+ });
54
+ }
55
+ /** Offset de caracter donde empieza cada linea. */
56
+ function offsetsDeLinea(lineas) {
57
+ const offsets = [];
58
+ let acumulado = 0;
59
+ for (const linea of lineas) {
60
+ offsets.push(acumulado);
61
+ acumulado += linea.length + 1; // +1 por el "\n"
62
+ }
63
+ return offsets;
64
+ }
65
+ /**
66
+ * Sangria comun que hay que ANADIR a `parte` para que coincida con `trozo`.
67
+ *
68
+ * Devuelve null si el texto sin sangria no coincide, o si el desfase no es el
69
+ * mismo en todas las lineas: un desfase distinto por linea significa que el
70
+ * modelo cambio la estructura, no solo la sangria, y ahi ya no se puede
71
+ * reconstruir sin inventar.
72
+ */
73
+ function desfaseDeSangria(trozo, parte) {
74
+ if (trozo.length !== parte.length)
75
+ return null;
76
+ for (let i = 0; i < trozo.length; i++) {
77
+ if (trozo[i].trimStart() !== parte[i].trimStart())
78
+ return null;
79
+ }
80
+ const prefijos = new Set();
81
+ for (let i = 0; i < trozo.length; i++) {
82
+ if (!trozo[i].trim())
83
+ continue; // las lineas en blanco no dicen nada
84
+ const sobra = trozo[i].length - parte[i].length;
85
+ if (sobra < 0)
86
+ return null;
87
+ prefijos.add(trozo[i].slice(0, sobra));
88
+ }
89
+ if (prefijos.size !== 1)
90
+ return null;
91
+ return prefijos.values().next().value ?? "";
92
+ }
93
+ /** Quita a los dos bloques la sangria minima que comparten. */
94
+ function desangrar(lineas, otras) {
95
+ const sangrias = [];
96
+ for (const l of [...lineas, ...otras]) {
97
+ if (!l.trim())
98
+ continue;
99
+ sangrias.push(l.length - l.trimStart().length);
100
+ }
101
+ const minimo = sangrias.length > 0 ? Math.min(...sangrias) : 0;
102
+ if (minimo === 0)
103
+ return { lineas, otras };
104
+ const recortar = (l) => (l.trim() ? l.slice(minimo) : l);
105
+ return { lineas: lineas.map(recortar), otras: otras.map(recortar) };
106
+ }
107
+ /**
108
+ * Rechaza un tramo que abarque mucho mas de lo buscado.
109
+ *
110
+ * Los umbrales son los de opencode: mas del doble de lineas (o tres de mas), o
111
+ * cuatro veces los caracteres (o 500 de mas).
112
+ */
113
+ export function esDesproporcionada(buscado, encontrado) {
114
+ const lineasBuscadas = buscado.split("\n").length;
115
+ const lineasEncontradas = encontrado.split("\n").length;
116
+ if (lineasEncontradas >= Math.max(lineasBuscadas + 3, lineasBuscadas * 2))
117
+ return true;
118
+ if (encontrado.length > Math.max(buscado.length + 500, buscado.length * 4))
119
+ return true;
120
+ return false;
121
+ }
122
+ /** 1. Coincidencia exacta. Es la unica que puede casar texto a media linea. */
123
+ function exacta(contenido, viejo, nuevo, _original) {
124
+ if (!viejo)
125
+ return null;
126
+ const primera = contenido.indexOf(viejo);
127
+ if (primera === -1)
128
+ return null;
129
+ const segunda = contenido.indexOf(viejo, primera + viejo.length);
130
+ if (segunda !== -1) {
131
+ let ocurrencias = 2;
132
+ let desde = segunda + viejo.length;
133
+ while (true) {
134
+ const at = contenido.indexOf(viejo, desde);
135
+ if (at === -1)
136
+ break;
137
+ ocurrencias++;
138
+ desde = at + viejo.length;
139
+ }
140
+ return { ambiguo: true, estrategia: "exacta", ocurrencias };
141
+ }
142
+ return { inicio: primera, fin: primera + viejo.length, textoNuevo: nuevo, estrategia: "exacta" };
143
+ }
144
+ /**
145
+ * 2. Sangria uniforme desplazada.
146
+ *
147
+ * El error mas comun del modelo: copia las lineas correctas pero con menos
148
+ * sangria de la que tienen (o con ninguna). Se compara el texto sin sangria y
149
+ * se exige que el desfase sea IDENTICO en todas las lineas; ese mismo prefijo
150
+ * se le devuelve al texto de reemplazo.
151
+ */
152
+ function sangriaUniforme(contenido, viejo, nuevo, original = contenido) {
153
+ const lineasArchivo = contenido.split("\n");
154
+ // La sangria que se vuelve a escribir se toma del archivo REAL, no de la
155
+ // version normalizada con la que se busca. Es la unica estrategia que
156
+ // reconstruye texto a partir del contenido, y sin esta distincion una pasada
157
+ // de normalizacion podria convertir en espacios normales una sangria que en
158
+ // el archivo eran otros caracteres.
159
+ const lineasOriginales = original === contenido ? lineasArchivo : original.split("\n");
160
+ const sinSangria = desangrar(viejo.split("\n"), nuevo.split("\n"));
161
+ const parte = sinSangria.lineas;
162
+ const reemplazo = sinSangria.otras;
163
+ if (parte.length === 0)
164
+ return null;
165
+ const offsets = offsetsDeLinea(lineasArchivo);
166
+ let encontrado = null;
167
+ let ocurrencias = 0;
168
+ for (let i = 0; i + parte.length <= lineasArchivo.length; i++) {
169
+ const prefijo = desfaseDeSangria(lineasArchivo.slice(i, i + parte.length), parte);
170
+ if (prefijo === null)
171
+ continue;
172
+ ocurrencias++;
173
+ if (ocurrencias > 1)
174
+ return { ambiguo: true, estrategia: "sangria-uniforme", ocurrencias };
175
+ encontrado = { i, prefijo };
176
+ }
177
+ if (!encontrado)
178
+ return null;
179
+ const inicio = offsets[encontrado.i];
180
+ const ultima = encontrado.i + parte.length - 1;
181
+ const fin = offsets[ultima] + lineasArchivo[ultima].length;
182
+ const lineaReal = lineasOriginales[encontrado.i] ?? lineasArchivo[encontrado.i];
183
+ const prefijo = lineaReal.slice(0, encontrado.prefijo.length);
184
+ const textoNuevo = reemplazo.map((l) => (l.trim() ? prefijo + l : l)).join("\n");
185
+ return { inicio, fin, textoNuevo, estrategia: "sangria-uniforme" };
186
+ }
187
+ /**
188
+ * 3. Coincidencia por lineas ignorando espacios al final.
189
+ *
190
+ * Estaba ya en edit_file y aguanta bien: cubre el espacio de mas al final de
191
+ * linea, que ni el match exacto ni la sangria uniforme perdonan.
192
+ */
193
+ function lineasRecortadas(contenido, viejo, nuevo, _original) {
194
+ const lineasArchivo = contenido.split("\n");
195
+ const objetivo = viejo.split("\n").map((l) => l.trimEnd());
196
+ if (objetivo.length === 0)
197
+ return null;
198
+ const offsets = offsetsDeLinea(lineasArchivo);
199
+ let encontrado = -1;
200
+ let ocurrencias = 0;
201
+ for (let i = 0; i + objetivo.length <= lineasArchivo.length; i++) {
202
+ let casa = true;
203
+ for (let j = 0; j < objetivo.length; j++) {
204
+ if (lineasArchivo[i + j].trimEnd() !== objetivo[j]) {
205
+ casa = false;
206
+ break;
207
+ }
208
+ }
209
+ if (!casa)
210
+ continue;
211
+ ocurrencias++;
212
+ if (ocurrencias > 1)
213
+ return { ambiguo: true, estrategia: "lineas-recortadas", ocurrencias };
214
+ encontrado = i;
215
+ }
216
+ if (encontrado === -1)
217
+ return null;
218
+ const inicio = offsets[encontrado];
219
+ const ultima = encontrado + objetivo.length - 1;
220
+ const fin = offsets[ultima] + lineasArchivo[ultima].length;
221
+ return { inicio, fin, textoNuevo: nuevo, estrategia: "lineas-recortadas" };
222
+ }
223
+ /**
224
+ * 4. Ancla de bloque: primera y ultima linea.
225
+ *
226
+ * Para bloques de tres lineas o mas. El modelo resume el interior o lo recuerda
227
+ * de memoria con alguna diferencia, pero los bordes casi siempre los copia
228
+ * bien. Se ancla en ellos y se acepta que el interior no coincida, con dos
229
+ * cerrojos: el numero de lineas del tramo no puede desviarse mas de un 25%, y
230
+ * el ancla tiene que ser unica.
231
+ */
232
+ function anclaDeBloque(contenido, viejo, nuevo, _original) {
233
+ const objetivo = viejo.split("\n");
234
+ if (objetivo.length < 3)
235
+ return null;
236
+ const primera = objetivo[0].trim();
237
+ const ultima = objetivo[objetivo.length - 1].trim();
238
+ if (!primera || !ultima)
239
+ return null;
240
+ const lineasArchivo = contenido.split("\n");
241
+ const offsets = offsetsDeLinea(lineasArchivo);
242
+ const desvioMax = Math.max(1, Math.floor(objetivo.length * 0.25));
243
+ let encontrado = null;
244
+ let ocurrencias = 0;
245
+ for (let i = 0; i < lineasArchivo.length; i++) {
246
+ if (lineasArchivo[i].trim() !== primera)
247
+ continue;
248
+ const desde = Math.max(i + 2, i + objetivo.length - 1 - desvioMax);
249
+ const hasta = Math.min(lineasArchivo.length - 1, i + objetivo.length - 1 + desvioMax);
250
+ for (let j = desde; j <= hasta; j++) {
251
+ if (lineasArchivo[j].trim() !== ultima)
252
+ continue;
253
+ ocurrencias++;
254
+ if (ocurrencias > 1)
255
+ return { ambiguo: true, estrategia: "ancla-de-bloque", ocurrencias };
256
+ encontrado = { i, j };
257
+ break;
258
+ }
259
+ }
260
+ if (!encontrado)
261
+ return null;
262
+ const inicio = offsets[encontrado.i];
263
+ const fin = offsets[encontrado.j] + lineasArchivo[encontrado.j].length;
264
+ return { inicio, fin, textoNuevo: nuevo, estrategia: "ancla-de-bloque" };
265
+ }
266
+ /**
267
+ * Orden de la cascada, de mas estricta a mas flexible. Se para en la primera
268
+ * que devuelve algo: una estrategia mas laxa no puede mejorar lo que ya acerto
269
+ * una mas estricta.
270
+ *
271
+ * Un rechazo por ambiguedad NO deja pasar a la siguiente estrategia. Si el
272
+ * fragmento aparece dos veces exactas, encontrar un tercer sitio "parecido" no
273
+ * arregla nada: lo que hay que hacer es pedir mas contexto.
274
+ */
275
+ export function localizarFragmento(contenidoNormalizado, viejoNormalizado, nuevoNormalizado) {
276
+ const estrategias = [exacta, sangriaUniforme, lineasRecortadas, anclaDeBloque];
277
+ /**
278
+ * Cada pasada aplica la cascada entera sobre una version distinta del texto.
279
+ * El orden va de menos a mas permisivo, igual que dentro de la cascada.
280
+ *
281
+ * `contenido` es contra que se BUSCA; `nuevo` es lo que se ESCRIBE. Las
282
+ * transformaciones de busqueda no tocan nunca el texto de reemplazo: si el
283
+ * modelo escribio una comilla curva a proposito, ahi se queda.
284
+ *
285
+ * Las dos transformaciones conservan la longitud caracter a caracter, asi que
286
+ * los indices que devuelve una pasada valen tal cual sobre el contenido de
287
+ * verdad. Eso es lo que permite buscar en un texto y recortar en otro.
288
+ */
289
+ const pasadas = [
290
+ { contenido: contenidoNormalizado, viejo: viejoNormalizado, nuevo: nuevoNormalizado, sufijo: "" },
291
+ ];
292
+ // Los modelos anteponen una linea en blanco al fragmento mas de lo que
293
+ // parece.
294
+ const sinBlanco = /^[ \t]*\n/.test(viejoNormalizado)
295
+ ? {
296
+ viejo: viejoNormalizado.replace(/^[ \t]*\n/, ""),
297
+ nuevo: nuevoNormalizado.replace(/^[ \t]*\n/, ""),
298
+ }
299
+ : null;
300
+ if (sinBlanco) {
301
+ pasadas.push({ contenido: contenidoNormalizado, ...sinBlanco, sufijo: "+sin-blanco-inicial" });
302
+ }
303
+ // Ultimo recurso: comparar con la puntuacion normalizada a ASCII. Es el nivel
304
+ // que salva las ediciones en texto en castellano, donde el modelo devuelve un
305
+ // guion normal donde el archivo tiene uno largo, o comillas curvas donde hay
306
+ // rectas. Va el ultimo porque es el que mas relaja la comparacion.
307
+ if (tienePuntuacionRara(contenidoNormalizado) || tienePuntuacionRara(viejoNormalizado)) {
308
+ const contenidoAscii = normalizarPuntuacion(contenidoNormalizado);
309
+ pasadas.push({
310
+ contenido: contenidoAscii,
311
+ viejo: normalizarPuntuacion(viejoNormalizado),
312
+ nuevo: nuevoNormalizado,
313
+ sufijo: "+puntuacion",
314
+ });
315
+ if (sinBlanco) {
316
+ pasadas.push({
317
+ contenido: contenidoAscii,
318
+ viejo: normalizarPuntuacion(sinBlanco.viejo),
319
+ nuevo: sinBlanco.nuevo,
320
+ sufijo: "+puntuacion+sin-blanco-inicial",
321
+ });
322
+ }
323
+ }
324
+ for (const pasada of pasadas) {
325
+ for (const estrategia of estrategias) {
326
+ const r = estrategia(pasada.contenido, pasada.viejo, pasada.nuevo, contenidoNormalizado);
327
+ if (!r)
328
+ continue;
329
+ if (esRechazo(r))
330
+ return r;
331
+ const encontrado = pasada.contenido.slice(r.inicio, r.fin);
332
+ if (esDesproporcionada(pasada.viejo, encontrado))
333
+ continue;
334
+ return { ...r, estrategia: r.estrategia + pasada.sufijo };
335
+ }
336
+ }
337
+ return null;
338
+ }
@@ -1,8 +1,9 @@
1
- import { execa } from "execa";
1
+ import { spawn } from "node:child_process";
2
+ import { StringDecoder } from "node:string_decoder";
2
3
  /**
3
4
  * Ejecucion de comandos de shell.
4
5
  *
5
- * Tres fallos que tenia la version anterior:
6
+ * Cuatro fallos que tenia esto, y el cuarto explica por que ya no se usa execa:
6
7
  *
7
8
  * 1. Calculaba el shell correcto y luego NO lo usaba: pasaba `shell: true`, que
8
9
  * en Windows significa cmd.exe. Cualquier comando con sintaxis de PowerShell
@@ -16,9 +17,88 @@ import { execa } from "execa";
16
17
  * "Exit code: null", y concluia lo que le parecia. Un comando que no termino
17
18
  * no es un comando que fallo, y confundirlos lleva a "arreglar" cosas que no
18
19
  * estaban rotas.
20
+ *
21
+ * 4. El timeout no mataba nada. Con `shell: "powershell.exe"`, Node lanza
22
+ * `powershell.exe -c <comando>`, asi que el proceso de verdad (node, npm,
23
+ * python, git) es un NIETO. El kill del timeout cae sobre el hijo directo y
24
+ * en Windows eso es un TerminateProcess de ese PID: no hay job object ni
25
+ * grupo de procesos, y el nieto sobrevive con el pipe de stdout heredado
26
+ * abierto. Como execa espera al EOF de stdio antes de resolver, la promesa
27
+ * volvia cuando el nieto terminaba por su cuenta -- y si era un servidor o
28
+ * un watcher, no volvia jamas. Medido: con timeout de 2 s, un `node -e` de
29
+ * 8 s devolvia a los 8,2 s; con `npm run dev`, nunca.
30
+ *
31
+ * Los daños colaterales eran peores que el sintoma: loop.ts lanza el lote de
32
+ * tool calls con Promise.all, asi que un run_command colgado congela a todas
33
+ * las demas; el InterruptWatcher no cubre la ejecucion de herramientas, o
34
+ * sea que ni Esc salva; y el servidor quedaba huerfano ocupando el puerto.
35
+ * Ademas el flag `timedOut` se ponia igual aunque el comando hubiera
36
+ * terminado entero, asi que se etiquetaba de "salida incompleta" una salida
37
+ * completa, sin imprimir Exit code, y el agente relanzaba un comando que ya
38
+ * habia funcionado.
39
+ *
40
+ * Por eso aqui se baja a `child_process.spawn` y se hace a mano lo que execa no
41
+ * puede garantizar por encima de un shell que no hace exec:
42
+ *
43
+ * - la salida se acumula segun llega, no al final, asi que lo ya impreso se
44
+ * conserva aunque haya que matar el proceso a mitad;
45
+ * - el temporizador es nuestro y mata el ARBOL (taskkill /T en Windows, el
46
+ * grupo de procesos en Unix), no solo al hijo directo;
47
+ * - despues de matar se espera un margen corto a que cierren los pipes y se
48
+ * devuelve igualmente. El tiempo total esta acotado SIEMPRE: nunca mas de
49
+ * timeout + KILL_GRACE_MS, pase lo que pase con los descendientes.
19
50
  */
20
51
  const DEFAULT_TIMEOUT_MS = 120_000;
21
52
  const MAX_TIMEOUT_MS = 600_000;
53
+ /**
54
+ * Margen tras matar el arbol para que cierren los pipes. Si un nieto reparentado
55
+ * los sigue teniendo, se devuelve igual: esperar mas no aporta nada y colgarse
56
+ * es exactamente el fallo que se esta arreglando.
57
+ */
58
+ const KILL_GRACE_MS = 2_000;
59
+ /**
60
+ * Margen tras la muerte del shell para recoger lo que quede en los pipes. Casi
61
+ * siempre 'close' llega en el mismo tick; si no llega es que el comando dejo
62
+ * algo en segundo plano, y eso no justifica bloquear el turno.
63
+ */
64
+ const DRAIN_MS = 500;
65
+ const MAX_OUTPUT_CHARS = 20 * 1024 * 1024;
66
+ /**
67
+ * Mata al proceso Y a sus descendientes. Matar solo al pid es inutil aqui: el
68
+ * shell casi nunca es la carga de trabajo, solo el que la lanzo.
69
+ */
70
+ function killTree(pid) {
71
+ if (process.platform === "win32") {
72
+ // /T recorre la cadena de padres y se lleva a los nietos. Sin /T se mata
73
+ // powershell.exe y el node/npm/python real sigue vivo, escribiendo a disco.
74
+ try {
75
+ const t = spawn("taskkill", ["/pid", String(pid), "/t", "/f"], {
76
+ stdio: "ignore",
77
+ windowsHide: true,
78
+ });
79
+ t.on("error", () => { });
80
+ }
81
+ catch { }
82
+ return;
83
+ }
84
+ // En Unix el hijo se lanza con detached, o sea que es lider de su propio
85
+ // grupo: el pid en negativo alcanza tambien a lo que haya forkeado (tuberias,
86
+ // npm lanzando node). Sin grupo, bash exec-a el comando simple y el kill
87
+ // funciona, pero un comando compuesto filtra huerfanos igual que en Windows.
88
+ const enviar = (sig) => {
89
+ try {
90
+ process.kill(-pid, sig);
91
+ }
92
+ catch {
93
+ try {
94
+ process.kill(pid, sig);
95
+ }
96
+ catch { }
97
+ }
98
+ };
99
+ enviar("SIGTERM");
100
+ setTimeout(() => enviar("SIGKILL"), 1_000).unref();
101
+ }
22
102
  export async function runCommand(command, cwd = process.cwd(), timeoutMs) {
23
103
  const trimmed = command.trim();
24
104
  if (!trimmed)
@@ -29,31 +109,139 @@ export async function runCommand(command, cwd = process.cwd(), timeoutMs) {
29
109
  }
30
110
  const timeout = Math.min(Math.max(timeoutMs || DEFAULT_TIMEOUT_MS, 1_000), MAX_TIMEOUT_MS);
31
111
  const isWindows = process.platform === "win32";
112
+ // Se usa de verdad el shell que toca. En Windows, PowerShell.
113
+ const shellPath = isWindows ? "powershell.exe" : "/bin/bash";
114
+ const shellArgs = isWindows
115
+ ? // -NoProfile para que el perfil del usuario no ensucie la salida ni cueste
116
+ // medio segundo, y -NonInteractive para que cualquier prompt sea un error
117
+ // en el sitio en vez de una espera eterna por un teclado que aqui no hay.
118
+ ["-NoProfile", "-NonInteractive", "-Command", trimmed]
119
+ : ["-c", trimmed];
32
120
  try {
33
- const result = await execa(trimmed, {
34
- cwd,
35
- // Se usa de verdad el shell que toca. En Windows, PowerShell.
36
- shell: isWindows ? "powershell.exe" : "/bin/bash",
37
- timeout,
38
- reject: false,
39
- all: false,
40
- maxBuffer: 20 * 1024 * 1024,
41
- env: { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1" },
121
+ return await new Promise((resolve) => {
122
+ const child = spawn(shellPath, shellArgs, {
123
+ cwd,
124
+ env: { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1" },
125
+ windowsHide: true,
126
+ // stdin cerrado: un comando que pida confirmacion muere en el sitio en
127
+ // vez de esperar para siempre a nadie. detached solo en Unix, para tener
128
+ // un grupo de procesos al que matar; en Windows no existe equivalente y
129
+ // el arbol se derriba con taskkill.
130
+ stdio: ["ignore", "pipe", "pipe"],
131
+ detached: !isWindows,
132
+ });
133
+ const decOut = new StringDecoder("utf8");
134
+ const decErr = new StringDecoder("utf8");
135
+ let stdout = "";
136
+ let stderr = "";
137
+ let recortada = false;
138
+ let resuelto = false;
139
+ let salio = false;
140
+ let porTiempo = false;
141
+ let colaDeFondo = false;
142
+ let exitCode = null;
143
+ let temporizador;
144
+ let margen;
145
+ const acumular = (trozo, cual) => {
146
+ if (stdout.length + stderr.length >= MAX_OUTPUT_CHARS) {
147
+ recortada = true;
148
+ return;
149
+ }
150
+ // StringDecoder y no toString() porque un chunk puede partir un
151
+ // caracter multibyte por la mitad.
152
+ if (cual === "out")
153
+ stdout += decOut.write(trozo);
154
+ else
155
+ stderr += decErr.write(trozo);
156
+ };
157
+ child.stdout?.on("data", (d) => acumular(d, "out"));
158
+ child.stderr?.on("data", (d) => acumular(d, "err"));
159
+ // Un pipe roto tras matar el arbol es lo esperado, no un error que contar.
160
+ child.stdout?.on("error", () => { });
161
+ child.stderr?.on("error", () => { });
162
+ const acabar = () => {
163
+ if (resuelto)
164
+ return;
165
+ resuelto = true;
166
+ if (temporizador)
167
+ clearTimeout(temporizador);
168
+ if (margen)
169
+ clearTimeout(margen);
170
+ const limpio = (s) => s.replace(/\r?\n$/, "");
171
+ const partes = [];
172
+ if (stdout)
173
+ partes.push(`STDOUT:\n${limpio(stdout)}`);
174
+ if (stderr)
175
+ partes.push(`STDERR:\n${limpio(stderr)}`);
176
+ if (recortada) {
177
+ partes.push(`(salida recortada: paso de ${MAX_OUTPUT_CHARS} caracteres)`);
178
+ }
179
+ if (porTiempo) {
180
+ partes.push(
181
+ // Con toFixed(0) un timeout de 1500 ms se anunciaba como "2s", que
182
+ // no es lo que se pidio ni lo que se espero.
183
+ `TIEMPO AGOTADO: el comando seguia corriendo tras ${(timeout / 1000).toFixed(timeout % 1000 === 0 ? 0 : 1)}s ` +
184
+ `y se ha matado junto con todos sus procesos descendientes.\n` +
185
+ `La salida de arriba esta incompleta y NO significa que el comando fallara. ` +
186
+ `Si necesita mas tiempo, vuelve a lanzarlo con un 'timeout' mayor (hasta ${MAX_TIMEOUT_MS / 1000}s).`);
187
+ resolve(partes.join("\n\n"));
188
+ return;
189
+ }
190
+ // Fuera de la rama de timeout SIEMPRE se dice el codigo de salida: sin
191
+ // el, el agente no puede distinguir un exito silencioso de un fallo.
192
+ partes.push(`Exit code: ${exitCode}`);
193
+ if (colaDeFondo) {
194
+ partes.push("Aviso: el comando termino pero dejo algo corriendo en segundo plano con la " +
195
+ "salida abierta. Se dejo de leer ahi: lo que imprima a partir de ahora no se vera.");
196
+ }
197
+ resolve(partes.join("\n\n"));
198
+ };
199
+ child.on("error", (error) => {
200
+ // No llego a arrancar (shell inexistente, cwd que no existe...). No hay
201
+ // proceso que matar, pero si una promesa que resolver.
202
+ if (resuelto)
203
+ return;
204
+ resuelto = true;
205
+ if (temporizador)
206
+ clearTimeout(temporizador);
207
+ if (margen)
208
+ clearTimeout(margen);
209
+ resolve(`Error al ejecutar comando: ${error?.message || String(error)}`);
210
+ });
211
+ child.on("exit", (code) => {
212
+ salio = true;
213
+ exitCode = code;
214
+ // Si ya se mato por tiempo, el margen del kill esta corriendo y manda el.
215
+ if (porTiempo || resuelto)
216
+ return;
217
+ margen = setTimeout(() => {
218
+ colaDeFondo = true;
219
+ acabar();
220
+ }, DRAIN_MS);
221
+ });
222
+ // 'close' es el unico evento que garantiza que ya se leyo todo. Puede no
223
+ // llegar nunca si un nieto heredo el pipe: de ahi los dos temporizadores.
224
+ child.on("close", (code) => {
225
+ if (code !== null)
226
+ exitCode = code;
227
+ acabar();
228
+ });
229
+ temporizador = setTimeout(() => {
230
+ if (resuelto)
231
+ return;
232
+ // Murio justo en la frontera y solo faltaba drenar: eso no es un
233
+ // timeout, y llamarlo asi era la mentira que hacia relanzar comandos que
234
+ // habian terminado bien.
235
+ if (salio) {
236
+ acabar();
237
+ return;
238
+ }
239
+ porTiempo = true;
240
+ if (typeof child.pid === "number")
241
+ killTree(child.pid);
242
+ margen = setTimeout(acabar, KILL_GRACE_MS);
243
+ }, timeout);
42
244
  });
43
- const parts = [];
44
- if (result.stdout)
45
- parts.push(`STDOUT:\n${result.stdout}`);
46
- if (result.stderr)
47
- parts.push(`STDERR:\n${result.stderr}`);
48
- if (result.timedOut) {
49
- parts.push(`TIEMPO AGOTADO: el comando seguia corriendo tras ${(timeout / 1000).toFixed(0)}s y se detuvo.\n` +
50
- `La salida de arriba esta incompleta y NO significa que el comando fallara. ` +
51
- `Si necesita mas tiempo, vuelve a lanzarlo con un 'timeout' mayor (hasta ${MAX_TIMEOUT_MS / 1000}s).`);
52
- return parts.join("\n\n");
53
- }
54
- parts.push(`Exit code: ${result.exitCode}`);
55
- const output = parts.join("\n\n");
56
- return output || "Comando ejecutado sin salida (código 0).";
57
245
  }
58
246
  catch (error) {
59
247
  return `Error al ejecutar comando: ${error?.message || String(error)}`;