Hooks de Claude Code: los 3 fallos que corrigió v2.1.141
Claude Code 2.1.141 corrigió tres fallos reales en sus hooks: alertas de terminal, escape de argumentos y reintentos seguros con continueOnBlock.

Tres de los fallos en los hooks de Claude Code que más problemas me causaron durante el Q1 se resolvieron casi en silencio en un periodo de 7 días. A la mañana siguiente eliminé tres soluciones provisionales en shell de mi repositorio de administración.
La semana en que desaparecieron tres fallos reales de los hooks de Claude Code
Entre el 6 y el 13 de mayo de 2026, Anthropic publicó las versiones de Claude Code comprendidas entre la 2.1.132 y la 2.1.141. La mayoría de los cambios se concentró en el sistema de hooks.
Lo noté porque mantenía tres parches en mi repositorio con comentarios // TODO: remove when claude-code fixes this. En el transcurso de una semana pude borrar los tres.
Estos fueron los fallos, ordenados según el costo que tuvieron para mí:
El primero hacía que se perdieran las alertas de escritorio. Mi hook Notification enviaba la campana del sistema operativo a stdout para avisarme cuando terminaba una compactación larga. Funcionaba si Claude Code tenía el TTY en primer plano, pero fallaba sin mostrar ningún error en el resto de los casos: divisiones de tmux, terminales integradas de VS Code y paneles de WezTerm con el foco en otro panel. La solución llegó en 2.1.141 con terminalSequence.
El segundo era un problema de escape de caracteres en shell. Mi hook Stop ejecutaba bash -lc "node post-stop.js --reason '$CLAUDE_STOP_REASON'" y el campo del motivo rompía el comando en cuanto Claude devolvía una cadena con un apóstrofo. La solución fue la forma de ejecución args: string[] incorporada en 2.1.139.
El tercero provocaba que los rechazos de PostToolUse terminaran el turno en vez de devolver el control a Claude. Tuve que desactivar por completo un hook de validación de esquemas porque el runtime interpretaba una decisión block como un error fatal. La versión 2.1.139 añadió continueOnBlock, que convierte el bloqueo en una señal de reintento e incorpora el motivo al contexto.
Al final está el settings.json completo. Sin rodeos entre medias.
Anatomía de los hooks en 90 segundos
Claude Code 2.1.x ofrece nueve eventos de hook: SessionStart, PreToolUse, PostToolUse, UserPromptSubmit, Notification, Stop, SubagentStop, PreCompact y SessionEnd. Cada hook es un comando que el runtime inicia con un entorno y un payload de stdin documentados. El runtime interpreta el stdout del hook como JSON; determinados campos controlan su comportamiento: decision (allow/deny/block), reason (una cadena que se devuelve a Claude o se muestra al usuario), terminalSequence (bytes sin procesar enviados al TTY de control) y varios campos específicos de cada evento.
La idea clave es esta: para el runtime, el stdout del hook es la fuente de autoridad. Cualquier campo que no sepa interpretar es peso muerto. Cualquier campo que sí interprete cambiará lo que Claude reciba en el turno siguiente. Eso es todo.
terminalSequence: notificaciones de escritorio sin controlar el TTY
Antes de 2.1.141, mi hook Notification tenía este aspecto:
{
"hooks": {
"Notification": [{
"command": "bash -lc 'printf \"\\a\" && notify-send \"Claude\" \"$CLAUDE_MESSAGE\"'"
}]
}
}Se suponía que printf "\a" debía hacer sonar la campana del terminal. En la práctica, escribía \a en el descriptor de archivo heredado por el proceso del hook, que no es el terminal del usuario salvo que Claude Code esté en primer plano. Si Claude se ejecutaba en el panel 2 de una división de tmux mientras yo editaba en el panel 1, la campana nunca llegaba al terminal exterior. notify-send sí funcionaba, pero la alerta quedaba en la bandeja de notificaciones de GNOME y se me pasaba.
La versión 2.1.141 añadió el campo terminalSequence al JSON que el hook escribe en stdout. El runtime toma esa cadena y la envía directamente al dispositivo del terminal de control, sin pasar por la entrada o salida estándar del proceso del hook. Así quedó después de actualizar:
{
"hooks": {
"Notification": [{
"command": "node hooks/notify.mjs"
}]
}
}import { execSync } from "node:child_process";
const payload = JSON.parse(
await new Response(process.stdin).text()
);
execSync(`notify-send "Claude" ${JSON.stringify(payload.message)}`);
process.stdout.write(JSON.stringify({
terminalSequence: ""
})); es BEL. El runtime lo escribe en el TTY de control, por lo que el terminal exterior suena incluso cuando Claude está en un panel en segundo plano. macOS Terminal, iTerm2, WezTerm y Alacritty gestionaron correctamente este comportamiento en mis pruebas. tmux transmite BEL; de todos modos, conviene mantener set -g allow-passthrough on en tmux.conf para OSC 52.
Hay un caso límite que merece atención: el terminal integrado de VS Code descarta BEL de forma predeterminada. Active "terminal.integrated.enableBell": true en la configuración de usuario para que la alerta funcione allí.
args: string[] eliminó el escape de shell de mis comandos de hook
El hook Stop se dispara cuando termina un turno. Lo uso para registrar metadatos de la sesión en una instancia de Cloudflare D1 y así poder buscar en ejecuciones anteriores con grep.
Esta era la versión anterior a 2.1.139:
{
"hooks": {
"Stop": [{
"command": "bash -lc \"node scripts/post-stop.js --session $CLAUDE_SESSION_ID --reason '$CLAUDE_STOP_REASON'\""
}]
}
}Durante el primer mes me encontré con tres fallos de escape:
Los apóstrofos en $CLAUDE_STOP_REASON cerraban el argumento entre comillas simples y hacían que el resto se interpretara como tokens del shell. Cuando Claude devolvía un motivo como user's request completed, el hook fallaba y se perdía el registro de la sesión.
También daban problemas los backticks en los nombres de herramientas. Si se disparaba un hook y $CLAUDE_TOOL_NAME contenía la cadena `bash` porque Claude la había escrito en una respuesta, el shell intentaba ejecutar ese contenido como un subshell. En este caso no causaba daños; en términos generales, resultaba alarmante.
El tercer caso era el Unicode en los prompts del usuario. La mayoría de las veces, UTF-8 completa el recorrido de ida y vuelta por bash -lc sin problemas, pero ciertas combinaciones de puntos de código CJK y configuraciones regionales descartaban bytes sin avisar.
La versión 2.1.139 incorporó comandos en formato exec. Al pasar args como un array de cadenas, el runtime inicia el comando directamente, sin interponer un shell:
{
"hooks": {
"Stop": [{
"args": [
"node",
"scripts/post-stop.js",
"--session", "$CLAUDE_SESSION_ID",
"--reason", "$CLAUDE_STOP_REASON"
]
}]
}
}El runtime resuelve las variables $CLAUDE_* desde su propio entorno antes de invocar execve. No hay shell, interpolación del shell ni comillas que gestionar. La cadena user's request completed llega a mi script de Node como una única entrada argv[5], exactamente como Claude la generó.
En un hook PreToolUse que se ejecuta con cada llamada a una herramienta, esto importa.
Cuándo conservar la forma de shell: cuando se necesitan pipes, redirecciones o expansión de patrones. args:[] y command:"" son mutuamente excluyentes en cada entrada de hook; si hace falta ejecutar node x.js | jq | tee log, hay que mantener command:"" y asumir el costo del escape. Para el 90% de los hooks, el formato exec es la opción correcta.
continueOnBlock: un rechazo de PostToolUse que sí activa otro intento
Esta era la corrección que llevaba más tiempo esperando. Uso un hook PostToolUse para validar la salida de cualquier llamada a una herramienta que escriba en disco: si Claude crea un archivo TypeScript, el hook ejecuta tsc --noEmit y lo rechaza si encuentra errores de tipos.
Antes de 2.1.139, el flujo de rechazo estaba roto:
{
"hooks": {
"PostToolUse": [{
"matcher": "Write|Edit",
"command": "node hooks/validate-ts.mjs"
}]
}
}Cuando validate-ts.mjs devolvía { "decision": "block", "reason": "tsc failed: ..." }, el runtime terminaba el turno. Claude no recibía el motivo. Al usuario solo le aparecía el críptico mensaje "hook blocked the operation" y tenía que volver a enviar un prompt manualmente con el error pegado. Después de tres sesiones en vivo en las que esto cortó turnos productivos, desactivé el hook.
La versión 2.1.139 añadió continueOnBlock como configuración individual de cada hook:
{
"hooks": {
"PostToolUse": [{
"matcher": "Write|Edit",
"args": ["node", "hooks/validate-ts.mjs"],
"continueOnBlock": true,
"maxAttempts": 3
}]
}
}Ahora, una decisión block devuelve la cadena reason al contexto de Claude como un error en el resultado de la herramienta. Claude recibe tsc failed: src/api.ts(14,3): error TS2322: Type 'string' is not assignable to type 'number' y se corrige en el turno siguiente. El usuario no ve nada porque el bucle es interno.
maxAttempts impone un límite estricto. Sin ese límite, una validación no determinista —por ejemplo, una que dependa de una API remota con caídas intermitentes— consumirá contexto en reintentos infinitos. Yo uso tres. Tras tres fallos, el hook escala a un bloqueo definitivo y lo muestra al usuario.
Un antipatrón: no active continueOnBlock en hooks cuya decisión dependa del momento en que se ejecutan. Un hook que rechace escrituras durante una ventana de despliegue entrará en un bucle interminable si el despliegue sigue en curso cuando Claude reintente. La alternativa es condicionar el hook a $CLAUDE_EFFORT o incluir un contador de intentos en su script.
El dúo extra: $CLAUDE_EFFORT y CLAUDE_PROJECT_DIR
En este intervalo aparecieron, casi sin llamar la atención, dos variables de entorno que resultan muy útiles.
La versión 2.1.133 incorporó $CLAUDE_EFFORT al entorno de los hooks. Sus valores son low, medium, high y xhigh, y coinciden con el nivel de esfuerzo de Claude en el turno actual. Esto permite que un hook elija una rama según el esfuerzo sin analizar el prompt:
const effort = process.env.CLAUDE_EFFORT;
if (effort === "low" || effort === "medium") {
// skip expensive tsc check on quick edits
process.stdout.write(JSON.stringify({ decision: "allow" }));
process.exit(0);
}
// run full tsc --noEmit on high/xhighMe ahorro unos 800ms por cada edición rápida al omitir tsc con un nivel de esfuerzo bajo. En un turno de planificación high en el que Claude escribe una docena de archivos, la comprobación completa se sigue ejecutando y detecta errores reales.
La versión 2.1.139 añadió CLAUDE_PROJECT_DIR al entorno de los servidores MCP stdio iniciados por el runtime. Antes, esos servidores tenían que deducir la raíz del espacio de trabajo a partir de process.cwd(), algo que fallaba si el usuario iniciaba Claude Code desde un subdirectorio. Ahora cualquier servidor MCP puede leer process.env.CLAUDE_PROJECT_DIR y resolver correctamente las rutas relativas al espacio de trabajo.
Quienes mantienen un servidor MCP deberían actualizar la resolución de rutas del manifiesto para usar CLAUDE_PROJECT_DIR y recurrir a cwd() como alternativa en clientes antiguos. Es un parche de dos líneas que elimina toda una clase de fallos.
El settings.json exacto que uso
Este es el bloque de producción de omidsaffari-admin, con algunos datos omitidos. Seis DOs y un Workflow dependen de la salida de los hooks para las alertas de escritorio y los controles de CI.
{
"model": "claude-sonnet-4-7-20260501",
"permissions": {
"edit": "ask"
},
"hooks": {
"SessionStart": [{
"args": ["node", "hooks/session-start.mjs"]
}],
"PreToolUse": [{
"matcher": "Bash",
"args": ["node", "hooks/gate-bash.mjs"]
}],
"PostToolUse": [{
"matcher": "Write|Edit",
"args": ["node", "hooks/validate-ts.mjs"],
"continueOnBlock": true,
"maxAttempts": 3
}],
"Notification": [{
"args": ["node", "hooks/notify.mjs"]
}],
"Stop": [{
"args": [
"node",
"scripts/post-stop.js",
"--session", "$CLAUDE_SESSION_ID",
"--reason", "$CLAUDE_STOP_REASON"
]
}],
"PreCompact": [{
"args": ["node", "hooks/notify.mjs"]
}]
}
}{
"devDependencies": {
"@anthropic-ai/claude-code": "2.1.141"
}
}Instalación y verificación:
pnpm add -D @anthropic-ai/claude-code@2.1.141
claude --version # expect: 2.1.141
claude config doctor # expect: 0 hook warningsLos puntos clave: continueOnBlock y maxAttempts forman el par que controla el bucle de reintentos desde 2.1.139. La forma args:[] se usa en todos los hooks que no requieren funciones del shell, es decir, en todos los de esta configuración. Los hooks Notification y PreCompact comparten notify.mjs porque ambos necesitan alertas de escritorio con la misma salida terminalSequence.
Lista de verificación para migrar y cuándo conviene esperar
Antes de empezar, haga una copia de su settings.json actual. Identifique qué hooks usan command:"" y cuáles ya utilizan args:[]. Enumere todos los tipos de eventos que tenga configurados.
Migre un tipo de evento a la vez y déjelo funcionar durante 24 horas antes del siguiente cambio. Revise claude config doctor para detectar advertencias de hooks y busque en los registros las cadenas decision y reason para confirmar que el runtime recibe lo esperado.
Este es el orden que seguiría:
- Actualice el paquete a 2.1.141.
- Convierta un hook
command:""aargs:[]y compruebe que se ejecuta. - Añada
terminalSequencea su hookNotificationy verifique la campana desde un panel de tmux en segundo plano. - Añada
continueOnBlockal hookPostToolUseque más problemas le cause. Observe una sesión real para confirmar que Claude recibe el motivo y se corrige. - Migre el resto de sus hooks a
args:[]por lotes.
Omita la actualización si utiliza una instalación administrada fijada por un SDK superior que todavía no haya validado 2.1.141, o si depende de un campo de hook que 2.1.141 haya marcado como obsoleto. En el momento de escribir este artículo, ninguno de los cambios de mayo rompía los campos existentes, pero conviene fijar la versión y probarla en una rama antes de desplegarla para el equipo.
Si busca la guía completa que uso para fijar versiones, diseñar hooks y estructurar proyectos en seis agentes de producción, la lista de verificación de Claude Code + Codex recorre el proceso de principio a fin. Incluye los mismos patrones de settings.json, además de la estructura del lado del agente (Workflows, DOs y enlaces de Vectorize) que protegen los hooks.
Como lectura complementaria sobre herramientas de desarrollo, consulte la comparación entre entornos de Cursor Cloud Agent y Cloudflare Workers. Para conocer el contexto de la arquitectura de producción que hay detrás de este settings.json, lea el artículo sobre el ingeniero 100x con Cloudflare.
¿terminalSequence funciona en tmux?
Sí, con una salvedad. El runtime escribe la secuencia en el dispositivo del terminal de control, que tmux reenvía al terminal exterior siempre que set -g allow-passthrough on esté en tmux.conf o la secuencia sea un BEL simple (). BEL siempre atraviesa tmux. Las secuencias OSC necesitan que el passthrough esté habilitado.
¿Puedo usar args:[] y command:'' en la misma configuración de hook?
No. Son mutuamente excluyentes en cada entrada de hook. Elija el formato exec (args:[]) para comandos iniciados directamente y el formato shell (command:"") para operaciones que necesiten pipes, redirecciones o expansión de patrones. Si necesita combinar ambos, escriba un script envoltorio que use el formato exec y contenga internamente las funciones del shell.
¿continueOnBlock seguirá indefinidamente si Claude repite la misma validación fallida?
No si configura maxAttempts. El runtime limita los reintentos a ese número y después escala a un bloqueo definitivo. Sin maxAttempts, sí puede consumir contexto con un validador no determinista. Configúrelo siempre. Tres es un valor predeterminado razonable para validaciones de tipos; uno es lo adecuado para cualquier operación que dependa de un estado remoto.
¿$CLAUDE_EFFORT está disponible en todos los eventos de hook?
Sí. Desde 2.1.133, se incorpora al entorno de todos los hooks que inicia el runtime. El valor refleja el nivel de esfuerzo del turno actual, por lo que SessionStart recibe el nivel con el que el usuario inició la sesión y PostToolUse, el que estaba activo cuando se ejecutó la herramienta.
¿Qué deja de funcionar si vuelvo a 2.1.138?
terminalSequence, args:[], continueOnBlock y CLAUDE_PROJECT_DIR dejan de funcionar sin mostrar errores. El runtime ignora los campos JSON desconocidos y vuelve al análisis de command:"". Los hooks seguirán ejecutándose, pero perderán los comportamientos nuevos. Pruebe la reversión en una rama antes de depender de ella.
5 sept 2026







