读 Herdr 的 Claude Code Hook 脚本
从 Claude Code Hook 脚本读起,梳理 Herdr 如何接收代理事件,以及 Shell、Python 和 Unix socket 各自负责什么。
目录
Herdr 的 Claude 集成 hook(
herdr-agent-state.sh)阅读笔记
前言
前段时间,我给终端装了 Herdr。它是一个面向 AI 编码代理的终端多路复用器,可以理解为能识别 AI 代理的 tmux。装完顺手执行了 herdr integration install claude,它往 ~/.claude/hooks/ 里放了一个脚本,又在我的 settings.json 里注册了一行。
这个脚本叫 herdr-agent-state.sh,连注释一百行出头,不到 3KB。源码在 Herdr 仓库里:src/integration/assets/claude/herdr-agent-state.sh,下文引用的代码片段都出自这里。
装完之后,我有个疑问:Herdr 已经能识别窗格里的 Claude,侧边栏也会亮状态点,为什么还需要这个 hook?脚本涉及 shell 与 Python 的分工、防御式编程、消息去重和 Unix socket 通信,我也在注释里读到了一次旧问题的修复。这篇笔记按当时的阅读顺序写,包括几个卡住我的问题。
一、为什么需要这个 hook
Herdr 对代理的感知分为两级。
第一级是屏幕检测(screen detection)。Herdr 根据每个窗格的终端输出匹配规则:看到 ❯ 提示符,判定 idle;看到 do you want to proceed?,判定 blocked。侧边栏的绿点、黄点、红点都来自这些判断,不需要代理配合。
第二级是会话身份(session identity)。屏幕检测能识别 Claude,却不知道它运行的是哪个会话。Herdr 服务器重启后,窗格还在,但 Claude 进程没了。如果没有记录之前的 session id,就无法恢复那个会话。
这个 hook 在 Claude Code 会话启动时,向 Herdr 服务器上报会话身份及所在窗格。官方文档把这类集成归为 session identity 类:集成负责上报身份,状态判定仍由屏幕检测完成。
这次上报的执行顺序如下:
Claude Code 启动 → 触发 SessionStart 事件
→ 执行 hook 脚本(stdin 喂入一串 JSON)
→ 脚本检查"我在 Herdr 窗格里吗"
→ 连上 Herdr 的 unix socket
→ 发出一条 {"method": "pane.report_agent_session", ...}
→ 退出,不留痕迹
二、入口:settings.json 里的一行注册
Claude Code 在生命周期事件发生时触发 hook。注册信息在我的 ~/.claude/settings.json 里:
"hooks": {
"SessionStart": [{
"matcher": "*",
"hooks": [{
"type": "command",
"command": "bash '/Users/dongxu/.claude/hooks/herdr-agent-state.sh' session",
"timeout": 10
}]
}]
}
三个要素:
- 事件:
SessionStart,会话启动时触发。其他事件还有UserPromptSubmit、PreToolUse、PostToolUse、Stop等,完整列表见官方 hook 文档。 - matcher:
"*"表示通配。如果换成PreToolUse这类带工具名的事件,这里可以填"Bash"、"Edit|Write"做精确匹配,比如只在调用 Bash 前执行检查。 - timeout:10 秒,超时后强制终止。脚本内部的 socket 通信超时为 0.5 秒,远小于外部上限,以免 hook 卡住后拖慢 Claude 启动。
命令末尾的 session 是传给脚本的第一个参数,对应脚本里的 action="${1:-}"。
三、Shell:保存输入与检查环境
脚本前面是 shell,后面内嵌了一段 Python。shell 部分负责设置退出规则、保存输入和检查运行环境。
设置退出规则:
set -eu
-e 是任何命令失败立即退出,-u 是用到未定义变量立即报错。hook 在宿主进程的生命周期中运行,出错后继续执行可能污染环境,因此这里选择立即退出。shebang 用的是 #!/bin/sh。macOS 上它实际是以 POSIX 模式运行的 bash,Debian/Ubuntu 上则是 dash;两者都是系统自带的,不需要另外安装。
把 stdin 存起来:
hook_input_file="$(mktemp "${TMPDIR:-/tmp}/herdr-claude-hook.XXXXXX")" || exit 0
trap 'rm -f "$hook_input_file"' EXIT HUP INT TERM
cat >"$hook_input_file" 2>/dev/null || true
读到临时文件时,我没想明白,为什么不直接把 stdin 传给 Python?后面的 python3 - <<'PY' 解释了这个安排:python3 - 从 stdin 读取程序,heredoc 提供的正是这段程序。stdin 已经用来读代码了,Claude Code 传入的 JSON 就得先存到别处。shell 于是把 JSON 写进临时文件,再通过环境变量把文件路径传给 Python。
两个窗格同时启动 Claude 时,会各自运行一个 hook 实例。mktemp 用随机后缀避免临时文件冲突。trap ... EXIT HUP INT TERM 则保证临时文件会被清理,不论脚本以哪种方式退出。cat 后的 || true 让脚本在读不到数据时继续执行,空数据会在后面被过滤掉。
检查参数和环境:
case "$action" in
session) ;;
*) exit 0 ;;
esac
[ "${HERDR_ENV:-}" = "1" ] || exit 0
[ -n "${HERDR_SOCKET_PATH:-}" ] || exit 0
[ -n "${HERDR_PANE_ID:-}" ] || exit 0
command -v python3 >/dev/null 2>&1 || exit 0
五道检查中,任何一道不通过都会 exit 0。退出码仍是 0,表示这次不执行上报,也不因 hook 自身的问题打扰宿主。
case 检查 settings.json 中传入的 session 参数。目前只处理 session,其他参数会让脚本直接退出,这也为以后扩展用法留了位置。
接下来的三道检查针对环境变量。HERDR_ENV、HERDR_SOCKET_PATH、HERDR_PANE_ID 是 Herdr 启动窗格时注入的,并非由 Claude Code 提供。如果当前不在 Herdr 窗格内,hook 就会直接退出,因此在普通终端启动 Claude Code 时不会受到影响。最后检查 python3 是否可用,后面的解析和通信需要它。
四、Python 内核:从 JSON 到一条消息
解析外部输入
hook_input = {}
if hook_input_file:
try:
with open(hook_input_file, encoding="utf-8") as handle:
content = handle.read()
if content.strip():
hook_input = json.loads(content)
except Exception:
hook_input = {}
try/except Exception 捕获解析和读取异常。JSON 格式有误或文件读不到时,hook_input 保持为空字典,后面不再执行上报。hook 接收的是外部数据,格式可能随版本变化,需要按不可信输入处理。
Python 开头又检查了一遍 pane_id 和 socket_path,尽管 shell 已经检查过。这让 Python 部分即使被单独使用,也保留了必要的环境检查。
两道过滤与一次旧问题
is_subagent = bool(hook_input.get("agent_id"))
if is_subagent:
raise SystemExit(0)
if hook_event_name == "SubagentStop":
# SubagentStop is a completion event. Older Herdr integrations mapped it
# to durable working, but Claude recap/away-summary can emit it after the
# main turn has already stopped. Never let it revive an idle pane.
raise SystemExit(0)
首先过滤子代理。Claude Code 会派生子代理处理并行任务,它们启动时同样触发 SessionStart。如果子代理也上报,侧边栏就会出现多余的 Claude 条目,干扰会话记录,因此这里只登记主会话。
另一道过滤旁的注释说明了一个旧问题。旧版集成曾把 SubagentStop 映射为“持续工作中”,但 Claude 的 recap/away-summary 功能会在主循环停止后再次发出这个事件。已经变绿(idle)的窗格因此又变成黄色(working),用户看到状态点,会以为代理还在运行。修复方式是跳过这类事件。
按这里的理解,这属于迟到事件的问题,并没有两条执行流争抢资源。状态事件可能重复或迟到;对这个上报脚本来说,不能确定是否该更新时,少报一次比显示错误状态更合适。
区分几个容易混淆的字段
第一次读到构造请求数据这部分时,我没分清几个字段。request_id、report_seq、session_id、transcript_path、agent_session_id、agent_session_path、session_start_source,七个名字放在一起,分不清各自的用途。
按来源区分后,就容易理解了。
Claude Code 传入的会话信息:
session_id:这次会话的唯一 IDtranscript_path:会话转录文件的路径source(仅 SessionStart 事件有):会话怎么来的,startup/resume之类
脚本生成的消息元数据:
request_id:herdr:claude:<毫秒时间戳>:<6位随机数>,保证每条消息绝对唯一report_seq:time.time_ns()纳秒时间戳,用来排序
读到这里,我又有个疑问:为什么 request_id 不也用纳秒,而要用毫秒加随机数?我当时的理解是,两者的用途不同。request_id 要保证绝对唯一,毫秒精度下两个并发实例可能产生相同值,所以再加 6 位随机数;report_seq 用来比较先后,纳秒精度下碰撞概率可以忽略,时间戳又天然单调递增,方便服务器判断新旧。前者用于防重复,后者用于防乱序。
session_id 校验非空后改名为 agent_session_id,transcript_path 改名为 agent_session_path。输入字段使用 Claude Code 的命名,发出的消息使用 Herdr 协议的命名,转换发生在两者之间。
组装与发送:JSON-RPC over Unix Socket
组装后的消息如下,省略了几个可选字段:
request = {
"id": request_id,
"method": "pane.report_agent_session",
"params": {
"pane_id": pane_id,
"agent": "claude",
"seq": report_seq,
"agent_session_id": agent_session_id,
},
}
id / method / params 这三个字段采用了 JSON-RPC 的形式。我原来以为 JSON-RPC 必须通过 HTTP 传输,后来才理解,RPC 约定的是远程调用方式,传输方式可以另选。Herdr 使用的是本地 unix socket。
发送部分:
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
client.settimeout(0.5)
client.connect(socket_path)
client.sendall((json.dumps(request) + "\n").encode())
try:
client.recv(4096)
except Exception:
pass
client.close()
关于 unix socket,我最初理解错了。我以为它是一个约定好的文件位置,脚本往里写,Herdr 再定时读取。实际上,socket 文件不存放消息内容,不能用 cat 读取。服务器在 ~/.config/herdr/herdr.sock 这个地址上监听,客户端 connect 时,服务器接受连接(accept),双方随后交换数据。这里靠事件驱动,不需要定时读取文件。Nginx、Redis 监听本地 socket 也是这种机制。它不走网络栈,并受文件权限控制;本机这个 socket 的权限是 srw-------,只有当前用户能访问,因此比 TCP 更快、更安全。
逐行看发送逻辑:
settimeout(0.5):0.5 秒超时,和前面 settings.json 的 10 秒上限呼应。如果 Herdr 服务器挂了或不存在,connect会阻塞;没有这 0.5 秒,hook 会卡满 10 秒被强杀,用户会感觉到 Claude 启动变慢。设置后,最坏情况是半秒后退出。json.dumps(request) + "\n":末尾换行是协议要求。Herdr 按行解析,一行一条完整请求;不带换行,服务器会继续等待这一行结束。recv(4096):读取后丢弃响应内容,用来确认对方收到了消息。- 最外层
except Exception: pass:忽略通信异常,包括服务器不可用和写入失败。
脚本从头到尾不向 stdout 输出任何内容。在 Claude Code 的 hook 协议中,输出有特定含义,可以用于阻断操作或注入上下文。这个脚本只负责上报,所以成功和失败时都不输出内容,避免影响宿主。
五、读完后仍然卡住我的三个问题
1. 一个 pane 同时只跑一个代理,为什么还要 request_id 和 seq?
我当时没想通的是,单个 pane 里明明是串行的,pane_id 配上 session id 不就够了吗?重复和乱序从哪里来?
我后来从下面三种情况理解这个问题:
- 同一 pane 先后跑两个会话。退出 Claude 再启动,
pane_id不变、session_id变了,两条消息先后发出。万一到达顺序乱了(两个独立进程,谁先连上服务器没人能保证),没有seq,旧记录可能盖掉新记录。 - 不同 pane 并发上报。两个窗格同时启动 Claude,两个 hook 实例互不感知,到达顺序不确定。
- 未来的重试。如果哪天 hook 加了失败重发,同一事件会投递两次,
request_id用于让服务器识别已经处理过的消息。
pane_id 标明事件发生在哪个窗格,request_id 标识一次事件,seq 表示先后顺序。按这个理解,唯一 ID 用来防止重复处理,序列号用来避免旧消息覆盖新消息。
2. 这个 hook 只跑一次吗?resume 会再触发吗?
它挂在 SessionStart 上,只在会话启动时触发一次。使用过程中和退出时都不会再运行,直到下一次启动 claude。
按 Claude Code 的 hook 文档,恢复会话(claude --resume)也会触发 SessionStart,输入 JSON 中的 source 字段会变为 resume。脚本把 source 单独上报,Herdr 据此区分新会话与恢复的会话。恢复时 session_id 不变,Herdr 收到上报后按 seq 判断新旧,刷新绑定关系。
多个 pane 分别启动 Claude 时,各自上报自己的 pane_id 和 session_id,互不干扰。侧边栏显示两个 Claude 条目,对应的就是两个进程。
3. 服务器端是定时去读 socket 文件的吗?
前面已经解释过这一点,但这是我阅读时卡得最久的地方。服务器监听 socket 地址,收到连接后 accept,再按行读取数据、解析、处理并返回响应,最后关闭连接。hook 发完消息便退出,整个通信过程大概只有几毫秒。它没有定时读取 socket 文件。
六、读完后对 Hook 的几点理解
读完这个脚本,我对 hook 的职责有了更具体的认识。
脚本每道检查不通过就 exit 0,不报错、不输出,尽量不影响宿主。对这类依附于宿主生命周期的代码,我认为失败时应尽量不增加宿主的负担。安全审查类的阻断型 hook 则有不同取舍:宁可错杀,也不能放行。写 hook 前,需要先分清它负责上报还是阻断。
消息设计上,一条只有五个字段的消息,仍然配了唯一 ID 和单调序号,用来处理重复和乱序。即使消息很小,也需要考虑重复处理和旧消息覆盖新消息的问题。
读之前,我以为这个 hook 主要负责 Herdr 的代理状态检测;读完才发现,它负责上报会话身份。状态判定仍由屏幕检测完成,hook 记录的是哪个会话运行在哪个窗格。
附
- 脚本源码(Herdr 仓库):src/integration/assets/claude/herdr-agent-state.sh
- 本地安装位置:
herdr integration install claude之后位于~/.claude/hooks/herdr-agent-state.sh - 本文基于 Herdr 0.8.2(Claude 集成 v8)与 Claude Code 官方文档撰写,hook 事件列表、socket API 等细节以官方文档为准