Claude Code Hooks 实战:实时感知会话状态的绝佳机制
这篇文章来自一次真实的开发经历。
最初的灵感来自 @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" 表示只在 Write 或 Edit 工具触发时执行。这里的 Write 和 Edit 是 Claude Code 的内置工具名 - Write 用于创建/覆盖文件,Edit 用于修改文件中的字符串。同一个事件下可以放多个匹配器组,各自匹配不同的工具。
第三层:钩子数组。"hooks" 是实际要执行的操作列表。这里定义了一个 command 类型的钩子,会运行指定的 shell 脚本,超时 30 秒。
几个容易踩的坑:
matcher是正则表达式字符串。"Write|Edit"用|表示"或","mcp__.*"用.*匹配所有 MCP 服务器工具。省略 matcher 字段则匹配该事件的所有触发- 内置工具名是大小写敏感的:
Bash、Read、Write、Edit、Glob、Grep、Task、WebFetch、WebSearch等。MCP 工具名格式为mcp__<服务器名>__<工具名> - 不是所有事件都支持 matcher。
UserPromptSubmit、Stop、TeammateIdle、TaskCompleted这四个事件不支持 matcher,加了会被静默忽略 - 支持 matcher 的事件,过滤的内容也不同。工具类事件(
PreToolUse/PostToolUse/PostToolUseFailure/PermissionRequest)按工具名匹配;SessionStart按启动方式匹配(startup、resume、clear、compact);Notification按通知类型匹配
事件生命周期
Claude Code 一共提供 14 种 hook 事件,覆盖了会话的完整生命周期:
| 事件 | 触发时机 | 能否阻断 |
|---|---|---|
SessionStart | 会话开始或恢复 | 否 |
UserPromptSubmit | 用户提交 prompt | 是 |
PreToolUse | 工具执行前 | 是 |
PermissionRequest | 权限弹窗出现时 | 是 |
PostToolUse | 工具执行成功后 | 否(已执行) |
PostToolUseFailure | 工具执行失败后 | 否(已失败) |
Notification | 发送通知时 | 否 |
SubagentStart | 子 Agent 启动时 | 否 |
SubagentStop | 子 Agent 完成时 | 是 |
Stop | Agent 完成响应 | 是 |
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
}
适合复杂验证场景,比如“确认所有改动都有对应的测试”。
决策控制:三种模式
不同事件使用不同的决策模式:
顶层 decision(UserPromptSubmit、PostToolUse、Stop 等):
{ "decision": "block", "reason": "测试未通过" }
hookSpecificOutput(PreToolUse):更精细的三态控制 - 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 事件 | 宠物状态 | 显示 |
|---|---|---|
PreToolUse | working | 显示工具名:Reading file、Running command... |
PostToolUse | working | 工具完成,Agent 还在工作 |
PostToolUseFailure | error | 工具失败,显示错误信息 |
Stop | completed | Agent 完成,宠物切换到庆祝动画 |
Notification | completed | 等待用户输入 |
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 秒)才恢复。
可能的方案:
- 添加
UserPromptSubmithook - 用户提交下一个 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 周边生态的关键基础设施。
参考资料
- Claude Code Hooks 参考文档 - 完整的事件、配置和 API 说明
- Claude Code Hooks 指南 - 入门教程和实际用例
- Sessionly - 用 Hooks 驱动桌面宠物的 Electron 应用
相关文章
2026年2月6日
OpenCode 的 Hooks 机制:事件总线、插件钩子与配置钩子
深入 OpenCode 源码,解析其三层 Hooks 架构:类型安全的事件总线、插件生命周期钩子、以及配置驱动的实验性钩子。理解编码智能体如何在松耦合的前提下实现精确的行为控制。
2026年2月15日
AI 时代的福利:小白的 SEO 实战
一个 SEO 新手借助 AI 的第一次实战:从 Google Search Console 的红色警告出发,修复结构化数据、降低跳出率、理解页面索引趋势。整个过程只花了一个下午。
2026年2月9日
给 Agent 加定时任务?七个你一定会踩的坑
从 OpenClaw 一次关掉 60+ cron issues 的重构中,提炼出 Agent 定时任务系统的七个可靠性教训:亚秒精度陷阱、LLM 调用必须有超时、失败退避不能省、单次任务的死循环、投递上下文会过期、重复管道要合并、以及 —— 不是所有模型都会按你的 schema 传参。