A practical upgrade guide for senior devs already running Claude Code hooks in production. Walks through the three concrete fixes that landed in v2.1.139 to v2.1.141 (terminalSequence for headless notifications, args:string[] for…

Three of the Claude Code hook bugs that bit me hardest in Q1 quietly closed in a 7-day window. I deleted three shell workarounds from my admin repo the next morning.
Between May 6 and May 13, 2026, Anthropic shipped Claude Code 2.1.132 through 2.1.141. Most of the diff was hook-system work.
I noticed because I had three hacks in my repo with // TODO: remove when claude-code fixes this comments, and within a week I deleted all three.
The bugs, by what they cost me:
The first was missed desktop alerts. My Notification hook fired the OS bell into stdout so I would get pinged when a long compaction finished. It worked when Claude Code owned the foreground TTY and silently failed everywhere else: tmux splits, VS Code integrated terminals, WezTerm panes with the focus on a sibling. The fix landed as terminalSequence in 2.1.141.
The second was shell-quoting drift. My Stop hook ran bash -lc "node post-stop.js --reason '$CLAUDE_STOP_REASON'", and the reason field broke the moment Claude returned a string with an apostrophe. The fix was the args: string[] exec form in 2.1.139.
The third was PostToolUse rejections that terminated the turn instead of looping back to Claude. I had to disable a schema-validation hook entirely because the runtime treated a block decision as fatal. 2.1.139 added continueOnBlock, which converts a block into a retry signal with the reason injected into context.
Full settings.json at the end. No waffle in the middle.
Claude Code 2.1.x exposes nine hook events: SessionStart, PreToolUse, PostToolUse, UserPromptSubmit, Notification, Stop, SubagentStop, PreCompact, and SessionEnd. Each hook is a command the runtime spawns with a documented environment and stdin payload. The runtime parses the hook's stdout as JSON, and specific fields gate runtime behavior: decision (allow/deny/block), reason (string echoed back to Claude or surfaced to the user), terminalSequence (raw bytes piped to the controlling TTY), and a handful of event-specific fields.
The thing to internalize: the runtime treats hook stdout as authoritative. Any field it does not parse is dead weight. Any field it does parse will change what Claude sees on the next turn. That is the whole game.
Before 2.1.141, my Notification hook looked like this:
The printf "\a" was supposed to ring the terminal bell. In practice it wrote \a to whatever file descriptor the hook process inherited, which is not the user's terminal unless Claude Code is currently in the foreground. In a tmux split where Claude is running in pane 2 and I am editing in pane 1, the bell never reached the outer terminal. notify-send worked, but it sits in the GNOME notification tray and I miss it.
2.1.141 added a terminalSequence field in the hook's stdout JSON. The runtime takes that string and writes it to the controlling terminal device directly, bypassing the hook process's stdio. The post-upgrade version:
is BEL. The runtime writes it to the controlling TTY, and the outer terminal rings even when Claude is in a background pane. macOS Terminal, iTerm2, WezTerm, and Alacritty all handle this correctly in my testing. tmux passes BEL through; keep set -g allow-passthrough on in your tmux.conf for OSC 52 anyway.
One edge case worth knowing: VS Code's integrated terminal swallows BEL by default. Set "terminal.integrated.enableBell": true in user settings if you want it to fire there.
The Stop hook fires when a turn finishes. I use it to log session metadata to a Cloudflare D1 instance so I can grep through old runs.
The pre-2.1.139 version:
Three escaping bugs hit me with this in the first month:
Apostrophes in $CLAUDE_STOP_REASON closed the single-quoted argument and the rest got parsed as shell tokens. When Claude returned a reason like user's request completed, the hook crashed and the session log was lost.
Backticks in tool names. When a hook fired with $CLAUDE_TOOL_NAME containing the string `bash` because Claude had written that in a reply, the shell tried to execute the backticked content as a subshell. Harmless in this case, terrifying in general.
Unicode in user prompts. UTF-8 round-trips through bash -lc cleanly most of the time, but certain CJK code points combined with certain locale settings dropped bytes silently.
2.1.139 added exec-form commands. Pass args as a string array and the runtime spawns the command directly with no shell in the middle:
The runtime resolves the $CLAUDE_* variables from its own environment before calling execve. No shell, no shell interpolation, no quoting. The string user's request completed arrives at my Node script as a single argv[5] entry, exactly as Claude produced it.
On a PreToolUse hook that runs on every tool call, that matters.
When to keep the shell form: anything that needs pipes, redirects, or globbing. args:[] and command:"" are mutually exclusive per hook entry, so if you need node x.js | jq | tee log, stick with command:"" and accept the escaping cost. For 90% of hooks, exec form is correct.
This was the one I waited longest for. I run a PostToolUse hook that validates the output of any tool call that writes to disk: if Claude writes a TypeScript file, the hook runs tsc --noEmit on it and rejects if there are type errors.
Before 2.1.139, the rejection flow was broken:
When validate-ts.mjs returned { "decision": "block", "reason": "tsc failed: ..." }, the runtime terminated the turn. Claude did not see the reason. The user got a cryptic "hook blocked the operation" message and had to manually re-prompt with the error pasted in. After three live sessions where this killed productive turns, I disabled the hook.
2.1.139 added continueOnBlock as a per-hook config:
Now a block decision routes the reason string back into Claude's context as a tool-result error. Claude sees tsc failed: src/api.ts(14,3): error TS2322: Type 'string' is not assignable to type 'number' and self-corrects on the next turn. The user sees nothing because the loop is internal.
maxAttempts is a hard cap. Without it, a non-deterministic validation (say, one that depends on a remote API that is intermittently down) will burn context on infinite retries. Three is what I run. After three failures, the hook escalates to a hard block and surfaces to the user.
Anti-pattern: do not enable continueOnBlock on hooks whose decisions depend on wall-clock state. A hook that rejects writes during a deploy window will loop forever if the deploy is still in progress when Claude retries. Either gate on $CLAUDE_EFFORT or include an attempt counter in your hook script.
Two env-var additions snuck into this window, and both are quietly useful.
2.1.133 injected $CLAUDE_EFFORT into the hook environment. Values are low, medium, high, xhigh, matching Claude's effort level for the current turn. This lets a hook branch on effort without parsing the prompt:
I save about 800ms per quick edit by skipping tsc on low effort. On a high planning turn where Claude writes a dozen files, the full check still runs and catches real bugs.
2.1.139 added CLAUDE_PROJECT_DIR to the environment of stdio MCP servers spawned by the runtime. Before this, stdio MCP servers had to infer the workspace root from process.cwd(), which broke when the user launched Claude Code from a subdirectory. Now any MCP server can read process.env.CLAUDE_PROJECT_DIR and resolve workspace-relative paths correctly.
If you maintain an MCP server, update the manifest's path resolution to use CLAUDE_PROJECT_DIR with a fallback to cwd() for older clients. Two-line patch, real bug class deleted.
This is the production block from omidsaffari-admin, lightly redacted. Six DOs and one Workflow rely on hook output for desktop alerts and CI gating.
Install and verify:
The highlights: continueOnBlock and maxAttempts are the retry-loop pair from 2.1.139. The args:[] form is used on every hook that does not need shell features, which is all of them in this setup. The Notification and PreCompact hooks share notify.mjs because both want desktop alerts with the same terminalSequence output.
Pre-flight: snapshot your current settings.json. Note which hooks use command:"" and which already use args:[]. List every event type you have wired up.
Migrate one event type at a time. Run for 24 hours per migration. Watch claude config doctor for hook warnings and grep your logs for the strings decision and reason to confirm the runtime is seeing what you expect.
Order I would do it in:
command:"" hook to args:[]. Verify it fires.terminalSequence to your Notification hook. Verify the bell from a background tmux pane.continueOnBlock to your most painful PostToolUse hook. Watch one real session to confirm Claude sees the reason and self-corrects.args:[] in batches.Skip the upgrade if you are on a managed install pinned by a parent SDK that has not yet validated 2.1.141, or if you depend on a hook field that 2.1.141 deprecated. As of this writing, nothing in the May window was a breaking change for existing fields, but pin and test in a branch before rolling to your team.
If you want the full version-pinning, hook strategy, and project-scaffolding playbook I run across six production agents, the Claude Code + Codex Setup Checklist covers it end-to-end. Same settings.json patterns, plus the agent-side scaffolding (Workflows, DOs, Vectorize bindings) that the hooks gate.
For the parallel devtool write-up on running these agents in remote sandboxes, see Cursor Cloud Agent environments vs Cloudflare Workers. For the production stack context behind this settings.json, see the Cloudflare 100x engineer post.
May 14, 2026
Dev