AIDevDesignMarketingFoundersBusinessNewsAbout
Work With Me
Work With Me

AI Blueprints to Leverage Your Business. Strategies. Systems. Execution.

hi@omidsaffari.com
Instagram·X·LinkedIn·GitHub
Navigation
  • HomeHome
  • AboutAbout
  • BlogBlog
  • NewsletterNewsletter
  • Work With MeWork With Me
  • ContactContact
Legal
  • PrivacyPrivacy
  • TermsTerms
  • DisclaimerDisclaimer
  • SitemapSitemap
  • RSS FeedRSS Feed
Categories
  • AIAI
  • StackStack
  • DesignDesign
  • WorkflowWorkflow
  • GrowthGrowth
Topics
  • AI AgentsAI Agents
  • PromptsPrompts
  • Next.jsNext.js
  • n8nn8n
  • NotionNotion
Formats
  • GuidesGuides
  • LabsLabs
  • ToolsTools
  • TrendsTrends
  • ResourcesResources
More Formats
  • TutorialsTutorials
  • Case StudiesCase Studies
  • ComparisonsComparisons
  • TemplatesTemplates
  • ChecklistsChecklists
Empire
  • DaVinci HorizonDaVinci Horizon
  • Imperfeqt AIImperfeqt AI
  • DVNC StudioDVNC Studio
  • DVNC.aeDVNC.ae
  • With LidaWith Lida
Connect
  • YouTubeYouTube
  • Twitter/XTwitter/X
  • LinkedInLinkedIn
  • GitHubGitHub
  • InstagramInstagram
© 2026 omidsaffari.comBuilt with Next.js · Vercel
  1. Blog
  2. Dev

v2.1.141 Quietly Killed My Three Worst Claude Code Hook Bugs. Here's the Exact settings.json.

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…

v2.1.141 Quietly Killed My Three Worst Claude Code Hook Bugs. Here's the Exact settings.json.
Omid Saffari

Founder & CEO, AI Entrepreneur

Share
Stay updated

Get weekly AI blueprints and insights.

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.

The week three real bugs disappeared

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.

Hook anatomy in 90 seconds

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.

terminalSequence: desktop notifications without owning the TTY

Before 2.1.141, my Notification hook looked like this:

json
1{
2 "hooks": {
3 "Notification": [{
4 "command": "bash -lc 'printf \"\\a\" && notify-send \"Claude\" \"$CLAUDE_MESSAGE\"'"
5 }]
6 }
7}

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:

json
1{
2 "hooks": {
3 "Notification": [{
4 "command": "node hooks/notify.mjs"
5 }]
6 }
7}
js
1import { execSync } from "node:child_process";
2
3const payload = JSON.parse(
4 await new Response(process.stdin).text()
5);
6
7execSync(`notify-send "Claude" ${JSON.stringify(payload.message)}`);
8
9process.stdout.write(JSON.stringify({
10 terminalSequence: ""
11}));

 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.

args: string[] killed shell escaping in my hook commands

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:

json
1{
2 "hooks": {
3 "Stop": [{
4 "command": "bash -lc \"node scripts/post-stop.js --session $CLAUDE_SESSION_ID --reason '$CLAUDE_STOP_REASON'\""
5 }]
6 }
7}

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:

json
1{
2 "hooks": {
3 "Stop": [{
4 "args": [
5 "node",
6 "scripts/post-stop.js",
7 "--session", "$CLAUDE_SESSION_ID",
8 "--reason", "$CLAUDE_STOP_REASON"
9 ]
10 }]
11 }
12}

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.

continueOnBlock: PostToolUse rejection that actually loops

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:

json
1{
2 "hooks": {
3 "PostToolUse": [{
4 "matcher": "Write|Edit",
5 "command": "node hooks/validate-ts.mjs"
6 }]
7 }
8}

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:

json
1{
2 "hooks": {
3 "PostToolUse": [{
4 "matcher": "Write|Edit",
5 "args": ["node", "hooks/validate-ts.mjs"],
6 "continueOnBlock": true,
7 "maxAttempts": 3
8 }]
9 }
10}

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.

The bonus duo: $CLAUDE_EFFORT and CLAUDE_PROJECT_DIR

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:

js
1const effort = process.env.CLAUDE_EFFORT;
2
3if (effort === "low" || effort === "medium") {
4 // skip expensive tsc check on quick edits
5 process.stdout.write(JSON.stringify({ decision: "allow" }));
6 process.exit(0);
7}
8
9// run full tsc --noEmit on high/xhigh

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.

The exact settings.json I am running

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.

json
1{
2 "model": "claude-sonnet-4-7-20260501",
3 "permissions": {
4 "edit": "ask"
5 },
6 "hooks": {
7 "SessionStart": [{
8 "args": ["node", "hooks/session-start.mjs"]
9 }],
10 "PreToolUse": [{
11 "matcher": "Bash",
12 "args": ["node", "hooks/gate-bash.mjs"]
13 }],
14 "PostToolUse": [{
15 "matcher": "Write|Edit",
16 "args": ["node", "hooks/validate-ts.mjs"],
17 "continueOnBlock": true,
18 "maxAttempts": 3
19 }],
20 "Notification": [{
21 "args": ["node", "hooks/notify.mjs"]
22 }],
23 "Stop": [{
24 "args": [
25 "node",
26 "scripts/post-stop.js",
27 "--session", "$CLAUDE_SESSION_ID",
28 "--reason", "$CLAUDE_STOP_REASON"
29 ]
30 }],
31 "PreCompact": [{
32 "args": ["node", "hooks/notify.mjs"]
33 }]
34 }
35}
json
1{
2 "devDependencies": {
3 "@anthropic-ai/claude-code": "2.1.141"
4 }
5}

Install and verify:

bash
1pnpm add -D @anthropic-ai/claude-code@2.1.141
2claude --version # expect: 2.1.141
3claude config doctor # expect: 0 hook warnings

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.

Rollout checklist and when to skip

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:

  1. Upgrade the package to 2.1.141.
  2. Convert one command:"" hook to args:[]. Verify it fires.
  3. Add terminalSequence to your Notification hook. Verify the bell from a background tmux pane.
  4. Add continueOnBlock to your most painful PostToolUse hook. Watch one real session to confirm Claude sees the reason and self-corrects.
  5. Migrate the rest of your hooks to 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.

Key Takeaways

  • Pin to @anthropic-ai/claude-code@2.1.141 to get terminalSequence, args:[], continueOnBlock, and the two new env vars in one upgrade.
  • terminalSequence in Notification hooks fixes desktop alerts from background terminal panes.
  • args: string[] deletes a class of shell-escaping bugs and saves ~30ms per hook fire.
  • continueOnBlock with maxAttempts makes PostToolUse validation hooks safe to enable in live sessions.
  • $CLAUDE_EFFORT lets you skip expensive checks on low-effort turns; CLAUDE_PROJECT_DIR fixes path resolution in stdio MCP servers.
  • Migrate one hook event at a time, watch claude config doctor after each, and snapshot your settings.json before you start.
Last Updated

May 14, 2026

Category

Dev

Omid Saffari

Founder & CEO, AI Entrepreneur

Digital marketing specialist with expertise in AI, automation, and web development. Helping businesses build strong online presences that drive results.

X.com
Instagram
LinkedIn
WhatsApp
Email

More from Dev

Bun 1.3.14 Drops Bun.Image as a Sharp Drop-In. Here's the 4-Step Migration I Ran on My Image Pipeline.
Bun 1.3.14 Drops Bun.Image as a Sharp Drop-In. Here's the 4-Step Migration I Ran on My Image Pipeline.

A working migration playbook for senior devs running production image workloads on Bun. Maps every Sharp method I had in production to its Bun.Image equivalent, surfaces the four API differences that broke first, and benchmarks the result…

May 14, 2026
View all Dev articles