Claude Code Hooks: 3 Bugs, die v2.1.141 endlich behebt
Claude Code 2.1.141 behebt drei lästige Hook-Probleme: Terminal-Benachrichtigungen, Shell-Escaping und blockierte PostToolUse-Prüfungen im Alltag.

Drei Fehler in Claude Code Hooks, die mich in Q1 besonders hart getroffen hatten, wurden binnen 7 Tagen still und leise behoben. Am nächsten Morgen konnte ich drei Shell-Workarounds aus meinem Admin-Repository löschen.
Claude Code Hooks: Die Woche, in der drei echte Bugs verschwanden
Vom 6. bis 13. Mai 2026 veröffentlichte Anthropic Claude Code 2.1.132 bis 2.1.141. Der Großteil der Änderungen betraf das Hook-System.
Aufgefallen ist mir das durch drei Provisorien in meinem Repository, die mit // TODO: remove when claude-code fixes this kommentiert waren. Innerhalb einer Woche konnte ich alle drei entfernen.
Die Bugs, geordnet nach dem Aufwand, den sie verursacht haben:
Das erste Problem waren verpasste Desktop-Benachrichtigungen. Mein Notification-Hook schrieb die Systemglocke nach stdout, damit ich nach einer langen Compaction einen Hinweis bekam. Das funktionierte, solange Claude Code das TTY im Vordergrund belegte, und versagte überall sonst lautlos: in tmux-Splits, integrierten VS-Code-Terminals und WezTerm-Panes, wenn der Fokus auf einem benachbarten Pane lag. Die Lösung kam mit terminalSequence in 2.1.141.
Das zweite Problem war fehlerhaftes Shell-Quoting. Mein Stop-Hook führte bash -lc "node post-stop.js --reason '$CLAUDE_STOP_REASON'" aus; sobald Claude einen String mit Apostroph zurückgab, zerbrach das Feld für den Grund. Behoben wurde das mit der Exec-Form args: string[] in 2.1.139.
Das dritte Problem: Ablehnungen durch PostToolUse beendeten den Turn, statt an Claude zurückzuspringen. Einen Hook zur Schema-Validierung musste ich deshalb vollständig deaktivieren, weil die Runtime eine block-Entscheidung als fatal behandelte. Mit 2.1.139 kam continueOnBlock hinzu. Dadurch wird ein Block zu einem Wiederholungssignal, und der angegebene Grund landet im Kontext.
Die vollständige settings.json steht am Ende. Dazwischen geht es direkt zur Sache.
Aufbau von Claude Code Hooks in 90 Sekunden
Claude Code 2.1.x kennt neun Hook-Ereignisse: SessionStart, PreToolUse, PostToolUse, UserPromptSubmit, Notification, Stop, SubagentStop, PreCompact und SessionEnd. Jeder Hook ist ein Befehl, den die Runtime mit einer dokumentierten Umgebung und einem stdin-Payload startet. Die Runtime interpretiert die stdout-Ausgabe des Hooks als JSON. Bestimmte Felder steuern dabei ihr Verhalten: decision (allow/deny/block), reason (ein String, den Claude zurückerhält oder der dem Nutzer angezeigt wird), terminalSequence (Rohbytes, die an das steuernde TTY gesendet werden) sowie einige ereignisspezifische Felder.
Entscheidend ist: Für die Runtime ist die stdout-Ausgabe des Hooks maßgeblich. Jedes Feld, das sie nicht auswertet, ist wirkungslos. Jedes ausgewertete Feld verändert dagegen, was Claude im nächsten Turn sieht. Darauf läuft das gesamte System hinaus.
terminalSequence: Desktop-Benachrichtigungen ohne eigenes TTY
Vor 2.1.141 sah mein Notification-Hook so aus:
{
"hooks": {
"Notification": [{
"command": "bash -lc 'printf \"\\a\" && notify-send \"Claude\" \"$CLAUDE_MESSAGE\"'"
}]
}
}printf "\a" sollte die Terminalglocke auslösen. Tatsächlich schrieb der Befehl \a jedoch in den Dateideskriptor, den der Hook-Prozess geerbt hatte. Das ist nur dann das Terminal des Nutzers, wenn Claude Code gerade im Vordergrund läuft. In einem tmux-Split, in dem Claude in Pane 2 arbeitet und ich in Pane 1 editiere, erreichte die Glocke das äußere Terminal nie. notify-send funktionierte zwar, verschwand aber leicht übersehen im GNOME-Benachrichtigungsbereich.
2.1.141 ergänzte das Feld terminalSequence im stdout-JSON des Hooks. Die Runtime schreibt diesen String direkt auf das steuernde Terminalgerät und umgeht dabei die Standardein- und -ausgabe des Hook-Prozesses. So sieht die Variante nach dem Upgrade aus:
{
"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: ""
})); ist BEL. Die Runtime schreibt das Zeichen an das steuernde TTY, sodass das äußere Terminal auch dann klingelt, wenn Claude in einem Pane im Hintergrund läuft. macOS Terminal, iTerm2, WezTerm und Alacritty haben dies in meinen Tests korrekt verarbeitet. tmux reicht BEL durch; für OSC 52 sollte set -g allow-passthrough on dennoch in der tmux.conf stehen.
Eine Besonderheit ist wichtig: Das integrierte Terminal von VS Code verschluckt BEL standardmäßig. Damit es dort ausgelöst wird, muss in den Benutzereinstellungen "terminal.integrated.enableBell": true gesetzt sein.
args: string[] beseitigt Shell-Escaping in Hook-Befehlen
Der Stop-Hook wird nach Abschluss eines Turns ausgelöst. Ich protokolliere damit Sitzungsmetadaten in einer Cloudflare-D1-Instanz, um alte Läufe per grep durchsuchen zu können.
Die Variante vor 2.1.139:
{
"hooks": {
"Stop": [{
"command": "bash -lc \"node scripts/post-stop.js --session $CLAUDE_SESSION_ID --reason '$CLAUDE_STOP_REASON'\""
}]
}
}Schon im ersten Monat traten damit drei Escaping-Fehler auf:
Apostrophe in $CLAUDE_STOP_REASON beendeten das in einfache Anführungszeichen gesetzte Argument; den Rest interpretierte die Shell als einzelne Tokens. Gab Claude einen Grund wie user's request completed zurück, stürzte der Hook ab und das Sitzungsprotokoll ging verloren.
Backticks in Tool-Namen. Enthielt $CLAUDE_TOOL_NAME beim Auslösen eines Hooks den String `bash`, weil Claude ihn in einer Antwort verwendet hatte, versuchte die Shell den Inhalt zwischen den Backticks als Subshell auszuführen. In diesem Fall harmlos, grundsätzlich jedoch beunruhigend.
Unicode in Nutzer-Prompts. Meist übersteht UTF-8 den Weg durch bash -lc unverändert. Bestimmte CJK-Codepoints verloren in Verbindung mit bestimmten Locale-Einstellungen jedoch stillschweigend Bytes.
2.1.139 führte Befehle in Exec-Form ein. Wird args als String-Array übergeben, startet die Runtime den Befehl direkt und ohne zwischengeschaltete Shell:
{
"hooks": {
"Stop": [{
"args": [
"node",
"scripts/post-stop.js",
"--session", "$CLAUDE_SESSION_ID",
"--reason", "$CLAUDE_STOP_REASON"
]
}]
}
}Die Runtime löst die $CLAUDE_*-Variablen aus ihrer eigenen Umgebung auf, bevor sie execve aufruft. Keine Shell, keine Shell-Interpolation, kein Quoting. Der String user's request completed kommt in meinem Node-Skript als einzelner argv[5]-Eintrag an – exakt so, wie Claude ihn erzeugt hat.
Bei einem PreToolUse-Hook, der mit jedem Tool-Aufruf läuft, macht das einen Unterschied.
Wann die Shell-Form weiterhin sinnvoll ist: überall dort, wo Pipes, Umleitungen oder Globbing erforderlich sind. args:[] und command:"" schließen sich innerhalb eines Hook-Eintrags gegenseitig aus. Wer node x.js | jq | tee log benötigt, bleibt daher bei command:"" und nimmt den Escaping-Aufwand in Kauf. Für 90% der Hooks ist die Exec-Form die richtige Wahl.
continueOnBlock: Wenn eine PostToolUse-Ablehnung wirklich zurückspringt
Auf diese Änderung hatte ich am längsten gewartet. Bei mir validiert ein PostToolUse-Hook die Ausgabe jedes Tool-Aufrufs, der auf den Datenträger schreibt: Erstellt Claude eine TypeScript-Datei, führt der Hook tsc --noEmit aus und lehnt das Ergebnis bei Typfehlern ab.
Vor 2.1.139 war dieser Ablehnungsablauf defekt:
{
"hooks": {
"PostToolUse": [{
"matcher": "Write|Edit",
"command": "node hooks/validate-ts.mjs"
}]
}
}Gab validate-ts.mjs { "decision": "block", "reason": "tsc failed: ..." } zurück, beendete die Runtime den Turn. Claude sah den Grund nicht. Der Nutzer erhielt lediglich die kryptische Meldung "hook blocked the operation" und musste den Fehler von Hand in einen neuen Prompt kopieren. Nachdem dies in drei Live-Sitzungen produktive Turns abgebrochen hatte, deaktivierte ich den Hook.
2.1.139 ergänzte continueOnBlock als Einstellung pro Hook:
{
"hooks": {
"PostToolUse": [{
"matcher": "Write|Edit",
"args": ["node", "hooks/validate-ts.mjs"],
"continueOnBlock": true,
"maxAttempts": 3
}]
}
}Nun wird bei einer block-Entscheidung der reason-String als Tool-Ergebnisfehler in Claudes Kontext zurückgeführt. Claude sieht tsc failed: src/api.ts(14,3): error TS2322: Type 'string' is not assignable to type 'number' und korrigiert sich im nächsten Turn selbst. Für den Nutzer bleibt die Schleife unsichtbar, weil sie intern abläuft.
maxAttempts setzt eine harte Obergrenze. Ohne sie verbraucht eine nicht deterministische Validierung – etwa eine Prüfung gegen eine zeitweise ausgefallene Remote-API – Kontext mit endlosen Wiederholungen. Ich verwende drei Versuche. Nach drei Fehlschlägen eskaliert der Hook zu einem harten Block und meldet sich beim Nutzer.
Anti-Pattern: continueOnBlock sollte nicht für Hooks aktiviert werden, deren Entscheidung von einem zeitabhängigen Zustand abhängt. Ein Hook, der während eines Deployment-Fensters Schreibvorgänge ablehnt, gerät in eine Endlosschleife, wenn das Deployment bei Claudes erneutem Versuch noch läuft. Abhilfe schafft entweder ein Gate über $CLAUDE_EFFORT oder ein Versuchszähler im Hook-Skript.
Das nützliche Duo: $CLAUDE_EFFORT und CLAUDE_PROJECT_DIR
In diesem Zeitraum kamen unauffällig zwei Umgebungsvariablen hinzu – und beide sind ausgesprochen nützlich.
Seit 2.1.133 wird $CLAUDE_EFFORT in die Hook-Umgebung injiziert. Die Werte low, medium, high und xhigh entsprechen Claudes Effort-Level im aktuellen Turn. Damit kann ein Hook abhängig vom Aufwand verzweigen, ohne den Prompt auszuwerten:
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/xhighBei schnellen Änderungen spare ich rund 800ms, indem ich tsc bei niedrigem Effort überspringe. In einem high-Planungsturn, in dem Claude ein Dutzend Dateien schreibt, läuft die vollständige Prüfung weiterhin und findet echte Fehler.
2.1.139 ergänzte CLAUDE_PROJECT_DIR in der Umgebung von stdio-MCP-Servern, welche die Runtime startet. Zuvor mussten stdio-MCP-Server das Workspace-Stammverzeichnis aus process.cwd() ableiten. Das scheiterte, wenn Claude Code aus einem Unterverzeichnis gestartet wurde. Nun kann jeder MCP-Server process.env.CLAUDE_PROJECT_DIR auslesen und Workspace-relative Pfade korrekt auflösen.
Wer einen MCP-Server pflegt, sollte die Pfadauflösung im Manifest auf CLAUDE_PROJECT_DIR umstellen und für ältere Clients auf cwd() zurückfallen. Zwei Zeilen ändern, eine ganze Fehlerklasse beseitigen.
Meine exakte Claude Code settings.json
Dies ist der leicht redigierte Produktionsblock aus omidsaffari-admin. Sechs DOs und ein Workflow nutzen Hook-Ausgaben für Desktop-Benachrichtigungen und CI-Gates.
{
"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"
}
}Installieren und prüfen:
pnpm add -D @anthropic-ai/claude-code@2.1.141
claude --version # expect: 2.1.141
claude config doctor # expect: 0 hook warningsDie entscheidenden Stellen: continueOnBlock und maxAttempts bilden seit 2.1.139 das Paar für Wiederholungsschleifen. Die args:[]-Form kommt bei jedem Hook ohne benötigte Shell-Funktionen zum Einsatz – in diesem Setup also bei allen. Notification und PreCompact teilen sich notify.mjs, weil beide Desktop-Benachrichtigungen mit derselben terminalSequence-Ausgabe auslösen sollen.
Checkliste für die Claude Code Konfiguration
Vor dem Start: Die aktuelle settings.json sichern. Anschließend notieren, welche Hooks command:"" verwenden und welche bereits mit args:[] arbeiten. Außerdem alle eingebundenen Ereignistypen auflisten.
Pro Schritt nur einen Ereignistyp migrieren und jede Migration 24 Stunden laufen lassen. claude config doctor auf Hook-Warnungen prüfen und die Protokolle per grep nach decision und reason durchsuchen. So lässt sich bestätigen, dass die Runtime die erwarteten Daten empfängt.
Diese Reihenfolge hat sich bewährt:
- Das Paket auf 2.1.141 aktualisieren.
- Einen
command:""-Hook aufargs:[]umstellen und prüfen, ob er ausgelöst wird. terminalSequencezumNotification-Hook hinzufügen und die Glocke aus einem tmux-Pane im Hintergrund testen.continueOnBlockzum problematischstenPostToolUse-Hook hinzufügen. In einer echten Sitzung prüfen, ob Claude den Grund sieht und sich selbst korrigiert.- Die übrigen Hooks schrittweise auf
args:[]migrieren.
Auf das Upgrade sollte verzichtet werden, wenn eine verwaltete Installation über ein übergeordnetes SDK festgeschrieben ist, das 2.1.141 noch nicht validiert hat, oder wenn ein benötigtes Hook-Feld in 2.1.141 als veraltet markiert wurde. Zum Zeitpunkt der Veröffentlichung enthielt das Mai-Fenster keine Breaking Changes für bestehende Felder. Dennoch empfiehlt es sich, die Version festzuschreiben und vor der Einführung im Team in einem Branch zu testen.
Wer das vollständige Playbook für Versions-Pinning, Hook-Strategie und Projekt-Scaffolding sucht, das ich für sechs Produktionsagenten nutze, findet alles in der Setup-Checkliste für Claude Code + Codex. Sie enthält dieselben settings.json-Muster sowie das agentenseitige Scaffolding aus Workflows, DOs und Vectorize-Bindings, das über die Hooks abgesichert wird.
Der ergänzende Devtool-Artikel zum Betrieb dieser Agenten in Remote-Sandboxes vergleicht Cursor-Cloud-Agent-Umgebungen mit Cloudflare Workers. Den Produktions-Stack hinter dieser settings.json erläutert der Beitrag zum Cloudflare 100x Engineer.
Funktioniert terminalSequence in tmux?
Ja, mit einer Einschränkung. Die Runtime schreibt die Sequenz an das steuernde Terminalgerät. tmux leitet sie an das äußere Terminal weiter, sofern set -g allow-passthrough on in der tmux.conf steht oder es sich bei der Sequenz um ein einfaches BEL () handelt. BEL wird immer durchgereicht. Für OSC-Sequenzen muss Passthrough aktiviert sein.
Kann ich args:[] und command:'' im selben Hook verwenden?
Nein. Innerhalb eines Hook-Eintrags schließen sich beide Formen gegenseitig aus. Für direkt gestartete Befehle eignet sich die Exec-Form (args:[]), für Pipes, Umleitungen oder Globbing die Shell-Form (command:""). Sind beide Funktionen nötig, bietet sich ein Wrapper-Skript an, das per Exec-Form gestartet wird und die Shell-Funktionen intern enthält.
Läuft continueOnBlock endlos, wenn Claude dieselbe Validierung wiederholt auslöst?
Nicht, wenn maxAttempts gesetzt ist. Die Runtime begrenzt die Wiederholungen auf diesen Wert und eskaliert danach zu einem harten Block. Ohne maxAttempts kann ein nicht deterministischer Validator tatsächlich Kontext verbrauchen. Der Wert sollte daher immer gesetzt werden. Drei ist ein sinnvoller Standard für Validierungen wie Typprüfungen; bei Abhängigkeiten von einem Remote-Zustand ist eins richtig.
Ist $CLAUDE_EFFORT in jedem Hook-Ereignis verfügbar?
Ja. Seit 2.1.133 wird die Variable in die Umgebung jedes von der Runtime gestarteten Hooks injiziert. Der Wert entspricht dem Effort-Level des aktuellen Turns: SessionStart sieht daher den beim Start gewählten Effort, PostToolUse den beim Auslösen des Tools aktiven Wert.
Was funktioniert nach einem Downgrade auf 2.1.138 nicht mehr?
terminalSequence, args:[], continueOnBlock und CLAUDE_PROJECT_DIR fallen allesamt lautlos aus. Die Runtime ignoriert unbekannte JSON-Felder und greift wieder auf das Parsing von command:"" zurück. Die Hooks werden weiterhin ausgelöst, das neue Verhalten jedoch nicht. Vor einer Abhängigkeit davon sollte der Downgrade-Pfad in einem Branch getestet werden.
5. Sept. 2026







