Hermes 卡在等人回答的时候,你的插件终于能知道了

凌晨两点,你的 Hermes 在服务器上跑一个长任务,中途遇到一条要输 sudo 密码的命令,或者弹出一个“是否允许”的审批提示。没有人坐在那里。任务就停在那里,直到超时,然后失败——你早上九点打开终端,只看到一行冷冰冰的结果。你想做的其实很简单:它停下来的那一刻,给我发一条推送。
问题是这件事以前很难做对。Hermes 会在好几个完全不同的地方停下来等人:sudo 密码提示、clarify 工具提出的问题、危险命令审批。每一种在公司内部的代码路径都不一样,想要一个“有人被阻塞了”的通知,就得给每种提示单独注册一个钩子,再对着终端命令做正则匹配去猜哪次是 sudo。
2026 年 10 月 5 日合并的 PR #133171 补上了这一块:一对通用的插件钩子,任何“停下等人”的时刻都会触发。
这一对钩子是什么
on_human_input_request —— 在提示即将展示给人之前触发
on_human_input_resolved —— 这次等待结束时触发,每个 request_id 恰好一次
on_human_input_request 触发点位于所有界面共用的那段代码里,所以 CLI、Ink TUI / Desktop、ACP 和网关平台(Telegram、Discord 等)都自动被覆盖,不需要为每个界面各写一套接线。覆盖范围:
kind |
什么提示 | 出现在哪些界面 |
|---|---|---|
"sudo" |
执行 sudo 命令前的密码提示 |
交互式 CLI、Ink TUI / Desktop(sudo.request 卡片)、/dev/tty 兜底 |
"clarify" |
clarify 工具提出的问题 |
CLI、Ink TUI / Desktop、网关平台 |
"approval" |
危险命令 / 写文件审批、MCP elicitation 同意、插件审批通道 | CLI、Ink TUI / Desktop、ACP、网关平台、注册了 ctx.register_approval_transport 的插件 |
有两种情况不会触发,而且都是故意的:一是 approvals.mode=smart 下由辅助模型做出的决定,因为根本没有人在被问;二是网关里合并到同一个提示上的跟随者——人只看到一条提示,这对钩子也只为一处触发一次。
回调能拿到什么
def my_callback(kind: str, request_id: str, session_id: str, session_key: str,
platform: str, prompt: str, **kwargs):
...
| 参数 | 说明 |
|---|---|
kind |
"sudo"、"clarify" 或 "approval";将来可能新增,不认识的直接忽略 |
request_id |
每次提示唯一,配对的 on_human_input_resolved 会带同一个值 |
session_id |
已知时是 Hermes 会话 id,否则空字符串 |
session_key |
会话 / 聊天的路由键,适合按聊天分别推通知 |
platform |
网关平台名("telegram"、"discord"…)或本地来源("cli"、"tui"、"desktop"…) |
prompt |
人在被问什么:sudo / 审批是命令本身,clarify 是问题文本 |
关于 prompt 有一处安全设计值得单独说:它始终经过密钥脱敏器(redact_sensitive_text(..., force=True)),即使你的配置里关掉了 security.redact_secrets。如果脱敏过程失败,prompt 就是空字符串——原始文本不会被送出去。另外,你输入的 sudo 密码和 clarify 的答案永远不会传给钩子,传的只是结果标签。
回调的返回值会被忽略;回调抛异常只会被记到调试日志里,提示照常展示,坏掉的观察者不会弄坏人的提示。回调运行在“即将阻塞的那个线程”上,所以保持轻快——要发网络请求(推送服务、webhook)就交给后台线程去做。
outcome 的取值
on_human_input_resolved 在等待结束后触发一次,多带一个 outcome:
kind |
outcome 可能的值 |
|---|---|
"sudo" |
"provided"、"skipped"(空答案)、"timeout"、"cancelled"、"error" |
"clarify" |
该界面自己的结果:"submitted"、"timed_out"、"cancelled"、"undelivered"、"terminated",或 "error" |
"approval" |
"once"、"session"、"always"、"deny"、"timeout"、"cancelled"、"notify_failed",插件审批通道失败时为 "transport_<failure>" |
一个真正有用的示例:30 秒没人理再叫我
最常见的需求不是“一有提示就轰炸我”,而是“如果没人管,过一会儿再叫我”。用请求 / 解决这一对钩子就很自然:
import threading
def push_notification(text):
... # 在这里调用你的推送服务
def register(ctx):
pending = {}
def on_request(kind, request_id, prompt, session_key, **kwargs):
pending[request_id] = threading.Timer(
30, push_notification, (f"Hermes needs you ({kind}): {prompt[:80]}",)
)
pending[request_id].start() # 30 秒内有人处理就不打扰
def on_resolved(request_id, **kwargs):
timer = pending.pop(request_id, None)
if timer:
timer.cancel()
ctx.register_hook("on_human_input_request", on_request)
ctx.register_hook("on_human_input_resolved", on_resolved)
这段代码的好处在于:能回答的提示不会打扰你,只有真的卡住的才推送。而且因为 request_id 是配对的,重复推送、丢失取消这类问题都不会出现。想了解钩子体系的整体形状(观察者钩子和拦截钩子的区别、注册与生命周期),可以看 hooks 命令参考 和之前那篇插件 provider 钩子指南。
上线状态
这对钩子目前只在 main 上,还没进任何已发布 tag(最新发布为 v0.21.5 / v2026.9.24)。如果你是从 git 安装的,拉一次最新 main 就能用;用 Docker 镜像或托管部署的,要等下一个 tag。写插件本身的方法见 plugins 命令参考。
小结
以前要做一个“Hermes 需要在等人了”的通知,你得了解内部每个提示各自走哪条代码路径;现在只需要记住一对钩子、一个 kind 字段和一组 outcome 值。更值得记住的是它背后的思路:把“停下来等人”当成一个一等公民事件暴露出去,而不是留给每个调用方自己去拼凑。对运维自动化来说,这通常意味着从“早上发现任务挂了”变成“任务刚卡住就有人知道”。