Skip to content

Hooks zh

Dev edited this page Sep 7, 2026 · 2 revisions

English · 中文

生命周期 hooks

对应 v0.12.0(3a762b1)。运行时在 src/agent/hook.rs,十一个触发点在 src/agent/loop_.rs,配置加载在 src/settings.rs 和 src/config.rs。 本页若与这些文件不符,以仓库为准。 重新验证:cargo test hook

⚠ v0.12 改掉了四个配置键,旧名字现在是硬错误

如果你的 hook 配置是照 v0.11、或者照 v0.12 之前的本页写的,它现在加载不了。 没有别名,也没有过渡期:

已废弃的键 改写成
[[hooks.pre_tool_use]] [[hooks.tool_execution_start]]
[[hooks.post_tool_use]] [[hooks.tool_execution_end]]
[[hooks.user_prompt_submit]] [[hooks.input]]
[[hooks.session_end]] [[hooks.session_shutdown]]

报错长什么样、以及为什么是报错而不是告警,见v0.12 的改名。


心智模型

hook 是一条 shell 命令,不是插件。nanopi 起一个 bash -c <command>,往它 stdin 写一行 JSON,然后读退出码和 stdout。全部约定就这些——任何能读 stdin、能设退出码的语言都可以写。

由此有两个推论:

hook 是策略,不是代码。 它住在配置文件里,多处配置是累加而不是覆盖,--no-hooks 一把全关。

hook 和 WASM 插件不再是两套彼此独立的机制。 v0.12 起,插件可以在一个单独的配置授权下观察同样这十一个事件,收到的载荷与 shell hook 的 stdin 逐字节相同。它做不到的是否决和改写——那仍然是 hook 独有的特权。见 插件事件。

所以:想拦住或改写什么,写 hook;想在进程内、在沙箱里、带着能跨重启存活的状态做反应,给插件订阅事件。


一套词汇:PI 的名字

v0.12 之前,这十一个事件里有四个用的是 Claude Code 的名字,另七个用的是 PI 的名字。v0.12 把十一个全部统一到 PI 的名字,而且是所有出现的地方一次改完——[[hooks.*]] 配置键、JSON 载荷里的 event 字段、NANOPI_EVENT 环境变量,以及插件的 events 授权。没有对照表可查,因为已经没有需要对照的东西了。

十一个事件

事件 配置键 / 载荷 event 触发点 matcher 匹配什么 能否 Block 能否 Transform
SessionStart session_start Agent 构造完成后、第一个 turn 之前,一次 session id 咨询性 否
SessionShutdown session_shutdown 进程退出前,一次——/new、/resume、/fork、/import 也会触发 session id 咨询性 否
BeforeAgentStart before_agent_start 每个 turn 开头,用户消息进入上下文之前 turn 计数 能 能(prompt)
Input input 紧接 BeforeAgentStart 之后、/skill: 展开之前 空字符串 能 能(prompt)
TurnStart turn_start agent 循环每轮迭代开头 turn 计数 被忽略 被忽略
ToolExecutionStart tool_execution_start 每次工具调用执行前 工具名 能 能(整个参数对象)
ToolExecutionEnd tool_execution_end 工具返回后、结果被渲染和被模型看到之前 工具名 被忽略 能(content / is_error)
TurnEnd turn_end agent 循环每轮迭代结尾 turn 计数 被忽略 被忽略
MessageEnd message_end 循环结束后、turn 后压缩之前,一次 turn 计数 被忽略 被忽略
SessionBeforeCompact session_before_compact compact_now 内部,压缩执行前 压缩原因 咨询性 否
SessionCompact session_compact 压缩成功之后 压缩原因 咨询性 否

第一列是 CamelCase 形式,也就是 NANOPI_EVENT 里的值。第二列是 snake_case 形式,它同时是配置键、载荷里 event 的值,以及插件在 events 授权里写的名字——一个字符串,三个界面。

**"咨询性"指结果按设计就被丢弃——会话总要开始、结束、压缩。"被忽略"**说的是同一件事,只不过发生在一个本可以执行否决的调用点上:TurnStart、TurnEnd、MessageEnd 和 ToolExecutionEnd 直接把否决决定丢掉。从这些事件返回 {"decision":"block"} 既不报错也不告警,纯粹没有任何效果。不要在它上面建策略。

从 v0.11.1 起,咨询性的否决至少会被说出来,nanopi 会打印:

nanopi: TurnStart hook asked to block; ignored (advisory event) [reason=...]

于是"拦了但没拦住"不再和"干干净净跑完"长得一模一样。

真正能拦住东西的只有三个事件:before_agent_start 和 input(中断这个 turn)以及 tool_execution_start(拒绝这次调用)。

两个 prompt hook 是串联的

before_agent_start 先跑,它改写后的 prompt 会成为 input 的输入,后者的输出才是最终进入上下文的内容。两者都在 /skill:name 展开之前跑,所以 hook 可以把一段 prompt 改写成一条斜杠命令,展开照样生效。

拦下 before_agent_start,这个 turn 会以一条带方括号的合成标记结束:

[BeforeAgentStart hook blocked the turn: <reason>]

拦下 input 的措辞是一个策略结果,而不是一次失败,并且刻意不加方括号(两个渲染器本来就会用 [error: …] 包一层):

your `input` hook refused this prompt (policy, not a failure) — <reason>

--output json 和会话记录里带的是同一段文字,所以脚本调用方和屏幕上看到的是一致的。

非工具事件上的 matcher

除 tool_execution_start / tool_execution_end 外都没有工具名,于是 matcher 被拿去匹配该事件手头有的别的东西——而这并不总是符合直觉:

  • 会话事件 → session id(配前缀正则有用)
  • 压缩事件 → 原因字符串,只可能是 threshold 或 manual
  • before_agent_start / turn_start / turn_end / message_end → turn 计数渲染成的字符串,所以 matcher = "^1$" 可以把 hook 限定在第一个 turn
  • input → 空字符串。只有 * 或省略 matcher 才可能匹配上;写任何真正的正则都会静默地永不触发

压缩事件是 matcher 唯一仍被刻意复用的地方:它测的是原因,而载荷里的 session_id 带的是真正的 session id。v0.12 之前这两者是同一个字段,压缩事件上的 session_id 就是字符串 "threshold"。这个已经修了——见下面的载荷表。


Wire protocol

stdin

一行 JSON,以换行结尾:

{"event":"tool_execution_start","tool_name":"bash","tool_call_id":"call_abc","arguments":{"command":"rm -rf /"},"cwd":"/home/u/proj","session_id":"019..."}
字段 说明
event 事件名的 snake_case 形式——tool_execution_start、before_agent_start、session_compact……
tool_name 没有工具的事件里不出现
tool_call_id provider 给这次调用的 id。v0.11.0 起真正填充——在那之前它被硬编码为 None,导致 tool_execution_end hook 无法把结果和它前面那次 start 对上
arguments 按事件而定的载荷,见下表
cwd 会话工作目录
session_id 部分路径上不出现;工具类、turn 类和会话类事件里有

各事件的 arguments:

事件 载荷
tool_execution_start 工具参数对象原样
tool_execution_end {"tool_input":{…},"tool_response":{"content":"…","is_error":false,"duration_ms":123}}
before_agent_start {"turn_count":N,"prompt":"…"}
input {"prompt":"…"}
turn_start {"turn_count":N,"iteration":I}
turn_end {"turn_count":N,"iteration":I,"had_tool_calls":bool}
message_end {"turn_count":N,"response_length":N}
四个会话 / 压缩事件 {"reason":"…"}

message_end 只给出 turn 计数和回复的长度,不含回复正文。想拿到助手说了什么,这条路目前走不通。

会话载荷多了一个 reason(v0.12)

四个会话生命周期事件过去发的是空的 {},现在发 {"reason": …}:

事件 reason 取值
session_start startup、new、resume、fork、import
session_shutdown quit、new、resume、fork、import
session_before_compact / session_compact threshold、manual

由此有两件事。session_shutdown 除了真退出,/new、/resume、/fork、/import 也会触发,所以只该在真退出时跑的清理脚本现在必须自己判断 arguments.reason == "quit",不能想当然。

以及靠读 session_id 来区分 threshold 和 manual 的压缩 hook 必须改成读 arguments.reason。v0.12 之前有三样不同的东西挤在那一个字段里;现在 session_id 在每个事件上都只带真正的 session id,没有哪个载荷字段会撒谎。

环境变量

hook 继承父进程的完整环境,外加:

变量 何时设置
NANOPI_EVENT 总是设置——CamelCase 名字(ToolExecutionStart、SessionStart……)
NANOPI_CWD 总是设置
NANOPI_SESSION_ID 存在 session id 时
NANOPI_TOOL_NAME 非会话事件(没有工具时是空串)
NANOPI_TOOL_CALL_ID 只在存在 tool_call_id 时,即那两个工具事件

NANOPI_EVENT 也跟着改名了:它现在是 ToolExecutionStart,不是 PreToolUse。靠 $NANOPI_EVENT 分支的脚本需要和配置键一起改。

NANOPI_EVENT 的存在意义是:同一个脚本可以挂到多个事件上,靠 $NANOPI_EVENT 分支,完全不用解析 stdin。

怎么回话

正好两条通道,外加一个默认值:

1. 退出码 2 = block。 stderr 就是 reason(stderr 为空时用 blocked by hook)。

echo "refusing: touches production" >&2
exit 2

2. stdout 上一个 JSON 对象。 解析的是最后一行非空行,所以上面尽管打日志。两种形状:

{"decision":"block","reason":"why"}
{"decision":"allow","updated_input":{"command":"ls -la"}}

hookSpecificOutput 作为 updated_input 的别名同样被接受,方便照 Claude Code 风格写的脚本直接复用。

3. 其余一切 = allow。 退出 0 且无输出、退出 1、stdout 解析不出 JSON——都当放行。

几条值得记住的优先级:

  • decision:"block" 压过同时出现的 updated_input。拦截型 hook 就该直接拒绝,不能悄悄改写后放行。
  • 单独给 updated_input 也会触发 Transform,写不写 "decision":"allow" 都行。
  • updated_input 不是对象(字符串、null)会被忽略——工具参数在 nanopi 的 schema 里永远是对象。

Transform 到底替换了什么

  • tool_execution_start —— 整个参数对象。工具随后跑在替换后的参数上,而且 TUI 的卡片会重渲染成实际跑了什么,而不是模型要求跑什么。
  • before_agent_start / input —— 只认 prompt 一个键。turn_count 是 nanopi 的,不归 hook 改。
  • tool_execution_end —— nanopi 从你返回的 updated_input 的顶层读 content(字符串)和 is_error(布尔)。这里最容易踩坑:你收到的载荷里这两个字段嵌在 tool_response 下面,但你返回的对象里必须放在顶层、不能嵌套。
{"decision":"allow","updated_input":{"content":"[redacted]","is_error":false}}

由于 tool_execution_end 在 ToolResult 事件发出之前触发,在这里改写的内容既是用户在屏幕上看到的,也是模型在上下文里看到的。


顺序、串联与失败

同一事件的多个 hook 按配置顺序串行执行:

  • type 不是 "command" 的条目跳过
  • matcher 不匹配的条目跳过
  • 一次 Transform 的结果会喂给下一个 hook——改写是串联的
  • 第一个 Block 就中断整条链,后面的 hook 不再执行

超时按 Block 处理。 timeout 默认 5000 ms;到点后子进程被杀,结果是一个 reason 里带着超时时长的 block。所以卡住的 tool_execution_start hook 会导致工具调用被拒,而卡住的 turn_start hook 只是白白花掉 5 秒墙钟——而且现在它会通过咨询性否决的那条 stderr 提示说出来。

崩溃则失败放行(fail open)。 命令根本起不来时 nanopi 放行该动作,并往 stderr 打印:

nanopi: hook errored; failing open (allow) [tool=bash matcher=* error=...]

非法正则则失败拦截(fail closed)。 matcher_matches 对解析不了的模式返回 false,于是该 hook 永不触发。matcher 在配置加载时还会被统一校验,按下标报出第一个非法项。


配置

hook 从四个文件读取,且四处全部累加——项目级 hook 不会替换全局 hook,两个都跑:

  1. ~/.nanopi/config.toml —— [hooks.*](v0.6 起为主)
  2. ./.nanopi/config.toml —— [hooks.*](主)
  3. ~/.nanopi/settings.toml —— legacy
  4. ./.nanopi/settings.toml —— legacy

上面的顺序就是执行顺序。该用的是 config.toml;settings.toml 仅为向后兼容保留。

每个 hook 是一条以 snake_case 事件名为键的 TOML array-of-tables:

[[hooks.tool_execution_start]]
matcher = "^bash$"          # 对工具名做正则;"*" 或省略 = 全匹配
type    = "command"         # 只有 "command" 一种,是默认值
command = "~/.nanopi/hooks/guard.sh"   # ~、$HOME、${HOME} 会展开
timeout = 5000              # 毫秒;默认 5000;超时 = block

相对路径的 command 原样交给 shell,按进程 cwd 解析。

CamelCase 写法([[hooks.ToolExecutionStart]])反序列化不出来。两个文件里键名都是 snake_case。

v0.12 的改名:旧键不再加载

[hooks](src/settings.rs 的 HooksSection)带着 #[serde(deny_unknown_fields)],所以一个不认识的键就是整个文件的解析错误——nanopi 不会带着一份自己没法完整履行的配置启动。四个废弃名字里的任何一个都会落到这里,而且报错会直接点出替代键:

unknown hook event "pre_tool_use" — renamed to "tool_execution_start" in v0.12 (see docs/v0.12-events.md §2.1)

这句话是把 serde 原始的 "unknown field" 文本重写出来的,config.toml 和 legacy 的 settings.toml 上都会做这个重写,并且保留出错文件的路径。普通拼写错误(turn_startt)不会被重写——你拿到的是 serde 的原文,它本来就会列出所有合法键,这比猜一个更有用。

为什么是报错而不是告警。 一个被静默忽略的 [[hooks.pre_tool_use]] 是最糟的结果:用户以为自己给 bash 上了守卫,其实什么都没有,而且任何地方都不会提这件事。加载失败是响亮的,改起来只是一行。这里刻意不提供别名——别名等于把两套词汇永久留着,而那正是 v0.12 要终结的东西。

一把全关

nanopi --no-hooks

紧急开关。它覆盖了包括会话生命周期在内的每一个触发点,开了之后什么都不会跑——而且 v0.12 起它同时切断 WASM 插件的事件投递,因为插件投递点也在同一个 hooks_active() 判断后面。一个开关,管住所有生命周期观察者。


例一:拒绝危险 shell 命令

~/.nanopi/hooks/guard.sh:

#!/usr/bin/env bash
# tool_execution_start 守卫:拒绝 rm -rf 和强推。
set -euo pipefail

payload=$(cat)
cmd=$(printf '%s' "$payload" | jq -r '.arguments.command // ""')

case "$cmd" in
  *"rm -rf"*|*"push --force"*|*"push -f"*)
    echo "refused by guard.sh: $cmd" >&2
    exit 2
    ;;
esac

exit 0
chmod +x ~/.nanopi/hooks/guard.sh

~/.nanopi/config.toml:

[[hooks.tool_execution_start]]
matcher = "^bash$"
command = "~/.nanopi/hooks/guard.sh"
timeout = 3000

模型拿到的工具结果是 blocked by hook: refused by guard.sh: rm -rf build/,所以它能据此调整,而不是盲目重试。

例二:抹掉工具输出里的密钥

~/.nanopi/hooks/redact.sh:

#!/usr/bin/env bash
# tool_execution_end:在模型(和屏幕)看到之前抹掉像 token 的东西。
set -euo pipefail

payload=$(cat)
content=$(printf '%s' "$payload" | jq -r '.arguments.tool_response.content // ""')
is_err=$(printf '%s' "$payload"  | jq -r '.arguments.tool_response.is_error // false')

clean=$(printf '%s' "$content" | sed -E \
  -e 's/gh[pousr]_[A-Za-z0-9]{20,}/[REDACTED_GITHUB_TOKEN]/g' \
  -e 's/sk-[A-Za-z0-9]{20,}/[REDACTED_API_KEY]/g' \
  -e 's/(AWS_SECRET_ACCESS_KEY=)[^ ]+/\1[REDACTED]/g')

# content 和 is_error 放在 updated_input 的顶层。
# -c 很关键:只有 stdout 的最后一行非空行会被解析,而
# 美化过的 JSON 最后一行是一个孤零零的 "}"。
jq -cn --arg c "$clean" --argjson e "$is_err" \
  '{decision:"allow", updated_input:{content:$c, is_error:$e}}'
[[hooks.tool_execution_end]]
matcher = "*"
command = "~/.nanopi/hooks/redact.sh"
timeout = 4000

两个脚本都需要 jq。两个都是纯 stdin→stdout,所以不启动 nanopi 也能测:

echo '{"event":"tool_execution_start","tool_name":"bash","arguments":{"command":"rm -rf /tmp/x"}}' \
  | ~/.nanopi/hooks/guard.sh; echo "exit=$?"

受限网络下的注意事项

hook 脚本继承 nanopi 进程的完整环境,http_proxy / https_proxy 也一并继承。所以在受限网络后面:

  • 需要联网的 hook(上报、拉规则表)会自动走已配置的代理,不用在脚本里重复设置;
  • 但要留意 timeout 默认只有 5 秒,走代理的一次请求很容易超时,而超时按 block 处理——挂在 tool_execution_start 上就会莫名其妙拒掉工具调用。把联网这类 hook 挂到咨询性事件上(turn_end、session_shutdown),或者把 timeout 调大;
  • 注意 session_shutdown 现在在 /new、/resume、/fork、/import 时也会触发,一个联网上报 hook 挂在这里会在切会话时反复跑。要么判断 arguments.reason == "quit",要么接受这个频率;
  • 最稳的做法是让 hook 只写本地队列文件,联网上报交给另一个后台进程做。

相关页面

  • 配置文件布局与合并规则 → 配置
  • 想加工具和斜杠命令而不是 hook → 写插件
  • 插件能碰什么 → 能力门
  • 让插件观察同样这十一个事件 → 插件事件

Clone this wiki locally