claude-mem Hook 生命周期修复实战:Stop Hook 死循环、suppressOutput 与 stderr 隔离
2026/9/7 17:53:24 网站建设 项目流程

claude-mem Hook 生命周期修复实战:Stop Hook 死循环、suppressOutput 与 stderr 隔离

【免费下载链接】claude-memPersistent Context Across Sessions for Every Agent – Captures everything your agent does during sessions, compresses it with AI, and injects relevant context back into future sessions. Works with Claude Code, OpenClaw, Codex, Gemini, Hermes, Copilot, OpenCode + More项目地址: https://gitcode.com/GitHub_Trending/cl/claude-mem

Claude Code 的 hook 机制中,stdout/stderr 的每一字节都有特定语义——Stop hook 的任何 stdout 输出都会被宿主 Agent 当作新指令消费,stderr 则直接渲染为用户可见的错误 UI。本文基于 claude-mem 仓库中 Issue Triage 的实际修复记录(对应 Issues #987、#984、#975、#1181、#598、#784),深入讲解如何定位 Stop hook 无限循环与 stderr 误报错误这两个典型故障,并给出suppressOutput: true输出抑制、事件类型 no-op 分发、hook 上下文 stderr 缓冲等可直接复用的修复方案,以及配套的测试验证手段。读完后,你能够独立排查并修复任何基于 Claude Code hook 契约的插件所面临的"hook 输出污染对话"类问题。

1. 问题现场:Stop Hook 死循环与 stderr 错误 UI

claude-mem 是一个为 Claude Code、Codex、Cursor、Windsurf、Antigravity CLI 等多平台 Agent 提供跨会话持久记忆的系统,其核心工作方式就是通过 hooks 在会话各节点(SessionStart、UserPromptSubmit、PostToolUse、Stop 等)介入——hook 注册定义见 plugin/hooks/hooks.json,其中Stop事件挂载了summarize命令,用于在会话结束时提取最后一条 assistant 消息并触发记忆摘要。

正是这个 Stop hook 引发了两个被多个 Issue 报告的故障:

  • Stop hook 无限循环(#987、#984、#975):Stop hook 生成摘要流程的输出会被 Claude Code 解释为新的指令,宿主 Agent 消费后再次触发 Stop,形成反馈循环;
  • stderr 被当作错误 UI 展示(#1181):hook 内部的诊断性日志(logger、第三方库)写入 stderr,Claude Code 将 stderr 内容渲染为用户可见的错误消息。

Triage 文档的结论非常明确:这是特定、有针对性的修复——不是要造一个"限流框架"或"hook 模式标志系统"。修复的本质是"设置一个属性 + 抑制一个流"。

2. 根因验证:两个故障各自的因果链

2.1 Stop hook 循环:stdout 即指令

Claude Code 的 hook 契约中,Stop/Summary 类 hook 的 stdout 输出会被当作指令解读。只要摘要流程向 stdout 泄露任何内容,就会出现"输出被消费 → 再次停止 → 再次输出"的循环。正确的做法不是限制调用频率,而是让 hook 响应显式声明不产生输出

{ "continue": true, "suppressOutput": true }

2.2 stderr 即错误 UI

Claude Code 会把 hook 进程的 stderr 内容直接呈现给用户。hook 代码中的 logger 或第三方库为了诊断目的写 stderr,用户看到的却是"报错"。修复方向是在 hook 上下文中抑制(或缓冲)stderr,让普通诊断日志对用户不可见。

3. 修复一:输出抑制与 no-op 事件分发

3.1 标准 hook 响应:常量化的 suppressOutput

claude-mem 将"无输出 hook 响应"收敛为一个常量,见 src/hooks/hook-response.ts:

export const STANDARD_HOOK_RESPONSE = JSON.stringify({ continue: true, suppressOutput: true });

这意味着所有不需要向宿主 Agent 传递内容的 hook(包括 Stop/Summary 类型)默认都以"继续会话 + 抑制输出"退出。配套的测试 tests/hook-lifecycle.test.ts 明确校验了该常量的两个字段:

const { STANDARD_HOOK_RESPONSE } = await import('../src/hooks/hook-response.js'); const parsed = JSON.parse(STANDARD_HOOK_RESPONSE); expect(parsed.continue).toBe(true); expect(parsed.suppressOutput).toBe(true);

3.2 summarize 处理器:每条路径都返回 suppressOutput

Stop 事件在 plugin/hooks/hooks.json 中映射为hook claude-code summarize,对应处理器在 src/cli/handlers/summarize.ts。审计该文件可以看到所有分支的返回值都是同一形状——包括早期跳过(项目被排除、检测到 Codex Stop hook 重入stopHookActive、子 agent 上下文agentId、缺少sessionId、transcript 提取失败)和正常路径(经 server 或 worker 队列提交摘要请求后):

// 例如:Codex Stop hook 重入检测 if (input.stopHookActive === true) { logger.debug('HOOK', 'Skipping summary: Codex Stop hook re-entry detected', {...}); return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS }; } // 正常路径:POST /api/sessions/summarize 提交后 return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };

处理器文件顶部的注释直接声明了 IO 纪律约束:

// IO discipline (see src/shared/hook-io.ts): this handler is PURE. It returns a // HookResult and MUST NOT call process.stderr.write / process.stdout.write / // console.* / process.exit.

3.3 未知事件类型:返回 no-op 而非报错(#984)

Triage 文档中提到的Unknown event type: session-complete错误(#984),对应事件分发器 src/cli/handlers/index.ts 的处理逻辑:

export function getEventHandler(eventType: string): EventHandler { const handler = handlers[eventType as EventType]; if (!handler) { logger.warn('HOOK', `Unknown event type: ${eventType}, returning no-op`); return { async execute() { return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS }; } }; } return handler; }

关键点在于双重安全:一方面未知事件(如session-completenonexistent-event)返回 no-op handler 并以退出码 0 结束,绝不抛错;另一方面该日志走的是logger.warn结构化日志路径而非console.error,配合第 4 节的 stderr 抑制,用户界面不会看到"Unknown event type"报错。测试 tests/hook-lifecycle.test.ts 用两个用例钉死了这一契约:

it('should return no-op handler for unknown event types (#984)', async () => { const handler = getEventHandler('nonexistent-event'); const result = await handler.execute({ sessionId: 'test-session', cwd: '/tmp' }); expect(result.continue).toBe(true); expect(result.suppressOutput).toBe(true); expect(result.exitCode).toBe(0); });

3.4 平台适配层:只输出契约内的键

即使 handler 内部携带了continue/suppressOutput/exitCode等内部字段,真正写 stdout 的是平台适配器。以 Claude Code 适配器 src/cli/adapters/claude-code.ts 的formatOutput为例,它只输出 hook 契约允许的两个键hookSpecificOutputsystemMessage),其余字段一律剥离:

formatOutput(result) { const r = result ?? ({} as HookResult); if (r.hookSpecificOutput) { const output: Record<string, unknown> = { hookSpecificOutput: result.hookSpecificOutput }; if (r.systemMessage) output.systemMessage = r.systemMessage; return output; } // 无 hookSpecificOutput 时,仅可能有 systemMessage,否则输出 {} const output: Record<string, unknown> = {}; if (r.systemMessage) output.systemMessage = r.systemMessage; return output; }

测试用例 "should only emit keys from the Claude Code hook contract"(tests/hook-lifecycle.test.ts)对多种输入组合断言输出键白名单['hookSpecificOutput', 'systemMessage', 'decision', 'reason'],从适配层杜绝了多余字段污染 stdout JSON。

4. 修复二:hook 上下文中的 stderr 纪律

4.1 演进路径:从粗暴 no-op 到缓冲 + 旁路通道

Triage 文档记录的最初修复是最小化方案:在hookCommand()入口处直接替换process.stderr.write = (() => true),并在finally块中恢复;同时把hook-command.tshandlers/index.ts中的console.error()全部转换为logger.warn()/logger.error()(logger 写入日志文件而非 stderr)。

当前仓库中这一方案已演进为带类型的缓冲机制(见 src/shared/hook-io.ts),核心思路与 Triage 文档一脉相承,但更精细:不是永远吞掉 stderr,而是先缓冲,只在决定"让运维者看到"时才 flush,成功路径直接丢弃。src/cli/hook-command.ts 中的注释完整说明了这一纪律:

// Hook IO Discipline (issue #2292): // We BUFFER stderr during handler execution so that unsolicited writes from // third-party libraries don't leak into model context. The buffer is FLUSHED // only when we choose to surface (logger errors at the catch-all branch, // fail-loud counter from worker-utils, blocking-error path). Successful exits // drop the buffer — preserving the original "quiet on success" behavior. const stderrBuffer = installHookStderrBuffer();

installHookStderrBuffer的实现(src/shared/hook-io.ts)做三件事:

  1. 先把当前真实的process.stderr.write固定为"旁路通道"(bypass channel),避免 flush 时重入缓冲写者;
  2. 替换process.stderr.write为缓冲写者——所有直接写入(包括第三方库的"不请自来"的输出)都被收入内存缓冲;
  3. 返回{ flush, drop, restore }三个操作:flush把缓冲写入真实 fd,drop静默丢弃,restore还原原写者。

hookCommand的 try/catch/finally 结构与该机制严格配合(src/cli/hook-command.ts):

分支stderr 行为
成功 / 适配器拒收 / worker 不可用exitGraceful调用前 drop 缓冲,成功即静默,exit 0
未分类异常logger.error记录 +emitBlockingError:先 flush 缓冲(让前置诊断可见),再写错误消息到真实 stderr,exit 2
finallystderrBuffer.restore()还原process.stderr.write,保证进程内复用与测试隔离

这正是 hook 契约中 BLOCKING_FEEDBACK 语义的实现:只有在 exit 2(宿主 Agent 必须看到错误)时 stderr 才有意义,其余时刻一律静默。logger 文件写入失败这类"日志系统自身的故障"则通过emitDiagnostic走旁路通道直达真实 stderr(src/utils/logger.ts 中可见该调用点)。

4.2 测试如何验证 stderr 纪律

tests/hook-lifecycle.test.ts 中专门有一组 "stderr Suppression (#1181)" 用例:在测试中替换process.stderr.write为收集器,调用未知事件 handler 后断言 stderr 中没有[claude-mem] Unknown event输出。另一组 "hookCommand - stderr discipline (plan 01 / #2292)" 用例则是对源码做静态契约检查——它断言hook-command.ts不再存在粗暴的process.stderr.write = (() => true),而是包含installHookStderrBufferemitModelContextemitBlockingErrorexitGraceful,且不再出现console.error([claude-mem]` 直写。这种"用测试锁住实现方式"的做法,防止了纪律在后续重构中被悄悄破坏。

5. 修复三:对话历史污染的全面审计(#598、#784)

Triage 文档指出,#598(对话污染)与 #784(agent 输出泄漏)与 Stop hook 循环同根:都是 hook 输出泄漏进对话上下文。修复动作是对 src/cli/handlers/ 下全部 7 个处理器做审计,确立两条规则:

  1. 任何返回输出的 handler 必须设置suppressOutput: true——除非它专门负责上下文注入;
  2. 唯一的例外是 SessionStart 的 context handler,它通过hookSpecificOutput注入记忆上下文(这属于契约内、显式声明的 MODEL_CONTEXT 输出,而非泄漏)。

src/cli/handlers/context.ts 展示了这个"合法例外"的形状——它输出hookSpecificOutput(供模型消费的additionalContext)与可选的systemMessage(供人看的提示),而不是裸 stdout 文本:

return { hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext }, systemMessage };

注意context唯一产生 SessionStart 输出的 handler 键。src/cli/hook-command.ts 中的buildNoOpResult还为此做了额外加固:当适配器拒收输入或 transcript 缺失需要提前退出时,context事件的 no-op 响应会携带最小合法载荷hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: '' }——从源码结构看,这是为了通过 Codex 严格的 SessionStart 输出校验器(一个没有任何hookSpecificOutput的裸{continue: true}会被 Codex 拒绝为 "invalid session start JSON output",对应 issue #2972)。

对于--continue场景(#784),记忆 agent 的内部处理输出永不外显:摘要流程全程suppressOutput: true(见 3.2 节),加上第 4 节的 stderr 缓冲,两条泄漏通道都被封死。Triage 文档的 DONE 记录与此一致:"Audited all 7 handlers — all returnsuppressOutput: true. Adapter defaults tosuppressOutput: true. Context handler useshookSpecificOutput(correct for context injection)."

6. 完整调用链与配套契约

把三个修复串起来,一次 Stop hook 调用的完整路径如下(入口 src/cli/hook-command.ts):

  1. hookCommand(platform, event)启动:resetHookIoState()重置 emit 标志,setActiveHookType(event)注册遥测事件,installHookStderrBuffer()接管 stderr;
  2. readJsonFromStdin()从 stdin 读取 hook 输入 JSON(格式如session_idcwdtranscript_path等字段),经平台适配器normalizeInput归一化——Claude Code 适配器还支持id/sessionId回退字段以兼容 Codex CLI 的字段命名(见 src/cli/adapters/claude-code.ts 与相应测试);
  3. getEventHandler(event)分派到对应 handler(未知事件 → no-op,exit 0);
  4. handler 执行(如summarize:提取 transcript 中最后一条 assistant 消息 → 提交到 server 或 worker 队列 → 返回{ continue: true, suppressOutput: true, exitCode: 0 });
  5. emitModelContext(adapter, result)将适配后的输出经 stdout 写出一次(重复调用会抛错,防止双写破坏 stdout JSON 流),随后exitGraceful丢弃 stderr 缓冲并以退出码 0 结束;
  6. 任何异常路径经 catch 分支:worker 不可用走静默降级(exit 0,配套失败计数遥测),其他错误 flush stderr 缓冲并以 exit 2 上报。

支撑这一链路的常量定义在 src/shared/hook-constants.ts:

export const HOOK_EXIT_CODES = { SUCCESS: 0, BLOCKING_ERROR: 2, } as const;

超时参数(如API_REQUEST: 30000POST_SPAWN_WAIT: 15000)也集中在该文件,并在 Windows 下乘以 1.5 的系数——hook 必须快进快出,超时预算是 hook 契约的一部分。

7. 验证与回归:测试基线

Triage 文档记录了验证结果:新增 10 个测试于 tests/hook-lifecycle.test.ts,全部通过;全量 hook 相关测试 52 个通过,完整套件 954 通过 / 21 失败(均为既有基线失败,与本次修复无关)。该测试文件覆盖的关键契约值得作为回归清单复用:

  • 事件分派:7 种已识别事件都有 handler;未知事件返回 no-op 且exitCode === 0(#984);
  • stdout 契约:Claude Code 适配器的formatOutput只输出白名单键,剥离continue/suppressOutput/exitCode等内部字段;
  • stderr 纪律:未知事件处理不向 stderr 泄漏[claude-mem] Unknown eventhookCommand源码包含installHookStderrBuffer而非旧的 no-op 吞写(#1181、#2292);
  • 标准响应STANDARD_HOOK_RESPONSE解析后必须含continue: truesuppressOutput: true
  • 多平台兼容:Codex 适配器对stop_hook_active字符串/布尔值的归一化、suppressOutput从基础输出中省略(Codex 契约不认这个字段)、Stop 输出中丢弃hookSpecificOutput等——说明"输出抑制"策略需要按平台适配层差异化落地。

8. 可复用的工程结论

从 TRIAGE-04 这次修复可以提炼出处理"hook 输出污染"类问题的通用方法论:

  1. 把输出语义显式化:hook 响应中用suppressOutput: true声明"无输出",优于依赖"恰好没打印东西";
  2. 在单一入口统一 IO 纪律:所有 stdout/stderr/exit 的决策收敛到一个模块(这里是src/shared/hook-io.ts),handler 保持"纯净"——只返回结果对象,不直接写流;
  3. stderr 缓冲而非一刀切:缓冲 + 选择性 flush 保留了诊断能力(阻塞错误路径仍能让运维者看到前置日志),同时保证成功路径"静默即正确";
  4. 未知输入一律 no-op + exit 0:hook 是对宿主 Agent 的旁路增强,任何不认识的输入都不应以非零退出或 stderr 噪音惩罚宿主;
  5. 用静态契约测试锁住纪律:对关键源码做"必须包含/不得包含"的断言(如禁止process.stderr.write = (() => true)回归、禁止console.error直写),防止 IO 纪律在重构中退化。

这些结论同样适用于任何在 Claude Code(或契约相似的 Codex、Cursor 等)hook 体系上开发的插件:hook 的每一字节输出都有契约含义,输出隔离不是功能,而是正确性。

【免费下载链接】claude-memPersistent Context Across Sessions for Every Agent – Captures everything your agent does during sessions, compresses it with AI, and injects relevant context back into future sessions. Works with Claude Code, OpenClaw, Codex, Gemini, Hermes, Copilot, OpenCode + More项目地址: https://gitcode.com/GitHub_Trending/cl/claude-mem

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询