夜雨聆风学习资料网

ARTICLE · 1056526

DeepSeek Harness 源码-Hook 桥接(Claude Code/Codex)

DeepSeek Harness 源码-Hook 桥接(Claude Code/Codex)

DeepSeek Harness 源码解析系列

第 39 讲:Hook 桥接(Claude Code/Codex)

基于 DeepSeek Harness 源码 · 2026-09-22

💡 本讲一句话:Claude Code 和 Codex 的用户习惯用 hooks.json 挂 shell 命令来管住 Agent——DeepSeek Harness 不让你重写这些 hook,而是造了两座"桥":把外部 shell hook 协议原样翻译到自己的拦截扩展点上。共享库管执行/解码/合并,两个桥只管方言差异;读完你会明白 "faithful-but-degraded(忠实但降级)" 是怎么落地的。

一、定位:不重写你的 hook,而是把你的 hook 搬进来

前面几讲分析的工具、沙箱、MCP,都是 DeepSeek Harness 自己提供的能力。但真实用户手里往往已经有一堆为 Claude Code 或 Codex 写好的 hook——一段 shell 命令,在 PreToolUse 时检查参数、在 Stop 时阻止过早收尾。这些资产不该因为换了 harness 就作废。

packages/hooks/ 的 README 把定位说得很直白:让 hook 子系统"lets users extend the agent at lifecycle points the way Claude Code and Codex do"——把一个桥插件指向你现有的 hooks.json,那些外部 shell hook 就能原样(unmodified)跑起来。源码分三个包:

角色
形态
hook-protocol
共享协议库:匹配、执行、解码、合并、审计事件、detached 静默
library(非插件)
hooks-claude-code
CC 桥:7 事件方言、payload/环境变量/命令替换、deny+ask 映射
Cordis 插件
hooks-codex
Codex 桥:5 事件方言、纯正则 matcher、无环境变量、只认 block
Cordis 插件

🔑 关键区分:桥(bridge)≠ 原生 hook。README 原话:"a 'native hook' is just an ordinary Cordis plugin on those extension points"——harness 真正的扩展面是类型化拦截点(tools/pre-execute、agent/turn-stopping 这些事件),写一个原生插件挂上去就是"原生 hook"。桥只是把外部 shell-hook 协议翻译到同一组拦截点上,让你不用重写 hooks.json。想要定制行为?写原生插件;想复用存量资产?上桥。

这个"共享库 + 两个方言桥"的拆分是本讲所有代码的组织原则:协议里两种方言共有的行为(退出码契约、JSON 解码、最严格合并)沉到 hook-protocol;只有方言差异(payload 字段名、matcher 语义、stdin 帧格式、决策映射)留在各自的桥里。下面按这个顺序拆。

二、方言中立的词汇表:HookOutput

hook-protocol/src/types.ts(137 行)定义了共享词汇。核心是 HookOutput——一条 hook 跑完后解码出的"中立结果"。注意它的 JSDoc:每个字段都是可选的,因为"一条 hook 可能只用到任意子集;哪个字段在哪个 hook 点有意义、哪些被忽略,由桥决定"(faithful-but-degraded)。

📄 packages/hooks/hook-protocol/src/types.ts (第 89-137 行)

export interface HookOutput {  // 方言中立的 hook 结果词汇表:两个桥共享的"通用语",各自再映射到类型化 Decision  exitCode: number | undefined  // 进程退出码;spawn 失败时为 undefined——没有干净退出码就没有可依据的决策  stderr: string  // trim 后的 stderr——exit 2 阻塞时它就是给模型/用户看的 block-reason  stdout: string  // 原样保留的 stdout:干净退出时可能是纯文本(CC 当输出、Codex 当 additionalContext),所以不能只留解析结果  continue?: boolean  // false ⇒ hook 要求停机;与 stopReason 配对,true/缺省 ⇒ 继续  stopReason?: string  // continue:false 时展示给人看的原因  decision?: 'approve' | 'allow' | 'block' | 'deny' | 'ask'  // 归一化后的权限决策:block/deny=禁止、approve/allow=放行、ask=要确认;缺省 ⇒ 由退出码说了算  reason?: string  // 伴随 decision 的解释文本(给模型或用户看)  hookEventName?: string  // hookSpecificOutput 声称的事件名——不匹配时保留记录但丢弃其事件域字段,防串台  additionalContext?: string  // 注入给下一次模型请求的额外上下文(CC additionalContext / Codex 纯 stdout)  systemMessage?: string  // 展示给用户的警告(CC systemMessage)——目前只记日志不上浮  updatedInput?: Record<string, unknown>  // hook 要求的工具入参改写——已解析但不生效,出现时桥会 warn}

为什么这样设计:注意 decision 字段把两套参考协议里刻意保持分离的两个通道折叠成了一个枚举——顶层遗留的 decision(只有 approve/block)和 hookSpecificOutput.permissionDecision(allow/deny/ask)。types.ts 的注释写得很清楚:"'allow'/'deny'/'ask' arise ONLY from a permissionDecision, never from a top-level decision"。折叠发生在解码层,但合法性校验留在 codec(下一节)——一个带外出现的 {"decision":"deny"} 是无效的,绝不能变成真正的阻塞决策。这种"词汇表归一、语义分权"的拆法,让两个桥可以共享同一个结果类型而不互相污染。

三、Matcher:同一份配置,两种解释方式

hook-protocol/src/matcher.ts(65 行)是共享的匹配器。两种方言对 matcher 的解释不一样,这是 hooks.json 里最容易踩坑的地方:Claude Code 把纯字母数字+竖线的模式当"精确候选列表",其他情况才当正则;Codex 则把所有非空模式都当无锚定正则。

📄 packages/hooks/hook-protocol/src/matcher.ts (第 17-65 行,节选)

const CLAUDE_LITERAL = /^[A-Za-z0-9_|]+$/  // CC 的"字面量判别式":纯字母数字下划线+竖线 ⇒ 按精确匹配处理,不当正则export function matcherDiagnostic(matcher: string | undefined, mode: MatcherMode): string | undefined {  if (isMatchAll(matcher)) return undefined  // 缺省/空串/'*' 三个哨兵值 = 全匹配,两种方言一致,无需校验  const pattern = matcher as string  // 过了 match-all 守卫后必然是非空字符串  if (mode === 'claude-code' && CLAUDE_LITERAL.test(pattern)) return undefined  // CC 字面量模式天然合法——它根本不是正则  const compiled = compileRegex(pattern)  // 其余情况必须能编译成正则;编不出来返回稳定诊断串(配置期报错用)  return compiled === undefined ? `invalid ${mode} regex matcher ${JSON.stringify(pattern)}` : undefined}export function matchesMatcher(matcher: string | undefined, query: string, mode: MatcherMode): boolean {  if (isMatchAll(matcher)) return true  // match-all 哨兵:缺省/空串/'*' 一律命中  const pattern = matcher as string  // 非空字符串  if (mode === 'claude-code' && CLAUDE_LITERAL.test(pattern)) {    return pattern.split('|').includes(query)  // CC 字面量模式:按 | 拆成候选列表做精确包含——"Bash|Edit" 只命中这两个工具名,不解释任何正则元字符  }  return compileRegex(pattern)?.test(query) ?? false  // 其余情况(CC 非字面量 + Codex 全部):无锚定正则;编译失败返回 undefined → 按不匹配处理而不是抛异常}

为什么这样设计:这里藏着两个刻意的"双轨制"。配置期 vs 运行期matcherDiagnostic 在解析配置时校验非法正则并抛 SyntaxError(fail loud,整份配置被拒);而 matchesMatcher 在运行期遇到坏正则只返回 false、不抛异常——因为运行期的 matcher 可能来自用户现场改的配置,一条坏规则不该炸掉整个 turn。字面量 vs 正则:CC 的 "Bash|Edit" 是精确候选(竖线不是正则或运算),如果按正则解释,Bash|Edit 会匹配任何包含 "Bash" 或 "Edit" 子串的名字——语义完全不同。桥在调用时传入自己的 mode,共享库不替方言做决定。

🔹 实战提醒:把 CC 的 hooks.json 原样喂给 Codex 桥时,"matcher": "Bash|Edit" 会被当正则解释——恰好也能工作(无锚定正则 Bash|Edit 匹配含这两个子串的名字),但 "matcher": "^Bash$" 这种 CC 里合法的"非字面量正则"在 Codex 桥同样合法,而 "matcher": "bash*" 在 CC 是精确候选(不命中)、在 Codex 是正则(命中 bash 开头的一切)。同一份配置、两种方言、三种结果——迁移 hook 时 matcher 是第一检查项。

四、Codec:退出码契约 + JSON 解码的"宽容解析"

hook-protocol/src/codec.ts(134 行)把进程输出解码成 HookOutput。两种方言共享的退出码契约是:exit 2 = 阻塞,stderr 即原因;exit 0 = 干净退出,stdout 可携带结构化 JSON 或纯文本;其他退出码 = 非阻塞错误

📄 packages/hooks/hook-protocol/src/codec.ts (第 59-89 行)

export function parseHookOutput(exitCode: number | undefined, stdout: string, stderr: string, expectedEventName?: string): HookOutput {  const trimmedErr = stderr.trim()  // stderr 先 trim——它将来是 block-reason,空白不该进日志和模型上下文  const trimmedOut = stdout.trim()  // stdout 同样 trim;纯文本场景下首尾空白没有语义  const output: HookOutput = { exitCode, stderr: trimmedErr, stdout: trimmedOut }  // 先落三个"永远可用"的原始字段——即使后面 JSON 解析失败,纯 stdout 也还在  if (exitCode === BLOCKING_EXIT_CODE) {  // exit 2:两种方言共有的阻塞信号    output.decision = 'block'  // 直接置为 block——这是退出码契约里唯一"无条件生效"的决策    if (trimmedErr.length > 0) output.reason = trimmedErr  // stderr 非空时作为原因;为空则不写 reason(调用方有兜底文案)  }  if (exitCode === 0) {  // 结构化 stdout 只在干净退出时才有效——非零退出的 stdout 是错误信息,不是协议数据    if (trimmedOut.startsWith('{')) {  // 只有"看起来像 JSON 对象"才尝试解析——对齐参考引擎:其他 stdout 当纯文本,不当错误      let parsed: Record<string, unknown> | undefined      try {        parsed = obj(JSON.parse(trimmedOut))  // 解析并断言是普通对象(非 null、非数组);类型不对就按没有结构化输出处理      } catch {        parsed = undefined  // 干净退出但 JSON 坏了 = 宽容降级:不报错,纯 stdout 留给桥自己用(参考引擎就是这么宽容的)      }      if (parsed) applyStructured(output, parsed, expectedEventName)  // 解析成功才折叠结构化字段;expectedEventName 守卫 hookSpecificOutput 串台    }  }  return output  // 函数是"全定义"(total):任何输入都有确定输出,绝不抛异常——hook 的坏输出不能炸掉宿主 turn}

为什么这样设计:这个函数是"宽容解析"(lenient parsing)的教科书——坏 JSON 不是错误,只是没有结构化输出。参考引擎(CC/Codex)对干净退出时的非 JSON stdout 一律当纯文本处理,这里逐字对齐。另一个细节:exit 2 的 block 决策无条件生效,而 exit 0 的结构化字段要过 expectedEventName 守卫——因为 hookSpecificOutput 是按事件键控的(PreToolUse 的块里才有 permissionDecision),一条给 PostToolUse 写的块混进 PreToolUse 的输出时,它的事件域字段必须被丢弃,否则一个写错的 hook 就能越权改决策。

📄 packages/hooks/hook-protocol/src/codec.ts (第 97-134 行,applyStructured)

function applyStructured(output: HookOutput, parsed: Record<string, unknown>, expectedEventName?: string): void {  const cont = bool(parsed, 'continue')  // 读顶层 continue(布尔);类型不对返回 undefined,不猜  if (cont !== undefined) output.continue = cont  // 有就折叠——false 时桥会映射成停机/拒绝  const stopReason = str(parsed, 'stopReason')  // 顶层 stopReason:continue:false 时的展示原因  if (stopReason !== undefined) output.stopReason = stopReason  const sysMsg = str(parsed, 'systemMessage')  // 顶层 systemMessage:给用户的警告(目前桥只记日志)  if (sysMsg !== undefined) output.systemMessage = sysMsg  const topDecision = topLevelDecisionOf(str(parsed, 'decision'))  // 顶层遗留 decision——只认 approve/block,allow/deny/ask 出现在这里是 schema 非法值  if (topDecision !== undefined) output.decision = topDecision  // 折叠进统一枚举;带外 {"decision":"deny"} 在这里被静默忽略(不能变成真阻塞)  const topReason = str(parsed, 'reason')  // 顶层 reason:伴随 decision 的解释  if (topReason !== undefined) output.reason = topReason  const hso = obj(parsed.hookSpecificOutput)  // hookSpecificOutput:按事件键控的"每事件通道",permissionDecision/additionalContext/updatedInput 都住这里  if (hso) {    const eventName = str(hso, 'hookEventName')  // 读它声称的事件名——即使不匹配也先记录(日志要能看到坏块声称了什么)    if (eventName !== undefined) output.hookEventName = eventName    if (expectedEventName !== undefined && eventName !== expectedEventName) {      return  // 守卫命中:声称的事件 ≠ 正在触发的事件 ⇒ 丢弃整个事件域字段,但顶层字段和判别式已保留——防串台的核心一行    }    const permission = permissionDecisionOf(str(hso, 'permissionDecision'))  // allow/deny/ask 三值;只认这三个词    if (permission !== undefined) output.decision = permission  // 覆盖前面折叠的顶层 decision——permissionDecision 优先级更高(schema 语义)    const permissionReason = str(hso, 'permissionDecisionReason')  // 伴随权限决策的原因    if (permissionReason !== undefined) output.reason = permissionReason    const addCtx = str(hso, 'additionalContext')  // 注入给模型的额外上下文    if (addCtx !== undefined) output.additionalContext = addCtx    const updated = obj(hso.updatedInput)  // 工具入参改写请求——解析出来但桥不执行(见 CC 桥的 warn)    if (updated !== undefined) output.updatedInput = updated  }}

为什么这样设计:注意折叠顺序——permissionDecision 在 topDecision 之后写入,所以后者覆盖前者。这不是随手写的:参考 schema 里 permissionDecision 就是"新通道",顶层 decision 是遗留兼容位。而 hookEventName 守卫放在所有事件域字段读取之前、判别式记录之后——顺序本身就是语义:先留证(记录声称值),再执法(丢弃越权字段)

五、Runner:借壳执行——hook 跑在 shell 能力里

hook-protocol/src/runner.ts(106 行)负责真正执行 hook。它不自己 spawn 进程,而是借道 ctx.shell——复用 shell 能力的凭据清洗、进程组取消和超时机制。这是"一切皆插件"的又一体现:hook 子系统没有自己的进程管理代码。

📄 packages/hooks/hook-protocol/src/runner.ts (第 67-106 行)

export async function runHook(bash: ShellExecutor, hook: CommandHook, options: RunHookOptions, now: () => number): Promise<RunHookResult> {  const started = now()  // 记起点——durationMs 要落进 hook/result 审计事件,用注入的时钟而不是 Date.now(可测试)  const timeoutMs = hook.timeoutSec !== undefined ? hook.timeoutSec * 1000 : options.defaultTimeoutMs  // 单条 hook 的秒级超时优先;没配就用桥传入的默认值(参考默认 600s=10 分钟)  const stdin = JSON.stringify(options.payload) + (options.trailingNewline ? '\n' : '')  // payload 序列化进 stdin——CC 要尾换行、Codex 不要,帧格式是方言差异所以由桥决定  const request = {    command: hook.command,  // 配置里的 shell 命令行(CC 已做过 ${CLAUDE_PLUGIN_ROOT} 替换)    timeoutMs,  // 超时交给 shell executor 的进程组取消机制——到点杀整棵进程树,不是只杀父进程    stdin,  // 事件 payload:session_id/cwd/tool_name/... 方言字段由桥构建    signal: options.signal,  // 宿主操作级 AbortSignal——turn 被取消时 hook 跟着死;detached 点传的是 tracker 的 signal    ...options.cwd !== undefined ? { workdir: options.cwd } : {},  // 工作目录 = agent 会话 workspace(不是服务启动目录)——相对路径要指向用户项目    ...options.env !== undefined ? { env: options.env } : {},  // CC 桥注入 CLAUDE_PROJECT_DIR;Codex 不注入任何 hook 环境变量  }  try {    const result = await bash.run(bash.resolve(request))  // resolve() 做凭据清洗(scrub)后执行——hook 进程拿不到宿主环境里的密钥    const exitCode = result.exitCode ?? undefined  // ShellRunResult.exitCode 是 number|null(null=被信号杀死);协议契约要数字,信号死映射成 undefined    return {      output: parseHookOutput(exitCode, result.stdout.text, result.stderr.text, options.expectedEventName),  // 解码——expectedEventName 守卫在这里生效      durationMs: now() - started,  // 墙钟耗时,落审计事件    }  } catch (error: unknown) {    const message = error instanceof Error ? error.message : String(error)  // executor 只在基础设施故障时 reject(workdir 不可用、shell 缺失)    return {      output: parseHookOutput(undefined, '', message),  // hook 跑不起来 = 非阻塞错误:无退出码、失败信息进 stderr 字段留痕——turn 继续走,绝不抛给调用方      durationMs: now() - started,    }  }}

为什么这样设计:三个"绝不炸宿主"的决策值得注意。① 借道 shell executor:凭据清洗、进程组超时取消都是现成的,hook 子系统零重复代码——而且 hook 是用户写的任意 shell 命令,让它跑在带 scrub 的执行器里比裸 spawn 安全得多。② catch 不抛:基础设施故障被折叠成"无退出码的结果对象",调用方拿到的是数据不是异常——一条坏 hook 不能中断整个 turn。③ 时钟注入now: () => number 参数让测试可以冻结时间断言 durationMs。另外注意 trailingNewline:CC 的 stdin 帧带尾换行、Codex 不带——这种一字节级的方言差异,正是"共享库不替方言做决定"原则的注脚。

六、Merge:多个 hook 说了不同的话,听谁的?

一个 hook 点可能命中多条 matcher group、每组里还有多条命令。它们都跑完后,结果怎么合成一个决策?hook-protocol/src/merge.ts(100 行)的答案:最严格者胜——deny > ask > allow。

📄 packages/hooks/hook-protocol/src/merge.ts (第 35-100 行,节选)

function rank(decision: HookOutput['decision']): number {  // 把决策映射成"严格度等级"——数字越大越严  switch (decision) {    case 'deny': case 'block': return 3  // block/deny 折叠到同一档:禁止(两种方言的措辞差异在这里抹平)    case 'ask': return 2  // ask:要求人工确认,比放行严、比禁止松    case 'approve': case 'allow': return 1  // approve/allow 同档:放行    default: return 0  // 没表达决策(退出码说了算)——不参与严格度竞争  }}export function mergeHookOutputs(outputs: HookOutput[]): MergedHookOutcome {  let maxRank = 0  // 记录全场最严等级;空列表保持 0 ⇒ decision:'none',调用方视为"没有 hook 说话"  const reasonsByRank = new Map<number, string[]>()  // 按等级分桶收集 reason——只让"赢的那一档"的理由上浮,低档的反对意见不刷屏  let stop = false  // continue:false 是粘性的:任何一条 hook 要求停机就停  let stopReason: string | undefined  // 取第一条停机 hook 的 stopReason(先到先得)  const additionalContext: string[] = []  // 所有 hook 的 additionalContext,按 hook 顺序累积——上下文只增不减  const systemMessages: string[] = []  // 同理:systemMessage 全量保留  for (const out of outputs) {    const r = rank(out.decision)  // 本条 hook 的严格度    if (r > maxRank) maxRank = r  // 只升不降——最严格者胜的核心一行    if ((r === 3 || r === 2) && out.reason !== undefined && out.reason.length > 0) {      const list = reasonsByRank.get(r) ?? []  // 只有禁止/确认两档的 reason 值得留——放行理由没有信息量      list.push(out.reason)      reasonsByRank.set(r, list)    }    if (out.continue === false && !stop) {  // 粘性停机:第一条 continue:false 生效,后续不再覆盖 stopReason      stop = true      if (out.stopReason !== undefined) stopReason = out.stopReason    }    if (out.additionalContext !== undefined && out.additionalContext.length > 0) {      additionalContext.push(out.additionalContext)  // 上下文全量累积——多条 hook 的补充信息都注入给模型,不互相覆盖    }    if (out.systemMessage !== undefined && out.systemMessage.length > 0) {      systemMessages.push(out.systemMessage)    }  }  const reasons = reasonsByRank.get(maxRank) ?? []  // 只取赢家的理由桶  return {    decision: decisionForRank(maxRank),  // 3→deny、2→ask、1→allow、0→none    ...reasons.length > 0 ? { reason: reasons.join('\n\n') } : {},  // 同档多条理由用空行拼接——模型能看到所有反对意见的完整文本    stop,    ...stopReason !== undefined ? { stopReason } : {},    additionalContext,    systemMessages,  }}

为什么这样设计:"最严格者胜"是安全系统的标准姿态——一条 hook 说放行、另一条说禁止,合成结果必须是禁止。反过来如果取"最宽松者胜",用户挂两条 hook(一条审计、一条拦截)时,审计 hook 的 exit 0 就会把拦截 hook 的 deny 冲掉。另一个细节是 reasonsByRank:只上浮赢家档位的理由——如果 allow(1) 和 deny(3) 并存,最终 reason 里只有 deny 的理由;allow 那条"我同意"的解释对决策没有贡献,留着只会稀释信号。

合并维度
规则
设计意图
decision
deny > ask > allow,取最严
安全姿态:一条 deny 不能被 N 条 allow 冲掉
reason
只收赢家档位(rank≥2)的理由,\n\n 拼接
信号不稀释:放行理由对最终决策无贡献
stop
粘性:首条 continue:false 生效,reason 先到先得
停机是单向门——不能"先停后继续"
additionalContext / systemMessage
全量累积,按 hook 顺序
信息只增不减:多条补充上下文都注入模型

七、审计与静默:hook/invoked + hook/result 事件对

每条 hook 的执行都会往 session 日志里写一对只读审计事件(log-only,不是 SurfaceEventType、不带 surfaceOp):hook/invoked + hook/result,用 handlerId 关联。这对事件的语义定义在共享库的 events.ts——"shared event's semantics live here, in the lib that declares it, not per-bridge"。

📄 packages/hooks/hook-protocol/src/events.ts (第 92-104 行)

export function appendHookResult(session: Session, record: HookResultRecord): void {  const { output } = record  // 解构解码结果——decision/exitCode/stderrSummary 三个持久化字段都从它派生  const stderrSummary = summarizeStderr(output.stderr, record.stderrSummaryMaxChars)  // stderr 截断:trim、空则 undefined、超长切 maxChars + '…';上限是桥的配置值(默认 500)  session.append('hook/result', {    turn: record.turn,  // 落在哪个 open turn 里——审计事件必须 turn-enclosed,不能跨 turn 悬挂    point: record.point,  // hook 点:PreToolUse / Stop / ...    handlerId: record.handlerId,  // 与 hook/invoked 配对的稳定 id(claude-code:PreToolUse:3 这种格式)    decision: output.decision ?? (output.continue === false ? 'stop' : 'pass'),  // 决策落盘优先级:显式 decision > continue:false 记为 stop > 其余记 pass——三态审计    ...output.exitCode !== undefined ? { exitCode: output.exitCode } : {},  // spawn 失败(无退出码)时省略该字段,而不是写 null——日志里"没有"和"是0"语义不同    ...stderrSummary !== undefined ? { stderrSummary } : {},  // 空 stderr 不写字段;非空才落截断摘要    durationMs: record.durationMs,  // 墙钟耗时——审计时序,排查慢 hook 用  })}

为什么这样设计:注意 decision 的三态落盘:显式决策 > stop > pass。一条 exit 2 的 hook(无 JSON)会记成 decision:'block';一条 continue:false 的记 'stop';其余全记 'pass'——审计日志里能直接回答"这条 hook 到底拦没拦、为什么拦"。而 handlerId 配对机制让 invoked/result 可以跨时间关联(hook 可能跑几十秒),配合 turn 边界约束,整条执行链在 session log 里是可回溯的。

但 SessionStart / SubagentStart 这类发射型(emit-shaped)hook 点没有 turn 可挂——桥不 await 它们,fire-and-forget。这就带来一个生命周期问题:插件 dispose 时,还在跑的 hook 进程怎么办?detached.ts(62 行)给出答案:

📄 packages/hooks/hook-protocol/src/detached.ts (第 43-62 行)

export function createDetachedRuns(): DetachedRuns {  const inflight = new Set<Promise<unknown>>()  // 在飞注册表:每条 detached 链(hook run + 后续副作用)都登记在这里  const controller = new AbortController()  // 一个 tracker 一个 signal——drain 时触发它,正在跑的 hook 进程被杀而不是等超时(默认要等 10 分钟!)  return {    signal: controller.signal,  // 桥必须把这个 signal 传给 runHook——这是"dispose 能杀死在飞 hook"的接线点    track(run: Promise<unknown>): void {      inflight.add(run)  // 登记整条链:不只是进程退出,还包括 .then 里的 inject/warn 副作用      const settled = (): void => { inflight.delete(run) }  // 结算即出表——长会话不会无限累积已完成的 Promise      void run.then(settled, settled)  // then/reject 都触发清理;注意:吞掉 rejection 只是簿记,真正的 .catch 仍是调用方的责任    },    async drain(): Promise<void> {      controller.abort(new Error('hook bridge disposed'))  // 先杀:abort signal ⇒ shell executor 取消进程组——不等超时      while (inflight.size > 0) {  // 再等:波次式排空——每轮 allSettled 后重查,因为前一波结算期间可能又有新链被 track        await Promise.allSettled([...inflight])      }    },  }}

为什么这样设计:这是仓库"dispose must reach quiescence(处置必须达到静默)"原则的具体实现。桥在 apply() 里注册 ctx.effect(() => () => detached.drain(), ...)——Cordis dispose fiber 时会 await 这个 disposer,所以 fiber.dispose() resolve 的那一刻,所有 hook 进程已死、所有副作用(inject/warn)已落地。没有这个机制,一个慢 SessionStart hook 就能在插件卸载后继续跑 10 分钟、往已注销的 agent 里 inject——典型的"幽灵回调"。

八、Claude Code 桥:七事件 + deny/ask 双通道

hooks-claude-code/src/index.ts(361 行)是 CC 方言的完整实现。入口 apply() 做三件事:加载并解析 hooks.json、注册 detached tracker、挂上七个事件监听器。

📄 packages/hooks/hooks-claude-code/src/index.ts (第 96-126 行)

export function apply(ctx: Context, config: Config): void {  const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? DEFAULT_STDERR_SUMMARY_MAX_CHARS  // 审计摘要上限,缺省用协议默认 500 字符  assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars)  // 先校验再解析配置——坏值不能藏在后面的 early return 里被吞掉  const defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS  // 单 hook 默认超时(参考默认 600s)  let parsed: ClaudeCodeHookConfig = {}  // 解析结果:事件名 → matcher group 列表;加载失败时保持空对象  try {    const raw: unknown = JSON.parse(readFileSync(config.configPath, 'utf8'))  // 进程级一次性读取——相对路径按启动 cwd 解析,一份配置管整个进程    const result = parseClaudeCodeConfig(raw, {      ...config.pluginRoot !== undefined ? { pluginRoot: config.pluginRoot } : {},  // ${CLAUDE_PLUGIN_ROOT} 替换值(插件根目录)      ...config.projectDir !== undefined ? { projectDir: config.projectDir } : {},  // ${CLAUDE_PROJECT_DIR} 替换值(项目根)——解析期就代入命令串    })    parsed = result.config  // 只保留可运行的 command hook    for (const s of result.skipped) {      ctx.logger.warn(`hooks-claude-code: skipping unsupported "${s.type}" hook on ${s.event} (only command hooks run)`)  // prompt/agent/http 等非命令类型:不静默丢弃,逐条 warn——用户能看到自己的 hook 没生效及原因    }  } catch (error: unknown) {    ctx.logger.warn(`hooks-claude-code: could not load hook config "${config.configPath}": ${String(error)} — no hooks registered`)  // 读文件/解析失败 = 降级为"无 hook",不炸启动——但留一条能定位的 warn    return  }  const detached = createDetachedRuns()  // SessionStart/SubagentStart/Stop 这类发射型点跑在 tracker 里  const subagentChildren = new Map<SubagentRunId, Agent>()  // start 边保留子 agent 引用——stop hook 要在 handle 注销后仍能拿到会话 workspace  ctx.effect(() => () => detached.drain(), 'hooks-claude-code: drain detached hook runs')  // 注册 disposer:fiber dispose 时 abort + 排空所有在飞 hook(见第七节)}

为什么这样设计:注意 subagentChildren 这个 Map——注释解释得很细:"Only the start edge guarantees registry access. Retain each local child through its paired end so stop hooks keep the session workspace after the handle unregisters the agent." 也就是说 subagent/end 事件触发时,子 agent 可能已经从注册表注销了,但 SubagentStop hook 还需要它的 session.header.cwd 来跑命令——所以 start 边把 Agent 引用存下来、end 边用完即删。这是"生命周期配对"思想的又一次出现(和第六节的 invoked/result 事件对同构)。

核心是 runPoint()——所有七个 hook 点共用这一个执行函数,差异只在传入的 point/payload/opts:

📄 packages/hooks/hooks-claude-code/src/index.ts (第 143-186 行,runPoint 主体)

    const groups: MatcherGroup[] = parsed[point] ?? []  // 取该 hook 点的配置组;没配就是空数组,后面循环直接跳过    const outputs: HookOutput[] = []  // 收集每条命中 hook 的解码结果,最后统一合并    const workdir = opts.agent?.session.header.cwd  // 工作目录 = agent 会话 workspace(session/new 时的 cwd)——不是服务启动目录,相对路径才指向用户项目    const projectDir = config.projectDir ?? workdir  // CLAUDE_PROJECT_DIR:显式配置优先;没配就默认成会话 workspace(CC 总是导出这个变量,存量 hook 常引用它)    const hookEnv = projectDir !== undefined ? { CLAUDE_PROJECT_DIR: projectDir } : undefined  // CC 方言专属环境变量——Codex 桥不注入任何 hook env    for (const group of groups) {      if (!matchesMatcher(group.matcher, matchQuery, 'claude-code')) continue  // matcher 过滤:matchQuery 是事件主体(工具名/会话来源);'claude-code' mode 启用字面量快路径      for (const hook of group.hooks) {        const handlerId = nextHandlerId(point)  // claude-code:PreToolUse:N——invoked/result 配对用的稳定 id        const session = opts.agent?.session  // 有 agent 才有会话可写审计事件        if (session && opts.turn !== undefined) {          appendHookInvoked(session, { turn: opts.turn, point, dialect: 'claude-code', handlerId, ...group.matcher !== undefined ? { matcher: group.matcher } : {} })  // 先落 invoked——执行前就留痕,hook 卡死也能看到"谁被调用了"        }        const { output, durationMs } = await runHook(ctx.shell, hook, {          payload,  // 方言 payload(CC 形状:session_id/transcript_path/cwd/tool_name/...)由调用方构建          defaultTimeoutMs,  // 桥持有的默认超时          ...hookEnv ? { env: hookEnv } : {},  // CLAUDE_PROJECT_DIR          ...workdir !== undefined ? { cwd: workdir } : {},  // 会话 workspace          signal: opts.signal,  // turn 级取消信号;detached 点传的是 tracker signal          trailingNewline: true,  // CC stdin 帧带尾换行——Codex 桥这里是 false,一字节差异          expectedEventName: point,  // hookSpecificOutput 串台守卫:声称其他事件的块被丢弃事件域字段        }, () => performance.now())        outputs.push(output)  // 累积结果        if (output.updatedInput !== undefined) {          ctx.logger.warn(`hooks-claude-code: ${point} hook requested updatedInput, which is not yet honored (ignored)`)  // CC 的入参改写:解析了但不执行——faithful-but-degraded 的典型,warn 让用户知道        }        if (output.systemMessage !== undefined) {          ctx.logger.warn(`hooks-claude-code: ${point} hook emitted a systemMessage, which is not yet surfaced (ignored)`)  // 用户可见警告:同样只记日志不上浮 UI        }        if (session && opts.turn !== undefined) {          appendHookResult(session, { turn: opts.turn, point, handlerId, output, stderrSummaryMaxChars, durationMs })  // 落 result——与 invoked 配对,decision/exitCode/stderrSummary/durationMs 全量审计        }      }    }    return mergeHookOutputs(outputs)  // 最严格者胜合并(第六节);空列表返回中性结果 decision:'none'

为什么这样设计:runPoint 是"共享执行 + 方言参数化"的样板——七个 hook 点共用一套匹配/执行/审计逻辑,方言差异全部收敛成 opts 里的几个字段(trailingNewline、env、payload)。这样新增一个 CC 事件时,桥只需要写 payload builder + 监听器映射,执行路径零改动。而"invoked 先落盘再执行"的顺序保证了审计完整性:即使 hook 进程卡到超时被杀,日志里也有 invoked 记录可以追查。

📄 packages/hooks/hooks-claude-code/src/index.ts (第 219-244 行,两个拦截点映射)

  ctx.on('agent/pre-step', async ({ agent, messages, turn, signal }, next): Promise<PreStepDecision> => {    if (messages.length === 0) return next()  // 没有消息就不触发 UserPromptSubmit——空 prompt 不是 hook 的管辖范围    const content = messages.flatMap(message => message.content)  // 拍平所有消息的内容块,提取文本进 payload    const merged = await runPoint('UserPromptSubmit', '', promptPayload(ctx, agent, content), { agent, turn, signal })  // CC 对 UserPromptSubmit 忽略 matcher(matchQuery='')    if (merged.decision === 'deny') {      return { kind: 'reject' }  // deny ⇒ 拒绝本步——prompt 被 hook 否决,模型看不到它    }    const downstream = await next()  // 关键:先委托下游监听器——它们仍可能改写或拒绝;hook 的上下文不是否决权    const ours = contextFrom(merged)  // additionalContext → UserMessage(source: plugin/hooks-claude-code)    if (!ours || downstream.kind !== 'enter') return downstream  // 没有上下文、或下游决定不是 enter,都原样透传    return { kind: 'enter', messages: [...downstream.messages, ours] }  // 把 hook 上下文追加到下游消息列表——顺序在下游改写之后,保证基于最终 prompt 注入  })  ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {    const turn = lastTurn(exec.agent)  // 从会话日志找最后一个 turn/start——审计事件要落在正确的 open turn 里    const merged = await runPoint('PreToolUse', exec.name, preToolPayload(ctx, exec), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal })  // matchQuery=工具名:matcher 按工具过滤(Bash|Edit / ^git.* 等)    if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' }  // deny ⇒ 拒绝执行,reason 给模型看;无 reason 时兜底文案    if (merged.decision === 'ask') return { kind: 'ask', ...merged.reason !== undefined ? { reason: merged.reason } : {} }  // ask ⇒ 转人工确认——CC 桥独有的第二通道(Codex 桥没有这行)    return next()  // allow/none ⇒ 放行,交给下游监听器继续决策链  })

为什么这样设计:两个细节值得放大。① "先 next() 再折叠上下文":hook 的 additionalContext 不是否决权——它必须等下游监听器(可能是权限系统、其他插件)做完改写/拒绝后,再把上下文追加到最终消息列表上。如果先注入再委托,下游基于旧 prompt 做的决策就和实际发给模型的不一致了。② ask 通道:CC 的 permissionDecision: "ask" 被映射成 harness 的 {kind:'ask'}——转人工审批。这是 CC 桥比 Codex 桥多出来的能力(下一节对比)。

📄 packages/hooks/hooks-claude-code/src/index.ts (第 270-295 行,Stop + Subagent)

  ctx.on('agent/turn-stopping', async ({ agent, turn, signal }): Promise<void> => {    const merged = await runPoint('Stop', '', stopPayload(ctx, agent), { agent, turn, signal })  // Stop hook:在"即将收尾"的边界上触发,payload 带 stop_hook_active:false(循环守卫位)    if (merged.decision === 'deny') {      const text = merged.reason ?? 'continue: blocked by Stop hook'  // deny ⇒ 不许停;无 reason 时给一条通用 steering 文案      agent.steer(createUserMessage({ content: [{ type: 'text', text }], source: PLUGIN_SOURCE }))  // steer():注入一条待处理输入——机器观察到 pending input 就会再跑一步,实现"强制继续"    }  })  ctx.on('subagent/start', (info) => {    const child = ctx.get('agents')?.get(info.id)  // start 边是唯一保证能拿到注册表引用的时刻    if (child !== undefined) subagentChildren.set(info.runId, child)  // 存下来——end 时 handle 可能已注销,stop hook 还要用它的 workspace    detached.track(runPoint('SubagentStart', SUBAGENT_TYPE, subagentPayload(ctx, 'SubagentStart', info, child), { ...child ? { agent: child } : {}, signal: detached.signal })      .then((merged) => {        const context = contextFrom(merged)  // SubagentStart 的 additionalContext → 注入子 agent        if (context && child) child.inject(context)  // inject:子 agent 收到一条 plugin-source 的用户消息      })      .catch((error: unknown) => { ctx.logger.warn(`hooks-claude-code: SubagentStart hook failed: ${String(error)}`) }))  // detached.track 吞掉 rejection 只是簿记,真正的 warn 在这里——不 catch 就静默失败  })  ctx.on('subagent/end', (info) => {    const child = subagentChildren.get(info.runId) ?? ctx.get('agents')?.get(info.id)  // 优先用 start 边保留的引用;拿不到再查注册表兜底    subagentChildren.delete(info.runId)  // 用完即删——Map 不泄漏    detached.track(runPoint('SubagentStop', SUBAGENT_TYPE, subagentPayload(ctx, 'SubagentStop', info, child), { ...child ? { agent: child } : {}, signal: detached.signal }))  // SubagentStop 只观察(CC 语义),结果不进任何决策链  })

为什么这样设计:Stop hook 的"deny = 强制继续"是最有意思的映射——harness 没有"阻止 turn 结束"的原生动词,但 agent.steer()(注入待处理输入)天然实现了它:机器看到 pending input 就会再跑一步。代价是循环风险:一条无条件 deny 的 Stop hook 会让 agent 永远停不下来。源码里留了 TODO:// TODO(stop-loop-guard): cap consecutive forced continuations; hooks must self-limit meanwhile.——目前靠 payload 里的 stop_hook_active: false 字段让存量 hook 自己判断(CC 的循环守卫协议),harness 侧的硬上限还没做。这是"忠实移植参考语义"与"harness 自身安全边界"之间的已知张力。

九、Codex 桥:五事件 + 只认 block 的降级映射

hooks-codex/src/index.ts(329 行)结构与 CC 桥高度同构(共享库承担了大量重复),但方言差异更"硬":五个事件、纯正则 matcher、无环境变量、stdin 不带尾换行,决策映射只认 block——allow/ask 一律忽略。

📄 packages/hooks/hooks-codex/src/index.ts (第 141-156 行,runPoint 的方言特例)

        const { output, durationMs } = await runHook(ctx.shell, hook, {          payload,  // Codex 方言 payload:snake_case + model + turn_id(见下文)          defaultTimeoutMs,          ...workdir !== undefined ? { cwd: workdir } : {},  // 同样跑在会话 workspace          signal: opts.signal,          trailingNewline: false,  // Codex stdin 帧不带尾换行——与 CC 桥的唯一字节级差异,由方言决定而不是共享库猜          expectedEventName: point,  // 串台守卫两桥一致        }, () => performance.now())        if (opts.plainStdoutAsContext === true && output.exitCode === 0          && output.additionalContext === undefined          && output.stdout.length > 0 && !output.stdout.startsWith('{')) {          output.additionalContext = output.stdout  // Codex 特例:SessionStart/UserPromptSubmit 的纯 stdout(非 JSON)直接当 additionalContext——CC 里这要靠 hookSpecificOutput 显式声明,Codex 是隐式约定        }        outputs.push(output)  // 累积后统一合并

为什么这样设计:plainStdoutAsContext 是"忠实但降级"的精确注脚——Codex 参考实现里,SessionStart/UserPromptSubmit hook 的纯 stdout 隐式成为上下文;而 CC 必须走 hookSpecificOutput.additionalContext。桥把两种方言都翻译成 harness 统一的 additionalContext → inject() 路径,但入口条件不同:Codex 多了一个"exit 0 + 非 JSON + 无显式 context"的隐式分支。四个条件缺一不可——非零退出的 stdout 是错误信息不能当上下文、JSON 文本不能当散文注入

📄 packages/hooks/hooks-codex/src/index.ts (第 225-231 + 319-325 行,deny-only 映射与 payload 形状)

  ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {    const turn = lastTurn(exec.agent)  // 同 CC 桥:审计事件落在正确的 open turn    const merged = await runPoint('PreToolUse', exec.name, preToolPayload(ctx, exec, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal })    if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' }  // Codex 只映射 deny——没有 ask 分支:Codex 参考协议里 pre-tool 只有 block/allow,ask 是 CC 独有语义    return next()  // allow 在 Codex 语义里就是"无意见"(默认放行),所以直接透传下游决策链  })  function preToolPayload(ctx: Context, exec: ToolExecution, model: string): Record<string, unknown> {    // tool_name 用真实工具名(与 matcher 主体一致)——硬编码常量会让配置里的工具 matcher 永远不命中;tool_input 保持 Codex 的 { command } 形状    return { ...turnBase(ctx, exec.agent, 'PreToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId }  // turnBase = base + turn_id;commandOf 从工具参数里抽 command 字段,没有就空串  }

为什么这样设计:Codex 桥的 payload 形状是"逐字段对齐参考协议"的产物:model(每个事件都带)、turn_id(turn 级事件才带)、permission_mode: 'default'tool_input: { command }——这些不是 harness 的设计选择,而是Codex 存量 hook 脚本会读取的字段名。桥的职责是让 jq -r '.tool_name' 这类现成脚本不改一行就能跑。反过来,commandOf() 从任意工具参数里抽 command 字段——因为 Codex 的 shell hook 习惯读 .tool_input.command,而 harness 的工具参数形状各异,桥做了这层适配。

🔹 "faithful-but-degraded" 清单(两桥共同的已知降级):① updatedInput(CC 入参改写)——解析 + warn,不执行;② systemMessage(用户警告)——只记日志,不上浮 UI;③ continue:false 的停机语义——merged.stop 已合并但缺 run-level halt 机制(TODO: hook-continue-false);④ Stop hook 强制继续无循环上限(TODO: stop-loop-guard);⑤ SessionStart 慢 hook 可能错过第一个请求(TODO: session-start-gating)。每一条都在源码里留了 TODO 标记——降级不是遗忘,是显式记账。

十、两桥对照:同一份 hooks.json 的两种命运

把前面散落的方言差异汇总成一张对照(每行一个维度,紫条是维度名):

支持事件

Claude Code 7 个:SessionStart / UserPromptSubmit / PreToolUse / PostToolUse / Stop / SubagentStart / SubagentStop

Codex 5 个:SessionStart / UserPromptSubmit / PreToolUse / PostToolUse / Stop(无 subagent 事件)

Matcher 语义

Claude Code 双模式:纯 [A-Za-z0-9_|]+ 按精确候选(Bash|Edit),其余当无锚定正则

Codex 一律无锚定正则,没有字面量快路径

stdin 帧与环境

Claude Code JSON + 尾换行;注入 CLAUDE_PROJECT_DIR;命令支持 ${CLAUDE_PLUGIN_ROOT}/${CLAUDE_PROJECT_DIR} 替换

Codex JSON 无尾换行;不注入任何 hook 环境变量、不做命令替换

决策映射(PreToolUse)

Claude Code deny → {kind:'deny'};ask → {kind:'ask'}(人工确认);allow/none → next()

Codex 只认 deny → {kind:'deny'};allow/ask 一律忽略(参考协议里 pre-tool 只有 block/allow)

纯 stdout 的归宿

Claude Code 需经 hookSpecificOutput.additionalContext 显式声明才注入模型

Codex SessionStart/UserPromptSubmit 的纯 stdout(exit 0、非 JSON)隐式成为 additionalContext

Payload 形状

Claude Code session_id / transcript_path(缺省 '')/ cwd / hook_event_name + 事件字段;无 model、无 turn_id

Codex 同基础字段(transcript_path 缺省 null)+ model + permission_mode:'default';turn 级事件带 turn_id;tool_input 固定 { command } 形状

非命令 hook 的处理

Claude Code prompt / agent / http 类型 → skipped + warn(只跑 command)

Codex 非 command 类型与 async:true 命令 → skipped + warn(只跑同步 command)

为什么这样设计:这张表读法上有个关键视角——每一行差异都不是 harness 的"产品决策",而是参考协议的既有语义。桥的设计目标是让存量 hooks.json 零修改迁移:CC 用户看到 ask 通道、Codex 用户看到纯 stdout 上下文,各自的行为和原来在 CC/Codex 里一致。harness 自己的类型化决策(PreToolDecision/PostToolDecision)只是"出口适配器"——共享库产出中立的 MergedHookOutcome,桥负责把它翻译成方言对应的 harness 动词。

十一、数据流:一条 PreToolUse hook 的完整旅程

PreToolUse hook 全链路(以 CC 桥为例)

① 拦截:tools/pre-execute 事件触发

工具执行管道在真正跑命令前广播 exec(agent/name/arguments/signal)。

② 匹配:matchesMatcher(工具名, 'claude-code')

逐组过滤 matcher;字面量候选或正则命中才进入执行,未命中的组整组跳过。

③ 留痕:appendHookInvoked → session log

执行前先落 hook/invoked(turn/point/handlerId/matcher)——hook 卡死也有据可查。

④ 执行:runHook → ctx.shell.run

payload JSON 进 stdin(CC 带尾换行);凭据清洗 + 进程组超时取消;cwd=会话 workspace。

⑤ 解码:parseHookOutput

exit 2=block(stderr 为因);exit 0+JSON 折叠结构化字段(hookEventName 防串台);坏 JSON 宽容降级。

⑥ 合并:mergeHookOutputs

deny > ask > allow 最严格者胜;reasons 只收赢家档位;context/systemMessage 全量累积。

⑦ 映射:MergedHookOutcome → PreToolDecision

deny→{kind:'deny',reason};ask→{kind:'ask'}(CC 独有);allow/none→next() 交给下游决策链。

⑧ 审计:appendHookResult → session log

decision/exitCode/stderrSummary(≤500字符)/durationMs 落盘,与 invoked 按 handlerId 配对。

🔹 Codex 桥差异:② 纯正则、④ 无尾换行/无 env、⑦ 只映射 deny(ask 通道不存在)

本讲小结:hooks 子系统是"协议翻译层"的典范——共享库把两种方言的共有契约(退出码、JSON 解码、最严格合并、审计事件、静默处置)沉淀成一次实现;两个桥只保留真正的方言差异(payload 字段名、matcher 语义、stdin 帧、决策映射),且每条降级都显式记账(warn + TODO)。对用户的承诺很具体:你为 Claude Code/Codex 写的 hooks.json,一行不改就能在 DeepSeek Harness 里继续管住 Agent——而 harness 自己的类型化拦截点,始终是更推荐的扩展面。

📚 系列导航

← 第 38 讲:扩展系统与自修改(extensions)

→ 第 40 讲:Compaction 与 Spill 内存管理

关注公众号「AI技术推荐官」获取更多源码解析内容

相关学习资料