Hook Claude Code: 3 bug risolti con l'upgrade alla 2.1.141
Tre bug degli hook Claude Code risolti tra le versioni 2.1.139 e 2.1.141: come usare args, terminalSequence e continueOnBlock senza workaround.

Tre dei bug degli hook Claude Code che mi avevano creato più problemi nel Q1 sono stati risolti in sordina nell'arco di 7 giorni. La mattina seguente ho eliminato tre workaround shell dal mio repository amministrativo.
Hook Claude Code: la settimana in cui sono spariti tre bug reali
Tra il 6 e il 13 maggio 2026, Anthropic ha pubblicato le versioni di Claude Code dalla 2.1.132 alla 2.1.141. Gran parte delle modifiche riguardava il sistema di hook.
Me ne sono accorto perché nel repository avevo tre workaround accompagnati dal commento // TODO: remove when claude-code fixes this. Nel giro di una settimana ho potuto eliminarli tutti.
Ecco i bug, ordinati in base all'impatto che avevano sul mio lavoro.
Il primo riguardava le notifiche desktop che non arrivavano. Il mio hook Notification inviava il segnale acustico del sistema operativo su stdout, così da avvisarmi al termine di una compattazione lunga. Funzionava quando Claude Code controllava il TTY in primo piano, ma falliva in silenzio in tutti gli altri casi: split di tmux, terminali integrati di VS Code, pannelli di WezTerm con il focus su quello adiacente. La correzione è arrivata con terminalSequence nella 2.1.141.
Il secondo era un problema di quoting della shell. Il mio hook Stop eseguiva bash -lc "node post-stop.js --reason '$CLAUDE_STOP_REASON'", ma bastava che Claude restituisse una stringa con un apostrofo nel campo del motivo per rompere il comando. La soluzione è stata la forma exec args: string[], introdotta nella 2.1.139.
Il terzo coinvolgeva i rifiuti di PostToolUse: invece di tornare a Claude e avviare un nuovo tentativo, interrompevano il turno. Avevo dovuto disattivare del tutto un hook di validazione dello schema, perché il runtime trattava una decisione block come un errore fatale. La 2.1.139 ha aggiunto continueOnBlock, che trasforma il blocco in un segnale di retry e inserisce il motivo nel contesto.
In fondo trovate il mio settings.json completo. Niente riempitivi nel mezzo.
Anatomia degli hook in 90 secondi
Claude Code 2.1.x espone nove eventi hook: SessionStart, PreToolUse, PostToolUse, UserPromptSubmit, Notification, Stop, SubagentStop, PreCompact e SessionEnd. Ogni hook è un comando che il runtime avvia con un ambiente documentato e un payload su stdin. Il runtime interpreta lo stdout dell'hook come JSON; alcuni campi specifici determinano il comportamento in esecuzione: decision (allow/deny/block), reason (una stringa restituita a Claude o mostrata all'utente), terminalSequence (byte grezzi inviati al TTY di controllo) e pochi altri campi legati al singolo evento.
Il concetto da fissare è questo: per il runtime, lo stdout dell'hook è la fonte autorevole. Ogni campo che non viene interpretato è inutile; ogni campo riconosciuto modifica ciò che Claude vedrà nel turno successivo. Il meccanismo è tutto qui.
terminalSequence: notifiche desktop senza controllare il TTY
Prima della 2.1.141, il mio hook Notification era configurato così:
{
"hooks": {
"Notification": [{
"command": "bash -lc 'printf \"\\a\" && notify-send \"Claude\" \"$CLAUDE_MESSAGE\"'"
}]
}
}In teoria, printf "\a" avrebbe dovuto attivare il bell del terminale. In pratica scriveva \a sul file descriptor ereditato dal processo dell'hook, che coincide con il terminale dell'utente solo quando Claude Code è in primo piano. Se Claude girava nel pannello 2 di uno split tmux mentre lavoravo nel pannello 1, il bell non raggiungeva mai il terminale esterno. notify-send funzionava, ma la notifica finiva nell'area notifiche di GNOME, dove spesso non la vedevo.
La 2.1.141 ha introdotto il campo terminalSequence nel JSON scritto dall'hook su stdout. Il runtime prende la stringa e la invia direttamente al dispositivo del terminale di controllo, evitando lo stdio del processo dell'hook. Dopo l'aggiornamento, la configurazione è diventata questa:
{
"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: ""
})); è BEL. Il runtime lo scrive sul TTY di controllo e il terminale esterno suona anche quando Claude si trova in un pannello in background. Nei miei test, macOS Terminal, iTerm2, WezTerm e Alacritty gestiscono correttamente questo comportamento. tmux lascia passare BEL; conviene comunque mantenere set -g allow-passthrough on nel proprio tmux.conf per OSC 52.
C'è un caso limite da conoscere: per impostazione predefinita, il terminale integrato di VS Code ignora BEL. Per abilitarlo, impostate "terminal.integrated.enableBell": true nelle preferenze utente.
args: string[] elimina i problemi di escaping nei comandi degli hook
L'hook Stop scatta alla fine di un turno. Lo uso per registrare i metadati della sessione in un'istanza Cloudflare D1, così posso cercare rapidamente nelle esecuzioni precedenti.
Prima della 2.1.139, la configurazione era questa:
{
"hooks": {
"Stop": [{
"command": "bash -lc \"node scripts/post-stop.js --session $CLAUDE_SESSION_ID --reason '$CLAUDE_STOP_REASON'\""
}]
}
}Nel primo mese mi sono scontrato con tre errori di escaping.
Gli apostrofi in $CLAUDE_STOP_REASON chiudevano l'argomento racchiuso tra apici singoli, facendo interpretare tutto il resto come token della shell. Quando Claude restituiva un motivo come user's request completed, l'hook andava in crash e il log della sessione andava perso.
I backtick nei nomi dei tool erano altrettanto insidiosi. Se $CLAUDE_TOOL_NAME conteneva la stringa `bash` perché Claude l'aveva usata in una risposta, la shell tentava di eseguire il contenuto tra backtick come subshell. In quel caso non succedeva nulla di grave, ma in generale è un comportamento inquietante.
Infine, l'Unicode nei prompt utente. Nella maggior parte dei casi UTF-8 attraversa bash -lc senza problemi, ma alcuni code point CJK, combinati con determinate impostazioni locali, perdevano byte senza produrre errori.
La 2.1.139 ha aggiunto i comandi in forma exec. Passando args come array di stringhe, il runtime avvia direttamente il comando senza interporre una shell:
{
"hooks": {
"Stop": [{
"args": [
"node",
"scripts/post-stop.js",
"--session", "$CLAUDE_SESSION_ID",
"--reason", "$CLAUDE_STOP_REASON"
]
}]
}
}Prima di chiamare execve, il runtime risolve le variabili $CLAUDE_* dal proprio ambiente. Niente shell, niente interpolazione della shell, niente quoting. La stringa user's request completed arriva allo script Node come un'unica voce argv[5], esattamente come l'ha prodotta Claude.
Su un hook PreToolUse eseguito a ogni chiamata di un tool, la differenza si sente.
Quando conviene mantenere la forma shell? Nei comandi che richiedono pipe, reindirizzamenti o globbing. args:[] e command:"" si escludono a vicenda all'interno della stessa voce hook; se serve node x.js | jq | tee log, bisogna restare su command:"" e accettare il costo dell'escaping. Per il 90% degli hook, la forma exec è quella giusta.
continueOnBlock: un rifiuto PostToolUse che avvia davvero un nuovo tentativo
Era la correzione che aspettavo da più tempo. Uso un hook PostToolUse per validare l'output di ogni chiamata a un tool che scrive su disco: se Claude crea un file TypeScript, l'hook esegue tsc --noEmit e lo rifiuta in presenza di errori di tipo.
Prima della 2.1.139, il flusso di rifiuto non funzionava:
{
"hooks": {
"PostToolUse": [{
"matcher": "Write|Edit",
"command": "node hooks/validate-ts.mjs"
}]
}
}Quando validate-ts.mjs restituiva { "decision": "block", "reason": "tsc failed: ..." }, il runtime chiudeva il turno. Claude non riceveva il motivo; l'utente vedeva soltanto l'enigmatico messaggio “hook blocked the operation” e doveva inviare un nuovo prompt incollando manualmente l'errore. Dopo tre sessioni reali interrotte in questo modo, ho disattivato l'hook.
La 2.1.139 ha introdotto continueOnBlock come opzione per singolo hook:
{
"hooks": {
"PostToolUse": [{
"matcher": "Write|Edit",
"args": ["node", "hooks/validate-ts.mjs"],
"continueOnBlock": true,
"maxAttempts": 3
}]
}
}Ora una decisione block reinserisce la stringa reason nel contesto di Claude come errore del risultato del tool. Claude vede tsc failed: src/api.ts(14,3): error TS2322: Type 'string' is not assignable to type 'number' e si corregge da solo nel turno successivo. L'utente non vede nulla, perché il ciclo resta interno.
maxAttempts impone un limite rigido. Senza questo parametro, una validazione non deterministica, per esempio dipendente da un'API remota disponibile a intermittenza, consumerebbe contesto in una serie infinita di tentativi. Io uso tre tentativi. Dopo tre fallimenti, l'hook passa a un blocco definitivo e mostra l'errore all'utente.
Un anti-pattern da evitare: non abilitate continueOnBlock sugli hook le cui decisioni dipendono dall'orario. Un hook che impedisce le scritture durante una finestra di deploy continuerà a ciclare se il deploy è ancora in corso quando Claude riprova. Meglio applicare un gate su $CLAUDE_EFFORT oppure includere un contatore dei tentativi nello script dell'hook.
Le due aggiunte extra: $CLAUDE_EFFORT e CLAUDE_PROJECT_DIR
Nella stessa finestra temporale sono arrivate quasi inosservate due nuove variabili d'ambiente, entrambe molto utili.
La 2.1.133 ha inserito $CLAUDE_EFFORT nell'ambiente degli hook. I valori possibili sono low, medium, high e xhigh, gli stessi del livello di effort usato da Claude nel turno corrente. In questo modo un hook può scegliere il ramo da eseguire in base all'effort, senza analizzare il 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/xhighSaltando tsc sui turni a effort basso risparmio circa 800ms per modifica rapida. Nei turni di pianificazione high in cui Claude scrive una dozzina di file, il controllo completo continua invece a essere eseguito e intercetta errori reali.
La 2.1.139 ha aggiunto CLAUDE_PROJECT_DIR all'ambiente dei server MCP stdio avviati dal runtime. Prima di allora, questi server dovevano ricavare la radice del workspace da process.cwd(), con risultati errati quando l'utente avviava Claude Code da una sottodirectory. Ora qualsiasi server MCP può leggere process.env.CLAUDE_PROJECT_DIR e risolvere correttamente i percorsi relativi al workspace.
Se gestite un server MCP, aggiornate la risoluzione dei percorsi nel manifest affinché usi CLAUDE_PROJECT_DIR, mantenendo cwd() come fallback per i client meno recenti. È una patch di due righe che elimina un'intera categoria di bug.
La configurazione Claude Code completa che uso in produzione
Questo è il blocco di produzione di omidsaffari-admin, con poche parti oscurate. Sei DO e un Workflow dipendono dall'output degli hook per le notifiche desktop e i gate di 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"
}
}Installazione e verifica:
pnpm add -D @anthropic-ai/claude-code@2.1.141
claude --version # expect: 2.1.141
claude config doctor # expect: 0 hook warningsI punti chiave sono questi: continueOnBlock e maxAttempts formano la coppia che gestisce il ciclo di retry dalla 2.1.139. La forma args:[] viene usata per ogni hook che non richiede funzionalità della shell, quindi per tutti quelli di questa configurazione. Gli hook Notification e PreCompact condividono notify.mjs, perché entrambi devono produrre avvisi desktop con lo stesso output terminalSequence.
Checklist di rollout: come configurare Claude Code e quando rimandare
Prima di iniziare, create uno snapshot del settings.json attuale. Annotate quali hook usano command:"" e quali sono già passati a args:[]. Elencate tutti i tipi di evento configurati.
Eseguite la migrazione di un tipo di evento alla volta, lasciandolo in funzione per 24 ore prima di passare al successivo. Controllate gli avvisi degli hook con claude config doctor e cercate nei log le stringhe decision e reason, per verificare che il runtime riceva i dati previsti.
Questo è l'ordine che seguirei:
- Aggiornare il pacchetto alla 2.1.141.
- Convertire un hook da
command:""adargs:[]e verificare che venga eseguito. - Aggiungere
terminalSequenceall'hookNotificatione verificare il bell da un pannello tmux in background. - Aggiungere
continueOnBlockall'hookPostToolUseche crea più problemi; osservare una sessione reale per confermare che Claude riceva il motivo e si corregga da solo. - Migrare in gruppi tutti gli altri hook verso
args:[].
Rimandate l'aggiornamento se usate un'installazione gestita e bloccata da un SDK superiore che non ha ancora validato la 2.1.141, oppure se dipendete da un campo hook deprecato dalla 2.1.141. Al momento della pubblicazione, nessuna modifica introdotta nella finestra di maggio rompe i campi esistenti; conviene comunque fissare la versione e testarla in un branch prima del rollout al team.
Per la guida completa su version pinning, strategia degli hook e scaffolding dei progetti che uso con sei agenti in produzione, consultate la checklist di configurazione per Claude Code e Codex. Include gli stessi pattern di settings.json e lo scaffolding lato agente — Workflows, DO e binding Vectorize — controllato dagli hook.
Per l'approfondimento parallelo sui devtool dedicato all'esecuzione di questi agenti in sandbox remote, leggete ambienti Cursor Cloud Agent e Cloudflare Workers a confronto. Per il contesto dello stack di produzione alla base di questo settings.json, c'è l'articolo sull'ingegnere Cloudflare 100x.
terminalSequence funziona in tmux?
Sì, con una precisazione. Il runtime scrive la sequenza sul dispositivo del terminale di controllo, che tmux inoltra al terminale esterno se set -g allow-passthrough on è presente nel tmux.conf oppure se la sequenza è un semplice BEL (). BEL passa sempre; per le sequenze OSC è necessario abilitare il passthrough.
Posso usare args:[] e command:'' nella stessa configurazione hook?
No. Si escludono a vicenda per ogni voce hook. Scegliete la forma exec (args:[]) per i comandi avviati direttamente e la forma shell (command:"") quando servono pipe, reindirizzamenti o globbing. Se occorrono entrambe, create uno script wrapper che usi la forma exec e contenga al proprio interno le funzionalità della shell.
continueOnBlock può ciclare all'infinito se Claude continua a fallire la stessa validazione?
Non se impostate maxAttempts. Il runtime limita i tentativi a quel numero e poi passa a un blocco definitivo. Senza maxAttempts, invece, una validazione non deterministica può consumare contesto. Tre è un valore predefinito ragionevole per le validazioni simili a un type check; per tutto ciò che dipende da uno stato remoto, il valore giusto è uno.
$CLAUDE_EFFORT è disponibile in ogni evento hook?
Sì. Dalla 2.1.133 viene inserito nell'ambiente di ogni hook avviato dal runtime. Il valore riflette il livello di effort del turno corrente: SessionStart vede quello scelto dall'utente all'avvio, mentre PostToolUse vede quello attivo quando è scattata la chiamata al tool.
Cosa smette di funzionare se torno alla 2.1.138?
terminalSequence, args:[], continueOnBlock e CLAUDE_PROJECT_DIR smettono tutti di funzionare senza mostrare errori. Il runtime ignora i campi JSON sconosciuti e torna al parsing di command:"". Gli hook continuano a scattare, ma i nuovi comportamenti scompaiono. Testate il downgrade in un branch prima di farvi affidamento.
5 set 2026







