跳到主要内容

接入层:生命周期钩子、CLI 分发与 Hook IO 纪律

30 秒导读: claude-mem 靠 Claude Code 的生命周期钩子挂进来——你每提交一次 prompt、 每用一次工具、每结束一次回合,Claude Code 都会执行一小段 bash。这层的活就是:把那一小段 bash 稳稳地找到插件、转成一次 hook <平台> <事件> 的 CLI 调用,再把该事件 POST 给本地常驻的 worker 守护进程。它本身不记忆、不压缩、不注入内容(那些在 02/03/05 章),只负责 "把每个钩子变成一次干净、安静、绝不拖垮宿主的 worker 调用"。

本章覆盖 claude-mem 的最外一层:钩子注册(plugin/hooks/hooks.json)、CLI 管道 (src/cli/hook-command.ts)、IO 纪律(src/shared/hook-io.ts)、以及一组纯函数 handler (src/cli/handlers/*)如何通过 worker 回退把事件送出去。同组其它章见 全景与阅读地图


1. 这是什么(零基础也能懂)

一句话定义

接入层 = claude-mem 和 Claude Code 之间的插头。 Claude Code 在会话的关键时刻会"喊一声" (触发一个钩子),这层负责接住这一声、翻译成 claude-mem 内部能懂的一次调用。

先搞懂"钩子"是什么

Claude Code 允许插件在固定的生命周期时刻注入一条命令。你可以把它想成 "事件监听器",只不过监听的不是鼠标点击,而是一次编码会话里发生的事:

生命周期时刻什么时候触发claude-mem 想干嘛
SessionStart会话开始 / /clear / 压缩后启动 worker,并把上次的记忆注入进来
UserPromptSubmit你按下回车提交 prompt登记这一轮、按需做语义检索注入
PostToolUse每次工具调用完成后把这次工具动作交给 worker 去"观察"、压缩成记忆
PreToolUse(仅 Read)即将读某个文件前把和这个文件相关的历史记忆补到上下文里
Stop一次回合结束触发对本回合的总结落库
Setup插件安装 / 更新时跑一次版本检查

解决什么问题 / 给谁用

用户是装了 claude-mem 插件的 Claude Code(以及 Codex / Gemini / Cursor / Windsurf)用户。 他们什么都不用做——只要插件装好,这六个钩子就自动挂上。这层要保证的是:

  • 每个该被记住的动作,都被可靠地送进 claude-mem;
  • 绝不因为自己出问题拖累宿主——worker 挂了、Bun 没装、stdin 是空的,编码会话都得照常进行。

用起来什么样

用户永远不会直接调这层;它藏在 hooks.json 里。但如果把一次 PostToolUse 钩子"展开", 本质上就是宿主在你每次工具调用后,替你跑了这么一条命令:

# 示意:PostToolUse 钩子最终等价于这条命令
# (真实命令还带一大段定位插件目录的 bash,见 §2)
node .../scripts/bun-runner.js .../scripts/worker-service.cjs hook claude-code observation
# ^^^^ ^^^^^^^^^^^ ^^^^^^^^^^^
# 子命令 平台 事件

Claude Code 通过 stdin 把一段 JSON(session_id、工具名、工具入参出参、cwd……)喂给它; 这层读进来、翻译、POST 给 worker、然后安静地 exit 0

一句话直觉

把这层想成一个尽职但极度克制的前台: 每来一个访客(钩子事件),它登记、转交给后台 (worker),然后一言不发地退下。只有当后台连续联系不上、或它自己真的崩了,它才会 "抬高嗓门"让你和模型都听见(见 §5 的 fail-loud 与 §4 的 exit-code 契约)。


2. 顶层全景(它大概怎么转)

一次钩子事件的完整旅程

先看一次 PostToolUse 从"宿主喊一声"到"落到 worker"的全景。从上到下是时间顺序, 每一格换一个进程/模块:

Claude Code 宿主
│ 触发 PostToolUse,把事件 JSON 从 stdin 喂进来

┌─────────────────────────────────────────────────────────┐
│ hooks.json 里那段 bash │
│ · 拼 PATH、定位插件根目录($CLAUDE_PLUGIN_ROOT / 缓存) │
│ · 调 node bun-runner.js worker-service.cjs hook ... │
└─────────────────────────────────────────────────────────┘
│ node 进程

┌─────────────────────────────────────────────────────────┐
│ bun-runner.js (node,ES 兼容层) │
│ · 找到 Bun 运行时 │
│ · 收集 stdin,转手用 Bun 重新拉起 worker-service.cjs │
│ · 空 stdin / 找不到 Bun 等环境问题在这里就地处理 │
└─────────────────────────────────────────────────────────┘
│ bun 进程

┌─────────────────────────────────────────────────────────┐
│ worker-service.cjs → case 'hook' │
│ · 确保 worker 守护进程活着 │
│ · import hookCommand,交棒给 CLI 管道 │
└─────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────┐
│ hookCommand (src/cli/hook-command.ts) ← 本章主角 │
│ ① installHookStderrBuffer 装 stderr 缓冲 │
│ ② adapter.normalizeInput 平台 JSON → 统一结构 │
│ ③ handler.execute 纯函数,POST 给 worker │
│ ④ emitModelContext 唯一的 stdout JSON 出口 │
│ ⑤ 按错误分类决定 exit code │
└─────────────────────────────────────────────────────────┘
│ HTTP POST(仅当 worker 活着)

worker 守护进程 (02 章:心脏)

怎么读这张图: 前三格是"怎么把插件找到并用对运行时启动"(纯工程/平台适配); 从 hookCommand 起才是本章真正的核心——管道 + IO 纪律 + 错误分类

部件一句话职责

部件干什么在哪个文件
hooks.json六个钩子的注册表,每段是一坨自定位的 bashplugin/hooks/hooks.json
bun-runner.jsnode 兜底层:找 Bun、收 stdin、拉起真正的脚本plugin/scripts/bun-runner.js
worker-service.cjs case 'hook'CLI 分发:确保 worker 起来,再进 hookCommandsrc/services/worker-service.ts:1240
hookCommand钩子执行管道 + try/catch 错误分类src/cli/hook-command.ts:85
平台适配器各家宿主的 JSON ↔ 统一 NormalizedHookInputsrc/cli/adapters/*
事件 handler纯函数,把一个事件 POST 给 workersrc/cli/handlers/*
Hook IOstdout/stderr/exit 的唯一出口,定 IO 纪律src/shared/hook-io.ts
worker 回退POST 给 worker;不可达就优雅降级 + 计数src/shared/worker-utils.ts

三条贯穿本章的暗线

这层所有"看起来啰嗦"的设计,都在服务三条铁律。记住它们,后面的细节就都有了归属:

  1. 成功即安静、失败才冒泡——正常路径下不能往 stdout/stderr 吐任何东西污染模型上下文。
  2. worker 不可达 = exit 0——一次记忆写失败远比让宿主报错重要。
  3. handler 是纯函数——业务逻辑只返回一个 HookResult 值,所有 IO 副作用收口到一处。

3. 核心原理(逐个机制,由浅入深)

3.1 钩子里那段 bash:为什么这么长

它要解决的小问题: Claude Code 触发钩子时,并不告诉你插件装在哪。插件可能装在 marketplace 目录、可能在版本化的缓存目录、可能由环境变量 CLAUDE_PLUGIN_ROOT 指定, 还得兼容 Windows(cygpath)和各种 shell 的 PATH。这段 bash 必须在任何机器上都能自己找到自己

思路: 每段命令都做同一件事——把几个候选根目录按优先级排出来,逐个检查 "这个目录里有没有我要的脚本",第一个命中的就用。看 plugin/hooks/hooks.json 的 SessionStart start 段,核心逻辑是:

步骤做什么
拼 PATH从登录 shell 捞 PATH,补上 nvm/homebrew 常见路径
排候选根$CLAUDE_PLUGIN_ROOT → 缓存目录里最新的版本号目录 → marketplace 根
逐个验证命中"同时存在 bun-runner.jsworker-service.cjs"的第一个目录
找不到echo 'claude-mem: plugin scripts not found' >&2; exit 1(装坏了,值得报错)
调用node "$_P/scripts/bun-runner.js" "$_P/scripts/worker-service.cjs" hook claude-code <事件>

关键细节: 六段 bash 高度雷同,只有结尾的事件名不同——context / session-init / observation / file-context / summarize,外加 SessionStart start 段调的是 worker 的 start 生命周期命令(先启动守护进程,再跑 context 注入)。也就是说,这一层 bash 的 全部智能只有两件事:定位插件、把事件名当参数传进去。

注意 SessionStart 的 matcher"startup|clear|compact",PostToolUse / PreToolUse 用 "*",PreToolUse 额外把 matcher 收窄到 "Read"——只有读文件前才触发文件级上下文补全 (hook claude-code file-context)。

3.2 bun-runner:node 兜底层与 stdin 收集

它要解决的小问题: 真正的 claude-mem 代码用 Bun 运行,但钩子最初是被 node 拉起的 (宿主自带的 node,版本可能很老)。中间需要一个"最朴素的 node 也能跑"的桥,负责找到 Bun、 把 stdin 透传过去,再用 Bun 重启真正的脚本。

为什么这个桥必须极度保守: 有些 Claude Code 安装会用打包的、ES2020 之前的 node 跑钩子,连可选链 ?. 都会抛 SyntaxError。所以 bun-runner.js 里刻意不用 ?.——见 plugin/scripts/bun-runner.js:69-77 isPluginDisabledInClaudeSettings 上方的注释(issue #2791)。

找 Bun 的顺序(findBun,bun-runner.js:23):先 which bun / where bun,不行再挨个探 ~/.bun/bin/bun/usr/local/bin/opt/homebrew/bin 等已知路径;全找不到就 console.error 提示装 Bun 并 exit 1(环境问题值得让用户看见)。

空 stdin 的处理是这层最讲究的坑。 钩子事件靠 stdin 传 JSON;但在某些平台(比如 Windows 上旧版 Claude Code 的 SessionStart)宿主根本不 pipe 数据。collectStdin (bun-runner.js:104)带 5 秒超时收集,拿到结果后分三类应对:

stdin 收集结果
├─ 有数据 → 写给子进程,正常跑
├─ 空 & 是生命周期命令 → start/stop/restart/status 本就不吃 stdin,关闭管道让它跑
└─ 空 & 是钩子事件 → issue #2188:这多半是 WSL/broken shell 没 pipe 数据
· console.error 一段结构化诊断(USER_HINT,宿主会显示)
· 落一个 CLAUDE_MEM_DATA_DIR/CAPTURE_BROKEN 标记文件(durable 信号)
· exit 0(遵守"钩子错误 exit 0"的策略,见 §4)

注意最后一支:它没有报错退出,而是留下一个磁盘标记文件,让下一次 SessionStart 有机会 把"你的抓取坏了"提示出来。这就是本层反复出现的模式——用 exit 0 保护宿主,用旁路信号 (文件 / 计数器)记录真相(bun-runner.js:190-219)。

3.3 CLI 管道:normalizeInput → execute → emitModelContext

它要解决的小问题: 五个平台(claude-code / codex / cursor / gemini / windsurf)的钩子 JSON 字段名各不相同,七种事件各有各的处理逻辑。要把这个 M×N 的组合收敛成一条固定管道

思路:适配器 + handler 把"平台差异"和"事件差异"正交拆开。管道本体只有三步, 见 executeHookPipeline(src/cli/hook-command.ts:67):

readJsonFromStdin() 把 stdin 的 JSON 读进来(见 3.6)
│ raw: unknown

adapter.normalizeInput(raw) 平台专属字段 → 统一的 NormalizedHookInput
│ input

handler.execute(input) 纯函数,返回 HookResult(可能顺手 POST 给 worker)
│ result

emitModelContext(adapter, result) 唯一的 stdout JSON 出口


exitGraceful() exit 0(或 result.exitCode)

适配器只做翻译。claudeCodeAdapter.normalizeInput (src/cli/adapters/claude-code.ts:9)为例:把 session_id/tool_name/tool_input 等 snake_case 字段搬进统一的 NormalizedHookInput,并在这里就做入参校验——cwd 不是 非空字符串就抛 AdapterRejectedInput('invalid_cwd')(adapters/errors.ts:2)。 getPlatformAdapter(adapters/index.ts:9)按平台名分发,未知平台一律落到 rawAdapter

formatOutput 反向:把 HookResult 转成该平台宿主认得的 stdout JSON。Claude Code 版 (claude-code.ts:27)只在有 hookSpecificOutputsystemMessage 时才输出对应字段—— 没内容就输出 {},不给模型塞噪音。

handler 按事件名分发。 getEventHandler(src/cli/handlers/index.ts)是一张 EventType → EventHandler 的表;碰到未知事件名,返回一个 no-op handler ({ continue: true, suppressOutput: true })而不是抛错——宽进严出

3.4 handler 是纯函数:副作用收口

它要解决的小问题: 如果每个 handler 都能随手 console.log / process.exit,那"成功即 安静"的纪律就守不住了——任何第三方库的一行 stderr 都可能漏进模型上下文。

思路: 约定所有 handler 都是纯函数——只接收 NormalizedHookInput、只返回 HookResult,绝不碰 stdout/stderr/exit。每个 handler 文件顶部都钉着同一条契约注释, 例如 src/cli/handlers/observation.ts:1-4:

// this handler is PURE. It returns a HookResult and MUST NOT call // process.stderr.write / process.stdout.write / console.* / process.exit.

logger.* 调用被归类为 DIAGNOSTIC(经缓冲,见 §3.5);handler 里 throw 的错误由 hookCommand 的 try/catch 统一接住并路由。这样唯一的 IO 出口就只剩 hook-io.ts 一处, 纪律才守得住。

handler 的典型形状(以 observation 为例,observation.ts:41):

  1. toolName → 直接返回 no-op;
  2. cwd抛错(这是真异常,交给 hookCommand 分类);
  3. shouldTrackProject(cwd) 为假 → 返回 no-op(该项目被用户排除,见下);
  4. 决定运行时(server / worker,见 §3.5 末),把事件 POST /api/sessions/observations

shouldTrackProject 是廉价的前置过滤器(observation.ts:58session-init.ts:64): 用户可以把某些目录排除在记忆之外,handler 在发起任何网络调用之前先问这一句,命中排除 就地返回 no-op,连 worker 都不打扰。

3.5 worker 回退:executeWithWorkerFallback 与双运行时

它要解决的小问题: handler 想把事件送出去,但 worker 可能没起来、可能正在冷启动、可能 返回 5xx。这一步必须永不抛未处理异常、永不 hang,并且要在 worker 真的联系不上时 给出一个"降级"的返回值让 handler 平静收场。

思路: 所有 handler 都通过同一个函数出海——executeWithWorkerFallback (src/shared/worker-utils.ts:656)。它把"worker 活着吗 / POST / 解析 / 降级"打包:

executeWithWorkerFallback(url, method, body)

├─ ensureWorkerAliveOnce() ── 假 ──► recordWorkerUnreachable()(计数,见 §5)
│ (进程内只探一次) └─► 返回 WorkerFallback 哨兵值
│ 真

workerHttpRequest(带超时的 fetch)

├─ 429 / 5xx → 重置失败计数 + 返回 WorkerFallback(reason: worker_api_5xx)
├─ 其它非 2xx → 返回解析后的 body(4xx 当业务响应,不算 worker 挂)
└─ 2xx → 重置失败计数 + 返回解析后的 JSON

回退用的是"品牌化哨兵值",不是异常。 WorkerFallback 带一个 Symbol.for('claude-mem/worker-fallback') 标记(worker-utils.ts:638),handler 用 isWorkerFallback(result) 一判便知"这次没送成",然后返回 no-op——降级是一条正常返回路径, 不是 catch 块。这样 handler 里没有满屏的 try/catch,纯函数性质得以保持。

双运行时回退(server / worker)。 claude-mem 正从"本地 worker"演进到"远端 server"。 handler 先问 resolveRuntimeContext()(src/services/hooks/runtime-selector.ts:100):

  • 设置里 CLAUDE_MEM_RUNTIMEserver(或旧值 server-beta)且 server 配置齐全 → 先试 runtime.client.recordEvent(...) 打远端 /v1/events;
  • server 报可回退的错(error.isFallbackEligible())→ 记一条 logServerFallback, 穿透到 worker 路径继续兜底(observation.ts:90-98);
  • 否则(默认)→ 直接走 worker 的 executeWithWorkerFallback

一句话:server 是首选、worker 是兜底,而 executeWithWorkerFallback 又在 worker 内部 再兜一层(不可达就哨兵降级)。层层"打不通就静静退场"。

3.6 stdin 的健壮读取:早退与 EOF 兜底

它要解决的小问题: 钩子事件是流式 JSON,可能分多个 chunk 到达、可能永远不 EOF、可能是空。

思路: readJsonFromStdin(src/cli/stdin-reader.ts:38)边收边试解析:

  • 每来一个 chunk 就 tryParseJson,一旦能解析成完整 JSON 就立刻 resolve(不等 EOF,省时延);
  • end 事件时若仍没解析成:有内容但坏 → reject(交给 hookCommand 当错误);空 → resolve undefined;
  • 兜底 30s 安全超时:到点还没完整 JSON,同样按"有内容 reject / 空 resolve undefined"处理;
  • stdin 不可用(TTY)→ 直接 resolve undefined

这里的容错和 hookCommand 的错误分类是配套的: stdin 读出来 undefined 时,适配器拿到 空对象、handler 自然走 no-op 分支;stdin 坏了抛错则被下面的 §4 分类接住。


4. 深入实现:错误分类与 exit-code 契约

这是本层最该讲清的"坑"。hookCommand(src/cli/hook-command.ts:85)的整个 try/catch 就是 一台错误分类机:把 handler/管道抛出的任何异常,映射到一个明确的 exit code 和一种 IO 行为。

三种退出信号

常量语义谁用
HOOK_EXIT_CODES.SUCCESS0一切正常,或"可容忍的失败"绝大多数路径
HOOK_EXIT_CODES.BLOCKING_ERROR2模型/用户必须看见的错误只有真·未知异常
(exit 1)1进入 hookCommand 之前的环境问题bun-runner / worker-service 前置校验

exit 2 是 Claude Code 钩子契约里"把 stderr 反馈给模型"的信号 (HOOK_EXIT_CODES,src/shared/hook-constants.ts:12)。

catch 块的分类顺序

hookCommand 的 catch 从"最良性"到"最严重"依次判定,命中即止:

catch (error)

├─ AdapterRejectedInput? 平台拒了这条输入(如 invalid_cwd)
│ → logger.warn + emit 空上下文 + exit 0 ← 不是错,是"这条不该处理"

├─ isNonBlockingHookInputError? transcript 路径缺失/不存在
│ → logger.warn + emit 空上下文 + exit 0 ← 宿主没给齐料,跳过

├─ isWorkerUnavailableError? 网络/传输类:ECONNREFUSED、超时、5xx、429...
│ → await recordWorkerUnreachable() + exit 0 ← §5 的 fail-loud 计数

└─ 其它(真·未知异常)
→ logger.error
→ await captureCliEvent('hook_failed', ...) 遥测必须先 await
→ emitBlockingError(...) 刷缓冲 stderr + exit 2

为什么 worker 不可达要 exit 0

这是全层最反直觉、也最重要的决定。 一个记忆写失败,后果只是"这次没记住";但如果钩子 exit 非 0,某些宿主(尤其 Windows Terminal)会为每个失败的钩子堆积一个终端 tab, 几分钟就淹没屏幕。见 hook-command.ts:121-131 的注释(EXIT_SIGNAL per CLAUDE.md)。

所以:传输层错误一律 exit 0,失败信息不走 exit code,而走"fail-loud 计数器"(§5)。 isWorkerUnavailableError(hook-command.ts:25)就是这台分类机的传输层识别器,它的判定表:

消息特征判为 worker 不可达?
econnrefused / econnreset / epipe / fetch failed / socket hang up
timed out / timeout
HTTP 5xx / 429是(worker 过载或崩,值得容忍降级)
HTTP 其它 4xx(这是业务/协议错,不该被当成"worker 挂了"吞掉)
TypeError / ReferenceError / SyntaxError否(是代码 bug,应冒泡到 exit 2)

这张表的精妙在于 4xx 和 TypeError 都返回 false——它们会掉到 catch 的最后一支、走 exit 2, 因为"我们发了个 worker 不认的请求"或"我们代码写错了"都是真 bug,应该让人看见,而不是静静吞掉。

await 的时序讲究

catch 里两处 await 不是随手加的。exit 0 / exit 2 都会立刻杀掉进程,而遥测 captureCliEvent('hook_failed', ...)recordWorkerUnreachable() 里可能有一个在途的 POST。所以必须先 await 遥测发完,再 emit/exit,否则会把 fire-and-forget 的 POST 打断在 半路(hook-command.ts:126-129135-146)。captureCliEvent 自身承诺永不抛、硬上限 2s, 所以这个 await 不会把 fail-loud 路径挂死。


5. Hook IO 纪律:成功即安静,失败才冒泡(issue #2292)

这是本层的灵魂机制,单独一节。src/shared/hook-io.ts整个钩子执行路径里唯一会调 console.log / process.stderr.write / process.exit 的地方——所有其它模块都得经它出海。

意图词汇:五种"往外说话"的方式

hook-io.ts:1-21 的文件头定义了一套意图词汇,每个出口先声明意图,再走对应通道:

意图去哪用途
MODEL_CONTEXTstdout(JSON)给模型消费的上下文——唯一允许写 stdout 的意图
DIAGNOSTICstderr给运维/终端看的日志,永不进模型
USER_HINTHookResult.systemMessage给人看的一句短提示
BLOCKING_FEEDBACKstderr + exit 2模型必须看见的错误
EXIT_SIGNAL纯 exit 0只表状态,无 payload

核心把戏:缓冲 stderr

问题: 第三方库(甚至 Bun 本身)可能在 handler 执行期间往 stderr 乱写。Claude Code 会 把钩子的 stderr 当反馈处理,这些噪音会污染体验。

解法: installHookStderrBuffer(hook-io.ts:63)在 handler 跑之前,把 process.stderr.write 替换成一个只往数组里塞、不真写的函数。于是执行期间所有直写 stderr 的字节都被关进缓冲区。等到 hookCommand 决定退出时:

  • 正常成功 → exitGraceful(hook-io.ts:154)丢弃缓冲(保住"安静");
  • 需要报错 → emitBlockingError(hook-io.ts:138)先刷出缓冲、再写错误消息、再 exit 2。
installHookStderrBuffer()
│ 把 process.stderr.write 换成"只入缓冲"的写法
│ 同时"钉住"换之前的真 writer 作为旁路通道(bypass)

handler / logger 执行期间
├─ 第三方直写 stderr → 进缓冲(可能被丢弃)
└─ emitDiagnostic / emitBlockingError → 走 bypass,直达真 stderr(绕过缓冲)

退出时二选一
├─ exitGraceful: drop 缓冲 → 安静 exit 0
└─ emitBlockingError: flush 缓冲 + 写消息 → exit 2

为什么要"钉住旁路 writer"?hook-io.ts:44-51installHookStderrBuffer 内的 pinnedBypassWrite:如果 flush 时还用当前的 process.stderr.write,那就是被换掉的缓冲 writer 自己,会无限自我递归。所以安装缓冲前先把"真 fd writer"钉下来,flush/emitDiagnostic 都走它,永不回到缓冲。restore() 在 hookCommand 的 finally 里把一切还原(hook-command.ts:154)。

stdout 的双写保护

emitModelContext(hook-io.ts:117)是唯一往 stdout 写 JSON 的函数,并且用一个 moduleHasEmitted 标志防止一次调用里 emit 两次——两段 JSON 会撕裂 stdout 流,让宿主 的钩子解析器读到坏数据。resetHookIoState(hook-io.ts:168)在每次 hookCommand 入口 (hook-command.ts:86)重置这个标志,让守卫是"每次钩子"而非"每进程"级别(利于测试与 skipExit 复用)。它刻意用 console.log 而非 process.stdout.write——宿主解析器要那个尾随换行。

fail-loud 计数器:exit 0 之下如何"终究让人知道"

既然 worker 不可达永远 exit 0,那用户怎么发现 claude-mem 其实一直没在记?靠一个持久的 失败计数器recordWorkerUnreachable(src/shared/worker-utils.ts:594)每次被调:

  1. DATA_DIR/state/hook-failures.json 读出 consecutiveFailures,原子地 +1 写回;
  2. 达到阈值(默认 3,CLAUDE_MEM_HOOK_FAIL_LOUD_THRESHOLD 可调)时:
    • 恰好等于阈值那一次(=== threshold,worker-utils.ts:613)发一条 hook_failed 遥测—— 用 === 而非 >=,把遥测量每个失败连续段只发一次;
    • 然后 emitBlockingError(...)——刷缓冲 + exit 2,让 "claude-mem worker unreachable for N consecutive hooks" 这句话终于冒泡给用户和模型。

一旦某次 worker 调用成功,resetWorkerFailureCounter(worker-utils.ts:632)把计数清零。 于是行为是:偶发的 worker 抖动被静静吞掉;持续 N 次不可达才"翻脸"一次。

hook_failed 遥测的字段是闭集枚举:setActiveHookType(worker-utils.ts:584)只接受 TELEMETRY_HOOK_TYPES(context/session-init/observation/summarize/file-context, worker-utils.ts:573)里的事件名,不在集内(如 user-message/file-edit)就省略 hook_type——错误消息本身永远不进遥测,只发枚举值和计数。


6. 巧妙之处(可借鉴的技术)

  • "成功即安静、失败才冒泡"用缓冲实现,而非到处 if。 把 stderr 整体缓冲、退出时二选一 地 drop/flush,比在每个可能写 stderr 的点判断"该不该说"干净得多。hook-io.ts:63-97

  • 降级是返回值,不是异常。 WorkerFallbackSymbol.for 品牌哨兵 (worker-utils.ts:638),让 handler 用一次 isWorkerFallback 判断就平静收场,保住纯函数性。

  • exit code 与"通知"解耦。 worker 不可达永远 exit 0(保护 Windows 终端),真相另走磁盘 计数器 + 阈值化遥测。"不拖累宿主"和"终究让人知道"两个诉求各用一条独立通道满足 (hook-command.ts:121worker-utils.ts:594)。

  • 4xx vs 5xx 的分岔。 isWorkerUnavailableError 把 5xx/429 当"worker 挂了、容忍降级", 却把 4xx 和 TypeError 放行到 exit 2——"是我们请求错了"必须冒泡,"是 worker 状态差"才吞 (hook-command.ts:46-54)。

  • 遥测在 exit 前 await。 因为 process.exit 会打断在途 POST,凡"先发遥测再退出"处都 显式 await,并靠 captureCliEvent 的 2s 硬上限兜底不 hang(hook-command.ts:126-146)。

  • 平台无关靠"翻译层"。 适配器把 M 个平台的 JSON 收敛成一个 NormalizedHookInput, handler 只写一次逻辑;未知平台落 rawAdapter、未知事件落 no-op handler——宽进严出 (adapters/index.ts:9handlers/index.ts)。


7. 边界与局限(这层刻意不做什么)

  • 不生成任何记忆内容。 这层只把事件 POST 给 worker;观察、压缩、落库、检索、上下文生成 全在 worker 侧和 02/03/05 章。handler 拿到 worker 的返回后,顶多把 additionalContext 透传回宿主(如 session-init 的语义注入,session-init.ts:166),自己不产出内容。

  • worker 不可达时会静默丢事件。 exit 0 + 哨兵降级意味着那一次工具观察就是没了,不重试、 不排队;这是"不拖累宿主"的代价。只有连续 N 次才通过 fail-loud 让人知道。

  • stdin 契约脆弱。 整层依赖宿主把事件 JSON pipe 到 stdin;宿主不 pipe(某些平台/版本) 就只能靠 bun-runner 的空 stdin 诊断 + CAPTURE_BROKEN 标记兜底(bun-runner.js:163-220, issue #2188)。

  • 进程内只探一次 worker。 ensureWorkerAliveOnce(worker-utils.ts:492)缓存存活判定; 一个短命钩子进程里 worker 从"死"变"活"不会被重新发现——但钩子进程本就短命,下次事件是新进程。


8. 代码地图(导航索引)

主题文件路径符号名
六个钩子注册 + 自定位 bashplugin/hooks/hooks.jsonSetup/SessionStart/UserPromptSubmit/PostToolUse/PreToolUse/Stop
CLI hook 子命令分发src/services/worker-service.ts:1240case 'hook'
node 兜底层 / 找 Bun / stdinplugin/scripts/bun-runner.jsfindBun, collectStdin, isPluginDisabledInClaudeSettings
钩子执行管道src/cli/hook-command.ts:67executeHookPipeline
钩子入口 + 错误分类src/cli/hook-command.ts:85hookCommand
传输层错误识别src/cli/hook-command.ts:25isWorkerUnavailableError
输入缺失(可跳过)识别src/cli/hook-command.ts:59isNonBlockingHookInputError
stderr 缓冲安装src/shared/hook-io.ts:63installHookStderrBuffer
stdout 唯一出口(防双写)src/shared/hook-io.ts:117emitModelContext
阻塞反馈(flush + exit 2)src/shared/hook-io.ts:138emitBlockingError
优雅退出(drop + exit 0)src/shared/hook-io.ts:154exitGraceful
平台适配器分发src/cli/adapters/index.ts:9getPlatformAdapter
Claude Code 输入/输出翻译src/cli/adapters/claude-code.ts:8claudeCodeAdapter, normalizeInput, formatOutput
事件 handler 分发表src/cli/handlers/index.tsgetEventHandler, EventType
PostToolUse 观察 handlersrc/cli/handlers/observation.ts:41observationHandler, dispatchToWorker
UserPromptSubmit 会话初始化src/cli/handlers/session-init.ts:54sessionInitHandler
worker 回退包装src/shared/worker-utils.ts:656executeWithWorkerFallback, isWorkerFallback
fail-loud 失败计数器src/shared/worker-utils.ts:594recordWorkerUnreachable, resetWorkerFailureCounter
遥测事件闭集枚举src/shared/worker-utils.ts:573TELEMETRY_HOOK_TYPES, setActiveHookType
server/worker 运行时选择src/services/hooks/runtime-selector.ts:100resolveRuntimeContext, selectRuntime
stdin 流式读取src/cli/stdin-reader.ts:38readJsonFromStdin
项目跟踪过滤src/shared/should-track-project.ts:15shouldTrackProject

相邻章节: worker 内部如何处理这些 POST → 02-worker-and-observer.md; 上下文注入内容怎么生成 → 03-compression-prompts-and-parsing.md05-retrieval-context-and-search.md;全景与阅读地图 → index.md