Claude Code hooks 2.1.141 实战:3 个坑终于修了

Claude Code hooks 2.1.141 修复了后台终端提醒、Shell 参数转义和 PostToolUse 阻断重试三类顽疾。本文详解 terminalSequence、args:[]、continueOnBlock 的配置方式,并附完整 settings.json、环境变量用法和稳妥升级清单。

Saturday, September 5, 2026Omid Saffari
Claude Code hooks 2.1.141 实战:3 个坑终于修了

2026 年 Q1 最折腾我的 3 个 Claude Code hooks 问题,悄无声息地在 7 天内陆续修复。第二天一早,我就从管理仓库删掉了 3 段 Shell 临时方案。

Claude Code hooks:一周内修掉 3 个真实问题

2026 年 5 月 6 日至 5 月 13 日,Anthropic 连续发布了 Claude Code 2.1.132 到 2.1.141。这轮更新的大部分改动都集中在 hooks 系统。

我之所以注意到,是因为仓库里正好有 3 处临时补丁,都标着 // TODO: remove when claude-code fixes this。一周之内,这 3 处补丁全被我删掉了。

按造成的损失排序,这 3 个问题分别是:

第一是桌面提醒漏报。我的 Notification hook 会把系统响铃写入 stdout,这样耗时较长的上下文压缩结束后就能提醒我。只有 Claude Code 占用前台 TTY 时它才有效;换成 tmux 分屏、VS Code 集成终端,或焦点落在相邻 WezTerm 窗格时,都会悄无声息地失效。2.1.141 新增的 terminalSequence 解决了这个问题。

第二是 Shell 引号转义失控。我的 Stop hook 执行 bash -lc "node post-stop.js --reason '$CLAUDE_STOP_REASON'",Claude 只要返回一个带撇号的 reason 字符串,命令就会立即出错。2.1.139 引入的 args: string[] exec 形式彻底绕开了这个坑。

第三是 PostToolUse 拒绝操作后直接终止当前轮次,而不是把结果交回 Claude 继续处理。由于运行时把 block 决策当作致命错误,我只能彻底停用一个 schema 校验 hook。2.1.139 新增了 continueOnBlock:它会把 block 转成重试信号,并将 reason 注入上下文。

文末附上完整 settings.json,中间不绕弯子。

90 秒看懂 Claude Code hooks 的工作机制

Claude Code 2.1.x 提供 9 种 hook 事件:SessionStartPreToolUsePostToolUseUserPromptSubmitNotificationStopSubagentStopPreCompactSessionEnd。每个 hook 都是由运行时启动的一条命令,并接收文档规定的环境变量与 stdin payload。运行时会把 hook 的 stdout 当作 JSON 解析,其中几类字段会直接控制运行行为:decision(allow/deny/block)、reason(回传给 Claude 或展示给用户的字符串)、terminalSequence(写入控制 TTY 的原始字节),以及少量事件专用字段。

最需要记住的一点是:运行时以 hook 的 stdout 为准。无法识别的字段毫无作用;能够识别的字段,则会改变 Claude 下一轮看到的内容。整个机制就是这么简单。

terminalSequence:不占用 TTY 也能收到桌面通知

在 2.1.141 之前,我的 Notification hook 是这样写的:

JSON
{
  "hooks": {
    "Notification": [{
      "command": "bash -lc 'printf \"\\a\" && notify-send \"Claude\" \"$CLAUDE_MESSAGE\"'"
    }]
  }
}

printf "\a" 原本应该让终端响铃。实际情况却是,它只会把 \a 写入 hook 进程继承到的文件描述符;除非 Claude Code 此刻就在前台,否则那个描述符并不是用户的终端。比如 tmux 分屏中,Claude 运行在窗格 2、我在窗格 1 编辑时,铃声根本传不到外层终端。notify-send 虽然有效,但通知会躲进 GNOME 通知托盘,我经常看不到。

2.1.141 在 hook 的 stdout JSON 中增加了 terminalSequence 字段。运行时拿到这个字符串后,会绕过 hook 进程的 stdio,直接写入控制终端设备。升级后的配置如下:

JSON
{
  "hooks": {
    "Notification": [{
      "command": "node hooks/notify.mjs"
    }]
  }
}
JavaScript
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。运行时把它写进控制 TTY,因此即使 Claude 位于后台窗格,外层终端也会响铃。按我的测试,macOS Terminal、iTerm2、WezTerm 和 Alacritty 都能正确处理。tmux 会透传 BEL;考虑到 OSC 52,仍建议在 tmux.conf 中保留 set -g allow-passthrough on

有个边界情况值得注意:VS Code 集成终端默认会吞掉 BEL。希望它也响铃,需要在用户设置中配置 "terminal.integrated.enableBell": true

args: string[]:从根源上消除 hook 命令的 Shell 转义

Stop hook 会在一轮任务结束时触发。我用它把会话元数据写入 Cloudflare D1 实例,之后便能用 grep 检索历史运行记录。

2.1.139 之前的版本如下:

JSON
{
  "hooks": {
    "Stop": [{
      "command": "bash -lc \"node scripts/post-stop.js --session $CLAUDE_SESSION_ID --reason '$CLAUDE_STOP_REASON'\""
    }]
  }
}

采用这种写法的第一个月,我就遇到了 3 类转义问题:

$CLAUDE_STOP_REASON 中的撇号会提前闭合单引号参数,剩余内容随即被解析成 Shell token。当 Claude 返回 user's request completed 这样的 reason 时,hook 会崩溃,会话日志也随之丢失。

工具名中的反引号也会出问题。如果 Claude 在回复中写了 `bash`,hook 触发时 $CLAUDE_TOOL_NAME 就可能包含这段字符串,Shell 随后会尝试把反引号中的内容当作子 Shell 执行。这一次没有造成损害,但这种行为本身非常危险。

用户 prompt 中的 Unicode 同样不稳。大多数情况下,UTF-8 经过 bash -lc 能正常往返;但特定 CJK 码点遇上某些 locale 设置时,会在没有任何提示的情况下丢失字节。

2.1.139 增加了 exec 形式的命令。把 args 作为字符串数组传入后,运行时会直接启动命令,中间不再经过 Shell:

JSON
{
  "hooks": {
    "Stop": [{
      "args": [
        "node",
        "scripts/post-stop.js",
        "--session", "$CLAUDE_SESSION_ID",
        "--reason", "$CLAUDE_STOP_REASON"
      ]
    }]
  }
}

调用 execve 前,运行时会先从自身环境中解析 $CLAUDE_* 变量。没有 Shell,没有 Shell 插值,也没有引号处理。user's request completed 会原封不动地作为单个 argv[5] 参数传进我的 Node 脚本。

对于每次工具调用都会执行的 PreToolUse hook,这一点非常重要。

什么时候还该保留 Shell 形式?只要命令依赖管道、重定向或 glob 展开,就仍然需要它。同一个 hook 条目中的 args:[]command:"" 互斥,因此若要执行 node x.js | jq | tee log,就应继续使用 command:"",并承担转义成本。对 90% 的 hooks 来说,exec 形式才是正确选择。

continueOnBlock:让 PostToolUse 拒绝真正进入重试循环

这是我等得最久的一项修复。我有一个 PostToolUse hook,专门校验所有会写入磁盘的工具调用输出:只要 Claude 写了 TypeScript 文件,hook 就运行 tsc --noEmit;一旦发现类型错误,便拒绝这次操作。

2.1.139 之前,这套拒绝流程是断的:

JSON
{
  "hooks": {
    "PostToolUse": [{
      "matcher": "Write|Edit",
      "command": "node hooks/validate-ts.mjs"
    }]
  }
}

validate-ts.mjs 返回 { "decision": "block", "reason": "tsc failed: ..." } 后,运行时会直接终止当前轮次,Claude 根本看不到 reason。用户只能收到一句费解的 "hook blocked the operation",还得手动粘贴错误再发一次 prompt。这个问题连续毁掉 3 次真实会话后,我停用了该 hook。

2.1.139 增加了 hook 级配置 continueOnBlock

JSON
{
  "hooks": {
    "PostToolUse": [{
      "matcher": "Write|Edit",
      "args": ["node", "hooks/validate-ts.mjs"],
      "continueOnBlock": true,
      "maxAttempts": 3
    }]
  }
}

现在,block 决策会把 reason 字符串作为工具结果错误送回 Claude 的上下文。Claude 看到 tsc failed: src/api.ts(14,3): error TS2322: Type 'string' is not assignable to type 'number' 后,会在下一轮自行修正。整个循环发生在内部,用户不会看到任何中间过程。

maxAttempts 是硬上限。缺少它时,非确定性校验——例如依赖一个间歇性故障的远程 API——可能因无限重试不断消耗上下文。我的设置是 3 次;连续失败 3 次后,hook 会升级为硬阻断并通知用户。

反例也很明确:不要为决策依赖现实时间状态的 hook 启用 continueOnBlock。如果某个 hook 会在部署窗口拒绝写入,而 Claude 重试时部署仍未结束,它就会一直循环。解决办法是通过 $CLAUDE_EFFORT 设置门槛,或在 hook 脚本中加入尝试次数计数器。

两个额外亮点:$CLAUDE_EFFORT 与 CLAUDE_PROJECT_DIR

这轮更新还悄悄加入了 2 个环境变量,而且都很实用。

2.1.133 把 $CLAUDE_EFFORT 注入 hook 环境。它的取值为 lowmediumhighxhigh,对应 Claude 当前轮次的 effort level。这样一来,hook 无需解析 prompt,就能根据 effort 分支处理:

JavaScript
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/xhigh

low effort 时跳过 tsc,每次快速编辑大约能省下 800ms。到了 high 的规划轮次,即便 Claude 一次写入大约 12 个文件,完整检查仍会执行,并揪出真正的问题。

2.1.139 还把 CLAUDE_PROJECT_DIR 加入运行时启动的 stdio MCP server 环境。此前,stdio MCP server 只能通过 process.cwd() 推断工作区根目录;用户若从子目录启动 Claude Code,路径解析就会出错。现在,任何 MCP server 都可以读取 process.env.CLAUDE_PROJECT_DIR,正确解析相对于工作区的路径。

如果你在维护 MCP server,应把 manifest 的路径解析改为优先使用 CLAUDE_PROJECT_DIR,并为旧版客户端保留 cwd() fallback。只改两行,就能彻底消灭这一类问题。

我正在使用的完整 settings.json

下面是 omidsaffari-admin 的生产配置,仅做了少量脱敏。其中 6 个 DO 和 1 个 Workflow 都依赖 hook 输出完成桌面提醒与 CI 门控。

JSON
{
  "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"]
    }]
  }
}
JSON
{
  "devDependencies": {
    "@anthropic-ai/claude-code": "2.1.141"
  }
}

安装并验证:

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

几个重点:continueOnBlockmaxAttempts 是 2.1.139 引入的重试循环组合。所有不需要 Shell 特性的 hook 都采用 args:[],而这份配置中的 hook 恰好全都如此。NotificationPreCompact 共用 notify.mjs,因为两者都需要通过相同的 terminalSequence 输出触发桌面提醒。

Claude Code hooks 升级清单,以及何时不该升级

升级前先为当前 settings.json 做快照。记下哪些 hooks 使用 command:""、哪些已经使用 args:[],再列出所有已接入的事件类型。

每次只迁移一种事件类型,并在每次迁移后运行 24 小时。留意 claude config doctor 是否出现 hook 警告,同时用 grep 在日志中搜索 decisionreason,确认运行时实际收到的内容符合预期。

我会按这个顺序进行:

  1. 将 package 升级到 2.1.141。
  2. 先把一个 command:"" hook 改为 args:[],确认它能正常触发。
  3. Notification hook 添加 terminalSequence,从后台 tmux 窗格验证响铃。
  4. 为最棘手的 PostToolUse hook 添加 continueOnBlock,观察一次真实会话,确认 Claude 能看到 reason 并自行修正。
  5. 分批把其余 hooks 迁移到 args:[]

如果当前使用的是由上层 SDK 锁定版本的托管安装,且该 SDK 尚未验证 2.1.141,就先不要升级;依赖 2.1.141 已弃用 hook 字段的项目同样如此。截至本文撰写时,5 月这轮更新没有对现有字段造成 breaking change,但向团队推广前仍应锁定版本,并在分支中完成测试。

如果想查看我在 6 个生产 agent 上采用的完整版本锁定、hook 策略与项目脚手架方案,可以阅读 Claude Code + Codex 配置清单。其中不仅包含同一套 settings.json 模式,也涵盖由 hooks 负责门控的 agent 侧脚手架(Workflows、DOs、Vectorize bindings)。

如果还想了解如何在远程 sandbox 中运行这些 agents,可以阅读配套文章 Cursor Cloud Agent 运行环境与 Cloudflare Workers 对比。至于这份 settings.json 背后的生产技术栈,可参阅 Cloudflare 100x engineer 实战解析

terminalSequence 在 tmux 中有效吗?

有效,但有一个前提。运行时会把序列写入控制终端设备;只要 tmux.conf 中配置了 set -g allow-passthrough on,或该序列只是普通 BEL(),tmux 就会将其转发到外层终端。BEL 始终可以透传,OSC 序列则必须启用 passthrough。

同一份 hook 配置能同时使用 args:[] 和 command:'' 吗?

不能。同一个 hook 条目中,两者互斥。直接启动的命令应选 exec 形式(args:[]);需要管道、重定向或 glob 展开的命令则应选 Shell 形式(command:"")。如果确实需要混用,可以编写一个采用 exec 形式启动的 wrapper 脚本,把 Shell 功能封装在脚本内部。

如果 Claude 反复触发同一项校验,continueOnBlock 会无限循环吗?

设置 maxAttempts 后不会。运行时会把重试次数限制在该值以内,达到上限后升级为硬阻断。如果没有 maxAttempts,非确定性 validator 的确可能不断消耗上下文,因此务必设置。类型检查类校验默认设为 3 次比较合理;任何依赖远程状态的校验则应设为 1 次。

$CLAUDE_EFFORT 在所有 hook 事件中都可用吗?

是的。从 2.1.133 开始,运行时会把它注入所启动的每一个 hook 环境。其值反映当前轮次的 effort level,因此 SessionStart 读取到的是用户启动时的 effort,PostToolUse 读取到的则是工具触发时生效的值。

降级到 2.1.138 会失去哪些功能?

terminalSequenceargs:[]continueOnBlockCLAUDE_PROJECT_DIR 都会悄无声息地停止工作。运行时会忽略无法识别的 JSON 字段,并退回 command:"" 解析。hooks 仍会触发,但新行为不会生效。依赖降级方案前,务必先在分支中测试。

最近更新

2026年9月5日

分类Build

在 Google 中优先显示本站

将 omidsaffari.com 添加为 Google 搜索的优先来源

把 omidsaffari.com 设为优先来源,Google 会在 Top Stories、AI Overviews 和 AI Mode 中为您优先展示。

更多 Build 文章

查看全部 Build 文章
订阅通讯

每周日,一封信。 写运转中的系统,不写热评。

来自一组 AI 项目组合运营的构建日志、生产系统与一线笔记。

每周一期。无垃圾邮件。随时退订。