Claude Code hooks : 3 bugs corrigés en 7 jours
Guide pratique pour configurer les Claude Code hooks en production : terminalSequence, args:string[] et continueOnBlock, avec le settings.json complet.

Trois des bugs liés aux Claude Code hooks qui m’ont le plus pénalisé au T1 ont été corrigés discrètement en l’espace de 7 jours. Dès le lendemain matin, j’ai supprimé trois contournements shell de mon dépôt d’administration.
La semaine où trois vrais bugs ont disparu
Du 6 au 13 mai 2026, Anthropic a publié les versions 2.1.132 à 2.1.141 de Claude Code. L’essentiel des changements concernait le système de hooks.
Je m’en suis aperçu parce que mon dépôt contenait trois rustines accompagnées d’un commentaire // TODO: remove when claude-code fixes this. En une semaine, j’ai pu supprimer les trois.
Voici ces bugs, classés selon ce qu’ils m’ont coûté :
Le premier me privait d’alertes sur le bureau. Mon hook Notification envoyait la cloche du système d’exploitation vers stdout pour me prévenir lorsqu’une longue compaction était terminée. Cela fonctionnait quand Claude Code occupait le TTY au premier plan, mais échouait silencieusement partout ailleurs : splits tmux, terminaux intégrés à VS Code, ou panneaux WezTerm lorsque le focus était sur le voisin. Le correctif est arrivé avec terminalSequence dans la version 2.1.141.
Le deuxième venait de l’échappement shell. Mon hook Stop exécutait bash -lc "node post-stop.js --reason '$CLAUDE_STOP_REASON'", et tout cassait dès que Claude renvoyait une chaîne contenant une apostrophe. La solution a été la forme d’exécution args: string[], ajoutée dans la version 2.1.139.
Le troisième concernait les rejets de PostToolUse : ils mettaient fin au tour au lieu de renvoyer Claude dans une boucle de correction. J’avais dû désactiver entièrement un hook de validation de schéma, car le runtime traitait toute décision block comme fatale. La version 2.1.139 a ajouté continueOnBlock, qui transforme le blocage en signal de nouvelle tentative et injecte son motif dans le contexte.
Le fichier settings.json complet se trouve à la fin. Sans détour inutile entre les deux.
Claude Code hooks : l’anatomie en 90 secondes
Claude Code 2.1.x expose neuf événements de hook : SessionStart, PreToolUse, PostToolUse, UserPromptSubmit, Notification, Stop, SubagentStop, PreCompact et SessionEnd. Chaque hook est une commande que le runtime lance avec un environnement documenté et un payload sur stdin. Le runtime interprète la sortie stdout du hook comme du JSON ; certains champs déterminent directement son comportement : decision (allow/deny/block), reason (chaîne renvoyée à Claude ou affichée à l’utilisateur), terminalSequence (octets bruts transmis au TTY de contrôle), ainsi que quelques champs propres à chaque événement.
Le principe à retenir : le runtime considère la sortie stdout du hook comme la source de vérité. Un champ qu’il ne sait pas interpréter ne sert à rien. Un champ qu’il interprète modifie ce que Claude verra au tour suivant. Tout se joue là.
terminalSequence : des notifications même sans contrôler le TTY
Avant la version 2.1.141, mon hook Notification ressemblait à ceci :
{
"hooks": {
"Notification": [{
"command": "bash -lc 'printf \"\\a\" && notify-send \"Claude\" \"$CLAUDE_MESSAGE\"'"
}]
}
}Le printf "\a" était censé déclencher la cloche du terminal. En pratique, il écrivait \a vers le descripteur de fichier hérité par le processus du hook, qui n’est pas le terminal de l’utilisateur sauf lorsque Claude Code se trouve au premier plan. Dans un split tmux où Claude tourne dans le panneau 2 tandis que je travaille dans le panneau 1, la cloche n’atteignait jamais le terminal externe. notify-send fonctionnait, mais sa notification se perdait dans le centre de notifications de GNOME et je la manquais.
La version 2.1.141 a ajouté un champ terminalSequence au JSON écrit sur stdout par le hook. Le runtime récupère cette chaîne et l’écrit directement sur le périphérique du terminal de contrôle, sans passer par les entrées-sorties du processus du hook. Voici la version après mise à niveau :
{
"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: ""
})); correspond à BEL. Le runtime l’écrit sur le TTY de contrôle, et le terminal externe sonne même lorsque Claude se trouve dans un panneau en arrière-plan. macOS Terminal, iTerm2, WezTerm et Alacritty ont tous géré ce comportement correctement lors de mes tests. tmux transmet BEL ; conservez malgré tout set -g allow-passthrough on dans votre tmux.conf pour OSC 52.
Un cas limite mérite d’être signalé : le terminal intégré de VS Code absorbe BEL par défaut. Activez "terminal.integrated.enableBell": true dans les paramètres utilisateur pour que la cloche s’y déclenche.
args: string[] a éliminé les problèmes d’échappement shell
Le hook Stop se déclenche à la fin d’un tour. Je m’en sers pour enregistrer les métadonnées de session dans une instance Cloudflare D1, afin de pouvoir rechercher dans les anciennes exécutions.
Voici la version antérieure à la 2.1.139 :
{
"hooks": {
"Stop": [{
"command": "bash -lc \"node scripts/post-stop.js --session $CLAUDE_SESSION_ID --reason '$CLAUDE_STOP_REASON'\""
}]
}
}Trois bugs d’échappement m’ont touché dès le premier mois :
Les apostrophes présentes dans $CLAUDE_STOP_REASON fermaient l’argument entre apostrophes simples, puis le reste était interprété comme des tokens shell. Quand Claude renvoyait un motif tel que user's request completed, le hook plantait et le journal de session était perdu.
Les backticks dans les noms d’outils. Lorsqu’un hook se déclenchait avec un $CLAUDE_TOOL_NAME contenant la chaîne `bash` parce que Claude l’avait écrite dans une réponse, le shell tentait d’exécuter son contenu dans un sous-shell. Sans conséquence dans ce cas précis, mais terrifiant en général.
L’Unicode dans les prompts utilisateur. Les allers-retours UTF-8 à travers bash -lc se passent correctement la plupart du temps, mais certaines combinaisons de points de code CJK et de paramètres régionaux faisaient disparaître des octets sans aucun avertissement.
La version 2.1.139 a introduit les commandes sous forme exec. En fournissant args comme tableau de chaînes, le runtime lance directement la commande, sans intercaler de shell :
{
"hooks": {
"Stop": [{
"args": [
"node",
"scripts/post-stop.js",
"--session", "$CLAUDE_SESSION_ID",
"--reason", "$CLAUDE_STOP_REASON"
]
}]
}
}Le runtime résout les variables $CLAUDE_* depuis son propre environnement avant d’appeler execve. Aucun shell, donc aucune interpolation ni gestion de guillemets. La chaîne user's request completed arrive dans mon script Node sous la forme d’une seule entrée argv[5], exactement comme Claude l’a produite.
Sur un hook PreToolUse exécuté à chaque appel d’outil, la différence compte.
Quand faut-il conserver la forme shell ? Dès qu’il faut des pipes, des redirections ou du globbing. args:[] et command:"" sont mutuellement exclusifs dans une même entrée de hook. Si la commande exige node x.js | jq | tee log, restez sur command:"" et acceptez le coût de l’échappement. Pour 90% des hooks, la forme exec est le bon choix.
continueOnBlock : un rejet PostToolUse qui relance vraiment Claude
C’est le correctif que j’ai attendu le plus longtemps. J’exécute un hook PostToolUse qui valide la sortie de tout appel d’outil écrivant sur disque : si Claude écrit un fichier TypeScript, le hook lance tsc --noEmit et rejette l’opération en présence d’erreurs de types.
Avant la version 2.1.139, le flux de rejet était cassé :
{
"hooks": {
"PostToolUse": [{
"matcher": "Write|Edit",
"command": "node hooks/validate-ts.mjs"
}]
}
}Lorsque validate-ts.mjs renvoyait { "decision": "block", "reason": "tsc failed: ..." }, le runtime mettait fin au tour. Claude ne voyait pas le motif. L’utilisateur recevait un message obscur, « hook blocked the operation », et devait relancer manuellement avec l’erreur copiée dans le prompt. Après trois sessions en production interrompues en plein élan, j’ai désactivé le hook.
La version 2.1.139 a ajouté continueOnBlock dans la configuration de chaque hook :
{
"hooks": {
"PostToolUse": [{
"matcher": "Write|Edit",
"args": ["node", "hooks/validate-ts.mjs"],
"continueOnBlock": true,
"maxAttempts": 3
}]
}
}Désormais, une décision block réinjecte la chaîne reason dans le contexte de Claude sous forme d’erreur de résultat d’outil. Claude voit tsc failed: src/api.ts(14,3): error TS2322: Type 'string' is not assignable to type 'number' et se corrige au tour suivant. L’utilisateur ne voit rien, car la boucle reste interne.
maxAttempts impose une limite stricte. Sans ce réglage, une validation non déterministe — par exemple si elle dépend d’une API distante momentanément indisponible — peut consommer du contexte en réessayant indéfiniment. J’ai retenu trois tentatives. Après trois échecs, le hook transforme le rejet en blocage ferme et l’affiche à l’utilisateur.
Anti-pattern : n’activez pas continueOnBlock sur des hooks dont la décision dépend de l’heure. Un hook qui refuse les écritures pendant une fenêtre de déploiement bouclera sans fin si le déploiement est toujours en cours lorsque Claude réessaie. Filtrez plutôt sur $CLAUDE_EFFORT ou intégrez un compteur de tentatives au script du hook.
Le duo bonus : $CLAUDE_EFFORT et CLAUDE_PROJECT_DIR
Deux variables d’environnement sont discrètement apparues dans cette série de versions, et toutes deux se révèlent réellement utiles.
La version 2.1.133 a injecté $CLAUDE_EFFORT dans l’environnement des hooks. Ses valeurs sont low, medium, high et xhigh, conformément au niveau d’effort choisi pour le tour en cours. Un hook peut ainsi adapter son comportement sans analyser le 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/xhighJ’économise environ 800ms sur chaque modification rapide en sautant tsc lorsque l’effort est faible. Lors d’un tour de planification en high où Claude écrit une douzaine de fichiers, la vérification complète s’exécute toujours et détecte de vrais bugs.
La version 2.1.139 a ajouté CLAUDE_PROJECT_DIR à l’environnement des serveurs MCP stdio lancés par le runtime. Auparavant, ces serveurs devaient déduire la racine du workspace à partir de process.cwd(), ce qui cassait lorsque l’utilisateur lançait Claude Code depuis un sous-répertoire. Désormais, tout serveur MCP peut lire process.env.CLAUDE_PROJECT_DIR et résoudre correctement les chemins relatifs au workspace.
Si vous maintenez un serveur MCP, faites en sorte que la résolution des chemins dans son manifeste utilise CLAUDE_PROJECT_DIR, avec un repli sur cwd() pour les clients plus anciens. Deux lignes modifiées, toute une catégorie de bugs supprimée.
Le settings.json exact que j’utilise
Voici le bloc de production d’omidsaffari-admin, légèrement expurgé. Six DOs et un Workflow s’appuient sur la sortie des hooks pour les alertes de bureau et les contrôles 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"
}
}Installation et vérification :
pnpm add -D @anthropic-ai/claude-code@2.1.141
claude --version # expect: 2.1.141
claude config doctor # expect: 0 hook warningsÀ retenir : continueOnBlock et maxAttempts forment la paire qui gère la boucle de nouvelle tentative depuis la version 2.1.139. La forme args:[] est utilisée pour chaque hook qui n’a pas besoin des fonctions du shell — autrement dit, pour tous ceux de cette configuration. Les hooks Notification et PreCompact partagent notify.mjs, car ils doivent tous deux produire les mêmes alertes de bureau avec la même sortie terminalSequence.
Checklist de déploiement et cas où il vaut mieux attendre
Avant de commencer, prenez un instantané de votre settings.json actuel. Repérez les hooks qui utilisent command:"" et ceux qui sont déjà en args:[]. Dressez la liste de tous les types d’événements raccordés.
Migrez un seul type d’événement à la fois. Laissez chaque migration tourner pendant 24 heures. Surveillez les avertissements de hooks avec claude config doctor et recherchez les chaînes decision et reason dans vos logs pour vérifier que le runtime reçoit bien ce que vous attendez.
Voici l’ordre que je recommande :
- Mettez le package à niveau vers la version 2.1.141.
- Convertissez un hook
command:""enargs:[], puis vérifiez qu’il se déclenche. - Ajoutez
terminalSequenceà votre hookNotification. Vérifiez la cloche depuis un panneau tmux en arrière-plan. - Ajoutez
continueOnBlockau hookPostToolUsequi vous pose le plus de problèmes. Observez une vraie session pour confirmer que Claude voit le motif et se corrige. - Migrez le reste de vos hooks vers
args:[]par lots.
Ne procédez pas à la mise à niveau si votre installation administrée est verrouillée par un SDK parent qui n’a pas encore validé la version 2.1.141, ou si vous dépendez d’un champ de hook que la version 2.1.141 a déprécié. Au moment où j’écris ces lignes, aucun changement de la série de mai n’était incompatible avec les champs existants. Épinglez néanmoins la version et testez-la dans une branche avant de la déployer dans votre équipe.
Pour retrouver le guide complet sur l’épinglage des versions, la stratégie de hooks et la structure de projet que j’applique à six agents en production, consultez la checklist de configuration Claude Code + Codex. Vous y trouverez les mêmes modèles de settings.json, ainsi que la structure côté agents — Workflows, DOs et bindings Vectorize — que ces hooks contrôlent.
Pour une analyse parallèle des outils de développement permettant d’exécuter ces agents dans des sandboxes distantes, lisez Cursor Cloud Agent face aux environnements Cloudflare Workers. Pour comprendre la stack de production derrière ce settings.json, voyez l’article sur l’ingénieur Cloudflare 100x.
terminalSequence fonctionne-t-il dans tmux ?
Oui, à une nuance près. Le runtime écrit la séquence sur le périphérique du terminal de contrôle, que tmux transmet au terminal externe dès lors que set -g allow-passthrough on figure dans votre tmux.conf ou que la séquence est un simple BEL (). BEL passe sans condition. Les séquences OSC nécessitent l’activation du passthrough.
Peut-on utiliser à la fois args:[] et command:'' dans le même hook ?
Non. Les deux formes sont mutuellement exclusives dans une même entrée de hook. Choisissez la forme exec (args:[]) pour lancer directement une commande, et la forme shell (command:"") dès qu’il faut des pipes, des redirections ou du globbing. Si vous avez besoin des deux, écrivez un script wrapper appelé en forme exec et placez-y les fonctions shell.
continueOnBlock peut-il boucler indéfiniment si Claude échoue toujours à la même validation ?
Pas si maxAttempts est défini. Le runtime limite les nouvelles tentatives à ce nombre, puis transforme le rejet en blocage ferme. Sans maxAttempts, oui, un validateur non déterministe peut consommer du contexte. Définissez-le toujours. Trois est une valeur raisonnable pour les validations de type checking ; une seule tentative convient à tout ce qui dépend d’un état distant.
$CLAUDE_EFFORT est-il disponible pour chaque événement de hook ?
Oui. Depuis la version 2.1.133, cette variable est injectée dans l’environnement de chaque hook lancé par le runtime. Sa valeur reflète le niveau d’effort du tour en cours : SessionStart reçoit donc celui choisi au lancement par l’utilisateur, et PostToolUse celui qui était actif au déclenchement de l’outil.
Que se passe-t-il si je reviens à la version 2.1.138 ?
terminalSequence, args:[], continueOnBlock et CLAUDE_PROJECT_DIR cessent tous de fonctionner silencieusement. Le runtime ignore les champs JSON inconnus et revient à l’analyse de command:"". Les hooks continuent de se déclencher, mais sans les nouveaux comportements. Testez le retour en arrière dans une branche avant de vous y fier.
5 sept. 2026







