钩子
钩子是一个处理器模块,编译器会把它包装起来并注册到每个宿主原生的钩子文档中,或由 Amp 生成的 PluginAPI 工厂注册。你只需按规范事件分键 声明一次,适配器就会把事件名、匹配器与结果形状翻译成所选宿主期望的样子。
事件路由
在 src/events/ 下按规范事件路径创建文件:.ts 处理器返回轻量事件结果,.tsx 处理器渲染 JSX。
.ts 处理器也可以用 ctx.render('./name.view.js', data) 请求独立的 .view.tsx 同级模块。两种形式使用相同的事件路径、
宿主适配器与结果校验。渲染形式可以声明静态配置:
canonical 是框架推导出的跨宿主身份,event、由事件、target 与原生载荷哈希而来的 idempotencyKey、
observedAt、一个 sequence、记录触发宿主与原生事件名的 provenance,以及该事件族的规范 payload。
native 是经过校验的宿主信封的冻结快照。canonical 中没有任何伪造:宿主未报告的字段保持缺失。
requires 会选择能力表支持全部所列能力行的每一个已配置宿主。事件路由能力行包括
events.sessionStart.context、events.stop.deny、events.toolAfter.context,以及
events.toolBefore.deny;生成的事件与 Hook 矩阵展示了底层宿主支持情况。
当路由需要的是语义能力而非特定宿主时使用 requires。targets 仍是宿主特定的逃生口;一条事件路由
不能同时声明两者。如果已配置宿主中没有任何一个满足某项要求,校验诊断会同时列出该能力行与所有被检查
的宿主。
普通函数与 JSX 事件
两种形式使用同一个规范事件及宿主投影。.ts 处理器返回 { outcome: 'deny', reason }、
{ outcome: 'continue' },或者不返回值;继续执行绝不表示批准权限。.tsx 默认处理器仍返回
Agent.* JSX。
辅助函数推断该事件的载荷与允许的结果,事件名称必须与文件路径一致。也可使用普通默认函数,并用
EventHandler<'tool/before'> 标注类型。
在 tool/before 处理器的继续结果中直接返回 updatedInput,即可改写待执行的输入。
继续或拒绝结果可以带 additionalContext,用于简短的上下文消息。这些结果与 JSX 使用相同的
宿主投影,不启动渲染工作进程。updatedInput 必须是严格 JSON 对象,且不能与拒绝结果一起返回。
上下文通道仍取决于宿主:Cursor 忽略 tool/before 的上下文,但接受 tool/after 的上下文;
Claude 和 Codex 在两者上都接受上下文。其他存在宿主差异的事件可使用 requires 或 targets。
改写在 Claude 和 Codex 上保留宿主权限流程;Cursor 要求 updated_input 同时携带
permission: 'allow'。
需要先判断再渲染时,将 JSX 放入独立的同级模块:
处理器和视图是两个独立的编译入口。只有处理器返回 ctx.render() 时才加载视图;处理器中的
普通导入、局部常量和辅助函数无需特殊限制。模块参数必须指向同级的 .view.js 文件。
数据必须是严格 JSON,视图通过 renderInput 接收;其他路径或非 JSON 值会导致执行失败。
事件配置写在 .ts 处理器中,.view.tsx 只是配套视图,不会注册第二个事件。
拒绝结果必须有非空原因,且事件族必须支持拒绝。
可直接复制的完整路由见 examples/host-test/src/events/:其中都使用轻量 events.* 处理器;
只有 session/start.ts 会调用 ctx.render('./start.view.js', data),因为它的同级视图确实会添加会话上下文。
执行跟踪事件
导出的 EventTraceEvent 可辨识联合为开发工具提供稳定且不含载荷的事件内核词汇:
handler.start、handler.outcome、execute.start、providers.start /
providers.finish、render.start / render.finish,以及终止性的 failure。每个冻结事件
都携带同一份 EventTraceExecution 身份、单调时间戳和序号,并且只包含该阶段的元数据。
编译后的钩子包装器发出门控与执行边界,渲染处理器发出渲染边界;只有处理器请求 provider 时才会记录
对应观测。处理器结果为 continue、deny 或 render。失败使用有长度上限且不含堆栈的
EventTraceErrorSummary。createEventTracer 会发送到显式 observer,没有时则发送到每次发出时当前的进程级 observer(尚未安装时为空操作);observer 抛错也不会改变执行。
开发工具通过 installEventTraceObserver 安装进程级 sink;返回的 disposer 会恢复之前的 sink。
相关类型与辅助函数从 agent-bundle 和 agent-bundle/api 导出。
渲染事件默认使用 standalone;设置 runtime: 'shared' 可复用生成的 MCP 服务器。轻量 .ts 处理器始终在钩子包装器中执行。
请求级 provider
普通事件中使用 await context.provider('writePolicy'),JSX 中使用
await (await agent()).provider('writePolicy')。模块只在请求该值时加载;同一请求内的并发读取共享
一个 Promise,包括失败结果。生成的类型推断键名和值,不再需要声明 config.providers。
取消信号和请求上下文过期检查同样适用于 provider 访问。context.providers 已移除;
框架进程身份改由 context.process 提供。runAgentRequest({ providers }) 和测试输入仍可预置值,
处理器统一通过 provider(key) 读取。
规范载荷
canonical.payload 是该路由事件族对信封的跨宿主读法:至少两个宿主以各自名字报告的那些字段,工具族上的
toolName、toolInput、toolResponse,sessionId(Claude 与 Codex 的 session_id、Cursor 的
conversation_id)、transcriptPath、cwd、prompt、agentId 与 agentType、reentry(Claude 与 Codex 的
stop_hook_active、Cursor 的 loop_count > 0)等等。每个字段以 { value, nativeKey } 的形式到达:解码后的值
旁边是它读取自的宿主键,因此路由既能区分「已映射」与「缺失」,也能在自己的输出里说出宿主的拼法。宿主未发送的
字段是 undefined,绝不补默认值。只有两处读法超出「按键取值」:Cursor 的 tool_output JSON 字符串会被解析为
toolResponse(不是合法 JSON 时保留为字符串),Cursor 的 loop_count 计数变为 reentry 布尔值。
把路由类型标注到它的事件族,AgentEventRouteProps<'tool/after'>,payload 就收窄为该族的字段,于是在工具
路由上读取 payload.reentry 是编译错误;生成的 .agent-bundle/routes.d.ts 把同样的收窄带进 renderRoute。
用裸的 AgentEventRouteProps 标注的路由把所有字段都视为可选。载荷未建模的一切,Cursor 的 attachments、
Claude 的 background_tasks、只有一个宿主才有的 duration,仍在 native 上。按事件族、按宿主的表(字段 ×
宿主 → 原生键)是生成的事件参考中的规范载荷字段一节,由固定的
能力表渲染而来;运行时读取的是同一张表,以 agentEventPayloadFields 与 agentEventPayloadNativeKeys 导出。
路由单元测试用 agent-bundle/test 的 createEventRouteInput 从宿主信封构造同样的 props,这样 fixture 练的是
payload,而不是手写的身份:
路由通过它渲染出的文档作答。Agent.Context 文本成为宿主的附加上下文通道,Agent.Result 的 value 可以
携带 { outcome: 'continue' | 'allow' | 'ask' | 'deny', reason?, updatedInput? }。continue,或完全不给
value,是「放行」的回答:路由没有意见,因此不会写入任何决定,宿主的正常权限流程照常适用。只有显式的
allow、ask 或 deny 才会被投影为决定。投影按事件、按宿主进行,非法组合在任何内容到达宿主之前就会抛错:
session/end、compact/after、tool/failure 与 workspace/open 在所有宿主上都只可观察;做出决定的
tool/before 在 Claude 与 Codex 上成为 hookSpecificOutput.permissionDecision: 'allow' | 'ask' | 'deny',
在 Cursor 上则是 { permission: 'allow' | 'deny', … },Cursor 文档称 ask 不会被强制执行,因此会拒绝它;
被拒绝的 stop 成为 { decision: 'block', reason } 或 Cursor 的 followup_message。allow 与 ask 是
tool/before 与 model-switch/before 的决定(allow 也可以代表用户回答 permission/request);其他任何
事件族都会拒绝它们。做出决定的 model-switch/before 在 Claude Code 的 PreModelSwitch(2.1.251 或更新)上
成为 hookSpecificOutput.permissionDecision: 'allow' | 'ask' | 'deny'(在交互式 /model 之外,ask
等同于拒绝);model-switch/after 只观察已完成的切换,其 Agent.Context 会作为 additionalContext
随下一次请求送达 Claude。每个宿主支持哪些事件族,见生成的事件与钩子矩阵。
tool/before 上的 updatedInput 改写待执行的调用,并且与决定相互独立。在 Claude 与 Codex 上,随 continue
返回的改写不携带 permissionDecision,宿主会针对改写后的输入评估其权限规则;与 allow 搭配可自动批准该改写,
与 ask 搭配则把改写后的输入展示给用户确认。Cursor 只在与 permission 同时出现时记载 updated_input,因此
改写在那里以 permission: 'allow' 交付。
事件路由可以触达全部二十二个规范事件族(session/end、prompt/submit、compact/before、
permission/request、model-switch/before……);配置声明的 hooks 只覆盖上面列出的七个。
抛出异常的路由或处理器不会产生文档,因此也没有决定:生成的包装器写到 stderr,stdout 无输出,
退出码为 1,所有受支持的宿主都把它记作非阻塞错误,待执行的动作会像 pass-through 之后一样继续进行。
对于 runtime: 'standalone' 的路由或配置声明的处理器,stderr 文本是抛出的消息;共享运行时中的路由
报告 Event route rendering failed.,因为运行时以通用的 runtime-failed 错误应答包装器,并把原始消息
留在自己的进程中。
抛错的 tool/before 不会拒绝;要拒绝请返回 outcome: 'deny'。按表面分列的表格见
路由抛错时会发生什么。
线上到底传了什么
两种形态都编译成位于根目录 hooks/ 下的包装层,由宿主以
node "${CLAUDE_PLUGIN_ROOT}/hooks/<wrapper>.mjs"(或宿主自己的根令牌)调用。只有一个选中宿主
接收的钩子编译为 hooks/<name>.mjs;被多个选中宿主共享的钩子则为每个宿主各编译一份
hooks/<name>.<host>.mjs,因为每个包装层都烘焙了自己宿主的事件拼写,Claude Code 与 Codex 为
PreToolUse / PostToolUse,Cursor 为小驼峰的 preToolUse / postToolUse,而每个宿主的文档
都指向它自己的包装层。Claude Code 的文档是 hooks/hooks.json,它按约定加载该文件且从不扫描
hooks/ 下的其他文档,因此 Claude 清单不写 hooks 指针:Claude Code 会把指向约定文件的指针
报告为重复的 hooks 文件(manifest.hooks 只用于额外文档)。Codex 与 Cursor 的清单带有显式的
hooks 指针,因此它们的文档紧挨着各自的清单,分别是 .codex-plugin/hooks.json 与
.cursor-plugin/hooks.json。每个包装层都拿信封里的 hook_event_name 与为自己宿主烘焙的拼写
比较,所以 Claude 会话里出现 native hook_event_name must equal postToolUse,说明 Claude 的
文档调用的是为 Cursor 构建的包装层,而不是 Claude 发送了小驼峰。配置声明的处理器
在该包装层进程内运行。runtime: 'shared' 的事件路由则转发给生成的 MCP 服务器进程内的常驻运行时,
因此钩子与工具共享状态:
- 宿主 → 包装层(stdin)。 宿主写入一个 JSON 信封。包装层以 1 MiB 的硬上限流式读取 stdin,只解析
恰好一个值,并按宿主、按事件校验它(
session_id、transcript_path、cwd、工具字段……)。任何 不匹配都以非零退出。 - 包装层 → 运行时(IPC)。 一条按换行分隔的请求,
protocolVersion、artifactEpoch、event、hostContractRevision、target与校验过的native信封,经由每用户的 Unix socket(Windows 上为 命名管道)发送。socket 目录为0700,socket 为0600,端点哈希绑定了产物 epoch、target 与产物的 安装目录,因此两份安装绝不会共享运行时。请求与timeoutMs(默认 5000 ms)竞速。 - 运行时渲染。 服务端拒绝 epoch 不匹配与畸形消息,构造
canonicalprops,并在请求上下文内通过 react-server worker 渲染路由组件。 - 运行时 → 包装层。 一条 JSON 回复:
{ status: 'ok', output },或{ status: 'error', code }, 其中code为epoch-mismatch、invalid-message或runtime-failed。 - 包装层 → 宿主(stdout)。 宿主原生的响应,或者什么都不输出。没有渲染任何决定与上下文的路由不写
任何输出并以 0 退出,宿主会像没有这个钩子一样继续;对
tool/before而言,这意味着它自己的权限提示。 只有当路由返回allow、ask或deny时,才会写入决定字段。
失败默认是关闭的。唯一的回退是同时编译了独立形态的路由上的 fallback: 'standalone',而且它只在
runtime-unavailable(没有活的 socket)时触发。runtime-timeout、epoch-mismatch、invalid-message
与 runtime-failed 都以非零退出,过期的代码绝不会作答,也绝不会输出伪造的响应。
运行时一侧同样是失败即关闭。生成的 MCP 服务端拥有这个 socket:它用一个独占锁文件认领端点、开始监听,
并在关停时只有当它们仍是自己创建的那些文件时才把两者删除。无法删除自己 socket 路径的关停,或无法
释放认领锁的启动,会报告 code 为 runtime-failed 的 EventRuntimeTransportError(Unable to remove the event runtime endpoint. / Unable to release the event runtime endpoint claim.),而不是静默退出;
启动失败时会先关掉已经起来的监听器,因此绝不会在错误背后留下一个活的服务端。崩溃进程留下的过期
socket 或锁会由下一个服务端恢复:它探测端点,并回收所有者已被证明死亡的认领。
并发会话。 宿主经常从同一个安装根目录运行同一插件的多个会话,Claude Desktop 启动的每个
Claude Code 会话共享同一个 --plugin-dir,Cursor 也会从同一个缓存导入 Claude 安装的插件,因此多个
端点相同的生成 MCP 服务端进程是常态。任一时刻恰好只有一个进程拥有 socket。启动时发现端点已被另一个
进程拥有的服务端不会退出:它以 standby 角色启动,照常服务自己的 MCP 会话,向 stderr 写入
agent-bundle event runtime: <socket> is owned by another process; standing by,并大约每秒探测一次
端点。所有者一退出,某个待命者就会通过与无主启动完全相同的认领并监听路径接管 socket(并记录
… was released by its owner; took it over);若有多个待命者,认领锁保证恰好一个胜出,其余继续等待。
钩子包装层无需任何改动:只要所有者活着,它们的回答与以前一样;在接管间隙(最多约一秒)里它们会看到
runtime-unavailable,这正是上文 fallback: 'standalone' 路由覆盖的情形,仅共享的路由则对这一个事件
以非零退出。接管因"socket 仍被占有"之外的任何原因失败时(无法写入的 socket 目录、永远不清除的认领),
服务端会把失败报告到自己的 stderr,每种不同的失败一行,最多每 30 秒重复一次,并继续待命。因其他
任何原因失败的启动仍然失败即关闭,所有者无法删除的过期 socket 也一样。
在同一个复合根目录里,每个所选宿主都通过自己 MCP 文档所列出的第一个生成式 MCP 服务器到达该运行时,
当所有所选宿主都列出同一个服务器时是一个进程,否则每个宿主一个。运行时的 socket 只由制品本身标识(它的
epoch 与根目录,而非 targets 选择),发起调用的宿主随每个请求一起传递,因此无论哪个进程持有该 socket,
都会应答所有所选宿主的包装层。
runtime: 'standalone' 的路由把它的模块打进包装层自身:同样的规范身份、同样的投影、没有共享的进程状态。
AB4817 会拒绝这样的路由:它需要共享运行时,但在某个 target 上没有生成的 MCP 入口承载它,也没有独立
回退。
原生钩子兼容
以下配置式钩子仅作为适配器兼容入口。新框架处理器使用上面的规范事件文件。
声明一个钩子
每个事件都接受一个裸的处理器路径、一个条目对象,或者当同一事件需要多个处理器时,接受由两者组成的 数组:
规范事件
不实现某个事件的宿主,就不会收到对应的钩子;输出的文档保持诚实,而不是发明一个等价物。
Amp 把朴素 sessionStart 映射到 fire-and-forget 的 session.start,把 beforeTool 映射到
tool.call。它的 agent.start 与 agent.end 描述用户回合而非子智能体,因此不映射朴素
agentStart/agentStop。事件路由还会把 prompt/submit 映射到 agent.start、stop 映射到
agent.end、tool/after 映射到 tool.result。Amp 明确没有 session.end,适配器也绝不制造一个。
工具选择器
tools 接受规范选择器,shell、file.read、file.write、mcp、agent,在宿主有对应原生匹配器时
翻译过去。Cursor 映射全部五个;Claude Code 没有 agent;Codex 没有 agent 也没有 file.read。钩子上的每个
选择器都必须在每个所选 target 上可映射,因此以 codex 为 target 的 tools: ['file.read', 'shell'] 会导致构建
失败,尽管单独的 shell 本可以工作。事件与钩子矩阵渲染了这张匹配器表。tools 同时接受写成 <target>:<native-name> 的显式宿主原生选择器,例如 claude:WebSearch 或
codex:view_image,这类选择器只会贡献给该宿主的原生匹配器。
Amp 没有发布稳定的规范内置工具名表。Amp 钩子如需选择器,应使用显式的 amp:<native-name>;规范选择器
会失败,而不是猜测原生名字。
选择了工具的钩子必须让每个所选 target 都至少留下一个可用的选择器。一个只限定 claude:WebSearch
却同时以 codex 为 target 的钩子会导致构建失败,而不是输出一份匹配器为空、永远不会触发的 Codex 文档。
处理器契约
处理器模块默认导出一个函数,它接收事件负载并返回结果。不返回任何内容等同于继续。来自 agent-bundle 的
HookHandler<E>(也从声明 hook 的 agent-bundle/config 再导出)为一个规范事件同时给出两侧的类型,
因此一个 satisfies 子句就是完整的契约:
event 是 HookEvent<'sessionStart'>,sessionId 加上可选的 cwd、source、transcriptPath
以及宿主可能附带的其他会话事实,返回类型是 HookResult<'sessionStart'>,因此一个返回
{ outcome: 'deny', reason: '…' } 的 sessionStart 处理器是 tsc 错误,而不是 hook 运行时的失败。
按事件划分的负载类型(BeforeToolHookEvent 携带 toolName、toolInput、toolUseId;AfterToolHookEvent
增加 toolResponse;StopHookEvent 与 AgentStopHookEvent 携带 stopHookActive;AgentStartHookEvent
携带 agentId 与 agentType)都按名字导出,只有当每个宿主都会提供某个字段时它才是必填的;处理器的第二个
参数(HookHandlerContext)给出调用它的 target、该宿主的原生事件名以及原样的已校验原生输入,供需要
宿主特定字段的处理器使用。HookHandlerEventName 是配置 hook 可以声明的事件集合;每个适配器只映射其
文档化子集,workspaceOpen 改由事件路由提供。这些类型是可移植契约:它们所接纳的
字段既被每个宿主的包装层接受,也会到达该宿主的原生输出。宿主特定的行为不在其中,只能通过未带类型的
结果触及:来自 beforeTool 的 additionalContext(Claude 与 Codex 会投递;Cursor 的 preToolUse 输出
只携带权限或输入重写)、来自 agentStart 或 agentStop 的 additionalContext(分别为 Claude 与 Codex、
仅 Claude),以及仅 Cursor 支持的 agentStart 拒绝。还有一条类型无法承载的运行时注意事项:Cursor 只对
status 为 completed 的子代理消费 agentStop 拒绝,因此同时面向 Cursor 的处理器应在拒绝前检查
context.nativeInput.status。
生成的包装层仍会在把结果投影为宿主原生输出之前先校验它,同样的规则,在 hook 运行时也施加于 JavaScript 与预构建的处理器。只接受四个键,并且每一个都会被检查:
逐事件的限制是被强制执行的,而不是「写在文档里、希望有人遵守」:
sessionStart、afterTool与agentStart不能拒绝、停止或替换输入。类型契约(以及 Claude 与 Codex 的 包装层)拒绝agentStart的拒绝结果;只有 Cursor 的运行时包装层容忍带原因的agentStart拒绝,并把它 投影为permission: 'deny',因此它只能从未带类型的结果触及。beforeTool不能停止,也不能在拒绝的同时替换输入。继续(continue)的beforeTool处理器 不做任何权限决定:宿主的决定字段不会被写入,因此其正常的权限提示仍然适用;updatedInput改写会由该流程针对改写后的输入进行评估。只有deny会被投影为决定。若要显式批准一次调用,请使用 事件路由并返回outcome: 'allow'。stop只接受continue,或带原因的deny。agentStop不能停止父流程或替换输入;在 Codex 与 Cursor 上也不能追加上下文,只有 Claude Code 会投递agentStop的additionalContext。
未知的键、错误的类型,或违反上述任一规则,都会让钩子以清晰的错误消息失败,而不是被投影成一份行为 异常却毫无声响的宿主文档。
Amp 请求结果
Amp 的请求回调严格使用固定版本 @ampcode/plugin 的联合类型。tool/before 事件路由把显式 allow、
deny 与 updatedInput 分别映射为 allow、reject-and-continue 与 modify。
{ outcome: 'synthesize', output, exitCode? } 成为原生合成工具结果。透传不产生 wrapper 决定;生成的内联
tool.call 处理器只在全部匹配 wrapper 都透传后,才返回 API 必需的最终 allow。
tool/after 路由可返回 status(done、error 或 cancelled)以及替换的 output/error;不返回值
则保留原结果。prompt/submit 路由的 Agent.Context 成为 agent.start 追加消息。被拒绝的 stop 成为
{ action: 'continue', userMessage },会启动另一个回合,因此可重入的路由必须自行加 guard。适配器不添加
权限提示、模型选择或继续循环。
预构建处理器
自行掌控编译的项目可以把钩子指向已声明 payload 中某个已构建好的文件:
预构建钩子输出的原生命令形如 node "<root>/<payload path>" <args…>,一条配置声明取代了逐宿主手工
维护的 hooks/hooks.json。预构建钩子按原生钩子文档的方式打包:它们不编译包装层,也不会出现在清单的
executables.hooks[] 行中,因此 hooks list 与 hooks simulate 看不到它们。
检查与模拟
钩子是「构建通过」最没有说服力的表面,因此输出的包装层可以被直接运行:
--hook 必须是 hooks list 打印出的精确 name(或 id),规范化会把它推导为
<event>-<handler>-<hash>,因此像 session-start 这样的裸事件名不会匹配任何钩子。
session-start-session-start-7ab7e8a5 正是本页开头那段声明所产生的名称;哈希覆盖事件、处理器路径、
targets、tools、timeout 与 args,因此声明的任何改动都会得到新名称,要复制的永远是 hooks list
为你的构建打印出的那一个。
载荷是规范输入;包装层会把它转换为宿主的原生信封并按宿主的方式校验,所以 Claude 的
sessionStart 模拟需要 sessionId、transcriptPath、cwd 与 source(工具事件还需要
toolName、toolInput 与 toolUseId)。
hooks simulate 运行的是真实输出的包装层,正是宿主将要执行的那个文件,所以你看到的结果就是宿主
会得到的结果。在开发者 Workbench 中,于 Application → Events / Hooks 下选中该事件,选择一份
Canonical、Claude、Codex 或 Cursor 夹具,然后在那里运行。路由工作区首先展示结果,并把规范/原生
编解码细节留在投影检查器中。
下一步
- MCP 服务器与 MCP App:另一种可执行表面。
- 脚本与资源:钩子或 Skill 可以调用的代码。
- 编译器架构:事件路由、配置钩子与宿主包装层 在编译管线中的位置。