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
@@ -5,20 +5,79 @@ import { executeToolCall } from "../tools/runner.js";
5
5
  import { TokenTracker } from "./tracker.js";
6
6
  import { renderToolStart, renderToolSuccess, renderToolError, renderDiff, renderFooter, renderError, } from "../ui/renderer.js";
7
7
  import { CONTEXT_WINDOW, MAX_OUTPUT_TOKENS, COMPACT_THRESHOLD_TOKENS, } from "../config/constants.js";
8
- import { withRetry } from "./retry.js";
8
+ import { withRetry, isContextOverflow } from "./retry.js";
9
9
  import { canRunInParallel } from "../tools/safety.js";
10
- import { createEngineClient, explicarErrorDelMotor } from "../config/engine.js";
10
+ import { createEngineClient, explicarErrorDelMotor, motivoDeParadaDelError, } from "../config/engine.js";
11
11
  import { MarkdownStream } from "../ui/markdownStream.js";
12
12
  import { checkQuota, recordUsage, mensajeDeCorte, quotaBadge } from "../config/quota.js";
13
13
  import { ReasoningStream } from "../ui/reasoningStream.js";
14
- import { PermissionManager } from "../config/permissions.js";
14
+ import { PermissionManager, commandSignature, isDangerousCommand } from "../config/permissions.js";
15
15
  import { askToolPermission } from "../ui/permissionPrompt.js";
16
16
  import { DynamicSpinner } from "../ui/spinner.js";
17
17
  import { globalUndoManager } from "./undoManager.js";
18
18
  import { InterruptWatcher, isAbortError } from "../ui/interrupt.js";
19
19
  import { HookManager } from "../hooks/manager.js";
20
+ import { registerToolGate, autorizarHerramienta, notificarPostHerramienta } from "./toolGate.js";
21
+ import { DetectorDeBucle, mensajeDeBucle } from "./loopDetector.js";
22
+ import { preguntarPorBucle } from "../ui/loopPrompt.js";
23
+ import { MAX_REFLEXIONES, detectarComando, mensajeDeRendicion, mensajeDeVerificacion, verificacionAutomaticaActiva, verificar, } from "./verifier.js";
24
+ import { apuntarUso, tomarGastoAuxiliar } from "./usageMeter.js";
25
+ import { leerLimites } from "../config/limits.js";
26
+ import { corteSeguro, estimarTokens, limpiarHuerfanos, vaciarResultadosAntiguos, } from "./compaction.js";
27
+ /** Herramientas cuyo exito deja el proyecto en un estado que vale la pena verificar. */
28
+ const HERRAMIENTAS_QUE_ESCRIBEN = new Set([
29
+ "edit_file",
30
+ "write_file",
31
+ "move_copy_file",
32
+ "delete_file",
33
+ ]);
34
+ /** Cortes por longitud seguidos que se toleran antes de rendirse. */
35
+ const MAX_CORTES_POR_LONGITUD = 2;
36
+ /** Mensajes recientes que la compactacion no resume nunca. */
37
+ const COLA_INTACTA = 4;
38
+ /**
39
+ * Mensajes recientes cuyas salidas de herramienta se conservan enteras. Las
40
+ * anteriores se vacian antes de plantearse siquiera resumir.
41
+ */
42
+ const RESULTADOS_INTACTOS = 12;
43
+ /**
44
+ * Argumentos de una llamada a herramienta, distinguiendo el caso que importa.
45
+ *
46
+ * Un `JSON.parse` que falla no significa "esta herramienta no lleva
47
+ * argumentos": significa que el modelo se quedo sin presupuesto de salida a
48
+ * mitad de escribirlos. Tratar las dos cosas igual —que es lo que se hacia,
49
+ * con un `catch { fnArgs = {} }`— ejecuta la herramienta con las manos vacias:
50
+ * un write_file sin ruta ni contenido, un run_command sin comando. Y responde
51
+ * como si nada.
52
+ */
53
+ function parsearArgumentos(bruto) {
54
+ const texto = (bruto ?? "").trim();
55
+ // Sin argumentos de verdad: hay herramientas que no llevan ninguno.
56
+ if (!texto || texto === "{}")
57
+ return { ok: true, args: {} };
58
+ try {
59
+ const args = JSON.parse(texto);
60
+ if (args && typeof args === "object" && !Array.isArray(args))
61
+ return { ok: true, args };
62
+ }
63
+ catch { }
64
+ return { ok: false, args: {} };
65
+ }
66
+ /** Lo que se le dice al modelo cuando sus argumentos llegaron a medias. */
67
+ function mensajeDeArgumentosRotos(herramienta) {
68
+ return (`No se ejecuto "${herramienta}": los argumentos llegaron incompletos, seguramente porque la ` +
69
+ `respuesta se corto por longitud. No se ha hecho nada. Vuelve a pedirlo, y si el contenido es ` +
70
+ `largo, escribelo en varias llamadas mas pequenas en vez de una sola.`);
71
+ }
20
72
  export class AgentLoop {
21
73
  client;
74
+ /**
75
+ * Cliente aparte para resumir el historial. No es cosmetico: la cabecera
76
+ * X-Chocolatito-Route es lo que le permite al proxy saber en que se le va la
77
+ * cuota al usuario, y con un unico cliente las compactaciones —que son las
78
+ * llamadas mas caras que hace el agente— llegaban etiquetadas como "loop".
79
+ */
80
+ compactClient;
22
81
  currentEspectro;
23
82
  messages = [];
24
83
  tracker;
@@ -27,17 +86,36 @@ export class AgentLoop {
27
86
  hooks;
28
87
  apiKey;
29
88
  baseURL;
30
- maxIterations = 30;
89
+ // El presupuesto de vueltas ya no esta escrito en el codigo: se lee de la
90
+ // configuracion del usuario, que es quien conoce su tarea y su cuota.
91
+ maxIterations = leerLimites().iteracionesPorTurno;
92
+ /**
93
+ * Vigila que el agente no se quede dando vueltas. Vive en la instancia y no
94
+ * en runTask porque el estado tiene que sobrevivir a las iteraciones del
95
+ * while; se reinicia en cada turno nuevo.
96
+ */
97
+ detectorBucle = new DetectorDeBucle();
98
+ /**
99
+ * Archivos escritos en el turno. Solo sirve para saber si hay algo que
100
+ * verificar al cerrarlo: si el turno no toco el disco, no hay nada que
101
+ * comprobar y correr el compilador seria tiempo del usuario tirado.
102
+ */
103
+ archivosTocados = new Set();
31
104
  constructor(apiKey, baseURL, initialModel, systemPrompt) {
32
105
  this.apiKey = apiKey;
33
106
  this.baseURL = baseURL;
34
107
  // Un unico sitio construye el cliente: asi la credencial de suscripcion y
35
108
  // las cabeceras propias llegan igual desde las cuatro rutas.
36
109
  this.client = createEngineClient(apiKey, "loop");
110
+ this.compactClient = createEngineClient(apiKey, "compact");
37
111
  this.currentEspectro = initialModel;
38
112
  this.tracker = new TokenTracker();
39
113
  this.permissions = new PermissionManager("default", this.currentCwd);
40
114
  this.hooks = new HookManager(this.currentCwd);
115
+ // Los subagentes ejecutan herramientas fuera de este bucle, y estos dos
116
+ // gestores no les llegan por ninguna firma. Sin registrarlos aqui, corren sin
117
+ // permisos, sin hooks y sin el bloqueo del modo plan.
118
+ registerToolGate(this.permissions, this.hooks);
41
119
  this.messages.push({
42
120
  role: "system",
43
121
  content: systemPrompt,
@@ -73,11 +151,14 @@ export class AgentLoop {
73
151
  content: "Genera el resumen ejecutivo del contexto de trabajo hasta el momento.",
74
152
  },
75
153
  ];
76
- const res = await this.client.chat.completions.create({
154
+ const res = await this.compactClient.chat.completions.create({
77
155
  model: this.currentEspectro.underlyingModel,
78
156
  messages: summaryPrompt,
79
157
  temperature: 0.1,
80
158
  });
159
+ // Resumir manda el historial ENTERO como prompt. Es dinero real y hasta
160
+ // ahora no lo apuntaba nadie: el pie seguia marcando el mismo porcentaje.
161
+ apuntarUso(res.usage, this.currentEspectro.underlyingModel, "compact");
81
162
  const summary = res.choices[0]?.message?.content || "Contexto previo compactado.";
82
163
  spinner.stop();
83
164
  this.messages = [
@@ -98,41 +179,90 @@ export class AgentLoop {
98
179
  return `Fallo al compactar historial: ${err?.message || String(err)}`;
99
180
  }
100
181
  }
182
+ /**
183
+ * Tokens que OCUPA el historial ahora mismo, no los que se han gastado en el
184
+ * turno. Son dos numeros distintos y confundirlos era el fallo de abajo.
185
+ */
101
186
  lastTurnTokens = 0;
102
- async autoCompactIfNeeded() {
103
- // La ventana real es de 1.000.000 de tokens. Antes se compactaba a los 16
104
- // mensajes o 38k tokens, es decir al 4% de su capacidad: el agente perdia
105
- // el hilo constantemente y repetia trabajo ya hecho. Ahora solo se comprime
106
- // cuando de verdad se acerca el limite.
107
- if (this.lastTurnTokens < COMPACT_THRESHOLD_TOKENS) {
187
+ /**
188
+ * Compactacion automatica del contexto.
189
+ *
190
+ * Habia tres cosas mal, y cada una se comia el contexto por su lado:
191
+ *
192
+ * 1. El corte era por posicion fija: slice(1, -4) para lo que se resumia y
193
+ * slice(-4) para lo que se conservaba. Un turno que acaba en un bloque de
194
+ * cuatro herramientas deja la cola empezando por un mensaje "tool" cuya
195
+ * llamada se ha quedado en la mitad resumida. La API contesta 400 y el
196
+ * catch mudo lo tapaba: la sesion se rompia sin motivo visible, y siempre
197
+ * en las sesiones largas, que son las que llevan mas trabajo encima. El
198
+ * corte lo decide ahora la forma del historial, nunca una posicion.
199
+ *
200
+ * 2. El umbral solo se miraba al empezar el turno y sobre el dato del turno
201
+ * ANTERIOR. Un turno de veinte herramientas puede doblar el contexto sin
202
+ * que nadie lo compruebe, y el limite se choca a mitad de camino.
203
+ *
204
+ * 3. Resumir cuesta una llamada al modelo y pierde detalle. Vaciar las
205
+ * salidas de herramienta viejas no cuesta nada y casi siempre basta, asi
206
+ * que va primero.
207
+ */
208
+ async autoCompactIfNeeded(tokensActuales = this.lastTurnTokens) {
209
+ // La ventana real es de 1.000.000 de tokens. Comprimir antes de acercarse
210
+ // al limite es tirar contexto bueno.
211
+ if (tokensActuales < COMPACT_THRESHOLD_TOKENS)
108
212
  return false;
213
+ // Paso 1, gratis: vaciar las salidas de herramienta mas viejas. Un view_file
214
+ // de hace veinte pasos ocupa miles de tokens y ya no dice nada; las
215
+ // decisiones tomadas, que son lo que hay que conservar, estan en el texto.
216
+ const antes = estimarTokens(this.messages);
217
+ const podado = vaciarResultadosAntiguos(this.messages, RESULTADOS_INTACTOS);
218
+ if (podado.liberados > 0) {
219
+ this.messages = podado.mensajes;
220
+ const despues = estimarTokens(this.messages);
221
+ // La cifra absoluta buena la da la API en prompt_tokens; de la estimacion
222
+ // solo se usa la proporcion de lo que se acaba de quitar.
223
+ const proyectado = antes > 0 ? Math.round(tokensActuales * (despues / antes)) : tokensActuales;
224
+ this.lastTurnTokens = proyectado;
225
+ if (proyectado < COMPACT_THRESHOLD_TOKENS * 0.8) {
226
+ console.log(chalk.hex("#D97757")("\n⚡ [Auto-Compact]: ") +
227
+ chalk.gray(`Liberados ~${(antes - despues).toLocaleString()} tokens de salidas de herramientas antiguas. ` +
228
+ "La conversacion se conserva entera.\n"));
229
+ return true;
230
+ }
109
231
  }
232
+ const spinner = new DynamicSpinner();
110
233
  try {
111
234
  const activeSystemPrompt = this.messages[0]?.role === "system" ? this.messages[0].content : "";
112
- const toSummarize = this.messages.slice(1, -4);
113
- if (toSummarize.length < 4)
235
+ // El corte respeta siempre los pares llamada-respuesta; si el punto que
236
+ // tocaba parte un bloque, se conserva de mas.
237
+ const corte = corteSeguro(this.messages, COLA_INTACTA);
238
+ const cabeza = limpiarHuerfanos(this.messages.slice(1, corte));
239
+ if (cabeza.length < 4)
114
240
  return false;
115
- const spinner = new DynamicSpinner();
241
+ const cola = this.messages.slice(corte);
116
242
  spinner.start("Auto-optimizando memoria del contexto");
117
243
  const summaryPrompt = [
118
244
  {
119
245
  role: "system",
120
246
  content: "Resume de forma ultra concisa los archivos modificados, decisiones tomadas, arquitectura y estado actual del proyecto para continuar trabajando sin perder el hilo.",
121
247
  },
122
- ...toSummarize,
248
+ ...cabeza,
123
249
  {
124
250
  role: "user",
125
251
  content: "Genera el resumen ejecutivo del contexto de trabajo previo.",
126
252
  },
127
253
  ];
128
- const res = await this.client.chat.completions.create({
254
+ const res = await this.compactClient.chat.completions.create({
129
255
  model: this.currentEspectro.underlyingModel,
130
256
  messages: summaryPrompt,
131
257
  temperature: 0.1,
132
258
  });
259
+ // La llamada mas cara del agente: entra aqui con el contexto rozando el
260
+ // umbral (750.000 tokens) y lo manda entero. En Espectro 2.0 son ~0,99 $,
261
+ // el 85% del presupuesto semanal por defecto, que antes valian 0,00 $ en
262
+ // el contador local. De ahi el 402 por sorpresa.
263
+ apuntarUso(res.usage, this.currentEspectro.underlyingModel, "compact");
133
264
  const summary = res.choices[0]?.message?.content || "Contexto previo optimizado.";
134
265
  spinner.stop();
135
- const recentMessages = this.messages.slice(-4);
136
266
  this.messages = [
137
267
  { role: "system", content: activeSystemPrompt },
138
268
  {
@@ -143,38 +273,184 @@ export class AgentLoop {
143
273
  role: "assistant",
144
274
  content: "Entendido. He retenido el contexto clave del proyecto y liberado memoria.",
145
275
  },
146
- ...recentMessages,
276
+ ...cola,
147
277
  ];
278
+ // Queda una estimacion hasta la siguiente respuesta de la API: mejor eso
279
+ // que arrastrar la cifra vieja, que dispararia otra compactacion seguida.
280
+ this.lastTurnTokens = estimarTokens(this.messages);
148
281
  console.log(chalk.hex("#D97757")("\n⚡ [Auto-Compact]: ") +
149
282
  chalk.gray(`Contexto optimizado automáticamente (${this.messages.length} mensajes limpios) para mantener alta velocidad y mínimo consumo de API.\n`));
150
283
  return true;
151
284
  }
152
- catch {
285
+ catch (err) {
286
+ spinner.stop();
287
+ // El catch mudo era medio fallo: si el resumen no sale, el turno sigue con
288
+ // el contexto entero y el error de despues llega sin explicacion.
289
+ console.log(chalk.gray(` (no se pudo compactar el contexto: ${err?.message || String(err)})`));
153
290
  return false;
154
291
  }
155
292
  }
293
+ /**
294
+ * Verificacion del proyecto al cerrar un turno que escribio en disco.
295
+ *
296
+ * Devuelve el mensaje que hay que reinyectarle al modelo, o null si no hay
297
+ * nada que corregir (o si no toca verificar). El tope de vueltas evita el
298
+ * otro extremo: un agente peleandose solo con el compilador durante veinte
299
+ * iteraciones mientras el usuario mira.
300
+ */
301
+ async verificarTrasEditar(reflexiones) {
302
+ if (this.archivosTocados.size === 0)
303
+ return null;
304
+ if (!verificacionAutomaticaActiva(this.currentCwd))
305
+ return null;
306
+ const cmd = detectarComando(this.currentCwd);
307
+ if (!cmd)
308
+ return null;
309
+ if (reflexiones >= MAX_REFLEXIONES) {
310
+ console.log(chalk.yellow(`\n⚠ ${mensajeDeRendicion(cmd.comando)}\n`));
311
+ return null;
312
+ }
313
+ const spinner = new DynamicSpinner();
314
+ spinner.start(`Verificando (${cmd.comando})`);
315
+ let resultado;
316
+ try {
317
+ resultado = await verificar(cmd.comando, this.currentCwd);
318
+ }
319
+ finally {
320
+ spinner.stop();
321
+ }
322
+ if (resultado.ok) {
323
+ console.log(chalk.gray(` ✔ Verificado con ${cmd.comando}\n`));
324
+ // Lo verificado deja de estar pendiente: si el modelo vuelve a editar,
325
+ // se vuelve a comprobar; si no, no se comprueba dos veces lo mismo.
326
+ this.archivosTocados.clear();
327
+ return null;
328
+ }
329
+ console.log(chalk.yellow(`\n⚠ La verificacion fallo (${cmd.comando}). Se lo devuelvo al agente.\n`));
330
+ return mensajeDeVerificacion(cmd.comando, resultado.salida, reflexiones + 1);
331
+ }
332
+ /**
333
+ * Comprueba si esta llamada cierra un bucle y, si lo cierra, decide con el
334
+ * usuario que hacer. Devuelve el texto que se le pasa al modelo: el resultado
335
+ * tal cual, o la explicacion del atasco.
336
+ *
337
+ * Se llama DESPUES de ejecutar, no antes, porque la firma incluye el
338
+ * resultado: sin el, un sondeo legitimo (releer un log que cambia) parece un
339
+ * bucle. La tercera llamada repetida ya se ha pagado; lo que se evita son la
340
+ * cuarta y las veintiseis siguientes.
341
+ */
342
+ /** Apunta que este turno ha escrito en disco, para verificarlo al cerrarlo. */
343
+ apuntarEscritura(herramienta, args, resultado) {
344
+ if (!HERRAMIENTAS_QUE_ESCRIBEN.has(herramienta))
345
+ return;
346
+ // Un edit_file que fallo no ha cambiado nada: verificar por eso seria
347
+ // hacerle perder tiempo al usuario por un error que ya se conto.
348
+ if (resultado.startsWith("Error") || resultado.includes("bloqueada:"))
349
+ return;
350
+ const ruta = args?.path || args?.destinationPath || args?.sourcePath;
351
+ if (typeof ruta === "string")
352
+ this.archivosTocados.add(ruta);
353
+ }
354
+ async vigilarBucle(herramienta, argumentos, resultado, antesDePreguntar) {
355
+ const aviso = this.detectorBucle.registrar(herramienta, argumentos, resultado);
356
+ if (!aviso)
357
+ return resultado;
358
+ antesDePreguntar?.();
359
+ const respuesta = await preguntarPorBucle(aviso);
360
+ if (respuesta.accion === "seguir") {
361
+ this.detectorBucle.perdonar(herramienta);
362
+ return resultado;
363
+ }
364
+ this.detectorBucle.olvidar();
365
+ return mensajeDeBucle(aviso, respuesta.indicacion);
366
+ }
367
+ /**
368
+ * Ejecuta un turno completo y CUENTA como acabo.
369
+ *
370
+ * Antes devolvia void: el motivo del corte se imprimia por pantalla y ahi se
371
+ * moria. Quien llamaba no podia distinguir "terminado" de "cuota agotada al
372
+ * primer intento", que es justo lo que necesita el modo no interactivo para
373
+ * salir con un codigo honesto.
374
+ */
156
375
  async runTask(userPrompt) {
376
+ let iterations = 0;
377
+ // COSTE: se paga cada llamada, asi que los prompts se suman.
378
+ let turnPromptTokens = 0;
379
+ // CONTEXTO: lo que ocupa el historial es el prompt de la ULTIMA llamada,
380
+ // no la suma de todas. Con la suma, un turno de diez llamadas parecia diez
381
+ // veces mas lleno de lo que estaba.
382
+ let lastCallPromptTokens = 0;
383
+ let turnCachedTokens = 0;
384
+ let turnCompletionTokens = 0;
385
+ /**
386
+ * Unica salida de runTask, y por eso el sitio donde se cierra el checkpoint
387
+ * de /undo.
388
+ *
389
+ * commitTurn vivia despues del while, asi que ninguna de las salidas
390
+ * anticipadas —Esc a mitad de turno, error del motor, cuota agotada— pasaba
391
+ * por el. Consecuencia doble y las dos malas: el usuario que interrumpia un
392
+ * turno que ya habia reescrito tres archivos leia "no hay acciones previas
393
+ * para deshacer" con el disco a medias; y esas instantaneas huerfanas se
394
+ * quedaban en currentTurnSnapshots (snapshotFile hace early-return si la
395
+ * clave ya existe, asi que ni se refrescaban) hasta colgarse del primer
396
+ * turno que terminara limpio, que al deshacerse revertia tambien los
397
+ * archivos del turno interrumpido anunciando otra descripcion.
398
+ *
399
+ * El motivo va en la descripcion: en el historial de /undo hay que poder
400
+ * distinguir un turno terminado de uno cortado.
401
+ */
402
+ const parada = (motivo, detalle) => {
403
+ globalUndoManager.commitTurn(motivo === "completado" ? userPrompt : `${userPrompt} [${motivo}]`);
404
+ return {
405
+ exito: motivo === "completado",
406
+ motivo,
407
+ detalle,
408
+ turnos: iterations,
409
+ tokensEntrada: turnPromptTokens,
410
+ tokensSalida: turnCompletionTokens,
411
+ };
412
+ };
157
413
  // Cuota ANTES de gastar. Si no queda margen, se dice y no se llama al motor:
158
414
  // avisar despues de haber gastado no sirve de nada.
159
415
  const cuota = checkQuota();
160
416
  if (!cuota.allowed) {
161
- console.log(chalk.yellow(`\n⏸ ${mensajeDeCorte(cuota)}`) +
417
+ const corte = mensajeDeCorte(cuota);
418
+ console.log(chalk.yellow(`\n⏸ ${corte}`) +
162
419
  chalk.gray("\n Tu trabajo y tu contexto siguen aqui: al liberarse, continuas donde lo dejaste.\n"));
163
- return;
420
+ return parada("cuota", corte);
164
421
  }
165
422
  // Check and execute auto-compaction if context threshold is exceeded
166
423
  await this.autoCompactIfNeeded();
424
+ // El usuario ha dicho algo nuevo: lo que se repitiera en el turno anterior
425
+ // ya no cuenta, y lo que perdono tampoco tiene por que valer aqui.
426
+ this.detectorBucle.reiniciar();
167
427
  const startTime = Date.now();
168
428
  this.messages.push({
169
429
  role: "user",
170
430
  content: userPrompt,
171
431
  });
172
- let iterations = 0;
173
- let turnPromptTokens = 0;
174
- let turnCachedTokens = 0;
175
- let turnCompletionTokens = 0;
432
+ // Distingue "el modelo cerro su turno" de "se acabaron las iteraciones",
433
+ // que por fuera se parecen y no valen lo mismo.
434
+ let completado = false;
435
+ /** Un desbordamiento de contexto se rescata compactando, pero una sola vez. */
436
+ let yaCompactadoPorDesbordamiento = false;
437
+ /** Vueltas gastadas en corregir lo que dijo el verificador del proyecto. */
438
+ let reflexiones = 0;
439
+ /** Veces seguidas que la respuesta se corto por longitud sin hacer nada. */
440
+ let cortesPorLongitud = 0;
441
+ // Se limpia aqui: lo que se edito en el turno anterior ya se verifico (o no
442
+ // se iba a verificar nunca).
443
+ this.archivosTocados.clear();
176
444
  while (iterations < this.maxIterations) {
177
445
  iterations++;
446
+ // El contexto tambien crece dentro del turno: veinte herramientas con
447
+ // sus salidas pueden pasar del umbral sin que termine el turno. Mirarlo
448
+ // solo al principio era llegar tarde.
449
+ if (lastCallPromptTokens > 0) {
450
+ const compactado = await this.autoCompactIfNeeded(lastCallPromptTokens);
451
+ if (compactado)
452
+ lastCallPromptTokens = this.lastTurnTokens;
453
+ }
178
454
  // Near iteration limit notice
179
455
  if (iterations === this.maxIterations - 2) {
180
456
  this.messages.push({
@@ -213,23 +489,37 @@ export class AgentLoop {
213
489
  watcher.stop();
214
490
  if (isAbortError(err) || watcher.interrupted) {
215
491
  console.log(chalk.yellow("\n⏸ Interrumpido. Dime como seguir."));
216
- return;
492
+ return parada("interrumpido");
493
+ }
494
+ // El contexto no cabe. Reintentar la misma peticion es imposible por
495
+ // definicion, pero compactar y volver a intentarlo si tiene arreglo, y
496
+ // es justo lo que el usuario esperaria en vez de perder el turno. Una
497
+ // sola vez: si tras compactar sigue sin caber, el problema es otro.
498
+ if (isContextOverflow(err) && !yaCompactadoPorDesbordamiento) {
499
+ yaCompactadoPorDesbordamiento = true;
500
+ console.log(chalk.yellow("\n⚠ La peticion no cabia en el contexto. Compactando y reintentando..."));
501
+ const activo = this.messages[0]?.role === "system" ? this.messages[0].content : "";
502
+ await this.compactHistory(activo);
503
+ iterations--; // este intento no llego al modelo: no cuenta como vuelta
504
+ continue;
217
505
  }
218
506
  // Un 402 (cuota agotada) o un 401 (licencia caida) no son "fallos del
219
507
  // motor" y ademas no se reintentan nunca: decirlo como tal confunde y
220
508
  // hace que el usuario piense que el producto esta roto.
221
509
  const claro = explicarErrorDelMotor(err);
222
510
  if (claro) {
223
- console.log(chalk.yellow(`
224
- ⏸ ${claro}
511
+ console.log(chalk.yellow(`
512
+ ⏸ ${claro}
225
513
  `));
226
- return;
514
+ return parada(motivoDeParadaDelError(err), claro);
227
515
  }
228
- renderError(`Error en el motor tras varios reintentos: ${err?.message || String(err)}`);
229
- return;
516
+ const fallo = `Error en el motor tras varios reintentos: ${err?.message || String(err)}`;
517
+ renderError(fallo);
518
+ return parada("error-motor", fallo);
230
519
  }
231
520
  let content = "";
232
521
  let reasoning = "";
522
+ let motivoDeCorte = null;
233
523
  const rawToolCalls = [];
234
524
  let isFirstChunk = true;
235
525
  const reasoningStream = new ReasoningStream();
@@ -272,10 +562,17 @@ export class AgentLoop {
272
562
  rawToolCalls[idx].function.arguments += tc.function.arguments;
273
563
  }
274
564
  }
565
+ // El motivo por el que el modelo dejo de hablar. Sin esto, quedarse
566
+ // sin presupuesto de salida es indistinguible de terminar el turno.
567
+ const corte = chunk.choices?.[0]?.finish_reason;
568
+ if (corte)
569
+ motivoDeCorte = corte;
275
570
  // Track streaming usage metrics
276
571
  if (chunk.usage) {
277
572
  this.tracker.addUsage(chunk.usage);
278
573
  turnPromptTokens += chunk.usage.prompt_tokens || 0;
574
+ if (chunk.usage.prompt_tokens)
575
+ lastCallPromptTokens = chunk.usage.prompt_tokens;
279
576
  turnCompletionTokens += chunk.usage.completion_tokens || 0;
280
577
  turnCachedTokens += chunk.usage.prompt_tokens_details?.cached_tokens || 0;
281
578
  }
@@ -293,17 +590,18 @@ export class AgentLoop {
293
590
  this.messages.push({ role: "assistant", content });
294
591
  }
295
592
  console.log(chalk.yellow("\n⏸ Interrumpido. Dime como seguir."));
296
- return;
593
+ return parada("interrumpido");
297
594
  }
298
595
  const claroStream = explicarErrorDelMotor(streamErr);
299
596
  if (claroStream) {
300
- console.log(chalk.yellow(`
301
- ⏸ ${claroStream}
597
+ console.log(chalk.yellow(`
598
+ ⏸ ${claroStream}
302
599
  `));
303
- return;
600
+ return parada(motivoDeParadaDelError(streamErr), claroStream);
304
601
  }
305
- renderError(`Error en streaming: ${streamErr?.message || String(streamErr)}`);
306
- return;
602
+ const falloStream = `Error en streaming: ${streamErr?.message || String(streamErr)}`;
603
+ renderError(falloStream);
604
+ return parada("error-motor", falloStream);
307
605
  }
308
606
  spinner.stop();
309
607
  reasoningStream.flush();
@@ -313,10 +611,46 @@ export class AgentLoop {
313
611
  if (content.trim())
314
612
  this.messages.push({ role: "assistant", content });
315
613
  console.log(chalk.yellow("\n⏸ Interrumpido antes de ejecutar las herramientas. Dime como seguir."));
316
- return;
614
+ return parada("interrumpido");
317
615
  }
318
616
  // Filter valid accumulated tool calls
319
617
  const toolCalls = rawToolCalls.filter((tc) => tc && tc.function && tc.function.name);
618
+ // El modelo se quedo sin presupuesto de salida y no llego a pedir nada.
619
+ //
620
+ // Esto se veia EXACTAMENTE igual que un turno terminado: sin llamadas a
621
+ // herramienta, el bucle daba el turno por cerrado, imprimia el pie y
622
+ // volvia al prompt. El usuario pedia "creame un juego en un archivo html",
623
+ // el modelo gastaba los 16.384 tokens razonando el diseno, se cortaba a
624
+ // mitad de frase, y no se creaba ningun archivo ni se decia por que.
625
+ //
626
+ // Se le devuelve el aviso y se le pide que actue, con tope: si a la
627
+ // segunda sigue sin hacer nada, el problema no es el presupuesto.
628
+ if (motivoDeCorte === "length" && toolCalls.length === 0) {
629
+ cortesPorLongitud++;
630
+ if (cortesPorLongitud > MAX_CORTES_POR_LONGITUD) {
631
+ const aviso = `La respuesta se corto por longitud ${cortesPorLongitud} veces seguidas sin llegar a ` +
632
+ `hacer nada. Prueba a pedirlo por partes.`;
633
+ console.log(chalk.yellow(`\n ⚠ ${aviso}\n`));
634
+ if (content.trim())
635
+ this.messages.push({ role: "assistant", content });
636
+ return parada("limite-iteraciones", aviso);
637
+ }
638
+ console.log(chalk.yellow("\n ⚠ La respuesta se corto por longitud antes de hacer nada. Pidiendole que vaya al grano...\n"));
639
+ if (content.trim())
640
+ this.messages.push({ role: "assistant", content });
641
+ this.messages.push({
642
+ role: "user",
643
+ content: "Tu respuesta anterior se corto porque agoto el presupuesto de salida mientras razonabas, " +
644
+ "y no llegaste a usar ninguna herramienta: no se ha hecho nada todavia. Deja de planificar " +
645
+ "y actua ya. Si lo que hay que escribir es largo, hazlo en varias llamadas mas pequenas.",
646
+ });
647
+ // Sin esto se queda un vigilante de teclado vivo por cada corte, y cada
648
+ // uno vuelve a apuntarse como oyente: la misma tecla se procesa tantas
649
+ // veces como vigilantes haya, y el teclado deja de responder como
650
+ // deberia. La regla es: de este bucle no se sale sin soltar el teclado.
651
+ watcher.stop();
652
+ continue;
653
+ }
320
654
  if (toolCalls.length > 0) {
321
655
  this.messages.push({
322
656
  role: "assistant",
@@ -330,40 +664,77 @@ export class AgentLoop {
330
664
  const names = toolCalls.map((tc) => tc.function.name);
331
665
  const parallel = canRunInParallel(names) &&
332
666
  !toolCalls.some((tc) => {
333
- let a = {};
334
- try {
335
- a = JSON.parse(tc.function.arguments);
336
- }
337
- catch { }
338
- return this.permissions.requiresConfirmation(tc.function.name, a);
667
+ const parsed = parsearArgumentos(tc.function.arguments);
668
+ // Unos argumentos rotos se resuelven en serie, donde hay sitio para
669
+ // explicarselo al modelo.
670
+ if (!parsed.ok)
671
+ return true;
672
+ return this.permissions.requiresConfirmation(tc.function.name, parsed.args, this.currentCwd);
339
673
  });
340
674
  if (parallel) {
341
- for (const tc of toolCalls) {
342
- let a = {};
343
- try {
344
- a = JSON.parse(tc.function.arguments);
675
+ // Esta rama ejecutaba directamente, sin pasar por hooks ni por deny.
676
+ // Un PreToolUse que prohibe leer secretos se saltaba en silencio en
677
+ // cuanto el modelo pedia dos lecturas en el mismo turno —cosa que el
678
+ // propio prompt del sistema fomenta—, y los PostToolUse (linters,
679
+ // politicas) tampoco corrian. Fallaba callado y hacia el lado
680
+ // inseguro, con lo que la politica del proyecto valia o no segun
681
+ // cuantas herramientas pidiera el modelo a la vez.
682
+ //
683
+ // La puerta es la misma que usan la via secuencial y los subagentes
684
+ // (toolGate.ts). Se autoriza en serie —los hooks son procesos, y un
685
+ // dialogo simultaneo por duplicado no se puede ni leer— y se
686
+ // paraleliza solo la ejecucion, que es lo que se queria ganar.
687
+ const autorizadas = [];
688
+ const enOrden = toolCalls.map(() => null);
689
+ for (let i = 0; i < toolCalls.length; i++) {
690
+ const tc = toolCalls[i];
691
+ const parsed = parsearArgumentos(tc.function.arguments);
692
+ if (!parsed.ok) {
693
+ const motivo = mensajeDeArgumentosRotos(tc.function.name);
694
+ renderToolStart(tc.function.name, "argumentos incompletos");
695
+ renderToolError(tc.function.name, motivo);
696
+ enOrden[i] = { role: "tool", tool_call_id: tc.id, content: motivo };
697
+ continue;
345
698
  }
346
- catch { }
347
- renderToolStart(tc.function.name, getToolSummary(tc.function.name, a));
348
- }
349
- const settled = await Promise.all(toolCalls.map(async (tc) => {
350
- let a = {};
351
- try {
352
- a = JSON.parse(tc.function.arguments);
699
+ const a = parsed.args;
700
+ const decision = await autorizarHerramienta(tc.function.name, a, this.currentCwd);
701
+ if (!decision.permitida) {
702
+ const motivo = decision.motivo || "Accion no autorizada.";
703
+ renderToolStart(tc.function.name, getToolSummary(tc.function.name, a));
704
+ renderToolError(tc.function.name, motivo);
705
+ enOrden[i] = { role: "tool", tool_call_id: tc.id, content: motivo };
706
+ continue;
353
707
  }
354
- catch { }
355
- const r = await executeToolCall(tc.function.name, a, this.currentCwd, this.apiKey, this.currentEspectro.underlyingModel);
356
- return { tc, result: r.result };
708
+ autorizadas.push({ tc, args: decision.args, pos: i });
709
+ }
710
+ for (const { tc, args } of autorizadas) {
711
+ renderToolStart(tc.function.name, getToolSummary(tc.function.name, args));
712
+ }
713
+ const settled = await Promise.all(autorizadas.map(async ({ tc, args, pos }) => {
714
+ const r = await executeToolCall(tc.function.name, args, this.currentCwd, this.apiKey, this.currentEspectro.underlyingModel);
715
+ return { tc, args, pos, result: r.result };
357
716
  }));
358
- for (const { tc, result } of settled) {
717
+ for (const { tc, args, pos, result } of settled) {
359
718
  renderToolSuccess(tc.function.name, result);
360
- toolResults.push({ role: "tool", tool_call_id: tc.id, content: result });
719
+ const aviso = await notificarPostHerramienta(tc.function.name, args, result, this.currentCwd);
720
+ this.apuntarEscritura(tc.function.name, args, result);
721
+ const texto = await this.vigilarBucle(tc.function.name, tc.function.arguments, result);
722
+ enOrden[pos] = {
723
+ role: "tool",
724
+ tool_call_id: tc.id,
725
+ content: aviso ? `${texto}\n\n[Hook PostToolUse]: ${aviso}` : texto,
726
+ };
727
+ }
728
+ // En el orden en que las pidio el modelo, no en el que acabaron.
729
+ for (const r of enOrden) {
730
+ if (r)
731
+ toolResults.push(r);
361
732
  }
362
733
  toolResults.forEach((r) => this.messages.push(r));
363
734
  watcher.stop();
364
735
  if (watcher.interrupted) {
365
736
  console.log(chalk.yellow("\n⏸ Interrumpido. Dime como seguir."));
366
- return;
737
+ return parada("interrumpido");
367
738
  }
368
739
  continue;
369
740
  }
@@ -380,12 +751,18 @@ export class AgentLoop {
380
751
  }
381
752
  const fnName = tc.function.name;
382
753
  let fnArgs = {};
383
- try {
384
- fnArgs = JSON.parse(tc.function.arguments);
385
- }
386
- catch {
387
- fnArgs = {};
754
+ const parsed = parsearArgumentos(tc.function.arguments);
755
+ if (!parsed.ok) {
756
+ // Antes esto era `catch { fnArgs = {} }`: la herramienta se
757
+ // ejecutaba igual, sin argumentos. Un write_file sin ruta ni
758
+ // contenido, y respondiendo como si nada hubiera pasado.
759
+ const motivo = mensajeDeArgumentosRotos(fnName);
760
+ renderToolStart(fnName, "argumentos incompletos");
761
+ renderToolError(fnName, motivo);
762
+ toolResults.push({ role: "tool", tool_call_id: tc.id, content: motivo });
763
+ continue;
388
764
  }
765
+ fnArgs = parsed.args;
389
766
  // PreToolUse: el usuario puede vetar, autorizar o reescribir los
390
767
  // argumentos ANTES de que se ejecute nada. Va antes del dialogo de
391
768
  // permiso porque un veto no deberia ni llegar a preguntarse.
@@ -419,9 +796,25 @@ export class AgentLoop {
419
796
  });
420
797
  continue;
421
798
  }
799
+ // Un patron de denyPatterns corta aqui, antes de todo lo demas: no se
800
+ // pregunta, no lo salva el --yes, no lo salva el modo bypass y no lo
801
+ // salva un hook que diga "allow". Hasta ahora esa lista se cargaba y se
802
+ // volvia a guardar sin que nadie la consultara nunca.
803
+ const denegado = this.permissions.deniedBy(fnName, fnArgs);
804
+ if (denegado) {
805
+ const motivo = `Bloqueado por denyPatterns ("${denegado}") en la configuracion del usuario.`;
806
+ renderToolStart(fnName, getToolSummary(fnName, fnArgs));
807
+ renderToolError(fnName, motivo);
808
+ toolResults.push({
809
+ role: "tool",
810
+ tool_call_id: tc.id,
811
+ content: `${motivo} No hay forma de aprobarlo desde aqui: el usuario lo prohibio en su settings.json. Busca otra via o preguntale.`,
812
+ });
813
+ continue;
814
+ }
422
815
  // Permission gate
423
816
  const saltaPermiso = pre.permissionDecision === "allow";
424
- if (!saltaPermiso && this.permissions.requiresConfirmation(fnName, fnArgs)) {
817
+ if (!saltaPermiso && this.permissions.requiresConfirmation(fnName, fnArgs, this.currentCwd)) {
425
818
  watcher.stop();
426
819
  const permResult = await askToolPermission(fnName, fnArgs, this.currentCwd);
427
820
  if (!permResult.approved) {
@@ -436,8 +829,7 @@ export class AgentLoop {
436
829
  continue;
437
830
  }
438
831
  if (permResult.rememberPattern) {
439
- const pat = fnArgs.command ? fnArgs.command.split(" ")[0] : fnArgs.path || fnName;
440
- this.permissions.saveAllowPattern(pat);
832
+ this.recordarPermiso(fnName, fnArgs);
441
833
  }
442
834
  }
443
835
  const summary = getToolSummary(fnName, fnArgs);
@@ -453,11 +845,22 @@ export class AgentLoop {
453
845
  // PostToolUse: sirve para validar lo que acaba de pasar (linters,
454
846
  // tests, politicas) y devolverle al modelo un aviso accionable.
455
847
  const post = await this.hooks.run("PostToolUse", { tool_name: fnName, tool_input: fnArgs, tool_response: result.slice(0, 4000) }, fnName);
848
+ // El systemMessage de PreToolUse si se imprimia y el de PostToolUse
849
+ // no: el mismo campo, del mismo hook, se veia o no segun el evento.
850
+ if (post.systemMessage) {
851
+ console.log(chalk.yellow(`\n⚑ ${post.systemMessage}`));
852
+ }
853
+ // El dialogo del bucle usa stdin, igual que el de permisos: hay que
854
+ // soltarle el terminal al vigilante de Esc. Pero solo si de verdad se
855
+ // va a preguntar: pararlo en cada herramienta dejaria el resto del
856
+ // lote sin Esc, que es justo lo que no se quiere.
857
+ this.apuntarEscritura(fnName, fnArgs, result);
858
+ const texto = await this.vigilarBucle(fnName, tc.function.arguments, result, () => watcher.stop());
456
859
  toolResults.push({
457
860
  role: "tool",
458
861
  tool_call_id: tc.id,
459
- content: post.additionalContext ? `${result}\n\n
460
- [Hook PostToolUse]: ${post.additionalContext}` : result,
862
+ content: post.additionalContext ? `${texto}\n\n
863
+ [Hook PostToolUse]: ${post.additionalContext}` : texto,
461
864
  });
462
865
  }
463
866
  // Push all tool results in a single turn
@@ -465,7 +868,7 @@ export class AgentLoop {
465
868
  watcher.stop();
466
869
  if (watcher.interrupted) {
467
870
  console.log(chalk.yellow("\n⏸ Interrumpido. Dime como seguir."));
468
- return;
871
+ return parada("interrumpido");
469
872
  }
470
873
  continue;
471
874
  }
@@ -478,27 +881,82 @@ export class AgentLoop {
478
881
  content,
479
882
  });
480
883
  }
884
+ // El modelo da el turno por cerrado. Si toco archivos y el proyecto tiene
885
+ // verificacion automatica activada, se comprueba ANTES de creerselo: un
886
+ // error de tipos que nadie mira se queda en el disco y los turnos
887
+ // siguientes escriben encima.
888
+ const pendiente = await this.verificarTrasEditar(reflexiones);
889
+ if (pendiente) {
890
+ reflexiones++;
891
+ this.messages.push({ role: "user", content: pendiente });
892
+ continue;
893
+ }
894
+ completado = true;
481
895
  break;
482
896
  }
483
897
  const durationMs = Date.now() - startTime;
484
898
  const { formattedCost, totalCost } = this.tracker.calculateCost(turnPromptTokens, turnCachedTokens, turnCompletionTokens, this.currentEspectro.underlyingModel);
485
899
  const approxTotalTokens = turnPromptTokens + turnCompletionTokens;
486
- this.lastTurnTokens = approxTotalTokens;
487
- // El porcentaje libre se calcula sobre los tokens que ocupa el historial
488
- // (el prompt del ultimo turno), no sobre prompt+salida, que contaba doble.
489
- const used = turnPromptTokens || approxTotalTokens;
900
+ // Lo que ocupa el historial es el prompt de la ultima llamada. Sumar el de
901
+ // todas las iteraciones inflaba la cuenta hasta disparar la compactacion
902
+ // con el contexto medio vacio, y hacia mentir al porcentaje del pie.
903
+ this.lastTurnTokens = lastCallPromptTokens || approxTotalTokens;
904
+ const used = lastCallPromptTokens || turnPromptTokens || approxTotalTokens;
490
905
  const contextPercent = Math.max(1, 100 - Math.round((used / CONTEXT_WINDOW) * 100));
491
- globalUndoManager.commitTurn(userPrompt);
492
- // Se apunta lo gastado en este turno para las ventanas deslizantes.
906
+ // Se apunta lo gastado en este turno para las ventanas deslizantes. El
907
+ // checkpoint de /undo ya no se cierra aqui, sino en parada(): este punto
908
+ // solo lo alcanzaban los turnos que terminaban limpios.
493
909
  recordUsage(totalCost);
910
+ // Los subagentes, la vision y la compactacion ya se apuntaron solos segun
911
+ // gastaban. Se dice en voz alta porque si no el pie parece contradecirse:
912
+ // marca 0,0012 $ de turno y la insignia semanal sube diez veces mas.
913
+ const auxiliar = tomarGastoAuxiliar();
494
914
  const tras = checkQuota();
495
915
  renderFooter(formattedCost, turnCompletionTokens, turnCachedTokens, this.currentEspectro.name, durationMs, contextPercent, quotaBadge(tras));
916
+ if (auxiliar.total > 0) {
917
+ console.log(chalk.gray(` + $${auxiliar.total.toFixed(4)} USD fuera del bucle (${auxiliar.rutas}), ya contados en la cuota.\n`));
918
+ }
496
919
  // Aviso al acercarse, no solo al chocar: quedarse a medias sin previo aviso
497
920
  // es lo que hace que la gente cancele.
498
921
  if (tras.allowed && tras.spentWeekUsd >= tras.config.weeklyBudgetUsd * 0.8) {
499
922
  const queda = Math.max(0, tras.config.weeklyBudgetUsd - tras.spentWeekUsd);
500
923
  console.log(chalk.yellow(` ⚠ Te queda poca cuota semanal (${((queda / tras.config.weeklyBudgetUsd) * 100).toFixed(0)}%).\n`));
501
924
  }
925
+ if (!completado) {
926
+ // Se agotaron las iteraciones con trabajo a medias. En interactivo se ve
927
+ // porque el ultimo mensaje queda cortado; en CI no lo ve nadie.
928
+ const aviso = `El turno se agoto en el limite de ${this.maxIterations} iteraciones sin cerrar la tarea.`;
929
+ console.log(chalk.yellow(` ⚠ ${aviso}\n`));
930
+ return parada("limite-iteraciones", aviso);
931
+ }
932
+ return parada("completado");
933
+ }
934
+ /**
935
+ * Guarda lo que el usuario acaba de aprobar con "no preguntar mas".
936
+ *
937
+ * Aqui se hacia command.split(" ")[0] y se guardaba solo el binario. Aprobar
938
+ * un "git status" dejaba el patron "git" escrito en .chocolatito/settings.json
939
+ * y, como el allow autoriza por prefijo, a partir de ese momento pasaban sin
940
+ * dialogo "git push --force" y "git reset --hard", en esa sesion y en todas
941
+ * las siguientes. Un solo si de mas y el proyecto se queda sin barrera.
942
+ *
943
+ * Ahora se guarda la firma del comando (binario mas subcomando) y, si el
944
+ * comando es de los destructivos, no se guarda nada: eso se pregunta siempre,
945
+ * y decirlo en voz alta es mejor que escribir un patron que no serviria.
946
+ */
947
+ recordarPermiso(fnName, fnArgs) {
948
+ const command = typeof fnArgs?.command === "string" ? fnArgs.command : "";
949
+ if (command) {
950
+ if (isDangerousCommand(command)) {
951
+ console.log(chalk.gray(" (este comando es destructivo: se seguira preguntando cada vez, no se puede recordar)\n"));
952
+ return;
953
+ }
954
+ const firma = commandSignature(command);
955
+ if (firma)
956
+ this.permissions.saveAllowPattern(`${fnName}:${firma}`);
957
+ return;
958
+ }
959
+ this.permissions.saveAllowPattern(fnArgs?.path || fnName);
502
960
  }
503
961
  }
504
962
  function getToolSummary(name, args) {