Repository navigation
Hooks zh
English · 中文
对应 v0.12.0(
3a762b1)。运行时在src/agent/hook.rs,十一个触发点在src/agent/loop_.rs,配置加载在src/settings.rs和src/config.rs。 本页若与这些文件不符,以仓库为准。 重新验证:cargo test hook
如果你的 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;想在进程内、在沙箱里、带着能跨重启存活的状态做反应,给插件订阅事件。
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(拒绝这次调用)。
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 和会话记录里带的是同一段文字,所以脚本调用方和屏幕上看到的是一致的。
除 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"。这个已经修了——见下面的载荷表。
一行 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": …}:
| 事件 |
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 22. 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 里永远是对象。
-
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,两个都跑:
-
~/.nanopi/config.toml——[hooks.*](v0.6 起为主) -
./.nanopi/config.toml——[hooks.*](主) -
~/.nanopi/settings.toml—— legacy -
./.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。
[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() 判断后面。一个开关,管住所有生命周期观察者。
~/.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 0chmod +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 只写本地队列文件,联网上报交给另一个后台进程做。
English
- Home
- Features
- Configuration
- Hooks
- Plugin Development
- Example Plugins
- Plugin Capabilities
- Plugin Events
- Debugging Plugins
中文
v0.12.0 · source of truth:
wit/nanopi-extension.wit