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 事件:SessionStart、PreToolUse、PostToolUse、UserPromptSubmit、Notification、Stop、SubagentStop、PreCompact 和 SessionEnd。每个 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 环境。它的取值为 low、medium、high、xhigh,对应 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

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

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

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

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

我会按这个顺序进行:

  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 会失去哪些功能?

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

最近更新
2026年9月5日
分类
Build

在 Google 中优先显示本站

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

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

AgentRun 评测:AI 智能体工作流何时值得引入

AgentRun 评测:AI 智能体工作流何时值得引入

AgentRun 值得用于 AI 智能体工作流吗?本文实测 beta.4 的分支控制、schema 校验、升级机制、集成成本与定价,并与 TypeScript、LangGraph.js 和 Temporal 对比。结合 4 个客服案例、31 项测试及 31 行函数,说明适用团队、关键限制,以及何时该继续使用普通代码。2026年9月24日Build
Perplexity API Fast Search 对比默认 Web:该怎么选?

Perplexity API Fast Search 对比默认 Web:该怎么选?

Perplexity API 的 Fast Search 每 1,000 次成功请求仅需 $1,默认 web 为 $5。本文对比两种模式的延迟、检索质量、覆盖能力与真实成本,并给出按查询风险路由、用证据门槛自动升级的落地方法,帮助 Agent 团队判断哪些任务该追求速度,哪些研究必须保留更广的来源覆盖。2026年9月24日Build
AI智能体成本:付费兜底如何耗尽共享余额

AI智能体成本:付费兜底如何耗尽共享余额

一次沙箱文件交接失败,让付费图片兜底悄悄变成默认路径:共享余额从 $8.30 降至 $0,402 错误却没有阻止任务返回 done。本文复盘 AI智能体成本如何在凭据边界、共享依赖和软失败之间失控,解释为何更多兜底不等于更可靠,并给出由可信进程接管上传、将完成状态绑定到封面与嵌入等必需产物的修复方法。2026年9月24日Build
Cursor 价格拆解:Rollouts 免费吗,试用额度怎么算?

Cursor 价格拆解:Rollouts 免费吗,试用额度怎么算?

想弄清 Cursor 价格?Rollouts 并非免费功能,仅 Teams(每位用户每月 $40)和定制报价的 Enterprise 可用。本文拆解 10 天上线额度、Teams 约 50 次与 Enterprise 约 500 次变更、尚未公布的后续单价,以及启用前必须核对的计费字段、遥测条件和支出控制。2026年9月24日Build
Unreal Agent 使用教程:跑通 Runner,用 JSONL 验证效果

Unreal Agent 使用教程:跑通 Runner,用 JSONL 验证效果

这篇 Unreal Agent 使用教程带你配置 Runner,在隔离仓库中运行只读任务,读取 JSONL 会话、退出状态与 Token 用量,并判断异步工具执行是否值得接入产品。文章同时拆解 Runner 与 Go 库的选择、安全边界、成本比较、代码审查场景和可落地的产品方向,帮助团队用同一模型和标准完成可复现评估。2026年9月24日Build
JetBrains Air 教程:从首次会话到代码审查

JetBrains Air 教程:从首次会话到代码审查

这篇 JetBrains Air 教程带你在 JetBrains IDE 中安装 Air Alpha、连接编码智能体、添加项目上下文,并用 Standard Access 完成首次小改动。逐文件检查 diff、亲自复跑测试,再决定保留、修改、提交或回退,同时看清免费插件与智能体订阅、API 用量和人工审查成本的边界。2026年9月23日Build
JetBrains Air 免费吗?插件、Agent 与 AI 成本详解

JetBrains Air 免费吗?插件、Agent 与 AI 成本详解

JetBrains Air 免费吗?Air Alpha IDE 插件价格为 $0,但宿主 IDE、编程 Agent、第三方 API 与 JetBrains AI Credits 仍可能收费。本文拆解 Junie Lite 免费期、四种授权路径和约 27 Credits 的订阅分界,帮你确认每次会话由哪个账户付费。2026年9月23日Build
Firecrawl 自托管实战:部署、验证与成本账

Firecrawl 自托管实战:部署、验证与成本账

从固定版本开始完成 Firecrawl 自托管:按 v2.11.162 部署 Docker Compose,用真实抓取和重启复测保存证据,并对照 Firecrawl Cloud 的功能边界与 30 天成本。文中提供可复用的验证脚本、适用团队清单和成本工作表,帮助你判断基础设施控制权是否值得首月 $725.20 的投入。2026年9月22日Build
订阅通讯

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

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