Event Hooks(事件钩子)

Hermes 有三个钩子系统,可在关键生命周期点运行自定义代码:

系统注册方式运行环境使用场景
网关钩子(Gateway hooks)HOOK.yaml + handler.py~/.hermes/hooks/仅网关日志记录、告警、webhooks
插件钩子(Plugin hooks)ctx.register_hook()插件CLI + 网关工具拦截、指标、护栏
Shell 钩子(Shell hooks)hooks: 块在 ~/.hermes/config.yaml 中,指向 shell 脚本CLI + 网关即插即用的脚本,用于阻塞、自动格式化、上下文注入

所有三个系统都是非阻塞的——任何钩子中的错误都会被捕获并记录,绝不会使代理崩溃。

网关事件钩子

网关钩子会在网关运行期间(Telegram、Discord、Slack、WhatsApp、Teams)自动触发,不会阻塞主要代理管道。

创建钩子

每个钩子是 ~/.hermes/hooks/ 下的一个目录,包含两个文件:

~/.hermes/hooks/
└── my-hook/
    ├── HOOK.yaml
    └── handler.py

~/.hermes/hooks/my-hook/HOOK.yaml

name: my-hook
description: What this hook does
events:
  - gateway:startup
  - agent:turn_start

~/.hermes/hooks/my-hook/handler.py

"""处理网关生命周期事件。"""
 
import logging
 
logger = logging.getLogger("hooks.my-hook")
 
async def handle(event_type: str, context: dict) -> None:
    if event_type == "gateway:startup":
        logger.info("Gateway started — %s", context)
    elif event_type == "agent:turn_start":
        logger.info("Agent turn started — platform=%s", context.get("platform"))

可用事件

事件触发时机
gateway:startup网关初始化完成,准备接受消息
gateway:shutdown网关正在关闭
agent:turn_start代理开始处理用户消息
agent:turn_end代理完成处理(无论成功或出错)
command:*任何斜杠命令运行(例如 command:goalcommand:curator
command:before:*在特定命令处理之前(例如 command:before:goal)——可用于权限检查

针对特定命令的钩子带有通配符匹配:--events 'command:*' 匹配所有斜杠命令,--events command:goal,command:curator 匹配特定命令,--events 'command:before:*' 匹配每个命令的前置钩子。此模式使您可以编写在特定斜杠命令执行之前或之后运行的自定义逻辑。

钩子类型和自动订阅

钩子可以通过 --auto-subscribe CLI 标志自动订阅。默认情况下,hermes hooks 会提示确认。自动订阅允许钩子注册而无需在每个网关启动时手动重新连接。

BOOT.md 启动检查清单

一个实际示例——一个在每个网关启动时运行的钩子,该钩子生成一个一次性代理来执行位于 ~/.hermes/BOOT.md 的 Markdown 检查清单。当您的网关在远程 VPS 上重启而您想知道一切是否正常时很有用。

插件钩子

插件可以注册在 CLI 和网关会话中都触发的钩子。这些通过插件 register() 函数中的 ctx.register_hook() 以编程方式注册。

def register(ctx):
    ctx.register_hook("pre_tool_call", my_tool_observer)
    ctx.register_hook("post_tool_call", my_tool_logger)
    ctx.register_hook("pre_llm_call", my_memory_callback)
    ctx.register_hook("post_llm_call", my_sync_callback)
    ctx.register_hook("on_session_start", my_init_callback)
    ctx.register_hook("on_session_end", my_cleanup_callback)

所有钩子的一般规则:

  • 回调接收关键字参数。始终接受 **kwargs 以确保前向兼容——未来版本可能添加新参数而不会破坏您的插件。
  • 如果回调崩溃,会被记录并跳过。其他钩子和代理继续正常运行。行为不当的插件永远不会破坏代理。
  • 有两个钩子的返回值会影响行为:pre_tool_call 可以阻止工具执行,pre_llm_call 可以向 LLM 调用注入上下文。所有其他钩子是即发即忘的观察者。

快速参考

钩子触发时机返回值
pre_tool_call任何工具执行之前{"action": "block", "message": str} 以否决调用
post_tool_call任何工具返回之后忽略
pre_llm_call每轮一次,在工具调用循环之前{"context": str} 将上下文前置到用户消息
post_llm_call每轮一次,在工具调用循环之后忽略
on_session_start新会话创建(仅第一轮)忽略
on_session_end会话结束忽略
on_session_finalizeCLI/网关拆除活动会话(刷新、保存、统计)忽略
on_session_reset网关换入新会话密钥(例如 /new/reset忽略
subagent_stopdelegate_task 子代理退出忽略
pre_gateway_dispatch网关收到用户消息,在认证 + 分发之前{"action": "skip" | "rewrite" | "allow", ...} 以影响流程
pre_approval_request危险命令需要用户批准,在发送提示/通知之前忽略
post_approval_response用户回复了批准提示(或超时)忽略
transform_tool_result任何工具返回后,结果交给模型之前str 替换结果,None 保持原样
transform_terminal_outputterminal 工具内部,截断/ANSI 剥离/编辑之前str 替换原始输出,None 保持原样
transform_llm_output工具调用循环完成后,最终回复交付之前str 替换回复文本,None/空保持原样

pre_tool_call

每次工具执行之前立即触发——包括内置工具和插件工具。

回调签名:

def my_callback(tool_name: str, args: dict, task_id: str, **kwargs):
参数类型描述
tool_namestr即将执行的工具名称(例如 "terminal""web_search""read_file"
argsdict模型传递给工具的参数
task_idstr会话/任务标识符。如果未设置则为空字符串

触发位置:model_tools.pyhandle_function_call() 中,在工具处理程序运行之前。每工具调用触发一次——如果模型并行调用 3 个工具,则触发 3 次。

返回值 —— 否决调用:

return {"action": "block", "message": "工具调用被阻塞的原因"}

代理以 message 作为返回给模型的错误进行短路。第一个匹配的阻塞指令获胜(Python 插件先注册,然后是 shell 钩子)。任何其他返回值被忽略,因此现有的仅观察者回调可以保持不变。

使用场景: 日志记录、审计追踪、工具调用计数、阻止危险操作、速率限制、按用户策略执行。

使用场景: 日志记录、审计追踪、工具调用计数、阻止危险操作、速率限制、按用户策略执行。


post_tool_call

每次工具执行返回之后立即触发。

回调签名:

def my_callback(tool_name: str, args: dict, result: str, task_id: str,
                duration_ms: int, **kwargs):
参数类型描述
tool_namestr刚刚执行的工具名称
argsdict模型传递给工具的参数
resultstr工具的返回值(始终是 JSON 字符串)
task_idstr会话/任务标识符。如果未设置则为空字符串
duration_msint工具分发所用的毫秒数(通过 time.monotonic() 围绕 registry.dispatch() 测量)

触发位置:model_tools.pyhandle_function_call() 中,在工具处理程序返回之后。每工具调用触发一次。如果工具引发了未处理的异常,不会触发(异常会被捕获并作为错误 JSON 字符串返回,post_tool_call 会以该错误字符串作为 result 触发)。

返回值: 忽略。

使用场景: 日志记录工具结果、指标收集、追踪工具成功/失败率、延迟仪表盘、每工具预算告警、在特定工具完成时发送通知。


pre_llm_call

每轮触发一次,在工具调用循环开始之前。这是唯一返回值被使用的钩子——它可以向当前轮次的用户消息注入上下文。

回调签名:

def my_callback(session_id: str, user_message: str, conversation_history: list,
                is_first_turn: bool, model: str, platform: str, **kwargs):
参数类型描述
session_idstr当前会话的唯一标识符
user_messagestr用户本轮原始消息(在任何技能注入之前)
conversation_historylist完整消息列表的副本(OpenAI 格式:[{"role": "user", "content": "..."}]
is_first_turnbool如果是新会话的第一轮则为 True,后续轮次为 False
modelstr模型标识符(例如 "anthropic/claude-sonnet-4.6"
platformstr会话运行位置:"cli""telegram""discord"

触发位置:run_agent.pyrun_conversation() 中,在上下文压缩之后但主 while 循环之前。每次 run_conversation() 调用触发一次(即每用户轮次一次),而不是工具循环内的每次 API 调用。

返回值: 如果回调返回一个带有 "context" 键的字典,或一个非空字符串,则文本被追加到当前轮次的用户消息中。返回 None 表示不注入。

# 注入上下文
return {"context": "已回忆的记忆:\n- 用户喜欢 Python\n- 正在处理 hermes-agent"}
 
# 纯字符串(等效)
return "已回忆的记忆:\n- 用户喜欢 Python"
 
# 不注入
return None

上下文注入位置: 始终是用户消息,从不是系统提示。这保护了提示缓存——系统提示在轮次间保持一致,因此缓存的令牌可以被重用。系统提示是 Hermes 的领域(模型指导、工具强制、个性、技能)。插件在用户输入旁边贡献上下文。

所有注入的上下文都是临时的——仅在 API 调用时添加。对话历史中的原始用户消息永远不会被改变,也不会持久化到会话数据库。

多个插件返回上下文时,它们的输出按插件发现顺序(按目录名称字母顺序)用双换行符连接。

使用场景: 记忆回忆、RAG 上下文注入、护栏、每轮分析。


post_llm_call

每轮触发一次,在工具调用循环完成且代理已产生最终回复之后。仅在成功的轮次触发——如果轮次被中断则不会触发。

回调签名:

def my_callback(session_id: str, user_message: str, assistant_response: str,
                conversation_history: list, model: str, platform: str, **kwargs):
参数类型描述
session_idstr当前会话的唯一标识符
user_messagestr用户本轮原始消息
assistant_responsestr代理本轮最终文本回复
conversation_historylist轮次完成后的完整消息列表副本
modelstr模型标识符
platformstr会话运行位置

触发位置:run_agent.pyrun_conversation() 中,在工具循环退出并产生最终回复之后。受 if final_response and not interrupted 保护——因此当用户中断或代理达到迭代限制而未产生回复时不会触发。

返回值: 忽略。

使用场景: 同步对话数据到外部记忆系统、计算回复质量指标、记录轮次摘要、触发后续操作。


on_session_start

在创建全新会话时触发一次。在会话延续时(用户在现有会话中发送第二条消息)不会触发。

回调签名:

def my_callback(session_id: str, model: str, platform: str, **kwargs):
参数类型描述
session_idstr新会话的唯一标识符
modelstr模型标识符
platformstr会话运行位置

触发位置:run_agent.pyrun_conversation() 中,在新会话的第一轮期间——具体是在系统提示构建之后但工具循环开始之前。检查条件是 if not conversation_history(无先前消息 = 新会话)。

返回值: 忽略。

使用场景: 初始化会话作用域状态、预热缓存、向外部服务注册会话、记录会话开始。


on_session_end

在每次 run_conversation() 调用结束时触发,无论结果如何。如果用户退出时代理正在处理中,也会从 CLI 的退出处理程序触发。

回调签名:

def my_callback(session_id: str, completed: bool, interrupted: bool,
                model: str, platform: str, **kwargs):
参数类型描述
session_idstr会话的唯一标识符
completedbool如果代理产生了最终回复则为 True,否则为 False
interruptedbool如果轮次被中断则为 True(用户发送新消息、/stop 或退出)
modelstr模型标识符
platformstr会话运行位置

触发位置: 在两个地方:

  1. run_agent.py —— 每次 run_conversation() 调用结束时,在所有清理之后。始终触发,即使轮次出错。
  2. cli.py —— 在 CLI 的 atexit 处理程序中,但仅当代理在退出发生时处于处理中状态(_agent_running=True)。这捕获处理期间的 Ctrl+C 和 /exit。在这种情况下,completed=Falseinterrupted=True

返回值: 忽略。

使用场景: 刷新缓冲区、关闭连接、持久化会话状态、记录会话持续时间、清理在 on_session_start 中初始化的资源。


on_session_finalize

当 CLI 或网关拆除活动会话时触发——例如,用户运行 /new、网关 GC 了一个空闲会话、或 CLI 在代理活动时退出。这是在会话标识消失之前刷新与即将离开的会话绑定的状态的最后机会。

回调签名:

def my_callback(session_id: str | None, platform: str, **kwargs):
参数类型描述
session_idstrNone即将离开的会话 ID。如果没有活动会话可能存在 None
platformstr"cli" 或消息平台名称("telegram""discord" 等)

触发位置:cli.py/new / CLI 退出时)和 gateway/run.py(会话重置或 GC 时)。在网关侧始终与 on_session_reset 配对。

返回值: 忽略。

使用场景: 在会话 ID 被丢弃前持久化最终会话指标、关闭每会话资源、发出最终遥测事件、排干排队的写入。


on_session_reset

当网关为活动聊天换入新会话密钥时触发——用户调用了 /new/reset/clear,或适配器在空闲窗口后选择了新会话。这使插件能够对会话状态已擦除的事实做出反应,而无需等待下一次 on_session_start

回调签名:

def my_callback(session_id: str, platform: str, **kwargs):
参数类型描述
session_idstr新会话的 ID(已轮换为新值)
platformstr消息平台名称

触发位置:gateway/run.py 中,在新会话密钥分配后但在处理下一个入站消息之前。在网关上,顺序是:on_session_finalize(old_id) → 交换 → on_session_reset(new_id) → 在第一个入站轮次时 on_session_start(new_id)

返回值: 忽略。

使用场景: 重置以 session_id 为键的每会话缓存、发出”会话已轮换”分析、准备新的状态桶。


参见 构建插件指南 获取完整的操作说明,包括工具架构、处理程序和高级钩子模式。


subagent_stop

在每个子代理通过 delegate_task 完成后触发一次。无论您是委派单个任务还是三个任务的批次,这个钩子为每个子代理触发一次,在父线程上串行执行。

回调签名:

def my_callback(parent_session_id: str, child_role: str | None,
                child_summary: str | None, child_status: str,
                duration_ms: int, **kwargs):
参数类型描述
parent_session_idstr委派父代理的会话 ID
child_rolestr | None在子代理上设置的编排者角色标签(如果功能未启用则为 None
child_summarystr | None子代理返回给父代理的最终回复
child_statusstr"completed""failed""interrupted""error"
duration_msint运行子代理所花费的挂钟毫秒数

触发位置:tools/delegate_tool.py 中,在 ThreadPoolExecutor.as_completed() 耗尽所有子期物之后。触发被编排到父线程,因此钩子作者不必推理并发回调执行。

返回值: 忽略。

使用场景: 记录编排活动、累积子代理持续时间用于计费、撰写委派后审计记录。


pre_gateway_dispatch

每个传入的 MessageEvent触发一次,在网关中,在内部事件守卫之后但在认证/配对和代理分发之前。这是网关级消息流策略(仅监听窗口、人工交接、每聊天路由等)的拦截点,这些策略不易适合任何单一平台适配器。

回调签名:

def my_callback(event, gateway, session_store, **kwargs):
参数类型描述
eventMessageEvent标准化的入站消息(有 .text.source.message_id.internal 等)
gatewayGatewayRunner活动的网关运行器,因此插件可以为侧信道回复调用 gateway.adapters[platform].send(...)(所有者通知等)
session_storeSessionStore用于通过 session_store.append_to_transcript(...) 进行静默转录读取

触发位置:gateway/run.pyGatewayRunner._handle_message() 中,在 is_internal 计算之后。内部事件完全跳过钩子(它们是系统生成的——后台进程完成等——不能被面向用户的策略门控)。

返回值: None 或一个字典。第一个被识别的动作字典获胜;其余插件结果被忽略。插件回调中的异常会被捕获并记录;网关总是在出错时回退到正常分发。

返回值效果
{"action": "skip", "reason": "..."}丢弃消息——无代理回复、无配对流程、无认证。插件假定已处理(例如静默读取到转录)。
{"action": "rewrite", "text": "new text"}替换 event.text,然后以修改后的事件继续正常分发。对于将缓冲的周围消息折叠成单个提示很有用。
{"action": "allow"} / None正常分发——运行完整的认证/配对/代理循环链。

使用场景: 仅监听群聊(仅在被标记时回复;将周围消息缓冲到上下文中);人工交接(在所有者手动处理聊天时静默读取客户消息);每配置速率限制;策略驱动的路由。


pre_approval_request

在批准请求向用户显示之前立即触发——覆盖每个界面:交互式 CLI、Ink TUI、网关平台(Telegram、Discord、Slack、WhatsApp、Matrix 等)和 ACP 客户端(VS Code、Zed、JetBrains)。

这是连接自定义通知器的正确位置——例如,一个弹出允许/拒绝通知的 macOS 菜单栏应用,或一个记录每个批准请求及上下文的审计日志。

回调签名:

def my_callback(
    command: str,
    description: str,
    pattern_key: str,
    pattern_keys: list[str],
    session_key: str,
    surface: str,
    **kwargs,
):
参数类型描述
commandstr等待批准的 shell 命令
descriptionstr命令被标记的人类可读原因(当多个模式匹配时合并)
pattern_keystr触发批准的主模式键(例如 "rm_rf""sudo"
pattern_keyslist[str]所有匹配的模式键
session_keystr会话标识符,用于按聊天作用域通知
surfacestr"cli" 用于交互式 CLI/TUI 提示,"gateway" 用于异步平台批准

返回值: 忽略。这里的钩子仅为观察者;它们不能否决或预回答批准。使用 pre_tool_call 在工具到达批准系统之前阻止它。

使用场景: 桌面通知、推送告警、审计日志、Slack webhook、升级路由、指标。


post_approval_response

在用户回复批准提示(或提示超时)之后触发。

回调签名:

def my_callback(
    command: str,
    description: str,
    pattern_key: str,
    pattern_keys: list[str],
    session_key: str,
    surface: str,
    choice: str,
    **kwargs,
):

pre_approval_request 相同的 kwargs,加上:

参数类型描述
choicestr"once""session""always""deny""timeout" 之一

返回值: 忽略。

使用场景: 关闭匹配的桌面通知、在审计日志中记录最终决策、更新指标、推进速率限制器。


transform_tool_result

在工具返回之后、结果追加到对话之前触发。让插件重写任何工具的结果字符串——不仅是终端输出——在模型看到它之前。

回调签名:

def my_callback(
    tool_name: str,
    arguments: dict,
    result: str,
    task_id: str | None,
    **kwargs,
) -> str | None:
参数类型描述
tool_namestr产生结果的工具(read_fileweb_extractdelegate_task 等)
argumentsdict模型调用工具时使用的参数
resultstr工具的原始结果字符串,在截断和 ANSI 剥离之后
task_idstr | None在 RL/基准测试环境中运行时的任务/会话 ID

返回值: str 替换结果(返回的字符串是模型看到的),None 保持原样。

使用场景:web_extract 输出中编辑组织特定的 PII、用摘要标头包装长 JSON 工具响应、向 read_file 结果注入检索增强提示、将 delegate_task 子代理报告重写为项目特定的架构。

适用于每个工具。对于仅终端的重写,请参见下面的 transform_terminal_output——它更窄且在管道中更早运行(预截断、预编辑)。


transform_terminal_output

terminal 工具的前台输出流水线内部触发,在默认的 50 KB 截断、ANSI 剥离和秘密编辑之前。让插件在任意下游处理触及之前重写 shell 命令的原始 stdout/stderr。

回调签名:

def my_callback(
    command: str,
    output: str,
    exit_code: int,
    cwd: str,
    task_id: str | None,
    **kwargs,
) -> str | None:
参数类型描述
commandstr产生输出的 shell 命令
outputstr原始的合并 stdout/stderr(可能非常大——截断在钩子之后发生)
exit_codeint进程退出码
cwdstr命令运行的工作目录

返回值: str 替换输出,None 保持原样。

使用场景: 为产生大量输出的命令注入摘要(du -ahfindtree)、用项目特定的标记标记输出以便下游钩子知道如何处理、剥离在运行之间波动并破坏提示缓存的时序噪声。

transform_tool_result(覆盖其他所有工具)配合良好。


transform_llm_output

每轮触发一次,在工具调用循环完成且模型产生最终回复之后,在该回复交付给用户(CLI、网关或编程调用者)之前。让插件使用经典编程方法重写助手的最终文本——无需在 SOUL 风格文本或技能驱动的转换上消耗额外的推理令牌。

回调签名:

def my_callback(
    response_text: str,
    session_id: str,
    model: str,
    platform: str,
    **kwargs,
) -> str | None:
参数类型描述
response_textstr助手本轮最终回复文本
session_idstr此对话的会话 ID(对于一次性运行可能为空)
modelstr产生回复的模型名称(例如 anthropic/claude-sonnet-4.6
platformstr交付平台(clitelegramdiscord 等;未设置时为空)

返回值: 非空 str 替换回复文本,None 或空字符串保持原样。当多个插件注册时,第一个非空字符串获胜——与 transform_tool_result 一致。

使用场景: 应用个性/词汇转换(海盗语、海绵宝宝风格)、从最终文本中编辑用户特定的标识符、附加项目特定的签名页脚、强制执行团队风格指南而不在 SOUL 指令上消耗令牌。

钩子受非空、非中断回复的保护——它不会在停止按钮中断或空轮次时触发。异常被记录为警告,不会破坏代理执行。


Shell 钩子

在您的 cli-config.yaml 中声明 shell 脚本钩子,Hermes 会在相应的插件钩子事件触发时将作为子进程运行它们——同时在 CLI 和网关会话中。无需编写 Python 插件。

当您想要一个即插即用、单文件脚本(Bash、Python,任何带有 shebang 的)来:

  • 阻止工具调用 —— 拒绝危险的 terminal 命令、强制执行每目录策略、要求批准破坏性 write_file/patch 操作
  • 在工具调用后运行 —— 自动格式化代理刚刚编写的 Python 或 TypeScript 文件、记录 API 调用、触发 CI 工作流
  • 向下一次 LLM 轮次注入上下文 —— 将 git status 输出、当前工作日或检索到的文档前置到用户消息(参见 pre_llm_call
  • 观察生命周期事件 —— 在子代理完成(subagent_stop)或会话开始(on_session_start)时写入日志行

Shell 钩子通过在 CLI 启动(hermes_cli/main.py)和网关启动(gateway/run.py)时调用 agent.shell_hooks.register_from_config(cfg) 注册。它们与 Python 插件钩子自然组合——两者都通过同一个分发器流动。

一览对比

维度Shell 钩子插件钩子网关钩子
声明位置~/.hermes/config.yaml 中的 hooks:plugin.yaml 插件中的 register()HOOK.yaml + handler.py 目录
存放位置~/.hermes/agent-hooks/(按惯例)~/.hermes/plugins/<name>/~/.hermes/hooks/<name>/
语言任意(Bash、Python、Go 二进制等)仅 Python仅 Python
运行环境CLI + 网关CLI + 网关仅网关
事件VALID_HOOKS(包括 subagent_stopVALID_HOOKS网关生命周期(gateway:startupagent:*command:*
能否阻止工具调用是(pre_tool_call是(pre_tool_call
能否注入 LLM 上下文是(pre_llm_call是(pre_llm_call
同意机制每个 (event, command) 对首次使用提示隐式(Python 插件信任)隐式(目录信任)
进程间隔离是(子进程)否(进程内)否(进程内)

配置架构

hooks:
  <event_name>:                  # 必须在 VALID_HOOKS 中
    - matcher: "<regex>"         # 可选;仅用于 pre/post_tool_call
      command: "<shell command>" # 必需;通过 shlex.split 运行,shell=False
      timeout: <seconds>         # 可选;默认 60,上限 300
 
hooks_auto_accept: false         # 参见下面的"同意模型"

事件名称必须是插件钩子事件之一;拼写错误会产生”你是不是想找 X?“警告并被跳过。单个条目内未知的键被忽略;缺少 command 是带警告的跳过。timeout > 300 会带警告被钳制。

JSON 有线协议

每次事件触发时,Hermes 为每个匹配的钩子(匹配器允许)生成一个子进程,通过 stdin 传入 JSON 负载,并读取 stdout 作为 JSON。

stdin —— 脚本接收的负载:

{
  "hook_event_name": "pre_tool_call",
  "tool_name":       "terminal",
  "tool_input":      {"command": "rm -rf /"},
  "session_id":      "sess_abc123",
  "cwd":             "/home/user/project",
  "extra":           {"task_id": "...", "tool_call_id": "..."}
}

对于非工具事件(pre_llm_callsubagent_stop、会话生命周期),tool_nametool_inputnullextra 字典携带所有事件特定的 kwargs(user_messageconversation_historychild_roleduration_ms 等)。不可序列化的值会被字符串化而不是省略。

stdout —— 可选响应:

// 阻止 pre_tool_call(两种格式都接受;内部规范化):
{"decision": "block", "reason":  "禁止: rm -rf"}   // Claude-Code 风格
{"action":   "block", "message": "禁止: rm -rf"}   // Hermes 规范
 
// 为 pre_llm_call 注入上下文:
{"context": "今天是周五,2026-04-17"}
 
// 静默无操作——任何空/不匹配的输出都可以:

格式错误的 JSON、非零退出码和超时会记录警告,但绝不会中止代理循环。

实际示例

1. 每次写入后自动格式化 Python 文件

# ~/.hermes/config.yaml
hooks:
  post_tool_call:
    - matcher: "write_file|patch"
      command: "~/.hermes/agent-hooks/auto-format.sh"
#!/usr/bin/env bash
# ~/.hermes/agent-hooks/auto-format.sh
payload="$(cat -)"
path=$(echo "$payload" | jq -r '.tool_input.path // empty')
[[ "$path" == *.py ]] && command -v black >/dev/null && black "$path" 2>/dev/null
printf '{}\n'

代理上下文中的文件视图不会自动重新读取——重新格式化只影响磁盘上的文件。后续的 read_file 调用会获取格式化后的版本。

2. 阻止破坏性 terminal 命令

hooks:
  pre_tool_call:
    - matcher: "terminal"
      command: "~/.hermes/agent-hooks/block-rm-rf.sh"
      timeout: 5
#!/usr/bin/env bash
# ~/.hermes/agent-hooks/block-rm-rf.sh
payload="$(cat -)"
cmd=$(echo "$payload" | jq -r '.tool_input.command // empty')
if echo "$cmd" | grep -qE 'rm[[:space:]]+-rf?[[:space:]]+/'; then
  printf '{"decision": "block", "reason": "blocked: rm -rf / is not permitted"}\n'
else
  printf '{}\n'
fi

3. 向每轮注入 git status(相当于 Claude-Code 的 UserPromptSubmit

hooks:
  pre_llm_call:
    - command: "~/.hermes/agent-hooks/inject-cwd-context.sh"
#!/usr/bin/env bash
# ~/.hermes/agent-hooks/inject-cwd-context.sh
cat - >/dev/null   # 丢弃 stdin 负载
if status=$(git status --porcelain 2>/dev/null) && [[ -n "$status" ]]; then
  jq --null-input --arg s "$status" \
     '{context: ("工作目录中未提交的更改:\n" + $s)}'
else
  printf '{}\n'
fi

Claude Code 的 UserPromptSubmit 事件有意不作为单独的 Hermes 事件存在——pre_llm_call 在同一位置触发并已支持上下文注入。在此处使用它。

4. 记录每个子代理完成

hooks:
  subagent_stop:
    - command: "~/.hermes/agent-hooks/log-orchestration.sh"
#!/usr/bin/env bash
# ~/.hermes/agent-hooks/log-orchestration.sh
log=~/.hermes/logs/orchestration.log
jq -c '{ts: now, parent: .session_id, extra: .extra}' < /dev/stdin >> "$log"
printf '{}\n'

同意模型

每个唯一的 (event, command) 对在 Hermes 首次看到时提示用户批准,然后将决策持久化到 ~/.hermes/shell-hooks-allowlist.json。后续运行(CLI 或网关)跳过提示。

三个逃生舱口绕过交互式提示——任一都足够:

  1. CLI 上的 --accept-hooks 标志(例如 hermes --accept-hooks chat
  2. HERMES_ACCEPT_HOOKS=1 环境变量
  3. cli-config.yaml 中的 hooks_auto_accept: true

非 TTY 运行(网关、cron、CI)需要这三个之一——否则任何新添加的钩子会静默保持未注册状态并记录警告。

脚本编辑被静默信任。 允许列表以确切的命令字符串为键,而不是脚本的哈希,因此编辑磁盘上的脚本不会使同意失效。hermes hooks doctor 会标记 mtime 漂移,以便您可以发现编辑并决定是否重新批准。

hermes hooks CLI

命令作用
hermes hooks list列出已配置的钩子,包括匹配器、超时和同意状态
hermes hooks test <event> [--for-tool X] [--payload-file F]对合成负载触发每个匹配的钩子并打印解析后的响应
hermes hooks revoke <command>移除所有匹配 <command> 的允许列表条目(下次重启生效)
hermes hooks doctor对每个已配置的钩子:检查执行位、允许列表状态、mtime 漂移、JSON 输出有效性和大致执行时间

安全

Shell 钩子以您的完整用户凭据运行——与 cron 条目或 shell 别名相同的信任边界。将 config.yaml 中的 hooks: 块视为特权配置:

  • 只引用您编写或完整审查过的脚本
  • 将脚本保存在 ~/.hermes/agent-hooks/ 中,以便路径易于审计
  • 在拉取共享配置后重新运行 hermes hooks doctor,以便在钩子注册之前发现新添加的钩子
  • 如果您的 config.yaml 在团队中进行版本控制,审查更改 hooks: 部分的 PR,方式与审查 CI 配置相同

排序与优先级

Python 插件钩子和 shell 钩子都流经同一个 invoke_hook() 分发器。Python 插件先注册(discover_and_load()),shell 钩子第二(register_from_config()),因此 Python pre_tool_call 阻止决策在平局情况下优先。第一个有效的阻止获胜——聚合器在任何回调产生带有非空消息的 {"action": "block", "message": str} 后立即返回。