为 oh-my-pi 编写 Hook:事件驱动拦截器、阻断契约与上下文改写实战指南
【免费下载链接】oh-my-pi⌥ Coding agent with the IDE wired in项目地址: https://gitcode.com/GitHub_Trending/oh/oh-my-pi
导读:本文以 oh-my-pi 的HookAPI事件驱动拦截模型为主线,讲解如何在 coding agent 的主循环中注册事件处理器,实现工具调用拦截(block)、工具结果改写(override)与 LLM 上下文裁剪(rewrite)这三类横切关注点。读完本文,你将掌握 hook 模块的标准工厂签名、完整事件目录、三个可复制运行的实战示例,以及 hook 子系统在 packages/coding-agent/src/extensibility/hooks 下的源码级实现原理。
Hook 是什么:Agent 循环中的事件驱动拦截器
Hook 是与 agent 主循环并行运行的事件驱动拦截器(event-driven interceptor)。它最适合处理横切关注点(cross-cutting concerns):安全策略、密钥脱敏、上下文裁剪、审计日志。一个 hook 模块通过pi.on(event, handler)注册处理器,可以做到三件核心事情:
- 阻断工具执行(block tool execution)
- 改写工具输出(override tool output)
- 在每次 LLM 调用前重写消息上下文(rewrite the message context)
import type { HookAPI } from "@oh-my-pi/pi-coding-agent/extensibility/hooks"; export default function myHook(omp: HookAPI): void { omp.on("tool_call", async (event, ctx) => { // intercept every tool call }); }与 Extension 的关系:HookAPI 是遗留 API
与扩展的关系:hook 子系统(
HookAPI)是遗留 API。扩展运行器(extension runner)现在能处理 hooks 能做的一切,且能力更多。ExtensionAPI支持 hook 事件模型,外加扩展专属事件。新工作请使用ExtensionAPI;只有在你需要维护既有 hook 模块时才使用HookAPI。
这一结论在 docs/hooks.md 中有更详细的运行时佐证:当前默认 CLI 运行时初始化的是extension runner路径——--hook被当作--extension的别名,通过hookCapability发现的 JS/TS hook 工厂会作为扩展模块加载,工具由ExtensionToolWrapper而非HookToolWrapper包装。也就是说,本文描述的HookAPI是完整、可用的,但从代码演进角度看,它是被ExtensionAPI超集覆盖的兼容层。
工厂签名:默认导出必须是函数
Hook 模块的默认导出必须是一个函数(不能是类)。工厂函数接收一个HookAPI实例,并在工厂执行期间注册处理器;加载器会await返回的 Promise,因此异步初始化是被接受的。
import type { HookAPI } from "@oh-my-pi/pi-coding-agent/extensibility/hooks"; export default function myHook(omp: HookAPI): void { omp.on("tool_call", async (event, ctx) => { // intercept every tool call }); }更推荐的做法是使用ExtensionAPI(能力超集,新项目首选):
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent"; export default function myExtension(pi: ExtensionAPI): void { pi.on("tool_call", async (event, ctx) => { /* ... */ }); }从源码看,loader.ts 的loadHook使用原生 Bunimport()加载模块,然后校验module.default是否函数:if (typeof factory !== "function")会返回"Hook must export a default function"错误。加载过程中createHookAPI会构建一个收集 handler 的 API 对象,pi.on(event, handler)内部把 handler 按事件名压入handlers这个Map<string, HandlerFn[]>。工厂执行完毕后,该 Map 就构成了此 hook 的全部事件订阅。
事件目录:可订阅的完整事件清单
工具生命周期(Tool lifecycle)
| 事件 | 触发时机 | 可返回 |
|---|---|---|
tool_call | 每次工具执行前 | { block?: boolean; reason?: string; input?: Record<string, unknown> } |
tool_result | 每次工具执行后 | { content?; details?; isError?: boolean } |
会话生命周期(Session lifecycle)
| 事件 | 触发时机 | 可返回 |
|---|---|---|
session_start | 初始会话加载时 | — |
session_before_switch | 会话切换前 | { cancel?: boolean } |
session_switch | 会话切换后 | — |
session_before_branch | 会话分支前 | { cancel?: boolean; skipConversationRestore?: boolean } |
session_branch | 会话分支后 | — |
session_before_compact | 上下文压缩前 | { cancel?: boolean; compaction?: CompactionResult } |
session.compacting | 压缩过程中(注入上下文) | { context?: string[]; prompt?: string; preserveData?: Record<string, unknown> } |
session_compact | 压缩完成后 | — |
session_before_tree | 会话树导航前 | { cancel?: boolean; summary?: { summary: string; details?: unknown } } |
session_tree | 会话树导航后 | — |
session_shutdown | 会话关闭时 | — |
Agent / 回合生命周期(Agent/turn lifecycle)
| 事件 | 触发时机 | 可返回 |
|---|---|---|
before_agent_start | Agent 开始一个回合前 | { message?: { customType; content; display; details; attribution? } } |
agent_start | Agent 流式输出开始 | — |
agent_end | Agent 流式输出结束 | — |
turn_start | 用户→Agent 回合开始 | — |
turn_end | 用户→Agent 回合结束 | — |
context | 每次 LLM API 调用前 | { messages?: Message[] } |
auto_compaction_start | 自动压缩开始 | — |
auto_compaction_end | 自动压缩结束 | — |
auto_retry_start | 自动重试开始 | — |
auto_retry_end | 自动重试结束 | — |
ttsr_triggered | TTSR(过短回复)触发 | — |
todo_reminder | 待办提醒触发 | — |
扩展专属事件:tool_execution_start、tool_execution_update、tool_execution_end、input、user_bash、user_python等事件只有ExtensionAPI才提供。
从源码可以印证事件的全貌:HookAPI接口完整声明了上述每个事件的on(...)重载(见 types.ts),而事件载荷类型(如ToolCallEvent、ToolResultEvent、ContextEvent)集中在 shared-events.ts,并被 hook 与 extension 两个子系统共用,保证了两套 API 的事件语义一致。
预工具阻断契约(Pre-tool blocking contract)
在tool_call处理器中返回{ block: true, reason: "..." }即可阻止工具执行:
omp.on("tool_call", async (event, ctx) => { if (event.toolName === "bash") { const cmd = String(event.input.command ?? ""); if (/\brm\s+-rf\s+\//.test(cmd)) { return { block: true, reason: "Refusing to delete root filesystem" }; } } });契约细则:
- 只要任一处理器返回
{ block: true },执行立即停止; reason会成为 LLM 看到的工具错误文本(tool error text);- 如果某个处理器抛出异常,该工具同样被阻断(fail-closed,默认安全);
- 最后一个非阻断返回值生效;第一个
block: true短路后续处理器; - 非阻断处理器可以返回
input来替换传给工具的原生参数。处理器看不到前面处理器对 input 的修改(即每个处理器看到的都是原始event.input); - eval prelude 调用(如
browser.open(...)、直接调用BrowserTab辅助方法、tab.run(...)、直接调用computer辅助方法、computer.run(fnOrCode, options))不属于工具调用,不会触发这些钩子事件。
源码级验证:阻断是如何落到工具执行的
工具拦截的真正执行者是 tool-wrapper.ts 中的HookToolWrapper.execute:
- 先检查
hookRunner.hasHandlers("tool_call"),有处理器才走拦截逻辑; - 调用
hookRunner.emitToolCall(...)派发事件; - 若
callResult?.block为真,则throw new Error(reason),工具直接以错误结束——这正是"reason 变成 LLM 看到的错误文本"的实现; - 若返回了
input(且非computer工具),effectiveParams被替换为处理器提供的参数,随后工具以修订后的参数执行。
而 runner.ts 的emitToolCall实现严格遵循契约:按 hook 注册顺序遍历所有 handler,只要某个 handler 返回了result.block,就立即返回并停止处理后续 hook——即"第一个block: true短路";同时tool_call事件的派发没有超时("No timeout - user prompts can take as long as needed"),且异常不会被吞掉("Errors are thrown (not swallowed) so caller can block on failure"),这支撑了 fail-closed 语义。
工具后改写契约(Post-tool override contract)
在tool_result处理器中返回{ content, details, isError }即可修补 LLM 看到的工具结果:
omp.on("tool_result", async (event, ctx) => { if (event.toolName === "read" && !event.isError) { const redacted = event.content.map(chunk => { if (chunk.type !== "text") return chunk; return { ...chunk, text: chunk.text.replace(/(?:sk|pk)-[a-zA-Z0-9]{20,}/g, "[REDACTED_API_KEY]"), }; }); return { content: redacted }; } });契约细则:
- 处理器按注册顺序运行。对
HookAPI而言,每个处理器收到的都是原始工具结果事件,且最后一个返回的 override 生效; content替换发送给 LLM 的完整内容数组;details替换结构化 details 对象;isError存在于共享结果类型上,但HookToolWrapper不会把它传播进成功的工具结果;工具失败时,处理器运行完成后原始错误会被重新抛出;- 工具失败时,
tool_result仍然会以isError: true发出。
源码级验证:成功与失败两条路径
tool-wrapper.ts 中,成功路径在工具正常返回后派发tool_result(此时isError: false),若任一处理器返回了resultResult,则以content: resultResult.content ?? result.content、details: resultResult.details ?? result.details合成最终结果返回。失败路径在catch中派发tool_result,内容为错误文本、isError: true,随后throw err把原始错误重新抛给 agent 循环。这与文档契约完全一致。
上下文改写契约(Context modification contract)
在context处理器中返回{ messages: [...] }即可在每次 LLM API 调用前重写消息列表:
omp.on("context", async (event, ctx) => { // Remove debug-only custom messages from LLM context const filtered = event.messages.filter( msg => !(msg.role === "custom" && msg.customType === "debug-only") ); return { messages: filtered }; });契约细则:
event.messages是当前累积的消息列表;- 处理器按顺序运行,每个处理器收到的是前一个处理器的输出(链式传递);
- 返回
undefined(或不返回)则原样放行消息。
源码级验证:链式传递如何实现
runner.ts 的emitContext用currentMessages变量维护链式状态:每个 handler 返回{ messages }时,currentMessages被替换为返回值,作为下一个 handler 的event.messages;最后把最终消息列表返回给调用方。文件注释还注明消息在进入前已由调用方(pi-ai preprocessor)做了深拷贝,因此 handler 可以安全地就地修改内容而不污染原始会话。
三个可直接运行的完整示例
示例一:rm -rf 阻断器
拦截bash工具中危险命令,并结合交互式 UI 让用户显式确认:
import type { HookAPI } from "@oh-my-pi/pi-coding-agent/extensibility/hooks"; export default function rmRfBlocker(omp: HookAPI): void { omp.on("tool_call", async (event, ctx) => { if (event.toolName !== "bash") return; const cmd = String(event.input.command ?? ""); if (!/\brm\s+-rf\s+\//.test(cmd)) return; // Allow if user explicitly confirms (interactive mode only) if (ctx.hasUI) { const allow = await ctx.ui.confirm( "Dangerous command", `This command deletes from root:\n${cmd}\n\nProceed?` ); if (allow) return; } return { block: true, reason: "rm -rf / blocked by safety policy" }; }); }仓库中已有同主题的可运行示例:docs/skills/examples/safety-hook 目录下的 index.ts 用ExtensionAPI实现了等价的rm -rf /阻断,其 README.md 提供了两种加载方式:
# 方式一:复制到全局扩展目录,重启 omp 后所有会话生效 cp -r . ~/.omp/agent/extensions/safety-hook # 方式二:单次加载 omp --extension ./safety-hookREADME 还给出了该 hook 的工作流程示意:LLM 调用 bash →tool_call处理器运行 → 匹配/\brm\s+-rf\s+\//则{ block: true, reason: "..." }(执行停止、reason 发给 LLM)→ 不匹配则返回undefined(正常执行)→ 未阻断时工具执行。其中reason文本会被 LLM 当作工具错误接收,因此 Agent 能理解被拒原因并换一种思路。
示例二:API Key 脱敏器
在tool_result阶段扫描常见密钥形态并替换为占位符:
import type { HookAPI } from "@oh-my-pi/pi-coding-agent/extensibility/hooks"; // Common API-key shapes. Not exhaustive — providers using bespoke formats // (Anthropic `sk-ant-…`, JWT-style bearers, gateway-specific prefixes, etc.) // need their own entries. const SECRET_PATTERNS = [ /\b(sk|pk)-[a-zA-Z0-9]{20,}\b/g, /\bAKIA[A-Z0-9]{16}\b/g, /\bghp_[a-zA-Z0-9]{36}\b/g, // Zhipu / GLM Coding Plan: `<id>.<secret>` (no `sk-` prefix). /\b[a-zA-Z0-9]{16,}\.[a-zA-Z0-9]{16,}\b/g, /\b[a-zA-Z0-9_-]{20,}\s*=\s*["']?[a-zA-Z0-9._/+=-]{20,}["']?/g, ]; export default function apiKeyRedactor(omp: HookAPI): void { omp.on("tool_result", async (event) => { if (event.isError) return; let changed = false; const redacted = event.content.map(chunk => { if (chunk.type !== "text") return chunk; let text = chunk.text; for (const pattern of SECRET_PATTERNS) { const next = text.replace(pattern, "[REDACTED]"); if (next !== text) { changed = true; text = next; } } return { ...chunk, text }; }); if (changed) return { content: redacted }; }); }实现要点:SECRET_PATTERNS覆盖了 OpenAI 风格sk-/pk-前缀、AWSAKIA访问密钥、GitHub 个人访问令牌ghp_、智谱/GLM Coding Plan 的<id>.<secret>无前缀格式以及键值对形式的配置泄露。代码对每个文本 chunk 逐模式替换,只有实际发生替换(changed === true)才返回新的content,避免无意义的对象重建——这与tool_result契约"返回 undefined 则原样放行"配合得当。
示例三:上下文过滤器
在context阶段裁剪过大的工具输出,控制上下文占用:
import type { HookAPI } from "@oh-my-pi/pi-coding-agent/extensibility/hooks"; export default function contextFilter(omp: HookAPI): void { omp.on("context", async (event) => { const MAX_TOOL_OUTPUT_CHARS = 8_000; const trimmed = event.messages.map(msg => { // Truncate very large tool results to keep context manageable if (msg.role !== "toolResult") return msg; const content = msg.content.map(chunk => { if (chunk.type !== "text" || chunk.text.length <= MAX_TOOL_OUTPUT_CHARS) return chunk; return { ...chunk, text: chunk.text.slice(0, MAX_TOOL_OUTPUT_CHARS) + "\n[... truncated by context-filter hook]", }; }); return { ...msg, content }; }); return { messages: trimmed }; }); }实现要点:MAX_TOOL_OUTPUT_CHARS设为 8000 字符,只处理role === "toolResult"的消息,且只裁剪type === "text"的超长文本 chunk,保留截断标记以便 LLM 感知数据被裁剪。由于context契约规定"每个处理器收到前一个处理器的输出",多个上下文类 hook 可以按注册顺序叠加组合(例如先脱敏、再裁剪、最后过滤 debug 消息)。
Hook 上下文中的 UI 方法
ctx.ui是一个HookUIContext,可用方法如下:
| 方法 | 说明 |
|---|---|
notify(message, type?) | 显示应用内通知 |
setStatus(key, text) | 设置底部状态栏文本(按键分组,按 key 排序) |
select(title, options) | 显示选择对话框 |
confirm(title, message) | 显示是/否对话框 |
input(title, placeholder?) | 显示文本输入对话框 |
editor(title, prefill?, { signal }?, { promptStyle }?) | 显示多行编辑器 |
setEditorText(text) | 设置输入编辑器内容 |
getEditorText() | 获取当前输入编辑器内容 |
custom(factory) | 渲染自定义 TUI 组件 |
theme | 当前主题对象 |
把{ promptStyle: true }作为第四个参数传入时,Enter 提交、Shift+Enter 插入换行。默认的 hook 编辑器行为是 Enter 插入换行、通过app.message.followUp组合键(Ctrl+Q或Ctrl+Enter)提交。
重要前提:ctx.hasUI在 headless / print / subagent 模式下为false——始终要对交互式调用加守卫(示例一的if (ctx.hasUI)就是标准写法)。
从 types.ts 的源码看,HookUIContext是刻意收窄的 UI 面:不提供终端输入监听、编辑器组件覆写和主题管理,因为 hook 是在 agent 循环内部被调用的,不能夺取编辑器的所有权。而HookContext还额外提供cwd、只读sessionManager、modelRegistry、model、isIdle()、abort()、hasQueuedMessages()等运行时能力(见 types.ts)。
加载流程与生命周期:从配置到派发
结合 docs/hooks.md 与 loader.ts,hook 的完整生命周期如下:
- 发现:
discoverAndLoadHooks(configuredPaths, cwd)先从能力注册表(capability registry)发现 hook 路径,再追加显式配置的路径,按绝对路径去重; - 加载:
loadHooks对每个路径用 Bun 原生import()导入,要求模块default导出工厂函数;路径解析规则为:绝对路径原样使用、~路径展开、相对路径相对cwd解析; - 注册:工厂执行期间
pi.on(...)把 handler 收集进handlersMap,registerCommand/registerMessageRenderer分别登记斜杠命令与自定义渲染器; - 初始化:
HookRunner.initialize注入sendMessageHandler、appendEntryHandler、UI 上下文、hasUI等运行时依赖(runner.ts); - 派发:工具调用经
HookToolWrapper触发tool_call/tool_result,会话与回合事件经HookRunner.emit/emitContext/emitBeforeAgentStart派发,错误通过emitError通知订阅者。
此外,HookAPI还提供sendMessage()(发送参与 LLM 上下文的自定义消息,可触发新回合)、appendEntry()(持久化不进入 LLM 上下文的状态,如权限记录)、exec()(执行 shell 命令)以及注入的zod/arktype/typebox模式构建器(见 types.ts)。
延伸阅读
- docs/hooks.md — hook 子系统内部实现、排序规则、错误传播(含
--hook与--extension的运行时关系说明) - docs/extensions.md —
ExtensionAPI(HookAPI的超集,新项目推荐) - docs/skills/examples/safety-hook — 可运行的完整示例(含 README、index.ts、package.json)
- packages/coding-agent/src/extensibility/hooks/types.ts — hook 类型定义、事件与结果契约
- packages/coding-agent/src/extensibility/hooks/runner.ts — 事件派发实现
- packages/coding-agent/src/extensibility/hooks/tool-wrapper.ts — 工具前后拦截包装器
- packages/coding-agent/src/extensibility/shared-events.ts — hook 与 extension 共享的事件载荷与结果类型
【免费下载链接】oh-my-pi⌥ Coding agent with the IDE wired in项目地址: https://gitcode.com/GitHub_Trending/oh/oh-my-pi
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考