返回博客2026年2月6日3 分钟阅读

Claude Code Hooks 实战:实时感知会话状态的绝佳机制

摘要

深入 Claude Code Hooks 机制:配置格式、事件生命周期、三种钩子类型,以及一个真实案例 - 如何用 Hooks 驱动桌面宠物实时反映 AI 编码状态。

这篇文章来自一次真实的开发经历。

最初的灵感来自 @yetone - 他做了一个桌面宠物能实时反映编码智能体的工作状态,我觉得这个想法太酷了,于是开始动手做自己的版本。

我在做一个叫 Sessionly 的 Electron 桌面应用 - 窗口角落有一只浮动的像素宠物,实时反映 Claude Code 的工作状态。读文件时它在翻书,跑命令时它在奔跑,完成任务时切换到庆祝动画。

最初的实现是监听 ~/.claude/projects/ 下的 JSONL 文件变化,解析最后几行来猜测状态。能用,但问题不少:延迟高(debounce + 轮询),不准确(JSONL 里没有显式的 stop_reason,全靠推断),多会话时还会互相干扰。

后来发现 Claude Code 提供了 Hooks 机制 - 在工作流的每个关键节点主动推送事件,工具执行前、执行后、完成时、出错时都有对应的钩子。从轮询猜测变成事件驱动,精度和实时性都上了一个台阶。

这篇文章整理了我在接入 Hooks 过程中学到的机制细节、踩过的坑,以及最终的架构选择。

配置格式:新的三层结构

Hooks 在 ~/.claude/settings.json(全局)或 .claude/settings.json(项目级)中配置。配置有三层嵌套:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "/path/to/your-script.sh",
            "timeout": 30
          }
        ]
      }
    ]
  }
}

逐层拆解这个 JSON:

第一层:事件名"PostToolUse" 是你要监听的事件 - 工具执行成功之后。除了 PostToolUse,Claude Code 一共提供了 14 种事件,后面会列出完整清单。

第二层:匹配器组(matcher group)。事件名的值是一个数组,每个元素是一个匹配器组。"matcher": "Write|Edit" 表示只在 WriteEdit 工具触发时执行。这里的 WriteEdit 是 Claude Code 的内置工具名 - Write 用于创建/覆盖文件,Edit 用于修改文件中的字符串。同一个事件下可以放多个匹配器组,各自匹配不同的工具。

第三层:钩子数组"hooks" 是实际要执行的操作列表。这里定义了一个 command 类型的钩子,会运行指定的 shell 脚本,超时 30 秒。

几个容易踩的坑:

  • matcher正则表达式字符串"Write|Edit"| 表示"或","mcp__.*".* 匹配所有 MCP 服务器工具。省略 matcher 字段则匹配该事件的所有触发
  • 内置工具名是大小写敏感的:BashReadWriteEditGlobGrepTaskWebFetchWebSearch 等。MCP 工具名格式为 mcp__<服务器名>__<工具名>
  • 不是所有事件都支持 matcher。UserPromptSubmitStopTeammateIdleTaskCompleted 这四个事件不支持 matcher,加了会被静默忽略
  • 支持 matcher 的事件,过滤的内容也不同。工具类事件(PreToolUse / PostToolUse / PostToolUseFailure / PermissionRequest)按工具名匹配;SessionStart 按启动方式匹配(startupresumeclearcompact);Notification 按通知类型匹配

事件生命周期

Claude Code 一共提供 14 种 hook 事件,覆盖了会话的完整生命周期:

事件触发时机能否阻断
SessionStart会话开始或恢复
UserPromptSubmit用户提交 prompt
PreToolUse工具执行前
PermissionRequest权限弹窗出现时
PostToolUse工具执行成功后否(已执行)
PostToolUseFailure工具执行失败后否(已失败)
Notification发送通知时
SubagentStart子 Agent 启动时
SubagentStop子 Agent 完成时
StopAgent 完成响应
TeammateIdle团队中某个 Agent 即将闲置
TaskCompleted任务被标记为完成时
PreCompact上下文压缩前
SessionEnd会话结束

对于状态感知场景,最常用的是工具相关的四个:PreToolUse(知道 Agent 要做什么)、PostToolUse(知道做完了)、PostToolUseFailure(知道失败了)和 Stop(知道整轮结束了)。

关键细节Stop 事件在用户按 ESC 中断时不会触发。文档原文:

Does not run if the stoppage occurred due to a user interrupt.

这意味着你不能仅靠 Stop 事件来判断“工作结束了”。后面会讲我们怎么处理这个问题。

三种钩子类型

Command:运行 Shell 命令

最常用的类型。你的脚本通过 stdin 接收 JSON,通过 exit code 和 stdout 返回结果。

{
  "type": "command",
  "command": "/path/to/script.sh",
  "async": true,
  "timeout": 5
}

输入格式(stdin JSON):

{
  "session_id": "abc123",
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": { "command": "npm test" },
  "cwd": "/Users/me/project"
}

Exit code 决定行为:0 表示放行,2 表示阻断(stderr 内容反馈给 Agent),其他值是非阻断错误。

async: true 让钩子在后台运行,不阻塞 Agent。适合日志收集、状态通知这类不需要决策的场景。

Prompt:让 LLM 做判断

不想写脚本?可以让一个轻量模型来判断:

{
  "type": "prompt",
  "prompt": "检查以下操作是否涉及生产数据库。如果是,返回 {\"ok\": false, \"reason\": \"不允许操作生产数据库\"}。输入:$ARGUMENTS"
}

$ARGUMENTS 会被替换为钩子的 JSON 输入。模型返回 {"ok": true}{"ok": false, "reason": "..."} 来控制行为。

Agent:带工具的多轮验证

最强大的类型。生成一个子 Agent,可以使用 Read、Grep、Glob 等工具来实际检查代码后再做判断:

{
  "type": "agent",
  "prompt": "验证所有单元测试是否通过。运行测试套件并检查结果。$ARGUMENTS",
  "timeout": 120
}

适合复杂验证场景,比如“确认所有改动都有对应的测试”。

决策控制:三种模式

不同事件使用不同的决策模式:

顶层 decisionUserPromptSubmitPostToolUseStop 等):

{ "decision": "block", "reason": "测试未通过" }

hookSpecificOutputPreToolUse):更精细的三态控制 - allow、deny、ask:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "不允许执行 rm -rf"
  }
}

allow 直接放行绕过权限系统,deny 阻断,ask 弹出确认框让用户决定。还可以通过 updatedInput 在执行前修改工具参数。

PermissionRequest:在权限弹窗出现时自动响应:

{
  "hookSpecificOutput": {
    "hookEventName": "PermissionRequest",
    "decision": { "behavior": "allow" }
  }
}

实战:用 Hooks 驱动桌面宠物

说完机制,来看一个真实的应用。我在开发 Sessionly - 一个 Electron 桌面应用,窗口角落有一只浮动的像素宠物,实时反映 Claude Code 的工作状态。

架构

Claude Code CLI
   │
   ├── PreToolUse ──→ curl POST stdin JSON ──→ Hook Server (localhost:19823)
   ├── PostToolUse ──→ curl POST stdin JSON ──→      │
   ├── PostToolUseFailure ──→ ...              ──→      │
   ├── Stop ──→ ...                            ──→      │
   └── Notification ──→ ...                    ──→      ▼
                                                  Session Monitor
                                                       │
                                                       ▼
                                                  Pet Window
                                                  (状态动画)

Hook 配置

每个事件都注册一个 async command hook,通过 curl 把 stdin JSON 转发到本地 HTTP 服务:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "*",
        "hooks": [
          {
            "type": "command",
            "command": "curl -s -X POST http://localhost:19823/sessionly -d @- || true",
            "async": true,
            "timeout": 5
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "curl -s -X POST http://localhost:19823/sessionly -d @- || true",
            "async": true,
            "timeout": 5
          }
        ]
      }
    ]
  }
}

注意 Stop 没有 matcher,PreToolUse"*" 匹配所有工具。|| true 确保 curl 失败不影响 Claude Code。

事件到状态的映射

Hook Server 收到事件后,Session Monitor 做状态映射:

Hook 事件宠物状态显示
PreToolUseworking显示工具名:Reading file、Running command...
PostToolUseworking工具完成,Agent 还在工作
PostToolUseFailureerror工具失败,显示错误信息
StopcompletedAgent 完成,宠物切换到庆祝动画
Notificationcompleted等待用户输入

Hook 安装器

不能让用户手动编辑 settings.json。应用启动时自动检测并安装 hooks:

function createMatcherGroup(event: HookEventName): MatcherGroup {
  const group: MatcherGroup = {
    hooks: [{
      type: 'command',
      command: HOOK_COMMAND,
      async: true,
      timeout: 5,
    }],
  }
  // Stop 不支持 matcher,不要加
  if (!NO_MATCHER_EVENTS.includes(event)) {
    group.matcher = '*'
  }
  return group
}

安装时合并现有配置(不覆盖用户自己的 hooks),卸载时只删除自己的 matcher group。通过 HOOK_IDENTIFIER(URL 特征串)识别哪些是我们的 hooks。

文件监听 vs Hooks:二选一

最初的实现是两者并存 - hooks 提供实时状态,文件监听作为兜底。但这导致了一个棘手的 bug:

[22:45:30] HOOK Stop session=a5339cfd...
[22:45:30] [sessionly] working → completed ✅
[22:45:30] State unchanged: working (tool: none)  // ???

Stop hook 正确地把会话标记为 completed,但文件监听器的旧条目还留在活跃会话列表里,状态是 working。聚合状态计算时,working(优先级 2)> completed(优先级 1),宠物卡在工作状态。

解决方案:Hooks 启用时完全禁用文件监听,不做混合模式。Hook 服务启动失败时才回退到文件监听。

if (petSettings.hooksEnabled) {
  hookServer = new HookServer()
  hookServer.start().then((started) => {
    if (started) {
      hookServer!.on('hookEvent', (payload) => {
        sessionMonitor.handleHookEvent(payload)
      })
    } else {
      // 回退到文件监听
      sessionMonitor.start()
    }
  })
} else {
  sessionMonitor.start()
}

干净、明确、没有状态冲突。

未解决的问题:用户中断

Stop 不在用户按 ESC 时触发。这意味着中断后宠物会卡在 working 状态,直到闲置超时(目前 60 秒)才恢复。

可能的方案:

  • 添加 UserPromptSubmit hook - 用户提交下一个 prompt 时重置状态
  • 缩短 hook 驱动会话的闲置超时 - 如果 10 秒没有新事件,自动转为 idle
  • 两者结合效果最佳

这是 hooks 机制本身的限制,不是 bug。

踩坑记录

配置格式变更。如果你在网上搜到的示例是扁平数组格式:

// ❌ 旧格式,已不可用
"PreToolUse": [
  { "type": "command", "command": "..." }
]

现在必须用 matcher group 包裹:

// ✅ 新格式
"PreToolUse": [
  {
    "matcher": "*",
    "hooks": [
      { "type": "command", "command": "..." }
    ]
  }
]

热更新不生效。Claude Code 在启动时快照 hooks 配置,运行期间修改 settings.json 不会立即生效。需要通过 /hooks 菜单审核变更。

Shell profile 干扰 JSON 解析。如果你的 .bashrc / .zshrc 在启动时打印文本,可能污染 hook 的 stdout,导致 JSON 解析失败。确保 hook 脚本的 stdout 只包含纯 JSON。


本文基于 Claude Code Hooks 官方文档和 Sessionly 项目实战经验。Hooks 机制让外部工具能精确感知编码智能体的工作流,是构建 Agent 周边生态的关键基础设施。

参考资料

相关文章

2026年5月13日

Agent 输出 HTML 的时代到了

Anthropic 工程师发了一条 “HTML is the new markdown”,附 20 个 Claude Code 产出的单文件 HTML 示例,Simon Willison 次日放弃用了三年的 Markdown 默认值。读完来分享 - 变的不是 HTML 胜出,而是 agent 输出的本质。

最近一封 · Sample

我把 Typeless 删啦!开源产品 Airtype 终于支持本地模型,本地优先的语音输入真香!

Airtype v0.13.0 接入 Qwen3-ASR 与 Apple MLX,不再要求 API Key,也不必把录音上传到云端,语音转文字可以完整地在 Mac 本地完成。

—— william

Letters

来信

里面装的是

  • 新文章 — 写完一篇就寄一封,不攒货
  • 这周读到的、看到的、好用的工具
  • 正在折腾的实验,附带翻车记录

约莫 1–2 周一封 · 随时退订

合作伙伴

CompeteMap — 英国及爱尔兰学生竞赛一站式搜索

数学、编程、科学、写作等各类竞赛信息汇总,支持按年龄和科目筛选,再也不错过报名截止日。

准备开始了吗?

先简单说明目标,我会给出最合适的沟通方式。