For AI agents: the complete documentation index is available at https://scriptedalchemy.github.io/agent-bundle/zh/llms.txt, the full documentation bundle is available at https://scriptedalchemy.github.io/agent-bundle/zh/llms-full.txt, and this page is available as Markdown at https://scriptedalchemy.github.io/agent-bundle/zh/guide/authoring/hooks.md.
跳至主要内容
  • 简体中文
  • 钩子

    钩子是一个处理器模块,编译器会把它包装起来并注册到每个宿主原生的钩子文档中,或由 Amp 生成的 PluginAPI 工厂注册。你只需按规范事件分键 声明一次,适配器就会把事件名、匹配器与结果形状翻译成所选宿主期望的样子。

    事件路由

    在 src/events/ 下按规范事件路径创建文件:.ts 处理器返回轻量事件结果,.tsx 处理器渲染 JSX。 .ts 处理器也可以用 ctx.render('./name.view.js', data) 请求独立的 .view.tsx 同级模块。两种形式使用相同的事件路径、 宿主适配器与结果校验。渲染形式可以声明静态配置:

    // src/events/tool/after.tsx
    import { Agent } from '@agent-bundle/runtime';
    import type { AgentEventRouteConfig, AgentEventRouteProps } from 'agent-bundle';
    
    export const config = {
      runtime: 'standalone', // 'shared' 表示在生成的 MCP 服务器进程内渲染
      requires: ['events.toolAfter.context'],
      timeoutMs: 30_000, // 在宿主自身原生截止时间之内的预算
      tools: ['file.write'], // 规范选择器 -> 各宿主的原生匹配器
    } satisfies AgentEventRouteConfig;
    
    export default async function AfterFileEdit(
      { canonical, native, signal }: AgentEventRouteProps<'tool/after'>,
    ) {
      // canonical.provenance = { host, hostContractRevision, nativeEvent, source: 'native' }
      // 在每个宿主上,canonical.payload.toolName = { value: 'Write', nativeKey: 'tool_name' }
      const tool = canonical.payload.toolName?.value ?? 'a tool';
      return (
        <Agent.Result>
          <Agent.Context>
            {`Recorded an edit by ${tool}, reported by ${canonical.provenance.host}.`}
          </Agent.Context>
        </Agent.Result>
      );
    }

    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。

    // src/events/tool/before.ts
    import { events } from 'agent-bundle/routes';
    
    export default events.tool.before({}, async ({ canonical, provider }) => {
      if (canonical.payload.toolName?.value === 'Write') {
        return { outcome: 'deny', reason: await provider('writePolicy') };
      }
    });

    辅助函数推断该事件的载荷与允许的结果,事件名称必须与文件路径一致。也可使用普通默认函数,并用 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'。

    export default events.tool.before({}, () => ({
      outcome: 'continue',
      updatedInput: { command: 'cargo check --locked' },
      additionalContext: 'Checking with the lockfile.',
    }));

    需要先判断再渲染时,将 JSX 放入独立的同级模块:

    // src/events/tool/before.ts
    import { events } from 'agent-bundle/routes';
    
    export default events.tool.before({}, async (ctx) => {
      if (ctx.canonical.payload.toolName?.value !== 'Write') return;
      return ctx.render('./before.view.js', { message: 'A write is about to run.' });
    });
    // src/events/tool/before.view.tsx
    import { Agent } from '@agent-bundle/runtime';
    import type { AgentEventRouteProps } from 'agent-bundle';
    
    export default async function BeforeWrite(
      { renderInput }: AgentEventRouteProps<'tool/before', { message: string }>,
    ) {
      return <Agent.Result><Agent.Context>{renderInput?.message}</Agent.Context></Agent.Result>;
    }

    处理器和视图是两个独立的编译入口。只有处理器返回 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,而不是手写的身份:

    import { createEventRouteInput, renderRoute } from 'agent-bundle/test';
    
    const rendered = await renderRoute('event:tool/after', {
      input: createEventRouteInput('tool/after', claudePostToolUseEnvelope, {
        host: 'claude',
      }),
    });

    路由通过它渲染出的文档作答。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 服务器进程内的常驻运行时, 因此钩子与工具共享状态:

    1. 宿主 → 包装层(stdin)。 宿主写入一个 JSON 信封。包装层以 1 MiB 的硬上限流式读取 stdin,只解析 恰好一个值,并按宿主、按事件校验它(session_id、transcript_path、cwd、工具字段……)。任何 不匹配都以非零退出。
    2. 包装层 → 运行时(IPC)。 一条按换行分隔的请求,protocolVersion、artifactEpoch、event、 hostContractRevision、target 与校验过的 native 信封,经由每用户的 Unix socket(Windows 上为 命名管道)发送。socket 目录为 0700,socket 为 0600,端点哈希绑定了产物 epoch、target 与产物的 安装目录,因此两份安装绝不会共享运行时。请求与 timeoutMs(默认 5000 ms)竞速。
    3. 运行时渲染。 服务端拒绝 epoch 不匹配与畸形消息,构造 canonical props,并在请求上下文内通过 react-server worker 渲染路由组件。
    4. 运行时 → 包装层。 一条 JSON 回复:{ status: 'ok', output },或 { status: 'error', code }, 其中 code 为 epoch-mismatch、invalid-message 或 runtime-failed。
    5. 包装层 → 宿主(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 入口承载它,也没有独立 回退。

    原生钩子兼容

    以下配置式钩子仅作为适配器兼容入口。新框架处理器使用上面的规范事件文件。

    声明一个钩子

    import { 
    const defineConfig: (config: AgentBundleConfig | ConfigFactory) => AgentBundleConfig | ConfigFactory
    defineConfig
    } from 'agent-bundle/config';
    export default
    function defineConfig(config: AgentBundleConfig | ConfigFactory): AgentBundleConfig | ConfigFactory
    defineConfig
    ({
    AgentBundleConfig.hooks?: Partial<Record<"sessionStart" | "beforeTool" | "afterTool" | "stop" | "agentStart" | "agentStop" | "workspaceOpen", AgentBundleHookInput>> | undefined
    hooks
    : {
    sessionStart?: AgentBundleHookInput | undefined
    sessionStart
    : {
    AgentBundleHookEntry.handler: string | AgentBundlePrebuiltEntry
    handler
    : './src/hooks/session-start.ts' },
    },
    AgentBundleConfig.plugin: AgentBundlePluginConfig
    plugin
    : {
    AgentBundlePluginConfig.name: string
    name
    : 'hooks-and-scripts',
    AgentBundlePluginConfig.description?: string | undefined
    description
    : 'Release preparation helpers.',
    },
    AgentBundleConfig.targets?: string[] | undefined
    targets
    : ['portable', 'codex', 'claude'],
    });

    每个事件都接受一个裸的处理器路径、一个条目对象,或者当同一事件需要多个处理器时,接受由两者组成的 数组:

    import { 
    const defineConfig: (config: AgentBundleConfig | ConfigFactory) => AgentBundleConfig | ConfigFactory
    defineConfig
    } from 'agent-bundle/config';
    export default
    function defineConfig(config: AgentBundleConfig | ConfigFactory): AgentBundleConfig | ConfigFactory
    defineConfig
    ({
    AgentBundleConfig.hooks?: Partial<Record<"sessionStart" | "beforeTool" | "afterTool" | "stop" | "agentStart" | "agentStop" | "workspaceOpen", AgentBundleHookInput>> | undefined
    hooks
    : {
    beforeTool?: AgentBundleHookInput | undefined
    beforeTool
    : [
    './src/hooks/audit.ts', {
    AgentBundleHookEntry.handler: string | AgentBundlePrebuiltEntry
    handler
    : './src/hooks/guard-writes.ts',
    AgentBundleHookEntry.targets?: readonly string[] | undefined
    targets
    : ['claude'],
    AgentBundleHookEntry.timeout?: number | undefined

    Native hook timeout in seconds. Omit it to use the selected host's default.

    timeout
    : 10,
    AgentBundleHookEntry.tools?: readonly string[] | undefined
    tools
    : ['file.write', 'shell'],
    }, ], },
    AgentBundleConfig.plugin: AgentBundlePluginConfig
    plugin
    : {
    AgentBundlePluginConfig.description?: string | undefined
    description
    : 'Guarded tooling.',
    AgentBundlePluginConfig.name: string
    name
    : 'guarded' },
    AgentBundleConfig.targets?: string[] | undefined
    targets
    : ['portable', 'claude'],
    });
    字段含义
    handler处理器模块,或一个 prebuilt 标记,指向已声明 payload 中某个已构建好的文件。
    targets把钩子限制到特定 target。默认是所有支持钩子的所选 target。
    timeout原生钩子超时,单位为秒。省略则使用所选宿主的默认值。
    tools决定钩子何时触发的工具选择器。
    args额外的命令参数。只有预构建处理器接受参数,且只接受 shell 安全的字符串。

    规范事件

    事件触发时机
    sessionStart会话开始时。
    beforeTool工具调用派发之前。
    afterTool工具调用返回之后。
    stop智能体即将停止时。
    agentStart子智能体启动时。
    agentStop子智能体停止时。
    workspaceOpen工作区被打开时。请以事件路由(src/events/workspace/open.tsx)声明它,而不是配置钩子:没有任何 target 会映射这种朴素钩子形式,因此配置声明的 workspaceOpen 在每个宿主上都是构建错误,包括 Cursor,它只通过路由形式支持该事件。

    不实现某个事件的宿主,就不会收到对应的钩子;输出的文档保持诚实,而不是发明一个等价物。

    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 子句就是完整的契约:

    // src/hooks/session-start.ts
    import type { HookHandler } from 'agent-bundle';
    
    export default ((event) => ({
      additionalContext:
        `Run verify-release from ${event.cwd ?? process.cwd()} before publishing.`,
      outcome: 'continue',
    })) satisfies HookHandler<'sessionStart'>;

    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 与预构建的处理器。只接受四个键,并且每一个都会被检查:

    键契约
    outcomecontinue 或 deny。没有任何事件接受 stop:HookHandler<E> 的类型把它排除在外,生成的包装层在运行时也会拒绝它。
    reason非空字符串,仅在拒绝 beforeTool、stop 或 agentStop 钩子时有效。拒绝其中之一却不给出原因会失败。
    additionalContext追加到智能体上下文的字符串。
    updatedInput用于替换待执行调用的输入。

    逐事件的限制是被强制执行的,而不是「写在文档里、希望有人遵守」:

    • 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 中某个已构建好的文件:

    import { 
    const defineConfig: (config: AgentBundleConfig | ConfigFactory) => AgentBundleConfig | ConfigFactory
    defineConfig
    } from 'agent-bundle/config';
    export default
    function defineConfig(config: AgentBundleConfig | ConfigFactory): AgentBundleConfig | ConfigFactory
    defineConfig
    ({
    AgentBundleConfig.hooks?: Partial<Record<"sessionStart" | "beforeTool" | "afterTool" | "stop" | "agentStart" | "agentStop" | "workspaceOpen", AgentBundleHookInput>> | undefined
    hooks
    : {
    afterTool?: AgentBundleHookInput | undefined
    afterTool
    : [{
    AgentBundleHookEntry.args?: readonly string[] | undefined

    Extra command arguments. Only prebuilt handlers accept arguments.

    args
    : ['--host', 'claude'],
    AgentBundleHookEntry.handler: string | AgentBundlePrebuiltEntry
    handler
    : {
    AgentBundlePrebuiltEntry.prebuilt: string
    prebuilt
    : './dist/runtime/hook/index.js' },
    AgentBundleHookEntry.targets?: readonly string[] | undefined
    targets
    : ['claude'],
    AgentBundleHookEntry.tools?: readonly string[] | undefined
    tools
    : ['file.write'],
    }], },
    AgentBundleConfig.payload?: Readonly<Record<string, AgentBundlePayloadInput>> | undefined
    payload
    : {
    runtime: {
        source: string;
        targets: string[];
    }
    runtime
    : {
    AgentBundlePayloadEntry.source: string
    source
    : './dist/runtime',
    AgentBundlePayloadEntry.targets?: readonly string[] | undefined
    targets
    : ['claude'] } },
    AgentBundleConfig.plugin: AgentBundlePluginConfig
    plugin
    : {
    AgentBundlePluginConfig.description?: string | undefined
    description
    : 'Self-compiled runtime.',
    AgentBundlePluginConfig.name: string
    name
    : 'prebuilt-plugin' },
    AgentBundleConfig.targets?: string[] | undefined
    targets
    : ['claude'],
    });

    预构建钩子输出的原生命令形如 node "<root>/<payload path>" <args…>,一条配置声明取代了逐宿主手工 维护的 hooks/hooks.json。预构建钩子按原生钩子文档的方式打包:它们不编译包装层,也不会出现在清单的 executables.hooks[] 行中,因此 hooks list 与 hooks simulate 看不到它们。

    检查与模拟

    钩子是「构建通过」最没有说服力的表面,因此输出的包装层可以被直接运行:

    npx agent-bundle inspect --root . --hooks
    npx agent-bundle hooks list --artifact artifact --target claude
    npx agent-bundle hooks simulate --artifact artifact --target claude \
      --hook session-start-session-start-7ab7e8a5 \
      --input '{"sessionId":"local","transcriptPath":"/tmp/transcript.jsonl",
        "cwd":"/tmp/project","source":"startup"}'

    --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 夹具,然后在那里运行。路由工作区首先展示结果,并把规范/原生 编解码细节留在投影检查器中。

    下一步