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 值。更值得记住的是它背后的思路:把“停下来等人”当成一个一等公民事件暴露出去,而不是留给每个调用方自己去拼凑。对运维自动化来说,这通常意味着从“早上发现任务挂了”变成“任务刚卡住就有人知道”。