chocolatito-code 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +155 -0
  2. package/dist/agent/compaction.d.ts +65 -0
  3. package/dist/agent/compaction.js +134 -0
  4. package/dist/agent/context.d.ts +3 -0
  5. package/dist/agent/context.js +35 -0
  6. package/dist/agent/loop.d.ts +91 -2
  7. package/dist/agent/loop.js +642 -91
  8. package/dist/agent/loopDetector.d.ts +57 -0
  9. package/dist/agent/loopDetector.js +0 -0
  10. package/dist/agent/mentions.d.ts +13 -0
  11. package/dist/agent/mentions.js +49 -0
  12. package/dist/agent/repoMap.d.ts +117 -0
  13. package/dist/agent/repoMap.js +560 -0
  14. package/dist/agent/result.d.ts +64 -0
  15. package/dist/agent/result.js +48 -0
  16. package/dist/agent/retry.d.ts +35 -0
  17. package/dist/agent/retry.js +123 -4
  18. package/dist/agent/salidaDelAgente.d.ts +43 -0
  19. package/dist/agent/salidaDelAgente.js +53 -0
  20. package/dist/agent/subagent.js +62 -15
  21. package/dist/agent/syntaxValidator.js +288 -52
  22. package/dist/agent/toolGate.d.ts +58 -0
  23. package/dist/agent/toolGate.js +111 -0
  24. package/dist/agent/tracker.js +4 -7
  25. package/dist/agent/undoManager.d.ts +44 -11
  26. package/dist/agent/undoManager.js +95 -24
  27. package/dist/agent/usageMeter.d.ts +56 -0
  28. package/dist/agent/usageMeter.js +51 -0
  29. package/dist/agent/verifier.d.ts +49 -0
  30. package/dist/agent/verifier.js +158 -0
  31. package/dist/config/constants.js +14 -0
  32. package/dist/config/engine.d.ts +19 -0
  33. package/dist/config/engine.js +35 -3
  34. package/dist/config/license.d.ts +5 -0
  35. package/dist/config/license.js +107 -9
  36. package/dist/config/limits.d.ts +28 -0
  37. package/dist/config/limits.js +51 -0
  38. package/dist/config/nodeVersion.d.ts +13 -0
  39. package/dist/config/nodeVersion.js +26 -0
  40. package/dist/config/permissions.d.ts +76 -1
  41. package/dist/config/permissions.js +379 -23
  42. package/dist/config/quota.js +24 -1
  43. package/dist/config/updater.d.ts +68 -0
  44. package/dist/config/updater.js +215 -0
  45. package/dist/hooks/manager.js +159 -14
  46. package/dist/index.js +240 -37
  47. package/dist/mcp/client.d.ts +50 -0
  48. package/dist/mcp/client.js +155 -19
  49. package/dist/mcp/ide.d.ts +76 -0
  50. package/dist/mcp/ide.js +160 -0
  51. package/dist/mcp/manager.js +9 -0
  52. package/dist/prompts/systemPrompt.js +12 -1
  53. package/dist/sessions/manager.d.ts +12 -0
  54. package/dist/sessions/manager.js +33 -5
  55. package/dist/sessions/resume.d.ts +36 -0
  56. package/dist/sessions/resume.js +43 -0
  57. package/dist/skills/manager.d.ts +1 -0
  58. package/dist/skills/manager.js +9 -0
  59. package/dist/tools/binary.d.ts +46 -0
  60. package/dist/tools/binary.js +100 -0
  61. package/dist/tools/browserCdp.js +13 -1
  62. package/dist/tools/browserExtension.d.ts +5 -0
  63. package/dist/tools/browserExtension.js +237 -22
  64. package/dist/tools/browserSession.d.ts +23 -0
  65. package/dist/tools/browserSession.js +28 -0
  66. package/dist/tools/computerUse.js +33 -1
  67. package/dist/tools/createImage.d.ts +11 -0
  68. package/dist/tools/createImage.js +13 -1
  69. package/dist/tools/definitions.js +17 -4
  70. package/dist/tools/editDiagnosis.d.ts +54 -0
  71. package/dist/tools/editDiagnosis.js +142 -0
  72. package/dist/tools/editFile.d.ts +1 -0
  73. package/dist/tools/editFile.js +118 -62
  74. package/dist/tools/findFiles.d.ts +21 -0
  75. package/dist/tools/findFiles.js +75 -6
  76. package/dist/tools/getSystemInfo.js +15 -7
  77. package/dist/tools/gitTools.d.ts +24 -0
  78. package/dist/tools/gitTools.js +87 -26
  79. package/dist/tools/grepSearch.js +111 -23
  80. package/dist/tools/moveCopyFile.d.ts +18 -1
  81. package/dist/tools/moveCopyFile.js +74 -4
  82. package/dist/tools/patchCascade.d.ts +57 -0
  83. package/dist/tools/patchCascade.js +338 -0
  84. package/dist/tools/runCommand.js +213 -25
  85. package/dist/tools/runner.js +58 -5
  86. package/dist/tools/toolDefsComputer.js +12 -2
  87. package/dist/tools/truncator.d.ts +27 -1
  88. package/dist/tools/truncator.js +117 -11
  89. package/dist/tools/viewFile.js +15 -11
  90. package/dist/tools/visionBridge.js +7 -0
  91. package/dist/tools/webSearch.d.ts +21 -0
  92. package/dist/tools/webSearch.js +125 -14
  93. package/dist/tools/writeFile.d.ts +16 -1
  94. package/dist/tools/writeFile.js +40 -2
  95. package/dist/ui/interrupt.d.ts +4 -1
  96. package/dist/ui/interrupt.js +59 -7
  97. package/dist/ui/keyboardGuard.d.ts +76 -0
  98. package/dist/ui/keyboardGuard.js +94 -0
  99. package/dist/ui/loopPrompt.d.ts +17 -0
  100. package/dist/ui/loopPrompt.js +59 -0
  101. package/dist/ui/pegados.d.ts +50 -0
  102. package/dist/ui/pegados.js +83 -0
  103. package/dist/ui/permissionPrompt.js +84 -2
  104. package/dist/ui/prompt.d.ts +40 -0
  105. package/dist/ui/prompt.js +436 -131
  106. package/dist/ui/reasoningStream.d.ts +8 -0
  107. package/dist/ui/reasoningStream.js +99 -7
  108. package/dist/ui/salida.d.ts +10 -0
  109. package/dist/ui/salida.js +50 -0
  110. package/extension/README.md +95 -74
  111. package/extension/background.js +772 -181
  112. package/extension/content.js +527 -46
  113. package/extension/fuentes/Chocolatito-Marca.ttf +0 -0
  114. package/extension/iconos/128.png +0 -0
  115. package/extension/iconos/16.png +0 -0
  116. package/extension/iconos/32.png +0 -0
  117. package/extension/iconos/48.png +0 -0
  118. package/extension/iconos/logotipo.png +0 -0
  119. package/extension/manifest.json +28 -4
  120. package/extension/popup.html +16 -6
  121. package/extension/popup.js +28 -12
  122. package/package.json +3 -2
@@ -0,0 +1,215 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { spawn } from "node:child_process";
5
+ /**
6
+ * Actualizaciones solas, sin que el usuario tenga que enterarse.
7
+ *
8
+ * POR QUE HACE FALTA
9
+ *
10
+ * Se arreglo un fallo del teclado tres veces y el dueno del producto seguia
11
+ * viendolo, porque su `chocolatito` apuntaba al paquete publicado dos dias
12
+ * antes. Si al que tiene el repositorio delante le pasa eso, al que paga 16,99 $
13
+ * al mes le va a pasar siempre: nadie corre `npm update -g` por gusto. Un
14
+ * arreglo que no llega es un arreglo que no existe.
15
+ *
16
+ * COMO FUNCIONA
17
+ *
18
+ * Al arrancar se le pregunta al registro de npm cual es la ultima version. La
19
+ * respuesta se guarda seis horas, asi que el coste real es una peticion cada
20
+ * medio dia. Si hay una nueva se instala de fondo y se avisa; el cambio entra
21
+ * en el siguiente arranque, no a mitad de sesion (cambiarle el codigo por
22
+ * debajo a un proceso vivo es como se rompen las cosas de verdad).
23
+ *
24
+ * LAS TRES COSAS QUE NUNCA HACE
25
+ *
26
+ * 1. No toca una instalacion de desarrollo. Si `chocolatito` sale de un
27
+ * `npm link` sobre el repositorio, instalar encima le borraria el trabajo al
28
+ * que lo esta escribiendo. Se detecta y se calla.
29
+ * 2. No bloquea el arranque. Si el registro no contesta, no contesta: la sesion
30
+ * ya va por su cuenta y el aviso aparece cuando llegue, o no aparece.
31
+ * 3. No insiste. Si no hay permiso para escribir en la carpeta global, se dice
32
+ * una vez el comando que hay que correr y se deja en paz.
33
+ *
34
+ * Para apagarlo del todo: CHOCOLATITO_SIN_ACTUALIZAR=1.
35
+ */
36
+ const PAQUETE = "chocolatito-code";
37
+ const REGISTRO = `https://registry.npmjs.org/${PAQUETE}/latest`;
38
+ /** Cada cuanto se le pregunta al registro. Doce veces al dia no aporta nada. */
39
+ export const HORAS_ENTRE_COMPROBACIONES = 6;
40
+ /** Lo que se espera al registro. Pasado esto, la sesion vale mas que el aviso. */
41
+ const TIMEOUT_MS = 4000;
42
+ const FICHERO_CACHE = path.join(os.homedir(), ".chocolatito", "actualizacion.json");
43
+ /**
44
+ * Compara dos versiones de tipo 1.2.3.
45
+ *
46
+ * No se usa una libreria por una razon concreta: `semver` arrastra dependencias
47
+ * y esto se ejecuta en el arranque de un CLI. Lo que si se respeta es lo unico
48
+ * que importa aqui: una preversion (1.1.0-beta.1) NUNCA es mas nueva que la
49
+ * final del mismo numero, o el usuario acabaria bajando a una beta sin pedirlo.
50
+ */
51
+ export function esMasNueva(candidata, actual) {
52
+ const trocear = (v) => {
53
+ const [nucleo, pre] = String(v || "").trim().split("-", 2);
54
+ const partes = nucleo.split(".").map((n) => Number.parseInt(n, 10));
55
+ return {
56
+ nums: [partes[0] || 0, partes[1] || 0, partes[2] || 0],
57
+ pre: pre ?? null,
58
+ };
59
+ };
60
+ const a = trocear(candidata);
61
+ const b = trocear(actual);
62
+ for (let i = 0; i < 3; i++) {
63
+ if (a.nums[i] > b.nums[i])
64
+ return true;
65
+ if (a.nums[i] < b.nums[i])
66
+ return false;
67
+ }
68
+ // Mismo numero: solo gana la candidata si la actual es preversion y ella no.
69
+ return a.pre === null && b.pre !== null;
70
+ }
71
+ /**
72
+ * De donde sale el `chocolatito` que se esta ejecutando.
73
+ *
74
+ * Si al lado del `dist` hay un `.git` o un `src`, esto es el repositorio, no un
75
+ * paquete instalado: `npm link` o `node dist/index.js` a pelo. Instalar encima
76
+ * seria pisarle el codigo al que lo esta escribiendo.
77
+ */
78
+ export function esInstalacionDeDesarrollo(raizDelPaquete) {
79
+ try {
80
+ return (fs.existsSync(path.join(raizDelPaquete, ".git")) ||
81
+ fs.existsSync(path.join(raizDelPaquete, "src")));
82
+ }
83
+ catch {
84
+ return false;
85
+ }
86
+ }
87
+ export function leerCache(fichero = FICHERO_CACHE) {
88
+ try {
89
+ return JSON.parse(fs.readFileSync(fichero, "utf-8"));
90
+ }
91
+ catch {
92
+ return {};
93
+ }
94
+ }
95
+ export function guardarCache(datos, fichero = FICHERO_CACHE) {
96
+ try {
97
+ fs.mkdirSync(path.dirname(fichero), { recursive: true });
98
+ fs.writeFileSync(fichero, JSON.stringify(datos, null, 2), "utf-8");
99
+ }
100
+ catch {
101
+ // Sin cache se pregunta en cada arranque: molesto, no grave. No es motivo
102
+ // para romperle la sesion a nadie.
103
+ }
104
+ }
105
+ /** Si toca preguntar al registro. Un reloj adelantado no deja de comprobar nunca. */
106
+ export function tocaComprobar(cache, ahora) {
107
+ const ultima = cache.comprobadoEn;
108
+ if (typeof ultima !== "number" || !Number.isFinite(ultima))
109
+ return true;
110
+ const transcurrido = ahora - ultima;
111
+ // El valor absoluto cubre el reloj hacia atras: una marca en el futuro
112
+ // significa que algo esta mal, y lo seguro es volver a preguntar.
113
+ return Math.abs(transcurrido) >= HORAS_ENTRE_COMPROBACIONES * 60 * 60 * 1000;
114
+ }
115
+ /** Le pregunta al registro. Devuelve null si no contesta a tiempo o no se entiende. */
116
+ export async function ultimaVersionPublicada(url = REGISTRO) {
117
+ try {
118
+ const res = await fetch(url, {
119
+ signal: AbortSignal.timeout(TIMEOUT_MS),
120
+ headers: { accept: "application/json" },
121
+ });
122
+ if (!res.ok)
123
+ return null;
124
+ const datos = (await res.json());
125
+ const v = String(datos?.version ?? "");
126
+ return /^\d+\.\d+\.\d+/.test(v) ? v : null;
127
+ }
128
+ catch {
129
+ // Sin red, con proxy raro, o el registro caido. Nada de esto es problema
130
+ // del usuario y nada de esto merece un mensaje de error.
131
+ return null;
132
+ }
133
+ }
134
+ /**
135
+ * Instala la version nueva en la carpeta global.
136
+ *
137
+ * Se hace con `npm install -g`, que es como se instalo, en vez de inventarse
138
+ * una descarga: asi funciona igual con nvm, con volta y con la instalacion de
139
+ * Windows, que ponen la carpeta global en sitios distintos.
140
+ */
141
+ export function instalar(version, ejecutar = spawnNpm) {
142
+ return ejecutar(["install", "-g", `${PAQUETE}@${version}`]).then((salida) => {
143
+ if (salida.codigo === 0) {
144
+ return {
145
+ ok: true,
146
+ mensaje: `Actualizado a ${version}. Entra al reiniciar chocolatito.`,
147
+ };
148
+ }
149
+ // El caso que de verdad pasa: la carpeta global es de administrador o de
150
+ // otro usuario. Decir "fallo la actualizacion" no ayuda; decir el comando si.
151
+ const texto = `${salida.salida}`.toLowerCase();
152
+ const sinPermiso = texto.includes("eacces") || texto.includes("eperm") || texto.includes("acceso denegado");
153
+ return {
154
+ ok: false,
155
+ mensaje: sinPermiso
156
+ ? `Hay una version nueva (${version}) pero no hay permiso para instalarla. Ejecuta: npm i -g ${PAQUETE}@latest`
157
+ : `No se pudo instalar la ${version} sola. Ejecuta: npm i -g ${PAQUETE}@latest`,
158
+ };
159
+ });
160
+ }
161
+ function spawnNpm(args) {
162
+ return new Promise((resolve) => {
163
+ // shell:true porque en Windows `npm` es un .cmd y sin shell no se encuentra.
164
+ const p = spawn("npm", args, { shell: true, windowsHide: true });
165
+ let salida = "";
166
+ p.stdout?.on("data", (d) => (salida += d));
167
+ p.stderr?.on("data", (d) => (salida += d));
168
+ p.on("error", (e) => resolve({ codigo: 1, salida: String(e?.message || e) }));
169
+ p.on("close", (codigo) => resolve({ codigo: codigo ?? 1, salida }));
170
+ });
171
+ }
172
+ /**
173
+ * El paso completo: comprobar, y si procede instalar.
174
+ *
175
+ * Devuelve lo que hay que ensenarle al usuario, o cadena vacia si no hay nada.
176
+ * No imprime nada por su cuenta: quien llama decide cuando y como, que en un
177
+ * CLI con un prompt dibujado no es lo mismo escribir ahora que dentro de un
178
+ * segundo.
179
+ */
180
+ export async function comprobarActualizacion(ajustes) {
181
+ if (!ajustes.forzar && process.env.CHOCOLATITO_SIN_ACTUALIZAR === "1")
182
+ return "";
183
+ const desarrollo = esInstalacionDeDesarrollo(ajustes.raizDelPaquete);
184
+ if (desarrollo && !ajustes.forzar)
185
+ return "";
186
+ const cache = leerCache();
187
+ if (!ajustes.forzar && !tocaComprobar(cache, ajustes.ahora)) {
188
+ // Ya se pregunto hace poco. Si en su dia se vio una version nueva y sigue
189
+ // sin instalarse (sin permisos, por ejemplo), se recuerda sin volver a la red.
190
+ const guardada = cache.ultima;
191
+ if (guardada && esMasNueva(guardada, ajustes.versionActual) && cache.avisadaDe !== guardada) {
192
+ return `Hay una version nueva (${guardada}). Ejecuta: npm i -g ${PAQUETE}@latest`;
193
+ }
194
+ return "";
195
+ }
196
+ const ultima = await ultimaVersionPublicada();
197
+ guardarCache({ ...cache, comprobadoEn: ajustes.ahora, ultima: ultima ?? cache.ultima });
198
+ if (!ultima)
199
+ return ajustes.forzar ? "No se pudo consultar el registro de npm. ¿Hay conexion?" : "";
200
+ if (!esMasNueva(ultima, ajustes.versionActual)) {
201
+ return ajustes.forzar ? `Ya vas por la ultima version (${ajustes.versionActual}).` : "";
202
+ }
203
+ if (desarrollo) {
204
+ // Solo con /actualizar se llega aqui, y aun asi no se instala: el aviso es
205
+ // util (te dice que lo publicado va por delante de lo que tienes), pisar el
206
+ // repositorio no.
207
+ return `Publicada la ${ultima} y tu vas por la ${ajustes.versionActual}, pero esto es una instalacion de desarrollo: no se toca. Publica desde el repositorio.`;
208
+ }
209
+ if (ajustes.automatico === false) {
210
+ return `Hay una version nueva (${ultima}). Ejecuta: npm i -g ${PAQUETE}@latest`;
211
+ }
212
+ const res = await instalar(ultima);
213
+ guardarCache({ ...leerCache(), comprobadoEn: ajustes.ahora, ultima, avisadaDe: res.ok ? ultima : undefined });
214
+ return res.mensaje;
215
+ }
@@ -1,8 +1,96 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import os from "node:os";
4
+ import chalk from "chalk";
4
5
  import { execa } from "execa";
5
6
  const DEFAULT_TIMEOUT_MS = 30_000;
7
+ /**
8
+ * EL SHELL DE LOS HOOKS, RESUELTO A UNA RUTA QUE EXISTE
9
+ *
10
+ * Antes esto era una sola linea: `shell: usaPowerShell ? "powershell.exe" :
11
+ * "/bin/bash"`. Ese "/bin/bash" literal no existe en Windows (Git Bash se
12
+ * instala en "C:\Program Files\Git\bin\bash.exe"; /bin/bash es una ruta interna
13
+ * de MSYS que el spawn de Windows no resuelve) y tampoco en una imagen Alpine,
14
+ * NixOS o slim, donde el unico shell es /bin/sh.
15
+ *
16
+ * Y no se notaba: con `reject: false` execa no lanza excepcion cuando el shell
17
+ * no existe, devuelve exitCode undefined con stdout y stderr vacios. O sea que
18
+ * un hook PreToolUse de deny quedaba descartado por el `if (!salida) continue` y
19
+ * la accion peligrosa se ejecutaba: el fallo silencioso en la direccion
20
+ * insegura, que es la peor de las dos.
21
+ */
22
+ const shellsResueltos = new Map();
23
+ function primeraExistente(rutas) {
24
+ return rutas.find((r) => !!r && fs.existsSync(r));
25
+ }
26
+ /** Busca un ejecutable por el PATH sin lanzar procesos (nada de `which`). */
27
+ function buscarEnPath(nombres, filtroDir) {
28
+ const dirs = (process.env.PATH || process.env.Path || "")
29
+ .split(path.delimiter)
30
+ .map((d) => d.trim().replace(/^"|"$/g, ""))
31
+ .filter(Boolean)
32
+ .filter((d) => !filtroDir || filtroDir(d));
33
+ for (const nombre of nombres) {
34
+ for (const dir of dirs) {
35
+ const candidato = path.join(dir, nombre);
36
+ if (fs.existsSync(candidato))
37
+ return candidato;
38
+ }
39
+ }
40
+ return undefined;
41
+ }
42
+ function buscarBash() {
43
+ if (process.platform !== "win32") {
44
+ // /bin/sh va al final a proposito: en Alpine, NixOS o una imagen slim es el
45
+ // unico que hay, y un hook de una linea funciona igual. Preferir un shell
46
+ // que si existe es mejor que no ejecutar la politica del usuario.
47
+ return (primeraExistente(["/bin/bash", "/usr/bin/bash", "/usr/local/bin/bash", "/bin/sh", "/usr/bin/sh"]) ?? null);
48
+ }
49
+ const bases = [
50
+ process.env["ProgramFiles"],
51
+ process.env["ProgramW6432"],
52
+ process.env["ProgramFiles(x86)"],
53
+ process.env["LOCALAPPDATA"] ? path.join(process.env["LOCALAPPDATA"], "Programs") : undefined,
54
+ ].filter((b) => !!b);
55
+ const git = primeraExistente(bases.map((b) => path.join(b, "Git", "bin", "bash.exe")));
56
+ if (git)
57
+ return git;
58
+ // Ultimo recurso, el PATH, saltandose System32\bash.exe: ese es el lanzador de
59
+ // WSL, que ve otro sistema de archivos y otras rutas. Un hook que crea estar
60
+ // mirando el proyecto miraria otra cosa.
61
+ return buscarEnPath(["bash.exe"], (dir) => !/system32\\?$/i.test(dir)) ?? null;
62
+ }
63
+ function buscarPowerShell() {
64
+ if (process.platform === "win32") {
65
+ const sysroot = process.env["SystemRoot"] || "C:\\Windows";
66
+ return (primeraExistente([path.join(sysroot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe")]) ??
67
+ buscarEnPath(["pwsh.exe", "powershell.exe"]) ??
68
+ null);
69
+ }
70
+ // Fuera de Windows solo tiene sentido si el usuario pidio "powershell" a mano.
71
+ return buscarEnPath(["pwsh"]) ?? null;
72
+ }
73
+ function resolverShell(tipo) {
74
+ if (shellsResueltos.has(tipo))
75
+ return shellsResueltos.get(tipo);
76
+ const ruta = tipo === "powershell" ? buscarPowerShell() : buscarBash();
77
+ shellsResueltos.set(tipo, ruta);
78
+ return ruta;
79
+ }
80
+ function recortar(texto, max = 400) {
81
+ const limpio = texto.trim().replace(/\s+$/g, "");
82
+ return limpio.length > max ? `${limpio.slice(0, max)}…` : limpio;
83
+ }
84
+ /** Por que no salio bien, en una linea, para poder contarselo al usuario. */
85
+ function motivoDelFallo(res) {
86
+ if (res?.timedOut)
87
+ return "agoto el tiempo limite";
88
+ if (res?.exitCode === undefined || res?.exitCode === null) {
89
+ const detalle = recortar(String(res?.shortMessage || res?.message || ""), 160).split("\n")[0];
90
+ return detalle ? `no llego a ejecutarse (${detalle})` : "no llego a ejecutarse";
91
+ }
92
+ return `codigo de salida ${res.exitCode}`;
93
+ }
6
94
  export class HookManager {
7
95
  config = {};
8
96
  disabled = false;
@@ -113,17 +201,63 @@ export class HookManager {
113
201
  });
114
202
  const decision = {};
115
203
  const contextos = [];
204
+ /**
205
+ * Un hook que no arranca, revienta o se cuelga NO bloquea (solo el codigo 2
206
+ * bloquea, igual que en Claude Code), pero TIENE que verse. Antes se caia
207
+ * por el `if (!salida) continue` de mas abajo y la politica quedaba muerta
208
+ * sin una sola linea de aviso: el usuario seguia creyendo que su guardia
209
+ * estaba puesto. Se imprime aqui, y no en el llamador, porque a run() la
210
+ * llaman el bucle, la puerta de herramientas y los subagentes, y solo uno de
211
+ * ellos pinta systemMessage. Por eso el aviso NO se mete ademas en
212
+ * systemMessage: el bucle lo imprimiria otra vez y el usuario veria el mismo
213
+ * fallo dos veces.
214
+ */
215
+ const avisar = (texto) => {
216
+ contextos.push(`[${texto}]`);
217
+ console.error(chalk.yellow(`\n⚠ ${texto}`));
218
+ };
219
+ /**
220
+ * En PostToolUse la herramienta YA se ejecuto: "bloquear" no puede abortar
221
+ * nada, solo devolverle el motivo al modelo para que lo corrija. Y era justo
222
+ * eso lo que no pasaba: el bloqueo se empaquetaba en permissionDecision /
223
+ * permissionDecisionReason y se volvia de inmediato, mientras que el unico
224
+ * consumidor de PostToolUse lee additionalContext. Resultado: el atajo que
225
+ * documenta el propio proyecto —salir con codigo 2, o imprimir
226
+ * decision:"block"— era un agujero negro. El hook se ejecutaba, salia en
227
+ * /hooks como configurado, y ni el usuario ni el modelo veian una palabra.
228
+ *
229
+ * Devuelve true cuando hay que cortar la cadena, que solo tiene sentido
230
+ * antes de ejecutar. En PostToolUse se sigue: los demas hooks —linters,
231
+ * tests— tambien tienen algo que decir sobre lo que ya ha pasado.
232
+ */
233
+ const esPost = event === "PostToolUse";
234
+ const anotarBloqueo = (motivo) => {
235
+ decision.permissionDecision = "deny";
236
+ decision.permissionDecisionReason = motivo;
237
+ if (!esPost)
238
+ return true;
239
+ contextos.push(`BLOQUEO del hook PostToolUse: ${motivo}`);
240
+ return false;
241
+ };
116
242
  for (const hook of hooks) {
117
243
  try {
118
- const usaPowerShell = hook.shell === "powershell" || (hook.shell !== "bash" && process.platform === "win32");
244
+ const tipo = hook.shell === "powershell" || (hook.shell !== "bash" && process.platform === "win32")
245
+ ? "powershell"
246
+ : "bash";
247
+ const shell = resolverShell(tipo);
248
+ if (!shell) {
249
+ avisar(`El hook ${event} "${hook.command}" NO se ejecuto: no hay ningun ${tipo} en este sistema. ` +
250
+ "La regla que impone no se esta aplicando.");
251
+ continue;
252
+ }
119
253
  // PowerShell NO propaga el codigo de salida del programa que ejecuta:
120
254
  // powershell.exe sale con 0 aunque el hook haya salido con 2. Sin este
121
255
  // "exit $LASTEXITCODE", el bloqueo por codigo de salida no funcionaba en
122
256
  // Windows y el hook parecia estar aprobando siempre.
123
- const comando = usaPowerShell ? `${hook.command}; exit $LASTEXITCODE` : hook.command;
257
+ const comando = tipo === "powershell" ? `${hook.command}; exit $LASTEXITCODE` : hook.command;
124
258
  const res = await execa(comando, {
125
259
  cwd: this.cwd,
126
- shell: usaPowerShell ? "powershell.exe" : "/bin/bash",
260
+ shell,
127
261
  input,
128
262
  timeout: hook.timeout ? hook.timeout * 1000 : DEFAULT_TIMEOUT_MS,
129
263
  reject: false,
@@ -132,10 +266,19 @@ export class HookManager {
132
266
  // Codigo 2 = bloqueo. No se puede sobrescribir con JSON: es el atajo
133
267
  // para un hook de una linea que solo quiere decir "aqui no".
134
268
  if (res.exitCode === 2) {
135
- decision.permissionDecision = "deny";
136
- decision.permissionDecisionReason =
137
- (res.stderr || res.stdout || "").trim() || `El hook "${hook.command}" bloqueo la accion.`;
138
- return decision;
269
+ const motivo = (res.stderr || res.stdout || "").trim() || `El hook "${hook.command}" bloqueo la accion.`;
270
+ if (anotarBloqueo(motivo))
271
+ return decision;
272
+ continue;
273
+ }
274
+ // Cualquier otro final anormal (script inexistente, error de sintaxis,
275
+ // timeout, shell que no arranco) se cuenta. No bloquea, pero se dice:
276
+ // ese silencio era el fallo, no el hecho de dejar pasar.
277
+ if (res.exitCode !== 0) {
278
+ const detalle = recortar(String(res.stderr || res.stdout || ""));
279
+ avisar(`El hook ${event} "${hook.command}" fallo (${motivoDelFallo(res)}) y su regla no se aplico.` +
280
+ (detalle ? `\n ${detalle.split("\n").join("\n ")}` : ""));
281
+ continue;
139
282
  }
140
283
  const salida = (res.stdout || "").trim();
141
284
  if (!salida)
@@ -151,14 +294,16 @@ export class HookManager {
151
294
  }
152
295
  const esp = parsed.hookSpecificOutput || {};
153
296
  if (parsed.decision === "block") {
154
- decision.permissionDecision = "deny";
155
- decision.permissionDecisionReason = parsed.reason || "Bloqueado por un hook.";
156
- return decision;
297
+ if (anotarBloqueo(parsed.reason || "Bloqueado por un hook."))
298
+ return decision;
299
+ if (parsed.systemMessage)
300
+ decision.systemMessage = parsed.systemMessage;
301
+ continue;
157
302
  }
158
303
  if (esp.permissionDecision === "deny") {
159
- decision.permissionDecision = "deny";
160
- decision.permissionDecisionReason = esp.permissionDecisionReason || "Bloqueado por un hook.";
161
- return decision;
304
+ if (anotarBloqueo(esp.permissionDecisionReason || "Bloqueado por un hook."))
305
+ return decision;
306
+ continue;
162
307
  }
163
308
  // "allow" no gana sobre un "deny" posterior: por eso no se corta aqui.
164
309
  if (esp.permissionDecision && !decision.permissionDecision) {
@@ -178,7 +323,7 @@ export class HookManager {
178
323
  }
179
324
  }
180
325
  catch (err) {
181
- contextos.push(`[hook "${hook.command}" fallo: ${err?.message || err}]`);
326
+ avisar(`El hook ${event} "${hook.command}" fallo y su regla no se aplico: ${err?.message || err}`);
182
327
  }
183
328
  }
184
329
  if (contextos.length > 0)