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
package/dist/index.js CHANGED
@@ -10,36 +10,42 @@ import { getEngineBaseUrl, DEFAULT_ESPECTRO, ESPECTRO_MODELS, RULES_FILE_NAME }
10
10
  import { getProjectContext } from "./agent/context.js";
11
11
  import { buildSystemPrompt } from "./prompts/systemPrompt.js";
12
12
  import { AgentLoop } from "./agent/loop.js";
13
+ import { codigoDeSalida, EXIT_OK, EXIT_FALLO, EXIT_USO, EXIT_LICENCIA } from "./agent/result.js";
13
14
  import { showBanner, animateStartupBanner, showHelp, showEffortMenu } from "./ui/banner.js";
14
15
  import { askInteractivePrompt, SLASH_COMMANDS } from "./ui/prompt.js";
15
16
  import { changeDir } from "./tools/changeDir.js";
16
17
  import { gitDiff, gitCommit } from "./tools/gitTools.js";
17
18
  import { renderUserPrompt } from "./ui/renderer.js";
18
- import { parseFileMentions, buildPromptWithMentions } from "./agent/mentions.js";
19
+ import { parseFileMentions, buildPromptWithMentions, notaDeMencionesImplicitas } from "./agent/mentions.js";
19
20
  import { SkillManager } from "./skills/manager.js";
20
21
  import { MemoryManager } from "./memory/manager.js";
21
22
  import { HookManager } from "./hooks/manager.js";
22
23
  import { SessionManager } from "./sessions/manager.js";
24
+ import { prepararRestauracion } from "./sessions/resume.js";
23
25
  import { globalUndoManager } from "./agent/undoManager.js";
24
26
  import { setAutoApprove } from "./ui/permissionPrompt.js";
25
27
  import { onModeChange, getMode, modeAnnouncement } from "./ui/modes.js";
26
28
  import { releaseKeyboard } from "./ui/interrupt.js";
27
29
  import { takeQueued, resetTypeAhead } from "./ui/typeAhead.js";
28
30
  import { mcpManager } from "./mcp/manager.js";
31
+ import { comprobarActualizacion } from "./config/updater.js";
29
32
  /**
30
33
  * Version leida del package.json instalado, no escrita a mano: una constante
31
34
  * duplicada aqui se queda vieja en cuanto se publica una version nueva.
32
35
  */
33
36
  function readVersion() {
34
37
  try {
35
- const here = path.dirname(fileURLToPath(import.meta.url));
36
- const pkg = JSON.parse(fs.readFileSync(path.resolve(here, "..", "package.json"), "utf-8"));
38
+ const pkg = JSON.parse(fs.readFileSync(path.join(raizDelPaquete(), "package.json"), "utf-8"));
37
39
  return String(pkg.version || "desconocida");
38
40
  }
39
41
  catch {
40
42
  return "desconocida";
41
43
  }
42
44
  }
45
+ /** La carpeta que contiene el `dist`. El actualizador la necesita para saber si esto es el repositorio. */
46
+ function raizDelPaquete() {
47
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
48
+ }
43
49
  /**
44
50
  * Banderas que se resuelven sin arrancar el agente.
45
51
  *
@@ -76,6 +82,14 @@ function handleFlags(argv) {
76
82
  " API key: variable CHOCOLATITO_API_KEY, un .env del proyecto o ~/.chocolatitorc.",
77
83
  " Reglas del proyecto: CHOCOLATITO.md. Permisos y hooks: .chocolatito/settings.json.",
78
84
  "",
85
+ "CODIGOS DE SALIDA (modo orden unica)",
86
+ " 0 La tarea se completo.",
87
+ " 1 El agente no pudo terminar (fallo del motor o limite de iteraciones).",
88
+ " 2 Error de uso: falta la orden o no hay terminal para pedirla.",
89
+ " 3 Cuota agotada.",
90
+ " 4 Licencia o API key no valida.",
91
+ " 130 Interrumpido con Esc.",
92
+ "",
79
93
  ].join("\n"));
80
94
  return true;
81
95
  }
@@ -118,17 +132,81 @@ function instalarRedDeSeguridad() {
118
132
  restaurarTerminal();
119
133
  console.log(chalk.red(`\n✖ Error inesperado: ${err?.message || String(err)}\n`) +
120
134
  chalk.gray(`${(err?.stack || "").split("\n").slice(1, 4).join("\n")}\n`));
121
- process.exit(1);
135
+ process.exit(EXIT_FALLO);
122
136
  });
123
137
  }
138
+ /**
139
+ * UserPromptSubmit: se dispara con lo que el usuario acaba de escribir, antes de
140
+ * mandarlo al modelo. Sirve para vetar un prompt (un secreto pegado sin querer,
141
+ * una politica de la empresa) o para inyectarle contexto.
142
+ *
143
+ * Estaba declarado en HookEvent y /hooks lo listaba tan tranquilo, pero no lo
144
+ * disparaba nadie: se configuraba y no pasaba absolutamente nada. Un hook puesto
145
+ * para bloquear prompts con credenciales los dejaba pasar todos.
146
+ *
147
+ * Devuelve null si el prompt queda vetado.
148
+ */
149
+ async function pasarPorUserPromptSubmit(hooks, prompt) {
150
+ const d = await hooks.run("UserPromptSubmit", { prompt }, prompt);
151
+ if (d.systemMessage)
152
+ console.log(chalk.yellow(`\n⚑ ${d.systemMessage}`));
153
+ if (d.permissionDecision === "deny") {
154
+ console.log(chalk.red(`\n✖ Un hook rechazo este prompt: ${d.permissionDecisionReason || "sin motivo"}\n`));
155
+ return null;
156
+ }
157
+ if (d.additionalContext) {
158
+ return `${prompt}\n\n[Contexto anadido por un hook UserPromptSubmit]\n${d.additionalContext}`;
159
+ }
160
+ return prompt;
161
+ }
162
+ /**
163
+ * Stop: fin de turno, el sitio donde el usuario engancha su linter o sus tests.
164
+ * Mismo caso que el anterior: declarado, listado y jamas disparado.
165
+ *
166
+ * Un "deny" (codigo 2) significa "no pares todavia" y devuelve el motivo para
167
+ * reinyectarlo como siguiente orden; quien llama decide si lo hace, porque un
168
+ * hook que siempre diga que no dejaria al agente dando vueltas para siempre.
169
+ */
170
+ async function dispararStop(hooks, datos) {
171
+ const d = await hooks.run("Stop", datos);
172
+ const aviso = d.systemMessage || d.additionalContext;
173
+ if (aviso)
174
+ console.log(chalk.yellow(`\n⚑ ${aviso}`));
175
+ if (d.permissionDecision === "deny") {
176
+ return d.permissionDecisionReason || "Un hook Stop pidio seguir trabajando.";
177
+ }
178
+ return null;
179
+ }
124
180
  async function main() {
125
- if (handleFlags(process.argv.slice(2)))
181
+ const rawArgs = process.argv.slice(2);
182
+ if (handleFlags(rawArgs))
126
183
  return;
127
184
  instalarRedDeSeguridad();
185
+ // --yes / -y aprueba las acciones sin preguntar. Util en modo comando unico y CI.
186
+ const autoYes = rawArgs.includes("--yes") || rawArgs.includes("-y");
187
+ const args = rawArgs.filter((a) => a !== "--yes" && a !== "-y");
188
+ const singlePrompt = args.join(" ").trim();
189
+ // Los errores de uso se resuelven ANTES de la licencia y de la API key: no
190
+ // dependen de estar suscrito, y en CI hay que poder distinguir "lo has
191
+ // invocado mal" de "el agente no pudo".
192
+ if (args.length > 0 && !singlePrompt) {
193
+ console.error(chalk.red("\n✖ No hay ninguna orden que ejecutar.") +
194
+ chalk.gray('\n Uso: chocolatito "haz X". Con --help tienes el resto.\n'));
195
+ process.exit(EXIT_USO);
196
+ }
197
+ // Sin orden y sin terminal no hay a quien preguntar: el REPL moria en el
198
+ // primer prompt y salia con 0, que en CI se lee como "todo bien".
199
+ if (!singlePrompt && !process.stdin.isTTY) {
200
+ console.error(chalk.red("\n✖ La entrada no es una terminal y no se dio ninguna orden.") +
201
+ chalk.gray('\n En scripts y CI usa: chocolatito --yes "haz X".\n'));
202
+ process.exit(EXIT_USO);
203
+ }
204
+ if (autoYes)
205
+ setAutoApprove(true);
128
206
  // La licencia va ANTES de la API key: no tiene sentido pedirle a alguien que
129
207
  // configure DeepSeek si despues le vamos a decir que no ha comprado.
130
208
  if (!(await ensureLicense())) {
131
- process.exit(1);
209
+ process.exit(EXIT_LICENCIA);
132
210
  }
133
211
  // En suscripcion el motor lo paga Chocolatito: el cliente NO tiene ni necesita
134
212
  // clave de DeepSeek, y su credencial es la propia licencia. Preguntarsela era
@@ -165,7 +243,31 @@ async function main() {
165
243
  }
166
244
  return resumen;
167
245
  });
246
+ // Actualizacion sola, en segundo plano.
247
+ //
248
+ // No se espera: si el registro tarda, la sesion ya esta lista y el aviso
249
+ // aparece cuando llegue. Se guarda la promesa en vez de soltarla para que
250
+ // /actualizar pueda ensenar el resultado si el usuario pregunta antes.
251
+ let avisoDeActualizacion = "";
252
+ const actualizacionEnCurso = comprobarActualizacion({
253
+ versionActual: readVersion(),
254
+ raizDelPaquete: raizDelPaquete(),
255
+ ahora: Date.now(),
256
+ })
257
+ .then((aviso) => {
258
+ avisoDeActualizacion = aviso;
259
+ // Se escribe solo si hay algo que decir y el usuario no esta a mitad de
260
+ // frase: el prompt se redibuja entero en la siguiente tecla, asi que una
261
+ // linea de mas aqui no rompe el marco.
262
+ if (aviso)
263
+ console.log(chalk.gray(`\n ⬆ ${aviso}\n`));
264
+ return aviso;
265
+ })
266
+ .catch(() => "");
168
267
  // Add /resume to slash commands
268
+ if (!SLASH_COMMANDS.some((c) => c.name === "/actualizar")) {
269
+ SLASH_COMMANDS.push({ name: "/actualizar", description: "Buscar e instalar la última versión de Chocolatito Code" });
270
+ }
169
271
  if (!SLASH_COMMANDS.some((c) => c.name === "/licencia")) {
170
272
  SLASH_COMMANDS.push({ name: "/licencia", description: "Ver la licencia activa o liberar este equipo", args: "[liberar]" });
171
273
  }
@@ -173,21 +275,37 @@ async function main() {
173
275
  SLASH_COMMANDS.splice(1, 0, { name: "/resume", description: "Reanudar una sesión anterior guardada", args: "[id]" });
174
276
  }
175
277
  // Single-command mode: chocolatito "crea index.html"
176
- const rawArgs = process.argv.slice(2);
177
- // --yes / -y aprueba las acciones sin preguntar. Util en modo comando unico y CI.
178
- const autoYes = rawArgs.includes("--yes") || rawArgs.includes("-y");
179
- if (autoYes)
180
- setAutoApprove(true);
181
- const args = rawArgs.filter((a) => a !== "--yes" && a !== "-y");
182
- if (args.length > 0) {
183
- const singlePrompt = args.join(" ");
278
+ if (singlePrompt) {
184
279
  showBanner(context.cwd, currentModel, "idle");
185
280
  renderUserPrompt(singlePrompt);
186
- const { attachedFiles } = parseFileMentions(singlePrompt, context.cwd);
187
- const enrichedPrompt = buildPromptWithMentions(singlePrompt, attachedFiles);
281
+ // El modo no interactivo pasa por los mismos hooks que el REPL: un veto que
282
+ // solo valiera cuando hay alguien mirando no es una politica.
283
+ const revisado = await pasarPorUserPromptSubmit(hookManager, singlePrompt);
284
+ if (revisado === null) {
285
+ await hookManager.triggerHook("SessionEnd", { sessionId, cwd: agent.currentCwd });
286
+ process.exit(EXIT_FALLO);
287
+ }
288
+ const { attachedFiles } = parseFileMentions(revisado, context.cwd);
289
+ const enrichedPrompt = buildPromptWithMentions(revisado, attachedFiles) +
290
+ notaDeMencionesImplicitas(revisado, context.cwd, attachedFiles.map((f) => f.relativePath));
188
291
  await mcpReady;
189
- await agent.runTask(enrichedPrompt);
190
- process.exit(0);
292
+ // Aqui estaba el fallo: process.exit(0) incondicional. Una cuota agotada,
293
+ // una licencia caida o el limite de iteraciones salian con 0 y el pipeline
294
+ // seguia como si el trabajo estuviera hecho.
295
+ const resultado = await agent.runTask(enrichedPrompt);
296
+ // Aqui tampoco se disparaba nada: el modo de una sola orden salia por
297
+ // process.exit() sin pasar por Stop ni por SessionEnd, asi que el linter del
298
+ // final del turno y el cierre de sesion solo existian en la sesion
299
+ // interactiva. Su "deny" no reinyecta nada: no hay turno siguiente al que
300
+ // devolverle el trabajo, se avisa y se sale.
301
+ await dispararStop(hookManager, { session_id: sessionId, cwd: agent.currentCwd, stop_hook_active: false });
302
+ await hookManager.triggerHook("SessionEnd", { sessionId, cwd: agent.currentCwd });
303
+ if (!resultado.exito) {
304
+ console.error(chalk.red(`✖ La tarea no se completo (${resultado.motivo})`) +
305
+ (resultado.detalle ? chalk.gray(`: ${resultado.detalle}`) : "") +
306
+ "\n");
307
+ }
308
+ process.exit(codigoDeSalida(resultado));
191
309
  }
192
310
  // Interactive REPL Mode with lively startup walking animation
193
311
  await animateStartupBanner(context.cwd, currentModel);
@@ -198,21 +316,34 @@ async function main() {
198
316
  // Lo que el usuario escribio mientras el agente trabajaba entra como siguiente
199
317
  // orden, sin tener que volver a teclearlo.
200
318
  let pendiente = null;
319
+ // Lo que un hook Stop pide seguir haciendo va aparte de `pendiente`: hay que
320
+ // distinguir "sigue tu" de "el usuario escribio algo", porque el permiso para
321
+ // reinyectar se gasta una sola vez por orden del usuario. Sin ese limite, un
322
+ // Stop que siempre deniega deja al agente en bucle infinito quemando cuota.
323
+ let continuacionDeStop = null;
324
+ let stopYaForzado = false;
201
325
  while (true) {
202
326
  let userInput = "";
203
- if (pendiente) {
327
+ if (continuacionDeStop) {
328
+ userInput = continuacionDeStop;
329
+ continuacionDeStop = null;
330
+ renderUserPrompt(userInput);
331
+ }
332
+ else if (pendiente) {
204
333
  userInput = pendiente;
205
334
  pendiente = null;
335
+ stopYaForzado = false;
206
336
  renderUserPrompt(userInput);
207
337
  }
208
338
  else {
209
339
  try {
210
340
  userInput = await askInteractivePrompt(isPlanMode ? chalk.cyan("plan ❯") : "❯", agent.currentCwd);
341
+ stopYaForzado = false;
211
342
  }
212
343
  catch {
213
344
  console.log(chalk.gray("\n👋 Sesión finalizada."));
214
345
  await hookManager.triggerHook("SessionEnd", { sessionId, cwd: agent.currentCwd });
215
- process.exit(0);
346
+ process.exit(EXIT_OK);
216
347
  }
217
348
  }
218
349
  const trimmed = userInput.trim();
@@ -222,7 +353,7 @@ async function main() {
222
353
  if (trimmed === "/exit" || trimmed === "exit" || trimmed === "quit") {
223
354
  console.log(chalk.gray("\n👋 Sesión finalizada."));
224
355
  await hookManager.triggerHook("SessionEnd", { sessionId, cwd: agent.currentCwd });
225
- process.exit(0);
356
+ process.exit(EXIT_OK);
226
357
  }
227
358
  if (trimmed === "/licencia" || trimmed.startsWith("/licencia ")) {
228
359
  const sub = trimmed.slice("/licencia".length).trim();
@@ -237,14 +368,13 @@ async function main() {
237
368
  }
238
369
  if (trimmed === "/undo") {
239
370
  const undoRes = globalUndoManager.undoLast();
240
- if (undoRes.success) {
241
- console.log(chalk.green(`\n${undoRes.message}`));
242
- undoRes.restoredFiles.forEach((f) => console.log(` ${chalk.gray("•")} ${chalk.white(f)}`));
243
- console.log("");
244
- }
245
- else {
246
- console.log(chalk.yellow(`\n⚠ ${undoRes.message}\n`));
247
- }
371
+ // Lo que no se pudo deshacer se imprime siempre, tambien cuando algo si se
372
+ // restauro. Antes solo se veia la lista de exitos bajo un "Deshecho con
373
+ // exito" fijo, asi que un borrado irreversible pasaba por reparado.
374
+ console.log(undoRes.success ? chalk.green(`\n${undoRes.message}`) : chalk.yellow(`\n${undoRes.message}`));
375
+ undoRes.restoredFiles.forEach((f) => console.log(` ${chalk.gray("•")} ${chalk.white(f)}`));
376
+ undoRes.failures.forEach((f) => console.log(` ${chalk.red("✗")} ${chalk.yellow(f)}`));
377
+ console.log("");
248
378
  continue;
249
379
  }
250
380
  if (trimmed.startsWith("/export")) {
@@ -276,8 +406,26 @@ ${sessionData}
276
406
  const targetId = trimmed.slice(7).trim();
277
407
  const loaded = targetId ? sessionManager.loadSession(targetId) : sessionManager.loadLatestSession();
278
408
  if (loaded) {
279
- agent.messages = loaded.messages;
280
- console.log(chalk.green(`\n✔ Sesión "${loaded.id}" restaurada con éxito (${loaded.messages.length} mensajes cargados).\n`));
409
+ // Se restaura la conversacion, no el entorno. El array guardado trae en
410
+ // su primer mensaje el prompt de sistema de aquel dia; asignarlo entero
411
+ // dejaba al modelo anunciando otro directorio, otra fecha y otras reglas
412
+ // mientras las herramientas trabajaban contra el cwd real.
413
+ const restauracion = prepararRestauracion(loaded, { cwd: agent.currentCwd, modelId: currentModel.id });
414
+ agent.currentCwd = restauracion.cwd;
415
+ currentModel = ESPECTRO_MODELS[restauracion.modelId] || currentModel;
416
+ const refreshedContext = await getProjectContext(agent.currentCwd);
417
+ systemPrompt = buildSystemPrompt(refreshedContext, currentModel, skillManager, memoryManager, isPlanMode);
418
+ agent.messages = [{ role: "system", content: systemPrompt }, ...restauracion.conversacion];
419
+ // Sincroniza el motor del bucle con el de la sesion: el prompt ya
420
+ // anuncia ese nivel de esfuerzo, y anunciar uno y correr otro es el
421
+ // mismo desfase que se acaba de arreglar.
422
+ agent.setEspectroModel(currentModel);
423
+ console.log(chalk.green(`\n✔ Sesión "${loaded.id}" restaurada con éxito (${restauracion.conversacion.length} mensajes cargados).`));
424
+ console.log(chalk.gray(` Directorio: ${agent.currentCwd} · Motor: ${currentModel.name}`));
425
+ if (restauracion.directorioPerdido && loaded.cwd) {
426
+ console.log(chalk.yellow(` ⚠ El directorio guardado (${loaded.cwd}) ya no existe; se sigue aquí.`));
427
+ }
428
+ console.log("");
281
429
  }
282
430
  else {
283
431
  const available = sessionManager.listSessions();
@@ -434,6 +582,25 @@ Directivas de ejecución:
434
582
  console.log(chalk.green("✔ Historial reiniciado.\n"));
435
583
  continue;
436
584
  }
585
+ if (trimmed === "/actualizar") {
586
+ // Si la comprobacion del arranque ya trajo algo que decir, se dice eso en
587
+ // vez de volver a la red: preguntar dos veces lo mismo no cambia la
588
+ // respuesta y hace esperar al usuario para nada.
589
+ await actualizacionEnCurso;
590
+ if (avisoDeActualizacion) {
591
+ console.log(chalk.gray(`\n ⬆ ${avisoDeActualizacion}\n`));
592
+ continue;
593
+ }
594
+ console.log(chalk.gray("\n Buscando una version mas nueva…"));
595
+ const aviso = await comprobarActualizacion({
596
+ versionActual: readVersion(),
597
+ raizDelPaquete: raizDelPaquete(),
598
+ ahora: Date.now(),
599
+ forzar: true,
600
+ });
601
+ console.log(chalk.gray(` ${aviso || "Sin novedades."}\n`));
602
+ continue;
603
+ }
437
604
  if (trimmed === "/init") {
438
605
  const rulesPath = path.join(agent.currentCwd, RULES_FILE_NAME);
439
606
  if (!fs.existsSync(rulesPath)) {
@@ -446,13 +613,34 @@ Directivas de ejecución:
446
613
  }
447
614
  continue;
448
615
  }
616
+ // UserPromptSubmit: el hook ve el prompt antes que el modelo y puede vetarlo
617
+ // o anadirle contexto. Va aqui, despues de los comandos de barra, porque
618
+ // "/clear" o "/hooks" no son prompts para el modelo.
619
+ const revisado = await pasarPorUserPromptSubmit(hookManager, trimmed);
620
+ if (revisado === null)
621
+ continue;
449
622
  // Parse and auto-attach @file mentions
450
- const { attachedFiles } = parseFileMentions(trimmed, agent.currentCwd);
451
- const enrichedPrompt = buildPromptWithMentions(trimmed, attachedFiles);
623
+ const { attachedFiles } = parseFileMentions(revisado, agent.currentCwd);
624
+ // Y los que nombra sin la @ ("arregla el fileTracker"): solo la ruta, para
625
+ // que no tenga que buscar a ciegas un archivo que el usuario acaba de decir.
626
+ const enrichedPrompt = buildPromptWithMentions(revisado, attachedFiles) +
627
+ notaDeMencionesImplicitas(revisado, agent.currentCwd, attachedFiles.map((f) => f.relativePath));
452
628
  // Execute task with agent loop
453
629
  await agent.runTask(enrichedPrompt);
454
630
  // Lo que se escribio mientras trabajaba pasa a ser la siguiente orden.
455
631
  pendiente = takeQueued();
632
+ // Stop: fin de turno. Si deniega, pide seguir trabajando; solo se le hace
633
+ // caso una vez por orden del usuario y nunca por delante de lo que el
634
+ // usuario haya escrito mientras tanto.
635
+ const motivoSeguir = await dispararStop(hookManager, {
636
+ session_id: sessionId,
637
+ cwd: agent.currentCwd,
638
+ stop_hook_active: stopYaForzado,
639
+ });
640
+ if (motivoSeguir && !stopYaForzado && !pendiente) {
641
+ stopYaForzado = true;
642
+ continuacionDeStop = `[Hook Stop] ${motivoSeguir}`;
643
+ }
456
644
  // Persist session state
457
645
  sessionManager.saveSession({
458
646
  id: sessionId,
@@ -466,5 +654,5 @@ Directivas de ejecución:
466
654
  }
467
655
  main().catch((err) => {
468
656
  console.error(chalk.red(`\nError: ${err?.message || String(err)}`));
469
- process.exit(1);
657
+ process.exit(EXIT_FALLO);
470
658
  });
@@ -17,6 +17,28 @@ export interface McpServerConfig {
17
17
  * guardar la configuracion en el repositorio sin meter secretos dentro.
18
18
  */
19
19
  export declare function expandVars(value: string): string;
20
+ /**
21
+ * Extrae el mensaje JSON-RPC de una respuesta HTTP, sea JSON directo o SSE.
22
+ *
23
+ * Antes esto se decidia con `text.includes("data:")` sobre el cuerpo entero, y
24
+ * eso rompia por dos sitios a la vez:
25
+ *
26
+ * 1. Cualquier respuesta JSON normal que mencionara `data:` en su contenido (un
27
+ * data URI, una cabecera, un trozo de CSS, la descripcion de una herramienta
28
+ * de captura o de fetch web) entraba por la rama SSE. Ninguna linea empezaba
29
+ * por `data:`, el `.pop()` daba undefined y el `|| "{}"` convertia la
30
+ * respuesta en un objeto vacio. Como `httpRequest` es la unica via de todo el
31
+ * protocolo HTTP, eso no solo devolvia «(sin contenido)» en `tools/call`:
32
+ * tambien vaciaba `tools/list`, y el servidor quedaba «conectado» con cero
33
+ * herramientas sin un solo mensaje de error.
34
+ * 2. En SSE de verdad, `.pop()` se quedaba con el ULTIMO evento en vez de con el
35
+ * que responde a nuestro `id`. Un ping o una notificacion posterior tapaban
36
+ * el resultado.
37
+ *
38
+ * Ahora manda el Content-Type, la forma del cuerpo desempata cuando el servidor
39
+ * no lo declara bien, y entre los eventos se elige el que lleva el `id` pedido.
40
+ */
41
+ export declare function parseHttpBody(text: string, contentType: string | null, id?: number | string): any;
20
42
  export declare class McpClient {
21
43
  readonly name: string;
22
44
  tools: McpTool[];
@@ -37,6 +37,96 @@ function expandDeep(value) {
37
37
  }
38
38
  return value;
39
39
  }
40
+ /**
41
+ * Trocea un cuerpo SSE en la carga util de cada evento.
42
+ *
43
+ * Se sigue la especificacion: los comentarios (`:` inicial) se ignoran, las
44
+ * lineas `data:` de un mismo evento se concatenan con salto de linea y el
45
+ * evento termina en la linea en blanco. El espacio opcional tras los dos puntos
46
+ * no forma parte del dato.
47
+ */
48
+ function eventosSse(text) {
49
+ const eventos = [];
50
+ let buffer = [];
51
+ const cerrar = () => {
52
+ if (buffer.length)
53
+ eventos.push(buffer.join("\n"));
54
+ buffer = [];
55
+ };
56
+ for (const line of text.split(/\r?\n/)) {
57
+ if (line === "") {
58
+ cerrar();
59
+ continue;
60
+ }
61
+ if (line.startsWith(":"))
62
+ continue;
63
+ if (line.startsWith("data:"))
64
+ buffer.push(line.slice(5).replace(/^ /, ""));
65
+ }
66
+ cerrar();
67
+ return eventos;
68
+ }
69
+ /**
70
+ * Extrae el mensaje JSON-RPC de una respuesta HTTP, sea JSON directo o SSE.
71
+ *
72
+ * Antes esto se decidia con `text.includes("data:")` sobre el cuerpo entero, y
73
+ * eso rompia por dos sitios a la vez:
74
+ *
75
+ * 1. Cualquier respuesta JSON normal que mencionara `data:` en su contenido (un
76
+ * data URI, una cabecera, un trozo de CSS, la descripcion de una herramienta
77
+ * de captura o de fetch web) entraba por la rama SSE. Ninguna linea empezaba
78
+ * por `data:`, el `.pop()` daba undefined y el `|| "{}"` convertia la
79
+ * respuesta en un objeto vacio. Como `httpRequest` es la unica via de todo el
80
+ * protocolo HTTP, eso no solo devolvia «(sin contenido)» en `tools/call`:
81
+ * tambien vaciaba `tools/list`, y el servidor quedaba «conectado» con cero
82
+ * herramientas sin un solo mensaje de error.
83
+ * 2. En SSE de verdad, `.pop()` se quedaba con el ULTIMO evento en vez de con el
84
+ * que responde a nuestro `id`. Un ping o una notificacion posterior tapaban
85
+ * el resultado.
86
+ *
87
+ * Ahora manda el Content-Type, la forma del cuerpo desempata cuando el servidor
88
+ * no lo declara bien, y entre los eventos se elige el que lleva el `id` pedido.
89
+ */
90
+ export function parseHttpBody(text, contentType, id) {
91
+ const cuerpo = text.trim();
92
+ if (!cuerpo)
93
+ throw new Error("el servidor MCP devolvio un cuerpo vacio");
94
+ const declaraSse = /text\/event-stream/i.test(contentType || "");
95
+ if (!declaraSse) {
96
+ // JSON directo. Si el servidor miente en el Content-Type pero manda SSE,
97
+ // el parse falla y se reintenta abajo como flujo de eventos.
98
+ try {
99
+ return JSON.parse(cuerpo);
100
+ }
101
+ catch (err) {
102
+ if (!/^data:/m.test(cuerpo))
103
+ throw err;
104
+ }
105
+ }
106
+ const mensajes = [];
107
+ for (const evento of eventosSse(cuerpo)) {
108
+ try {
109
+ mensajes.push(JSON.parse(evento));
110
+ }
111
+ catch {
112
+ // Keepalives y eventos que no son JSON-RPC: se ignoran.
113
+ }
114
+ }
115
+ if (!mensajes.length)
116
+ throw new Error("el flujo SSE del servidor MCP no traia ningun mensaje JSON-RPC");
117
+ if (id !== undefined) {
118
+ const propio = mensajes.find((m) => m && m.id !== undefined && String(m.id) === String(id));
119
+ if (propio)
120
+ return propio;
121
+ }
122
+ // Sin coincidencia de id nos quedamos con la ultima respuesta de verdad, no
123
+ // con una notificacion del servidor (que no lleva ni result ni error).
124
+ const respuestas = mensajes.filter((m) => m && ("result" in m || "error" in m));
125
+ const elegido = respuestas.length ? respuestas[respuestas.length - 1] : null;
126
+ if (!elegido)
127
+ throw new Error("el flujo SSE del servidor MCP no traia respuesta a la peticion");
128
+ return elegido;
129
+ }
40
130
  export class McpClient {
41
131
  name;
42
132
  tools = [];
@@ -193,6 +283,7 @@ export class McpClient {
193
283
  async httpRequest(method, params, timeoutMs) {
194
284
  const controller = new AbortController();
195
285
  const timer = setTimeout(() => controller.abort(), timeoutMs);
286
+ const id = this.nextId++;
196
287
  try {
197
288
  const res = await fetch(this.config.url, {
198
289
  method: "POST",
@@ -201,22 +292,13 @@ export class McpClient {
201
292
  Accept: "application/json, text/event-stream",
202
293
  ...(this.config.headers || {}),
203
294
  },
204
- body: JSON.stringify({ jsonrpc: "2.0", id: this.nextId++, method, params }),
295
+ body: JSON.stringify({ jsonrpc: "2.0", id, method, params }),
205
296
  signal: controller.signal,
206
297
  });
207
298
  if (!res.ok)
208
299
  throw new Error(`HTTP ${res.status}`);
209
300
  const text = await res.text();
210
- // El transporte HTTP puede responder JSON directo o un flujo SSE.
211
- const body = text.includes("data:")
212
- ? text
213
- .split("\n")
214
- .filter((l) => l.startsWith("data:"))
215
- .map((l) => l.slice(5).trim())
216
- .filter(Boolean)
217
- .pop() || "{}"
218
- : text;
219
- const parsed = JSON.parse(body);
301
+ const parsed = parseHttpBody(text, res.headers.get("content-type"), id);
220
302
  if (parsed.error)
221
303
  throw new Error(parsed.error.message || JSON.stringify(parsed.error));
222
304
  return parsed.result;
@@ -20,6 +20,17 @@ export function buildSystemPrompt(context, model, skillManager, memoryManager, i
20
20
  const rulesSection = context.rulesContent
21
21
  ? `\n\n--- REGLAS DEL PROYECTO (CHOCOLATITO.md) ---\n${context.rulesContent}\n------------------------------------------`
22
22
  : "";
23
+ // El mapa del repositorio va aqui, antes de las reglas de conducta, porque es
24
+ // lo que le dice al agente que existe en el proyecto. Sin el, para saber que
25
+ // hay una funcion tiene que buscarla, y para buscarla tiene que sospechar que
26
+ // existe: de ahi los turnos gastados en grep_search antes de tocar nada.
27
+ const mapaSection = context.repoMap
28
+ ? `--- BLOQUE 3b: MAPA DEL REPOSITORIO ---\n` +
29
+ `Indice de los simbolos mas relevantes del proyecto, ordenado por cuanto los usa el resto\n` +
30
+ `del codigo. Son NOMBRES Y LINEAS, no contenido: sirve para saber que existe y donde mirar,\n` +
31
+ `no para editar a ciegas. Antes de tocar cualquiera de estos archivos, leelo con 'view_file'.\n` +
32
+ `Puede estar desactualizado respecto al disco; manda siempre lo que leas.\n\n${context.repoMap}\n\n`
33
+ : "";
23
34
  const planModeNotice = isPlanMode
24
35
  ? `\n\n⚠️ MODO PLAN ACTIVO (SOLO LECTURA): No intentes escribir ni modificar archivos. Investiga el código con herramientas de lectura y genera un plan arquitectónico estructurado para que el usuario lo apruebe.`
25
36
  : "";
@@ -44,7 +55,7 @@ ${memoryIndex}
44
55
  Tienes acceso a skills especializados. Usa la herramienta 'use_skill' para cargar las instrucciones completas de un skill cuando lo necesites:
45
56
  ${skillsListing}
46
57
 
47
- --- BLOQUE 4: SUBAGENTES DISPONIBLES ---
58
+ ${mapaSection}--- BLOQUE 4: SUBAGENTES DISPONIBLES ---
48
59
  Puedes delegar tareas aisladas con 'spawn_agent':
49
60
  - 'explore': Investigación y búsqueda de código de solo lectura sin saturar el contexto principal.
50
61
  - 'plan': Generación de planes arquitectónicos.
@@ -13,6 +13,18 @@ export declare class SessionManager {
13
13
  saveSession(session: SessionData): void;
14
14
  loadLatestSession(): SessionData | null;
15
15
  loadSession(id: string): SessionData | null;
16
+ /**
17
+ * Sesiones guardadas, de la mas reciente a la mas antigua.
18
+ *
19
+ * El try/catch envolvia el map ENTERO, asi que un solo archivo corrupto
20
+ * -una sesion que se estaba escribiendo cuando se cerro el portatil, o un
21
+ * JSON truncado- hacia saltar la excepcion y devolver la lista vacia. El
22
+ * usuario con treinta sesiones guardadas abria /resume y no veia ninguna, y
23
+ * no habia forma de saber por que: sus sesiones seguian en el disco.
24
+ *
25
+ * Ahora cada archivo se lee por su cuenta. Uno malo se salta; los demas se
26
+ * listan.
27
+ */
16
28
  listSessions(): Array<{
17
29
  id: string;
18
30
  updatedAt: string;
@@ -37,18 +37,46 @@ export class SessionManager {
37
37
  }
38
38
  return null;
39
39
  }
40
+ /**
41
+ * Sesiones guardadas, de la mas reciente a la mas antigua.
42
+ *
43
+ * El try/catch envolvia el map ENTERO, asi que un solo archivo corrupto
44
+ * -una sesion que se estaba escribiendo cuando se cerro el portatil, o un
45
+ * JSON truncado- hacia saltar la excepcion y devolver la lista vacia. El
46
+ * usuario con treinta sesiones guardadas abria /resume y no veia ninguna, y
47
+ * no habia forma de saber por que: sus sesiones seguian en el disco.
48
+ *
49
+ * Ahora cada archivo se lee por su cuenta. Uno malo se salta; los demas se
50
+ * listan.
51
+ */
40
52
  listSessions() {
41
53
  if (!fs.existsSync(this.sessionsDir))
42
54
  return [];
55
+ let files;
43
56
  try {
44
- const files = fs.readdirSync(this.sessionsDir).filter((f) => f.endsWith(".json") && f !== "latest.json");
45
- return files.map((f) => {
46
- const data = JSON.parse(fs.readFileSync(path.join(this.sessionsDir, f), "utf-8"));
47
- return { id: data.id, updatedAt: data.updatedAt, cwd: data.cwd };
48
- });
57
+ files = fs.readdirSync(this.sessionsDir).filter((f) => f.endsWith(".json") && f !== "latest.json");
49
58
  }
50
59
  catch {
51
60
  return [];
52
61
  }
62
+ const sesiones = [];
63
+ for (const f of files) {
64
+ try {
65
+ const data = JSON.parse(fs.readFileSync(path.join(this.sessionsDir, f), "utf-8"));
66
+ // Sin id no se puede reanudar, asi que listarla solo seria ofrecer algo
67
+ // que no funciona.
68
+ if (!data || typeof data.id !== "string")
69
+ continue;
70
+ sesiones.push({
71
+ id: data.id,
72
+ updatedAt: typeof data.updatedAt === "string" ? data.updatedAt : "",
73
+ cwd: typeof data.cwd === "string" ? data.cwd : "",
74
+ });
75
+ }
76
+ catch {
77
+ // Archivo ilegible: se salta este, no la lista.
78
+ }
79
+ }
80
+ return sesiones.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
53
81
  }
54
82
  }