为 oh-my-pi 编写 Hook:事件驱动拦截器、阻断契约与上下文改写实战指南
2026/9/10 8:41:03 网站建设 项目流程

为 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_startAgent 开始一个回合前{ message?: { customType; content; display; details; attribution? } }
agent_startAgent 流式输出开始
agent_endAgent 流式输出结束
turn_start用户→Agent 回合开始
turn_end用户→Agent 回合结束
context每次 LLM API 调用前{ messages?: Message[] }
auto_compaction_start自动压缩开始
auto_compaction_end自动压缩结束
auto_retry_start自动重试开始
auto_retry_end自动重试结束
ttsr_triggeredTTSR(过短回复)触发
todo_reminder待办提醒触发

扩展专属事件tool_execution_starttool_execution_updatetool_execution_endinputuser_bashuser_python等事件只有ExtensionAPI才提供。

从源码可以印证事件的全貌:HookAPI接口完整声明了上述每个事件的on(...)重载(见 types.ts),而事件载荷类型(如ToolCallEventToolResultEventContextEvent)集中在 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

  1. 先检查hookRunner.hasHandlers("tool_call"),有处理器才走拦截逻辑;
  2. 调用hookRunner.emitToolCall(...)派发事件;
  3. callResult?.block为真,则throw new Error(reason),工具直接以错误结束——这正是"reason 变成 LLM 看到的错误文本"的实现;
  4. 若返回了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.contentdetails: 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 的emitContextcurrentMessages变量维护链式状态:每个 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-hook

README 还给出了该 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+QCtrl+Enter)提交。

重要前提ctx.hasUI在 headless / print / subagent 模式下为false——始终要对交互式调用加守卫(示例一的if (ctx.hasUI)就是标准写法)。

从 types.ts 的源码看,HookUIContext是刻意收窄的 UI 面:不提供终端输入监听、编辑器组件覆写和主题管理,因为 hook 是在 agent 循环内部被调用的,不能夺取编辑器的所有权。而HookContext还额外提供cwd、只读sessionManagermodelRegistrymodelisIdle()abort()hasQueuedMessages()等运行时能力(见 types.ts)。

加载流程与生命周期:从配置到派发

结合 docs/hooks.md 与 loader.ts,hook 的完整生命周期如下:

  1. 发现discoverAndLoadHooks(configuredPaths, cwd)先从能力注册表(capability registry)发现 hook 路径,再追加显式配置的路径,按绝对路径去重;
  2. 加载loadHooks对每个路径用 Bun 原生import()导入,要求模块default导出工厂函数;路径解析规则为:绝对路径原样使用、~路径展开、相对路径相对cwd解析;
  3. 注册:工厂执行期间pi.on(...)把 handler 收集进handlersMap,registerCommand/registerMessageRenderer分别登记斜杠命令与自定义渲染器;
  4. 初始化HookRunner.initialize注入sendMessageHandlerappendEntryHandler、UI 上下文、hasUI等运行时依赖(runner.ts);
  5. 派发:工具调用经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 —ExtensionAPIHookAPI的超集,新项目推荐)
  • 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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询