pi agent 的hook/extension呢?
回复 群友 4769: pi 这块有个容易踩的坑先说一下:pi 里没有独立的「hook」概念,它把这套东西叫 extension(扩展),官方文档 pi.dev/docs/latest/extensions 全篇讲的是扩展。在 pi 生态的一些发行版里(比如 oh-my-pi)确实还留着 hook 子系统的源码,文档也写得很细,但那份文档自己承认:默认 CLI 运行时已经走 extension runner 了,--hook 现在只是 --extension 的别名,工具包装也从 HookToolWrapper 换成了 ExtensionToolWrapper。所以问 pi 的 hook,实际要答的是它的 extension 模型。 下面按上次那套框架对一遍,重点讲跟 Claude Code、Codex 的差别。 一、通信方式:进程内函数,不是子进程 JSON 这是我上次那句「各家最大差别在给不给、给多少」之外,pi 最本质的一条。Claude Code、Codex、Cursor、Gemini 都是子进程模式(stdin 收 JSON、stdout 回结果、配 exit code),pi 和 OpenCode 一样是进程内 TypeScript 模块,直接持对象、直接改数据结构。代价是语言绑定死了 TS(用 jiti 加载,本地.ts 不用编译),换来的是表达力:它能画界面。 官方文档里那句话很直白:extension 和 pi 跑在同一个进程、同样的操作系统权限,能看 prompt、工具调用、文件、凭据、会话历史,所以只加载你信得过的来源。子进程 hook 天然有一层隔离,进程内没有——这是权限设计上真正的取舍点。 还有一条约束是子进程 hook 不用操心的:factory 里不许起进程、socket、watcher、timer,因为有些调用会加载 extension 但根本不启动 session;长生命周期资源得从 session_start 里开,在 session_shutdown 里幂等关闭。这是进程内的代价。 二、一个作用点装三种能力,而不是三套机制 Claude Code 那边是分散的:hook 只负责事件回调,要给模型加个工具得走 MCP,要加个 /命令得另放 slash command 文件。pi 是一个默认导出的 factory 里全给你: pi.on 订阅事件 pi.registerTool 注册模型可调用的工具 pi.registerCommand 注册 / 命令 pi.registerShortcut / pi.registerFlag 加速键和 CLI 参数 pi.registerProvider / pi.registerVirtualModel / pi.registerMcpServer 换模型供应商、做请求路由、挂 MCP pi.sendMessage / pi.appendEntry 发消息、存不进模型上下文的数据 pi.events extension 之间互相通信的事件总线 所以「给 pi 加个新工具」和「给 pi 加个拦截器」在结构上是同一件事,写法一样。Claude Code 里这两件事的写法完全不同。 三、事件粒度:铺在运行时生命周期上,而且强调「边界」 pi 把一次 run 描述成:input → before_agent_start → 模型/消息/工具事件 → agent_end,然后自动重试、恢复、压缩、排队的工作还可能继续。有两个事件专门用来划边界,值得单拎出来: agent_before_settle:最后一个还能动手的点,可以追加条目并要求一次继续(continue: true),而且文档明确警告要加条件判断,无条件继续会死循环。 agent_settled:终态,只通知,什么都改不了。适合给「pi 不会再自动往下跑了」这种集成用。 对比一下:Claude Code 那边就是一个 Stop hook 兜住「模型准备收工」,pi 把它拆成了可干预档和纯通知档。这跟 Claude Code 挂记忆提取在 Stop 上是一个位置,但 pi 让你能区分「我还想插一脚」和「我就是想记个日志」。 其他别家少见的钩子: project_trust:在项目级 extension 加载之前跑,只有个人 extension 和显式命令行指定的 extension 能参与。这是专门为「从项目里加载可执行代码」这个信任问题设计的钩子事件,Claude Code 有信任提示但不以 hook 事件形式暴露给扩展作者。 cache_warming_decision:空闲时要不要刷 prompt cache,可以被 extension 覆盖成 warm 或 stop,最后一个返回 action 的赢。别家基本没这一层——pi 自己是多 provider harness,缓存命中直接是钱。 provider_stream_event:provider 流里每个被解析出的事件,在 pi 归一化之前让你看一眼,带 provider、api、model,event.data 是最早的结构化值。文档明确说这是只读的,改了会影响归一化,而且只通知不持久化。Claude Code 没有这个层次的钩子。 context / context_with_system:改这一次请求发给模型的会话消息(pi 请求完会恢复原状);带 system 的版本是「整个 transcript 归你管」,但必须把 system message 留在索引 0。 session_before_* 家族:session_before_switch、session_before_branch、session_before_compact、session_before_tree 都能返回 cancel 直接拦下,压缩还能自定义结果。这是 hook 子系统和 extension 都有的能力。会话结构级别的钩子,Claude Code 给得没这么细。 另外还有个生命周期事件的说明:pi.on 会返回一个取消订阅的函数,但改动不影响正在进行的那次分发;handler 顺序就是加载顺序加注册顺序。 四、拦截改判:给得满,但有明确契约 tool_call 能改输入、能返回 block 和 reason,handler 抛错按 fail-safe 处理,工具直接被 block 掉。 tool_result 是 compose 的:每个 handler 看到的是前面 handler 改过的结果,返回 content / details 就替换。有个它独有的细节——如果你在 tool_result 里改 content 做脱敏,必须同时替换 structuredContent,只改 content 会把结构化的那份丢掉。原因见下一条。 官方文档那句提醒我觉得挺关键:用每个事件声明的返回类型,别假设所有返回值都有效果。notify 类事件返回啥都没用。所以 pi 的「给得多」不是「什么都能改」,而是每类事件有契约、契约写清楚。 和 Claude Code 对比:Claude Code 的 PreToolUse 能改参数、能 block,PostToolUse 改结果的能力相对受限;Codex 那档给得更少。pi 是前两者都给满。 五、真正跟别家分叉的地方:工具怎么被暴露、怎么被调用 这段是 pi 自己的设计重心,不是 hook 机制本身,但直接决定了 extension 能干什么: exposure 五档:direct(默认,声明给模型且可调用)、model-only(声明给模型但谁都不能调,用来做编排工具或问用户)、codemode(一直可被别的工具调,不对模型声明)、deferred(同 codemode,但 codemode 脚本不列它,靠 tool_search 找出来激活)、hidden(注册了但够不着)。工具不能注销,要撤回只能重新注册成 hidden。这比「hook 拦一下」深一层:它在管工具可见性本身。 ctx.executeTool:工具可以调工具,而且嵌套调用照样走 tool_call / tool_result 处理器,带 parentToolCallId,toolCallId 形如 父id/序号。这些调用不写进 transcript,只在调用方工具的结果里留一份有界记录(最多 256 条,单次参数超 8KiB 或单结果超 32KiB 就省略,丢了东西会标 complete: false)。等于把 hook 面从「模型发起的调用」扩到了「工具发起的调用」——这是 Claude Code 目前不覆盖的一层。 prepareLoadout:工具活跃期间能改模型看到的工具声明、能藏声明。codemode 就是只用这个钩子加 exposure 加 executeTool 三样东西拼出来的。 annotations:readOnlyHint、destructiveHint、idempotentHint、openWorldHint,MCP 那套语义。官方给的示例就是拿它判断哪些调用需要确认,代替「rm -rf 黑名单」这种做法。方向是让权限决策基于工具声明的语义,而不是猜命令文本。 UI:extension 能画完整的终端界面(ctx.ui.confirm / select / custom / setStatus / setEditorComponent)。官方示例列表里连贪吃蛇、井字棋、vim 模式编辑器、以 35 FPS 跑 DOOM 当浮层的都有。子进程 hook 做不了这个,这是进程内换来的最直观的东西。 六、状态和清理 它把「状态存哪」分成四类,我觉得挺值得抄:跟着分支走的工具状态放 tool result 的 details;不进模型上下文但要持久的用 appendEntry;要发给模型的自定义内容用 sendMessage;跨 session 的放外部存储。分支敏感的 state 在 session_start 时用 ctx.sessionManager.getBranch 重建,别去遍历所有文件条目——被放弃的分支代表另一条历史。 错误处理:pi 报扩展的错误然后尽量继续,但 tool_call handler 挂掉会 block 工具(fail-safe),工具本身执行失败则变成给模型的错误结果。 七、那份 hook 子系统的文档还暴露了两个「历史遗留」细节,可以当参考 一是它列的限制比 extension 少很多:legacy hook 不能原地改工具输入参数(只能放行或拦),工具抛错后不能续跑,返回的 isError 字段虽然有类型但在 HookToolWrapper 里不生效。 二是文档自己写出来的 mismatch:发现逻辑还在按.claude/hooks/pre/* 这种 shell 风格文件去找 hook,但 loader 要的是能动态 import、默认导出一个工厂函数的 JS/TS 模块,找出来的路径不可 import 就直接报加载错误。这是它迁到 extension 之后没清干净的痕迹。 八、你问的差异,我总结成一句 Claude Code 的 hook 是「在固定位置留回调点」,pi 的 extension 是「在你的进程里给一个完整的运行时 API」——它把 hook 从循环上的插槽升级成宿主的一部分,代价是信任边界、语言绑定、生命周期约束都跟着变重。它俩关注点也不同:Claude Code 关注「回合里每个可编程的时刻都留个口子」,pi 关注「工具怎么被暴露、谁来调用、模型能不能看到」,后者是 harness 层的事,不是拦截层的事。 另外提一句它生态里的扩展生态挺活跃:pi.dev/packages 上能装到给 pi 加 sub-agent 和 workflow 编排的包(并行执行、fleet 视图、自定义 agent 类型),也有把三家 agent 生命周期事件统一成一套接口的 agent-hooks-playground 这种包——如果你想横向对比三家的事件面,那个正好是现成的。