ARTICLE · 1056526
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)跑起来。源码分三个包:
🔑 关键区分:桥(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 那条"我同意"的解释对决策没有贡献,留着只会稀释信号。
七、审计与静默: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技术推荐官」获取更多源码解析内容