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
@@ -5,20 +5,124 @@ 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
+ import { salida } from "./salidaDelAgente.js";
18
19
  import { InterruptWatcher, isAbortError } from "../ui/interrupt.js";
19
20
  import { HookManager } from "../hooks/manager.js";
21
+ import { registerToolGate, autorizarHerramienta, notificarPostHerramienta } from "./toolGate.js";
22
+ import { DetectorDeBucle, mensajeDeBucle } from "./loopDetector.js";
23
+ import { preguntarPorBucle } from "../ui/loopPrompt.js";
24
+ import { MAX_REFLEXIONES, detectarComando, mensajeDeRendicion, mensajeDeVerificacion, verificacionAutomaticaActiva, verificar, } from "./verifier.js";
25
+ import { apuntarUso, tomarGastoAuxiliar } from "./usageMeter.js";
26
+ import { leerLimites } from "../config/limits.js";
27
+ import { corteSeguro, estimarTokens, limpiarHuerfanos, vaciarResultadosAntiguos, } from "./compaction.js";
28
+ /** Herramientas cuyo exito deja el proyecto en un estado que vale la pena verificar. */
29
+ const HERRAMIENTAS_QUE_ESCRIBEN = new Set([
30
+ "edit_file",
31
+ "write_file",
32
+ "move_copy_file",
33
+ "delete_file",
34
+ ]);
35
+ /** Cortes por longitud seguidos que se toleran antes de rendirse. */
36
+ const MAX_CORTES_POR_LONGITUD = 2;
37
+ /**
38
+ * Respuestas vacias seguidas que se toleran antes de rendirse.
39
+ *
40
+ * Dos, no una: una respuesta vacia suelta es casi siempre un hipo del motor y
41
+ * el reintento la resuelve sin que el usuario se entere. Tres seguidas ya no es
42
+ * un hipo, y seguir reintentando en silencio es lo que hacia que pareciera que
43
+ * el agente te ignora.
44
+ */
45
+ const MAX_RESPUESTAS_VACIAS = 1;
46
+ /** Mensajes recientes que la compactacion no resume nunca. */
47
+ const COLA_INTACTA = 4;
48
+ /**
49
+ * Mensajes recientes cuyas salidas de herramienta se conservan enteras. Las
50
+ * anteriores se vacian antes de plantearse siquiera resumir.
51
+ */
52
+ const RESULTADOS_INTACTOS = 12;
53
+ /**
54
+ * Argumentos de una llamada a herramienta, distinguiendo el caso que importa.
55
+ *
56
+ * Un `JSON.parse` que falla no significa "esta herramienta no lleva
57
+ * argumentos": significa que el modelo se quedo sin presupuesto de salida a
58
+ * mitad de escribirlos. Tratar las dos cosas igual —que es lo que se hacia,
59
+ * con un `catch { fnArgs = {} }`— ejecuta la herramienta con las manos vacias:
60
+ * un write_file sin ruta ni contenido, un run_command sin comando. Y responde
61
+ * como si nada.
62
+ */
63
+ function parsearArgumentos(bruto) {
64
+ const texto = (bruto ?? "").trim();
65
+ // Sin argumentos de verdad: hay herramientas que no llevan ninguno.
66
+ if (!texto || texto === "{}")
67
+ return { ok: true, args: {} };
68
+ try {
69
+ const args = JSON.parse(texto);
70
+ if (args && typeof args === "object" && !Array.isArray(args))
71
+ return { ok: true, args };
72
+ }
73
+ catch { }
74
+ return { ok: false, args: {} };
75
+ }
76
+ /** Lo que se le dice al modelo cuando sus argumentos llegaron a medias. */
77
+ function mensajeDeArgumentosRotos(herramienta) {
78
+ return (`No se ejecuto "${herramienta}": los argumentos llegaron incompletos, seguramente porque la ` +
79
+ `respuesta se corto por longitud. No se ha hecho nada.
80
+
81
+ ` +
82
+ `Si era un archivo largo, hazlo por partes: una primera llamada a write_file con el principio ` +
83
+ `y las siguientes con modo="anadir" para ir pegando el resto. Cada trozo, por debajo de unas ` +
84
+ `400 lineas.`);
85
+ }
86
+ /**
87
+ * Deja una llamada a herramienta en un estado que la API acepte.
88
+ *
89
+ * EL FALLO QUE CIERRA ESTO
90
+ *
91
+ * Cuando la respuesta se corta a mitad de un `write_file` con un HTML largo
92
+ * dentro, los argumentos llegan asi:
93
+ *
94
+ * {"path":"index.html","content":"<!doctype html><html><head><style>body{ma
95
+ *
96
+ * La herramienta ya no se ejecutaba -eso se arreglo antes-, pero la llamada se
97
+ * guardaba en el historial TAL CUAL, y el historial se reenvia entero en cada
98
+ * peticion. La API valida que los argumentos sean JSON, no lo son, y contesta
99
+ * 400. Desde ese momento la sesion queda inservible: todo lo que escriba el
100
+ * usuario falla igual, la unica salida es /clear, y nadie lo sabe.
101
+ *
102
+ * Se vio en una sesion real, y encima el 400 se llevo por delante los cuatro
103
+ * huecos de peticion de la licencia, asi que al fallo se le sumo un "Tienes 4
104
+ * peticiones en curso" de dos minutos y medio.
105
+ *
106
+ * Guardando "{}" el historial sigue siendo valido y la pareja llamada/respuesta
107
+ * cuadra. Al modelo no se le oculta nada: el resultado de esa herramienta ya le
108
+ * dice que sus argumentos llegaron a medias y que no se hizo nada.
109
+ */
110
+ function sanearLlamada(tc) {
111
+ const bruto = tc?.function?.arguments;
112
+ const texto = typeof bruto === "string" ? bruto.trim() : "";
113
+ if (texto && parsearArgumentos(texto).ok)
114
+ return tc;
115
+ return { ...tc, function: { ...tc.function, arguments: "{}" } };
116
+ }
20
117
  export class AgentLoop {
21
118
  client;
119
+ /**
120
+ * Cliente aparte para resumir el historial. No es cosmetico: la cabecera
121
+ * X-Chocolatito-Route es lo que le permite al proxy saber en que se le va la
122
+ * cuota al usuario, y con un unico cliente las compactaciones —que son las
123
+ * llamadas mas caras que hace el agente— llegaban etiquetadas como "loop".
124
+ */
125
+ compactClient;
22
126
  currentEspectro;
23
127
  messages = [];
24
128
  tracker;
@@ -27,17 +131,36 @@ export class AgentLoop {
27
131
  hooks;
28
132
  apiKey;
29
133
  baseURL;
30
- maxIterations = 30;
134
+ // El presupuesto de vueltas ya no esta escrito en el codigo: se lee de la
135
+ // configuracion del usuario, que es quien conoce su tarea y su cuota.
136
+ maxIterations = leerLimites().iteracionesPorTurno;
137
+ /**
138
+ * Vigila que el agente no se quede dando vueltas. Vive en la instancia y no
139
+ * en runTask porque el estado tiene que sobrevivir a las iteraciones del
140
+ * while; se reinicia en cada turno nuevo.
141
+ */
142
+ detectorBucle = new DetectorDeBucle();
143
+ /**
144
+ * Archivos escritos en el turno. Solo sirve para saber si hay algo que
145
+ * verificar al cerrarlo: si el turno no toco el disco, no hay nada que
146
+ * comprobar y correr el compilador seria tiempo del usuario tirado.
147
+ */
148
+ archivosTocados = new Set();
31
149
  constructor(apiKey, baseURL, initialModel, systemPrompt) {
32
150
  this.apiKey = apiKey;
33
151
  this.baseURL = baseURL;
34
152
  // Un unico sitio construye el cliente: asi la credencial de suscripcion y
35
153
  // las cabeceras propias llegan igual desde las cuatro rutas.
36
154
  this.client = createEngineClient(apiKey, "loop");
155
+ this.compactClient = createEngineClient(apiKey, "compact");
37
156
  this.currentEspectro = initialModel;
38
157
  this.tracker = new TokenTracker();
39
158
  this.permissions = new PermissionManager("default", this.currentCwd);
40
159
  this.hooks = new HookManager(this.currentCwd);
160
+ // Los subagentes ejecutan herramientas fuera de este bucle, y estos dos
161
+ // gestores no les llegan por ninguna firma. Sin registrarlos aqui, corren sin
162
+ // permisos, sin hooks y sin el bloqueo del modo plan.
163
+ registerToolGate(this.permissions, this.hooks);
41
164
  this.messages.push({
42
165
  role: "system",
43
166
  content: systemPrompt,
@@ -73,11 +196,14 @@ export class AgentLoop {
73
196
  content: "Genera el resumen ejecutivo del contexto de trabajo hasta el momento.",
74
197
  },
75
198
  ];
76
- const res = await this.client.chat.completions.create({
199
+ const res = await this.compactClient.chat.completions.create({
77
200
  model: this.currentEspectro.underlyingModel,
78
201
  messages: summaryPrompt,
79
202
  temperature: 0.1,
80
203
  });
204
+ // Resumir manda el historial ENTERO como prompt. Es dinero real y hasta
205
+ // ahora no lo apuntaba nadie: el pie seguia marcando el mismo porcentaje.
206
+ apuntarUso(res.usage, this.currentEspectro.underlyingModel, "compact");
81
207
  const summary = res.choices[0]?.message?.content || "Contexto previo compactado.";
82
208
  spinner.stop();
83
209
  this.messages = [
@@ -98,41 +224,90 @@ export class AgentLoop {
98
224
  return `Fallo al compactar historial: ${err?.message || String(err)}`;
99
225
  }
100
226
  }
227
+ /**
228
+ * Tokens que OCUPA el historial ahora mismo, no los que se han gastado en el
229
+ * turno. Son dos numeros distintos y confundirlos era el fallo de abajo.
230
+ */
101
231
  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) {
232
+ /**
233
+ * Compactacion automatica del contexto.
234
+ *
235
+ * Habia tres cosas mal, y cada una se comia el contexto por su lado:
236
+ *
237
+ * 1. El corte era por posicion fija: slice(1, -4) para lo que se resumia y
238
+ * slice(-4) para lo que se conservaba. Un turno que acaba en un bloque de
239
+ * cuatro herramientas deja la cola empezando por un mensaje "tool" cuya
240
+ * llamada se ha quedado en la mitad resumida. La API contesta 400 y el
241
+ * catch mudo lo tapaba: la sesion se rompia sin motivo visible, y siempre
242
+ * en las sesiones largas, que son las que llevan mas trabajo encima. El
243
+ * corte lo decide ahora la forma del historial, nunca una posicion.
244
+ *
245
+ * 2. El umbral solo se miraba al empezar el turno y sobre el dato del turno
246
+ * ANTERIOR. Un turno de veinte herramientas puede doblar el contexto sin
247
+ * que nadie lo compruebe, y el limite se choca a mitad de camino.
248
+ *
249
+ * 3. Resumir cuesta una llamada al modelo y pierde detalle. Vaciar las
250
+ * salidas de herramienta viejas no cuesta nada y casi siempre basta, asi
251
+ * que va primero.
252
+ */
253
+ async autoCompactIfNeeded(tokensActuales = this.lastTurnTokens) {
254
+ // La ventana real es de 1.000.000 de tokens. Comprimir antes de acercarse
255
+ // al limite es tirar contexto bueno.
256
+ if (tokensActuales < COMPACT_THRESHOLD_TOKENS)
108
257
  return false;
258
+ // Paso 1, gratis: vaciar las salidas de herramienta mas viejas. Un view_file
259
+ // de hace veinte pasos ocupa miles de tokens y ya no dice nada; las
260
+ // decisiones tomadas, que son lo que hay que conservar, estan en el texto.
261
+ const antes = estimarTokens(this.messages);
262
+ const podado = vaciarResultadosAntiguos(this.messages, RESULTADOS_INTACTOS);
263
+ if (podado.liberados > 0) {
264
+ this.messages = podado.mensajes;
265
+ const despues = estimarTokens(this.messages);
266
+ // La cifra absoluta buena la da la API en prompt_tokens; de la estimacion
267
+ // solo se usa la proporcion de lo que se acaba de quitar.
268
+ const proyectado = antes > 0 ? Math.round(tokensActuales * (despues / antes)) : tokensActuales;
269
+ this.lastTurnTokens = proyectado;
270
+ if (proyectado < COMPACT_THRESHOLD_TOKENS * 0.8) {
271
+ salida.linea(chalk.hex("#D97757")("\n⚡ [Auto-Compact]: ") +
272
+ chalk.gray(`Liberados ~${(antes - despues).toLocaleString()} tokens de salidas de herramientas antiguas. ` +
273
+ "La conversacion se conserva entera.\n"));
274
+ return true;
275
+ }
109
276
  }
277
+ const spinner = new DynamicSpinner();
110
278
  try {
111
279
  const activeSystemPrompt = this.messages[0]?.role === "system" ? this.messages[0].content : "";
112
- const toSummarize = this.messages.slice(1, -4);
113
- if (toSummarize.length < 4)
280
+ // El corte respeta siempre los pares llamada-respuesta; si el punto que
281
+ // tocaba parte un bloque, se conserva de mas.
282
+ const corte = corteSeguro(this.messages, COLA_INTACTA);
283
+ const cabeza = limpiarHuerfanos(this.messages.slice(1, corte));
284
+ if (cabeza.length < 4)
114
285
  return false;
115
- const spinner = new DynamicSpinner();
286
+ const cola = this.messages.slice(corte);
116
287
  spinner.start("Auto-optimizando memoria del contexto");
117
288
  const summaryPrompt = [
118
289
  {
119
290
  role: "system",
120
291
  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
292
  },
122
- ...toSummarize,
293
+ ...cabeza,
123
294
  {
124
295
  role: "user",
125
296
  content: "Genera el resumen ejecutivo del contexto de trabajo previo.",
126
297
  },
127
298
  ];
128
- const res = await this.client.chat.completions.create({
299
+ const res = await this.compactClient.chat.completions.create({
129
300
  model: this.currentEspectro.underlyingModel,
130
301
  messages: summaryPrompt,
131
302
  temperature: 0.1,
132
303
  });
304
+ // La llamada mas cara del agente: entra aqui con el contexto rozando el
305
+ // umbral (750.000 tokens) y lo manda entero. En Espectro 2.0 son ~0,99 $,
306
+ // el 85% del presupuesto semanal por defecto, que antes valian 0,00 $ en
307
+ // el contador local. De ahi el 402 por sorpresa.
308
+ apuntarUso(res.usage, this.currentEspectro.underlyingModel, "compact");
133
309
  const summary = res.choices[0]?.message?.content || "Contexto previo optimizado.";
134
310
  spinner.stop();
135
- const recentMessages = this.messages.slice(-4);
136
311
  this.messages = [
137
312
  { role: "system", content: activeSystemPrompt },
138
313
  {
@@ -143,38 +318,186 @@ export class AgentLoop {
143
318
  role: "assistant",
144
319
  content: "Entendido. He retenido el contexto clave del proyecto y liberado memoria.",
145
320
  },
146
- ...recentMessages,
321
+ ...cola,
147
322
  ];
148
- console.log(chalk.hex("#D97757")("\n⚡ [Auto-Compact]: ") +
323
+ // Queda una estimacion hasta la siguiente respuesta de la API: mejor eso
324
+ // que arrastrar la cifra vieja, que dispararia otra compactacion seguida.
325
+ this.lastTurnTokens = estimarTokens(this.messages);
326
+ salida.linea(chalk.hex("#D97757")("\n⚡ [Auto-Compact]: ") +
149
327
  chalk.gray(`Contexto optimizado automáticamente (${this.messages.length} mensajes limpios) para mantener alta velocidad y mínimo consumo de API.\n`));
150
328
  return true;
151
329
  }
152
- catch {
330
+ catch (err) {
331
+ spinner.stop();
332
+ // El catch mudo era medio fallo: si el resumen no sale, el turno sigue con
333
+ // el contexto entero y el error de despues llega sin explicacion.
334
+ salida.linea(chalk.gray(` (no se pudo compactar el contexto: ${err?.message || String(err)})`));
153
335
  return false;
154
336
  }
155
337
  }
338
+ /**
339
+ * Verificacion del proyecto al cerrar un turno que escribio en disco.
340
+ *
341
+ * Devuelve el mensaje que hay que reinyectarle al modelo, o null si no hay
342
+ * nada que corregir (o si no toca verificar). El tope de vueltas evita el
343
+ * otro extremo: un agente peleandose solo con el compilador durante veinte
344
+ * iteraciones mientras el usuario mira.
345
+ */
346
+ async verificarTrasEditar(reflexiones) {
347
+ if (this.archivosTocados.size === 0)
348
+ return null;
349
+ if (!verificacionAutomaticaActiva(this.currentCwd))
350
+ return null;
351
+ const cmd = detectarComando(this.currentCwd);
352
+ if (!cmd)
353
+ return null;
354
+ if (reflexiones >= MAX_REFLEXIONES) {
355
+ salida.linea(chalk.yellow(`\n⚠ ${mensajeDeRendicion(cmd.comando)}\n`));
356
+ return null;
357
+ }
358
+ const spinner = new DynamicSpinner();
359
+ spinner.start(`Verificando (${cmd.comando})`);
360
+ let resultado;
361
+ try {
362
+ resultado = await verificar(cmd.comando, this.currentCwd);
363
+ }
364
+ finally {
365
+ spinner.stop();
366
+ }
367
+ if (resultado.ok) {
368
+ salida.linea(chalk.gray(` ✔ Verificado con ${cmd.comando}\n`));
369
+ // Lo verificado deja de estar pendiente: si el modelo vuelve a editar,
370
+ // se vuelve a comprobar; si no, no se comprueba dos veces lo mismo.
371
+ this.archivosTocados.clear();
372
+ return null;
373
+ }
374
+ salida.linea(chalk.yellow(`\n⚠ La verificacion fallo (${cmd.comando}). Se lo devuelvo al agente.\n`));
375
+ return mensajeDeVerificacion(cmd.comando, resultado.salida, reflexiones + 1);
376
+ }
377
+ /**
378
+ * Comprueba si esta llamada cierra un bucle y, si lo cierra, decide con el
379
+ * usuario que hacer. Devuelve el texto que se le pasa al modelo: el resultado
380
+ * tal cual, o la explicacion del atasco.
381
+ *
382
+ * Se llama DESPUES de ejecutar, no antes, porque la firma incluye el
383
+ * resultado: sin el, un sondeo legitimo (releer un log que cambia) parece un
384
+ * bucle. La tercera llamada repetida ya se ha pagado; lo que se evita son la
385
+ * cuarta y las veintiseis siguientes.
386
+ */
387
+ /** Apunta que este turno ha escrito en disco, para verificarlo al cerrarlo. */
388
+ apuntarEscritura(herramienta, args, resultado) {
389
+ if (!HERRAMIENTAS_QUE_ESCRIBEN.has(herramienta))
390
+ return;
391
+ // Un edit_file que fallo no ha cambiado nada: verificar por eso seria
392
+ // hacerle perder tiempo al usuario por un error que ya se conto.
393
+ if (resultado.startsWith("Error") || resultado.includes("bloqueada:"))
394
+ return;
395
+ const ruta = args?.path || args?.destinationPath || args?.sourcePath;
396
+ if (typeof ruta === "string")
397
+ this.archivosTocados.add(ruta);
398
+ }
399
+ async vigilarBucle(herramienta, argumentos, resultado, antesDePreguntar) {
400
+ const aviso = this.detectorBucle.registrar(herramienta, argumentos, resultado);
401
+ if (!aviso)
402
+ return resultado;
403
+ antesDePreguntar?.();
404
+ const respuesta = await preguntarPorBucle(aviso);
405
+ if (respuesta.accion === "seguir") {
406
+ this.detectorBucle.perdonar(herramienta);
407
+ return resultado;
408
+ }
409
+ this.detectorBucle.olvidar();
410
+ return mensajeDeBucle(aviso, respuesta.indicacion);
411
+ }
412
+ /**
413
+ * Ejecuta un turno completo y CUENTA como acabo.
414
+ *
415
+ * Antes devolvia void: el motivo del corte se imprimia por pantalla y ahi se
416
+ * moria. Quien llamaba no podia distinguir "terminado" de "cuota agotada al
417
+ * primer intento", que es justo lo que necesita el modo no interactivo para
418
+ * salir con un codigo honesto.
419
+ */
156
420
  async runTask(userPrompt) {
421
+ let iterations = 0;
422
+ // COSTE: se paga cada llamada, asi que los prompts se suman.
423
+ let turnPromptTokens = 0;
424
+ // CONTEXTO: lo que ocupa el historial es el prompt de la ULTIMA llamada,
425
+ // no la suma de todas. Con la suma, un turno de diez llamadas parecia diez
426
+ // veces mas lleno de lo que estaba.
427
+ let lastCallPromptTokens = 0;
428
+ let turnCachedTokens = 0;
429
+ let turnCompletionTokens = 0;
430
+ /**
431
+ * Unica salida de runTask, y por eso el sitio donde se cierra el checkpoint
432
+ * de /undo.
433
+ *
434
+ * commitTurn vivia despues del while, asi que ninguna de las salidas
435
+ * anticipadas —Esc a mitad de turno, error del motor, cuota agotada— pasaba
436
+ * por el. Consecuencia doble y las dos malas: el usuario que interrumpia un
437
+ * turno que ya habia reescrito tres archivos leia "no hay acciones previas
438
+ * para deshacer" con el disco a medias; y esas instantaneas huerfanas se
439
+ * quedaban en currentTurnSnapshots (snapshotFile hace early-return si la
440
+ * clave ya existe, asi que ni se refrescaban) hasta colgarse del primer
441
+ * turno que terminara limpio, que al deshacerse revertia tambien los
442
+ * archivos del turno interrumpido anunciando otra descripcion.
443
+ *
444
+ * El motivo va en la descripcion: en el historial de /undo hay que poder
445
+ * distinguir un turno terminado de uno cortado.
446
+ */
447
+ const parada = (motivo, detalle) => {
448
+ globalUndoManager.commitTurn(motivo === "completado" ? userPrompt : `${userPrompt} [${motivo}]`);
449
+ return {
450
+ exito: motivo === "completado",
451
+ motivo,
452
+ detalle,
453
+ turnos: iterations,
454
+ tokensEntrada: turnPromptTokens,
455
+ tokensSalida: turnCompletionTokens,
456
+ };
457
+ };
157
458
  // Cuota ANTES de gastar. Si no queda margen, se dice y no se llama al motor:
158
459
  // avisar despues de haber gastado no sirve de nada.
159
460
  const cuota = checkQuota();
160
461
  if (!cuota.allowed) {
161
- console.log(chalk.yellow(`\n⏸ ${mensajeDeCorte(cuota)}`) +
462
+ const corte = mensajeDeCorte(cuota);
463
+ salida.linea(chalk.yellow(`\n⏸ ${corte}`) +
162
464
  chalk.gray("\n Tu trabajo y tu contexto siguen aqui: al liberarse, continuas donde lo dejaste.\n"));
163
- return;
465
+ return parada("cuota", corte);
164
466
  }
165
467
  // Check and execute auto-compaction if context threshold is exceeded
166
468
  await this.autoCompactIfNeeded();
469
+ // El usuario ha dicho algo nuevo: lo que se repitiera en el turno anterior
470
+ // ya no cuenta, y lo que perdono tampoco tiene por que valer aqui.
471
+ this.detectorBucle.reiniciar();
167
472
  const startTime = Date.now();
168
473
  this.messages.push({
169
474
  role: "user",
170
475
  content: userPrompt,
171
476
  });
172
- let iterations = 0;
173
- let turnPromptTokens = 0;
174
- let turnCachedTokens = 0;
175
- let turnCompletionTokens = 0;
477
+ // Distingue "el modelo cerro su turno" de "se acabaron las iteraciones",
478
+ // que por fuera se parecen y no valen lo mismo.
479
+ let completado = false;
480
+ /** Un desbordamiento de contexto se rescata compactando, pero una sola vez. */
481
+ let yaCompactadoPorDesbordamiento = false;
482
+ /** Vueltas gastadas en corregir lo que dijo el verificador del proyecto. */
483
+ let reflexiones = 0;
484
+ /** Veces seguidas que la respuesta se corto por longitud sin hacer nada. */
485
+ let cortesPorLongitud = 0;
486
+ /** Veces seguidas que el turno acabo sin una sola letra que ensenar. */
487
+ let respuestasVacias = 0;
488
+ // Se limpia aqui: lo que se edito en el turno anterior ya se verifico (o no
489
+ // se iba a verificar nunca).
490
+ this.archivosTocados.clear();
176
491
  while (iterations < this.maxIterations) {
177
492
  iterations++;
493
+ // El contexto tambien crece dentro del turno: veinte herramientas con
494
+ // sus salidas pueden pasar del umbral sin que termine el turno. Mirarlo
495
+ // solo al principio era llegar tarde.
496
+ if (lastCallPromptTokens > 0) {
497
+ const compactado = await this.autoCompactIfNeeded(lastCallPromptTokens);
498
+ if (compactado)
499
+ lastCallPromptTokens = this.lastTurnTokens;
500
+ }
178
501
  // Near iteration limit notice
179
502
  if (iterations === this.maxIterations - 2) {
180
503
  this.messages.push({
@@ -203,7 +526,7 @@ export class AgentLoop {
203
526
  }, { signal }), {
204
527
  onRetry: (attempt, delay, reason) => {
205
528
  spinner.stop();
206
- console.log(chalk.yellow(`\n⟳ El motor respondio ${reason}. Reintento ${attempt} en ${(delay / 1000).toFixed(1)}s...`));
529
+ salida.linea(chalk.yellow(`\n⟳ El motor respondio ${reason}. Reintento ${attempt} en ${(delay / 1000).toFixed(1)}s...`));
207
530
  spinner.start();
208
531
  },
209
532
  });
@@ -212,24 +535,38 @@ export class AgentLoop {
212
535
  spinner.stop();
213
536
  watcher.stop();
214
537
  if (isAbortError(err) || watcher.interrupted) {
215
- console.log(chalk.yellow("\n⏸ Interrumpido. Dime como seguir."));
216
- return;
538
+ salida.linea(chalk.yellow("\n⏸ Interrumpido. Dime como seguir."));
539
+ return parada("interrumpido");
540
+ }
541
+ // El contexto no cabe. Reintentar la misma peticion es imposible por
542
+ // definicion, pero compactar y volver a intentarlo si tiene arreglo, y
543
+ // es justo lo que el usuario esperaria en vez de perder el turno. Una
544
+ // sola vez: si tras compactar sigue sin caber, el problema es otro.
545
+ if (isContextOverflow(err) && !yaCompactadoPorDesbordamiento) {
546
+ yaCompactadoPorDesbordamiento = true;
547
+ salida.linea(chalk.yellow("\n⚠ La peticion no cabia en el contexto. Compactando y reintentando..."));
548
+ const activo = this.messages[0]?.role === "system" ? this.messages[0].content : "";
549
+ await this.compactHistory(activo);
550
+ iterations--; // este intento no llego al modelo: no cuenta como vuelta
551
+ continue;
217
552
  }
218
553
  // Un 402 (cuota agotada) o un 401 (licencia caida) no son "fallos del
219
554
  // motor" y ademas no se reintentan nunca: decirlo como tal confunde y
220
555
  // hace que el usuario piense que el producto esta roto.
221
556
  const claro = explicarErrorDelMotor(err);
222
557
  if (claro) {
223
- console.log(chalk.yellow(`
558
+ salida.linea(chalk.yellow(`
224
559
  ⏸ ${claro}
225
560
  `));
226
- return;
561
+ return parada(motivoDeParadaDelError(err), claro);
227
562
  }
228
- renderError(`Error en el motor tras varios reintentos: ${err?.message || String(err)}`);
229
- return;
563
+ const fallo = `Error en el motor tras varios reintentos: ${err?.message || String(err)}`;
564
+ renderError(fallo);
565
+ return parada("error-motor", fallo);
230
566
  }
231
567
  let content = "";
232
568
  let reasoning = "";
569
+ let motivoDeCorte = null;
233
570
  const rawToolCalls = [];
234
571
  let isFirstChunk = true;
235
572
  const reasoningStream = new ReasoningStream();
@@ -272,10 +609,17 @@ export class AgentLoop {
272
609
  rawToolCalls[idx].function.arguments += tc.function.arguments;
273
610
  }
274
611
  }
612
+ // El motivo por el que el modelo dejo de hablar. Sin esto, quedarse
613
+ // sin presupuesto de salida es indistinguible de terminar el turno.
614
+ const corte = chunk.choices?.[0]?.finish_reason;
615
+ if (corte)
616
+ motivoDeCorte = corte;
275
617
  // Track streaming usage metrics
276
618
  if (chunk.usage) {
277
619
  this.tracker.addUsage(chunk.usage);
278
620
  turnPromptTokens += chunk.usage.prompt_tokens || 0;
621
+ if (chunk.usage.prompt_tokens)
622
+ lastCallPromptTokens = chunk.usage.prompt_tokens;
279
623
  turnCompletionTokens += chunk.usage.completion_tokens || 0;
280
624
  turnCachedTokens += chunk.usage.prompt_tokens_details?.cached_tokens || 0;
281
625
  }
@@ -292,18 +636,19 @@ export class AgentLoop {
292
636
  if (content.trim()) {
293
637
  this.messages.push({ role: "assistant", content });
294
638
  }
295
- console.log(chalk.yellow("\n⏸ Interrumpido. Dime como seguir."));
296
- return;
639
+ salida.linea(chalk.yellow("\n⏸ Interrumpido. Dime como seguir."));
640
+ return parada("interrumpido");
297
641
  }
298
642
  const claroStream = explicarErrorDelMotor(streamErr);
299
643
  if (claroStream) {
300
- console.log(chalk.yellow(`
644
+ salida.linea(chalk.yellow(`
301
645
  ⏸ ${claroStream}
302
646
  `));
303
- return;
647
+ return parada(motivoDeParadaDelError(streamErr), claroStream);
304
648
  }
305
- renderError(`Error en streaming: ${streamErr?.message || String(streamErr)}`);
306
- return;
649
+ const falloStream = `Error en streaming: ${streamErr?.message || String(streamErr)}`;
650
+ renderError(falloStream);
651
+ return parada("error-motor", falloStream);
307
652
  }
308
653
  spinner.stop();
309
654
  reasoningStream.flush();
@@ -312,16 +657,56 @@ export class AgentLoop {
312
657
  watcher.stop();
313
658
  if (content.trim())
314
659
  this.messages.push({ role: "assistant", content });
315
- console.log(chalk.yellow("\n⏸ Interrumpido antes de ejecutar las herramientas. Dime como seguir."));
316
- return;
660
+ salida.linea(chalk.yellow("\n⏸ Interrumpido antes de ejecutar las herramientas. Dime como seguir."));
661
+ return parada("interrumpido");
317
662
  }
318
663
  // Filter valid accumulated tool calls
319
664
  const toolCalls = rawToolCalls.filter((tc) => tc && tc.function && tc.function.name);
665
+ // El modelo se quedo sin presupuesto de salida y no llego a pedir nada.
666
+ //
667
+ // Esto se veia EXACTAMENTE igual que un turno terminado: sin llamadas a
668
+ // herramienta, el bucle daba el turno por cerrado, imprimia el pie y
669
+ // volvia al prompt. El usuario pedia "creame un juego en un archivo html",
670
+ // el modelo gastaba los 16.384 tokens razonando el diseno, se cortaba a
671
+ // mitad de frase, y no se creaba ningun archivo ni se decia por que.
672
+ //
673
+ // Se le devuelve el aviso y se le pide que actue, con tope: si a la
674
+ // segunda sigue sin hacer nada, el problema no es el presupuesto.
675
+ if (motivoDeCorte === "length" && toolCalls.length === 0) {
676
+ cortesPorLongitud++;
677
+ if (cortesPorLongitud > MAX_CORTES_POR_LONGITUD) {
678
+ const aviso = `La respuesta se corto por longitud ${cortesPorLongitud} veces seguidas sin llegar a ` +
679
+ `hacer nada. Prueba a pedirlo por partes.`;
680
+ salida.linea(chalk.yellow(`\n ⚠ ${aviso}\n`));
681
+ if (content.trim())
682
+ this.messages.push({ role: "assistant", content });
683
+ return parada("limite-iteraciones", aviso);
684
+ }
685
+ salida.linea(chalk.yellow("\n ⚠ La respuesta se corto por longitud antes de hacer nada. Pidiendole que vaya al grano...\n"));
686
+ if (content.trim())
687
+ this.messages.push({ role: "assistant", content });
688
+ this.messages.push({
689
+ role: "user",
690
+ content: "Tu respuesta anterior se corto porque agoto el presupuesto de salida mientras razonabas, " +
691
+ "y no llegaste a usar ninguna herramienta: no se ha hecho nada todavia. Deja de planificar " +
692
+ "y actua ya. Si lo que hay que escribir es largo, empieza con write_file y sigue con " +
693
+ 'write_file en modo="anadir", en trozos de menos de 400 lineas.',
694
+ });
695
+ // Sin esto se queda un vigilante de teclado vivo por cada corte, y cada
696
+ // uno vuelve a apuntarse como oyente: la misma tecla se procesa tantas
697
+ // veces como vigilantes haya, y el teclado deja de responder como
698
+ // deberia. La regla es: de este bucle no se sale sin soltar el teclado.
699
+ watcher.stop();
700
+ continue;
701
+ }
320
702
  if (toolCalls.length > 0) {
321
703
  this.messages.push({
322
704
  role: "assistant",
323
705
  content: content || null,
324
- tool_calls: toolCalls,
706
+ // Sanear NO es cosmetico: unos argumentos cortados guardados tal cual
707
+ // hacen que la API conteste 400 en TODAS las peticiones siguientes, y
708
+ // la sesion queda inservible hasta un /clear. Ver sanearLlamada().
709
+ tool_calls: toolCalls.map(sanearLlamada),
325
710
  });
326
711
  const toolResults = [];
327
712
  // Las llamadas independientes de solo lectura se lanzan a la vez. Cuando
@@ -330,40 +715,77 @@ export class AgentLoop {
330
715
  const names = toolCalls.map((tc) => tc.function.name);
331
716
  const parallel = canRunInParallel(names) &&
332
717
  !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);
718
+ const parsed = parsearArgumentos(tc.function.arguments);
719
+ // Unos argumentos rotos se resuelven en serie, donde hay sitio para
720
+ // explicarselo al modelo.
721
+ if (!parsed.ok)
722
+ return true;
723
+ return this.permissions.requiresConfirmation(tc.function.name, parsed.args, this.currentCwd);
339
724
  });
340
725
  if (parallel) {
341
- for (const tc of toolCalls) {
342
- let a = {};
343
- try {
344
- a = JSON.parse(tc.function.arguments);
726
+ // Esta rama ejecutaba directamente, sin pasar por hooks ni por deny.
727
+ // Un PreToolUse que prohibe leer secretos se saltaba en silencio en
728
+ // cuanto el modelo pedia dos lecturas en el mismo turno —cosa que el
729
+ // propio prompt del sistema fomenta—, y los PostToolUse (linters,
730
+ // politicas) tampoco corrian. Fallaba callado y hacia el lado
731
+ // inseguro, con lo que la politica del proyecto valia o no segun
732
+ // cuantas herramientas pidiera el modelo a la vez.
733
+ //
734
+ // La puerta es la misma que usan la via secuencial y los subagentes
735
+ // (toolGate.ts). Se autoriza en serie —los hooks son procesos, y un
736
+ // dialogo simultaneo por duplicado no se puede ni leer— y se
737
+ // paraleliza solo la ejecucion, que es lo que se queria ganar.
738
+ const autorizadas = [];
739
+ const enOrden = toolCalls.map(() => null);
740
+ for (let i = 0; i < toolCalls.length; i++) {
741
+ const tc = toolCalls[i];
742
+ const parsed = parsearArgumentos(tc.function.arguments);
743
+ if (!parsed.ok) {
744
+ const motivo = mensajeDeArgumentosRotos(tc.function.name);
745
+ renderToolStart(tc.function.name, "argumentos incompletos");
746
+ renderToolError(tc.function.name, motivo);
747
+ enOrden[i] = { role: "tool", tool_call_id: tc.id, content: motivo };
748
+ continue;
345
749
  }
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);
750
+ const a = parsed.args;
751
+ const decision = await autorizarHerramienta(tc.function.name, a, this.currentCwd);
752
+ if (!decision.permitida) {
753
+ const motivo = decision.motivo || "Accion no autorizada.";
754
+ renderToolStart(tc.function.name, getToolSummary(tc.function.name, a));
755
+ renderToolError(tc.function.name, motivo);
756
+ enOrden[i] = { role: "tool", tool_call_id: tc.id, content: motivo };
757
+ continue;
353
758
  }
354
- catch { }
355
- const r = await executeToolCall(tc.function.name, a, this.currentCwd, this.apiKey, this.currentEspectro.underlyingModel);
356
- return { tc, result: r.result };
759
+ autorizadas.push({ tc, args: decision.args, pos: i });
760
+ }
761
+ for (const { tc, args } of autorizadas) {
762
+ renderToolStart(tc.function.name, getToolSummary(tc.function.name, args));
763
+ }
764
+ const settled = await Promise.all(autorizadas.map(async ({ tc, args, pos }) => {
765
+ const r = await executeToolCall(tc.function.name, args, this.currentCwd, this.apiKey, this.currentEspectro.underlyingModel);
766
+ return { tc, args, pos, result: r.result };
357
767
  }));
358
- for (const { tc, result } of settled) {
768
+ for (const { tc, args, pos, result } of settled) {
359
769
  renderToolSuccess(tc.function.name, result);
360
- toolResults.push({ role: "tool", tool_call_id: tc.id, content: result });
770
+ const aviso = await notificarPostHerramienta(tc.function.name, args, result, this.currentCwd);
771
+ this.apuntarEscritura(tc.function.name, args, result);
772
+ const texto = await this.vigilarBucle(tc.function.name, tc.function.arguments, result);
773
+ enOrden[pos] = {
774
+ role: "tool",
775
+ tool_call_id: tc.id,
776
+ content: aviso ? `${texto}\n\n[Hook PostToolUse]: ${aviso}` : texto,
777
+ };
778
+ }
779
+ // En el orden en que las pidio el modelo, no en el que acabaron.
780
+ for (const r of enOrden) {
781
+ if (r)
782
+ toolResults.push(r);
361
783
  }
362
784
  toolResults.forEach((r) => this.messages.push(r));
363
785
  watcher.stop();
364
786
  if (watcher.interrupted) {
365
- console.log(chalk.yellow("\n⏸ Interrumpido. Dime como seguir."));
366
- return;
787
+ salida.linea(chalk.yellow("\n⏸ Interrumpido. Dime como seguir."));
788
+ return parada("interrumpido");
367
789
  }
368
790
  continue;
369
791
  }
@@ -380,12 +802,18 @@ export class AgentLoop {
380
802
  }
381
803
  const fnName = tc.function.name;
382
804
  let fnArgs = {};
383
- try {
384
- fnArgs = JSON.parse(tc.function.arguments);
385
- }
386
- catch {
387
- fnArgs = {};
805
+ const parsed = parsearArgumentos(tc.function.arguments);
806
+ if (!parsed.ok) {
807
+ // Antes esto era `catch { fnArgs = {} }`: la herramienta se
808
+ // ejecutaba igual, sin argumentos. Un write_file sin ruta ni
809
+ // contenido, y respondiendo como si nada hubiera pasado.
810
+ const motivo = mensajeDeArgumentosRotos(fnName);
811
+ renderToolStart(fnName, "argumentos incompletos");
812
+ renderToolError(fnName, motivo);
813
+ toolResults.push({ role: "tool", tool_call_id: tc.id, content: motivo });
814
+ continue;
388
815
  }
816
+ fnArgs = parsed.args;
389
817
  // PreToolUse: el usuario puede vetar, autorizar o reescribir los
390
818
  // argumentos ANTES de que se ejecute nada. Va antes del dialogo de
391
819
  // permiso porque un veto no deberia ni llegar a preguntarse.
@@ -404,7 +832,7 @@ export class AgentLoop {
404
832
  fnArgs = { ...fnArgs, ...pre.updatedInput };
405
833
  }
406
834
  if (pre.systemMessage) {
407
- console.log(chalk.yellow(`\n⚑ ${pre.systemMessage}`));
835
+ salida.linea(chalk.yellow(`\n⚑ ${pre.systemMessage}`));
408
836
  }
409
837
  // Modo plan: bloqueo duro, no un dialogo. Lo que el modo promete es
410
838
  // solo lectura, asi que aqui no hay nada que preguntar.
@@ -419,9 +847,25 @@ export class AgentLoop {
419
847
  });
420
848
  continue;
421
849
  }
850
+ // Un patron de denyPatterns corta aqui, antes de todo lo demas: no se
851
+ // pregunta, no lo salva el --yes, no lo salva el modo bypass y no lo
852
+ // salva un hook que diga "allow". Hasta ahora esa lista se cargaba y se
853
+ // volvia a guardar sin que nadie la consultara nunca.
854
+ const denegado = this.permissions.deniedBy(fnName, fnArgs);
855
+ if (denegado) {
856
+ const motivo = `Bloqueado por denyPatterns ("${denegado}") en la configuracion del usuario.`;
857
+ renderToolStart(fnName, getToolSummary(fnName, fnArgs));
858
+ renderToolError(fnName, motivo);
859
+ toolResults.push({
860
+ role: "tool",
861
+ tool_call_id: tc.id,
862
+ content: `${motivo} No hay forma de aprobarlo desde aqui: el usuario lo prohibio en su settings.json. Busca otra via o preguntale.`,
863
+ });
864
+ continue;
865
+ }
422
866
  // Permission gate
423
867
  const saltaPermiso = pre.permissionDecision === "allow";
424
- if (!saltaPermiso && this.permissions.requiresConfirmation(fnName, fnArgs)) {
868
+ if (!saltaPermiso && this.permissions.requiresConfirmation(fnName, fnArgs, this.currentCwd)) {
425
869
  watcher.stop();
426
870
  const permResult = await askToolPermission(fnName, fnArgs, this.currentCwd);
427
871
  if (!permResult.approved) {
@@ -436,8 +880,7 @@ export class AgentLoop {
436
880
  continue;
437
881
  }
438
882
  if (permResult.rememberPattern) {
439
- const pat = fnArgs.command ? fnArgs.command.split(" ")[0] : fnArgs.path || fnName;
440
- this.permissions.saveAllowPattern(pat);
883
+ this.recordarPermiso(fnName, fnArgs);
441
884
  }
442
885
  }
443
886
  const summary = getToolSummary(fnName, fnArgs);
@@ -453,52 +896,160 @@ export class AgentLoop {
453
896
  // PostToolUse: sirve para validar lo que acaba de pasar (linters,
454
897
  // tests, politicas) y devolverle al modelo un aviso accionable.
455
898
  const post = await this.hooks.run("PostToolUse", { tool_name: fnName, tool_input: fnArgs, tool_response: result.slice(0, 4000) }, fnName);
899
+ // El systemMessage de PreToolUse si se imprimia y el de PostToolUse
900
+ // no: el mismo campo, del mismo hook, se veia o no segun el evento.
901
+ if (post.systemMessage) {
902
+ salida.linea(chalk.yellow(`\n⚑ ${post.systemMessage}`));
903
+ }
904
+ // El dialogo del bucle usa stdin, igual que el de permisos: hay que
905
+ // soltarle el terminal al vigilante de Esc. Pero solo si de verdad se
906
+ // va a preguntar: pararlo en cada herramienta dejaria el resto del
907
+ // lote sin Esc, que es justo lo que no se quiere.
908
+ this.apuntarEscritura(fnName, fnArgs, result);
909
+ const texto = await this.vigilarBucle(fnName, tc.function.arguments, result, () => watcher.stop());
456
910
  toolResults.push({
457
911
  role: "tool",
458
912
  tool_call_id: tc.id,
459
- content: post.additionalContext ? `${result}\n\n
460
- [Hook PostToolUse]: ${post.additionalContext}` : result,
913
+ content: post.additionalContext ? `${texto}\n\n
914
+ [Hook PostToolUse]: ${post.additionalContext}` : texto,
461
915
  });
462
916
  }
463
917
  // Push all tool results in a single turn
464
918
  toolResults.forEach((r) => this.messages.push(r));
465
919
  watcher.stop();
466
920
  if (watcher.interrupted) {
467
- console.log(chalk.yellow("\n⏸ Interrumpido. Dime como seguir."));
468
- return;
921
+ salida.linea(chalk.yellow("\n⏸ Interrumpido. Dime como seguir."));
922
+ return parada("interrumpido");
469
923
  }
470
924
  continue;
471
925
  }
472
926
  watcher.stop();
473
- // If text content was printed, ensure newline and record assistant message
474
- if (content.trim()) {
475
- process.stdout.write("\n\n");
927
+ // EL TURNO ACABA SIN UNA SOLA LETRA QUE ENSENAR
928
+ //
929
+ // Salio de un caso real: cuatro ordenes seguidas ("puedes conectarte a mi
930
+ // vscode?", "si?", "puedes?", "a mi vscode") contestadas todas con esto:
931
+ //
932
+ // 💭 penso 1s
933
+ // ⚡ 4.8s · 0 tokens salida · $0.00000 USD · 100% contexto libre
934
+ //
935
+ // Ni un caracter en pantalla, ni un error, ni un aviso. Desde fuera es
936
+ // identico a que el agente te ignore, y el usuario reescribe la orden
937
+ // pensando que no llego. Encima cada una de esas cuatro se llevo por
938
+ // delante un hueco de peticion en el proxy, asi que la quinta choco con
939
+ // "Tienes 4 peticiones en curso": el silencio no solo confunde, ademas
940
+ // deja al usuario sin servicio.
941
+ //
942
+ // Es el mismo agujero que el corte por longitud, en su otra cara: el
943
+ // codigo no distingue "el modelo termino" de "no vino nada". Sin
944
+ // herramientas y sin texto, el bucle daba el turno por hecho, imprimia el
945
+ // pie y volvia al prompt.
946
+ if (!content.trim()) {
947
+ respuestasVacias++;
948
+ // Se reintenta UNA vez, no mas, y aqui la moderacion importa: cada
949
+ // intento consume un hueco de peticion de la licencia, y solo hay
950
+ // cuatro. Insistir con un motor que no contesta es la forma mas rapida
951
+ // de quedarse sin ninguno.
952
+ if (respuestasVacias > MAX_RESPUESTAS_VACIAS) {
953
+ const aviso = `El motor devolvio ${respuestasVacias} respuestas vacias seguidas. ` +
954
+ "No es tu conexion ni tu orden: no esta llegando nada del modelo. " +
955
+ "Espera un momento y reintenta, o /clear si venias de una conversacion larga.";
956
+ salida.linea(chalk.yellow(`\n ⚠ ${aviso}\n`));
957
+ return parada("error-motor", aviso);
958
+ }
959
+ salida.linea(chalk.gray("\n ⚠ El motor no devolvio nada. Reintentando una vez…\n"));
476
960
  this.messages.push({
477
- role: "assistant",
478
- content,
961
+ role: "user",
962
+ content: "Tu respuesta anterior llego vacia: ni texto ni llamadas a herramienta. " +
963
+ "El usuario no ha visto absolutamente nada. Contesta a lo que te pidio, " +
964
+ "aunque sea para decir que no puedes hacerlo o que no lo has entendido.",
479
965
  });
966
+ continue;
967
+ }
968
+ // Una respuesta con contenido cierra la racha: lo que cuenta son las
969
+ // vacias SEGUIDAS, no las de toda la sesion.
970
+ respuestasVacias = 0;
971
+ // If text content was printed, ensure newline and record assistant message
972
+ salida.crudo("\n\n");
973
+ this.messages.push({
974
+ role: "assistant",
975
+ content,
976
+ });
977
+ // El modelo da el turno por cerrado. Si toco archivos y el proyecto tiene
978
+ // verificacion automatica activada, se comprueba ANTES de creerselo: un
979
+ // error de tipos que nadie mira se queda en el disco y los turnos
980
+ // siguientes escriben encima.
981
+ const pendiente = await this.verificarTrasEditar(reflexiones);
982
+ if (pendiente) {
983
+ reflexiones++;
984
+ this.messages.push({ role: "user", content: pendiente });
985
+ continue;
480
986
  }
987
+ completado = true;
481
988
  break;
482
989
  }
483
990
  const durationMs = Date.now() - startTime;
484
991
  const { formattedCost, totalCost } = this.tracker.calculateCost(turnPromptTokens, turnCachedTokens, turnCompletionTokens, this.currentEspectro.underlyingModel);
485
992
  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;
993
+ // Lo que ocupa el historial es el prompt de la ultima llamada. Sumar el de
994
+ // todas las iteraciones inflaba la cuenta hasta disparar la compactacion
995
+ // con el contexto medio vacio, y hacia mentir al porcentaje del pie.
996
+ this.lastTurnTokens = lastCallPromptTokens || approxTotalTokens;
997
+ const used = lastCallPromptTokens || turnPromptTokens || approxTotalTokens;
490
998
  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.
999
+ // Se apunta lo gastado en este turno para las ventanas deslizantes. El
1000
+ // checkpoint de /undo ya no se cierra aqui, sino en parada(): este punto
1001
+ // solo lo alcanzaban los turnos que terminaban limpios.
493
1002
  recordUsage(totalCost);
1003
+ // Los subagentes, la vision y la compactacion ya se apuntaron solos segun
1004
+ // gastaban. Se dice en voz alta porque si no el pie parece contradecirse:
1005
+ // marca 0,0012 $ de turno y la insignia semanal sube diez veces mas.
1006
+ const auxiliar = tomarGastoAuxiliar();
494
1007
  const tras = checkQuota();
495
1008
  renderFooter(formattedCost, turnCompletionTokens, turnCachedTokens, this.currentEspectro.name, durationMs, contextPercent, quotaBadge(tras));
1009
+ if (auxiliar.total > 0) {
1010
+ salida.linea(chalk.gray(` + $${auxiliar.total.toFixed(4)} USD fuera del bucle (${auxiliar.rutas}), ya contados en la cuota.\n`));
1011
+ }
496
1012
  // Aviso al acercarse, no solo al chocar: quedarse a medias sin previo aviso
497
1013
  // es lo que hace que la gente cancele.
498
1014
  if (tras.allowed && tras.spentWeekUsd >= tras.config.weeklyBudgetUsd * 0.8) {
499
1015
  const queda = Math.max(0, tras.config.weeklyBudgetUsd - tras.spentWeekUsd);
500
- console.log(chalk.yellow(` ⚠ Te queda poca cuota semanal (${((queda / tras.config.weeklyBudgetUsd) * 100).toFixed(0)}%).\n`));
1016
+ salida.linea(chalk.yellow(` ⚠ Te queda poca cuota semanal (${((queda / tras.config.weeklyBudgetUsd) * 100).toFixed(0)}%).\n`));
1017
+ }
1018
+ if (!completado) {
1019
+ // Se agotaron las iteraciones con trabajo a medias. En interactivo se ve
1020
+ // porque el ultimo mensaje queda cortado; en CI no lo ve nadie.
1021
+ const aviso = `El turno se agoto en el limite de ${this.maxIterations} iteraciones sin cerrar la tarea.`;
1022
+ salida.linea(chalk.yellow(` ⚠ ${aviso}\n`));
1023
+ return parada("limite-iteraciones", aviso);
1024
+ }
1025
+ return parada("completado");
1026
+ }
1027
+ /**
1028
+ * Guarda lo que el usuario acaba de aprobar con "no preguntar mas".
1029
+ *
1030
+ * Aqui se hacia command.split(" ")[0] y se guardaba solo el binario. Aprobar
1031
+ * un "git status" dejaba el patron "git" escrito en .chocolatito/settings.json
1032
+ * y, como el allow autoriza por prefijo, a partir de ese momento pasaban sin
1033
+ * dialogo "git push --force" y "git reset --hard", en esa sesion y en todas
1034
+ * las siguientes. Un solo si de mas y el proyecto se queda sin barrera.
1035
+ *
1036
+ * Ahora se guarda la firma del comando (binario mas subcomando) y, si el
1037
+ * comando es de los destructivos, no se guarda nada: eso se pregunta siempre,
1038
+ * y decirlo en voz alta es mejor que escribir un patron que no serviria.
1039
+ */
1040
+ recordarPermiso(fnName, fnArgs) {
1041
+ const command = typeof fnArgs?.command === "string" ? fnArgs.command : "";
1042
+ if (command) {
1043
+ if (isDangerousCommand(command)) {
1044
+ salida.linea(chalk.gray(" (este comando es destructivo: se seguira preguntando cada vez, no se puede recordar)\n"));
1045
+ return;
1046
+ }
1047
+ const firma = commandSignature(command);
1048
+ if (firma)
1049
+ this.permissions.saveAllowPattern(`${fnName}:${firma}`);
1050
+ return;
501
1051
  }
1052
+ this.permissions.saveAllowPattern(fnArgs?.path || fnName);
502
1053
  }
503
1054
  }
504
1055
  function getToolSummary(name, args) {