返回博客2026年2月6日

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

Claude CodeHooksAgent实战Electron

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

最初的灵感来自 @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年2月15日

AI 时代的福利:小白的 SEO 实战

一个 SEO 新手借助 AI 的第一次实战:从 Google Search Console 的红色警告出发,修复结构化数据、降低跳出率、理解页面索引趋势。整个过程只花了一个下午。

SEOGoogle Search Console独立博客

2026年2月9日

给 Agent 加定时任务?七个你一定会踩的坑

从 OpenClaw 一次关掉 60+ cron issues 的重构中,提炼出 Agent 定时任务系统的七个可靠性教训:亚秒精度陷阱、LLM 调用必须有超时、失败退避不能省、单次任务的死循环、投递上下文会过期、重复管道要合并、以及 —— 不是所有模型都会按你的 schema 传参。

AIAgentCron

准备开始了吗?

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