claude-mem Hook 生命周期修复实战:Stop Hook 死循环、suppressOutput 与 stderr 隔离 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-memClaude 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 错误 UIclaude-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、#975Stop hook 生成摘要流程的输出会被 Claude Code 解释为新的指令宿主 Agent 消费后再次触发 Stop形成反馈循环stderr 被当作错误 UI 展示#1181hook 内部的诊断性日志logger、第三方库写入 stderrClaude 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 即错误 UIClaude Code 会把 hook 进程的 stderr 内容直接呈现给用户。hook 代码中的 logger 或第三方库为了诊断目的写 stderr用户看到的却是报错。修复方向是在 hook 上下文中抑制或缓冲stderr让普通诊断日志对用户不可见。3. 修复一输出抑制与 no-op 事件分发3.1 标准 hook 响应常量化的 suppressOutputclaude-mem 将无输出 hook 响应收敛为一个常量见 src/hooks/hook-response.tsexport 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 处理器每条路径都返回 suppressOutputStop 事件在 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 而非报错#984Triage 文档中提到的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-complete、nonexistent-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 契约允许的两个键hookSpecificOutput与systemMessage其余字段一律剥离formatOutput(result) { const r result ?? ({} as HookResult); if (r.hookSpecificOutput) { const output: Recordstring, unknown { hookSpecificOutput: result.hookSpecificOutput }; if (r.systemMessage) output.systemMessage r.systemMessage; return output; } // 无 hookSpecificOutput 时仅可能有 systemMessage否则输出 {} const output: Recordstring, unknown {}; if (r.systemMessage) output.systemMessage r.systemMessage; return output; }测试用例 should only emit keys from the Claude Code hook contracttests/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.ts与handlers/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 dont 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做三件事先把当前真实的process.stderr.write固定为旁路通道bypass channel避免 flush 时重入缓冲写者替换process.stderr.write为缓冲写者——所有直接写入包括第三方库的不请自来的输出都被收入内存缓冲返回{ flush, drop, restore }三个操作flush把缓冲写入真实 fddrop静默丢弃restore还原原写者。hookCommand的 try/catch/finally 结构与该机制严格配合src/cli/hook-command.ts分支stderr 行为成功 / 适配器拒收 / worker 不可用exitGraceful调用前 drop 缓冲成功即静默exit 0未分类异常logger.error记录 emitBlockingError先 flush 缓冲让前置诊断可见再写错误消息到真实 stderrexit 2finallystderrBuffer.restore()还原process.stderr.write保证进程内复用与测试隔离这正是 hook 契约中 BLOCKING_FEEDBACK 语义的实现只有在 exit 2宿主 Agent 必须看到错误时 stderr 才有意义其余时刻一律静默。logger 文件写入失败这类日志系统自身的故障则通过emitDiagnostic走旁路通道直达真实 stderrsrc/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)而是包含installHookStderrBuffer、emitModelContext、emitBlockingError、exitGraceful且不再出现console.error([claude-mem] 直写。这种用测试锁住实现方式的做法防止了纪律在后续重构中被悄悄破坏。5. 修复三对话历史污染的全面审计#598、#784Triage 文档指出#598对话污染与 #784agent 输出泄漏与 Stop hook 循环同根都是 hook 输出泄漏进对话上下文。修复动作是对 src/cli/handlers/ 下全部 7 个处理器做审计确立两条规则任何返回输出的 handler 必须设置suppressOutput: true——除非它专门负责上下文注入唯一的例外是 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.tshookCommand(platform, event)启动resetHookIoState()重置 emit 标志setActiveHookType(event)注册遥测事件installHookStderrBuffer()接管 stderrreadJsonFromStdin()从 stdin 读取 hook 输入 JSON格式如session_id、cwd、transcript_path等字段经平台适配器normalizeInput归一化——Claude Code 适配器还支持id/sessionId回退字段以兼容 Codex CLI 的字段命名见 src/cli/adapters/claude-code.ts 与相应测试getEventHandler(event)分派到对应 handler未知事件 → no-opexit 0handler 执行如summarize提取 transcript 中最后一条 assistant 消息 → 提交到 server 或 worker 队列 → 返回{ continue: true, suppressOutput: true, exitCode: 0 }emitModelContext(adapter, result)将适配后的输出经 stdout 写出一次重复调用会抛错防止双写破坏 stdout JSON 流随后exitGraceful丢弃 stderr 缓冲并以退出码 0 结束任何异常路径经 catch 分支worker 不可用走静默降级exit 0配套失败计数遥测其他错误 flush stderr 缓冲并以 exit 2 上报。支撑这一链路的常量定义在 src/shared/hook-constants.tsexport const HOOK_EXIT_CODES { SUCCESS: 0, BLOCKING_ERROR: 2, } as const;超时参数如API_REQUEST: 30000、POST_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#984stdout 契约Claude Code 适配器的formatOutput只输出白名单键剥离continue/suppressOutput/exitCode等内部字段stderr 纪律未知事件处理不向 stderr 泄漏[claude-mem] Unknown eventhookCommand源码包含installHookStderrBuffer而非旧的 no-op 吞写#1181、#2292标准响应STANDARD_HOOK_RESPONSE解析后必须含continue: true与suppressOutput: true多平台兼容Codex 适配器对stop_hook_active字符串/布尔值的归一化、suppressOutput从基础输出中省略Codex 契约不认这个字段、Stop 输出中丢弃hookSpecificOutput等——说明输出抑制策略需要按平台适配层差异化落地。8. 可复用的工程结论从 TRIAGE-04 这次修复可以提炼出处理hook 输出污染类问题的通用方法论把输出语义显式化hook 响应中用suppressOutput: true声明无输出优于依赖恰好没打印东西在单一入口统一 IO 纪律所有 stdout/stderr/exit 的决策收敛到一个模块这里是src/shared/hook-io.tshandler 保持纯净——只返回结果对象不直接写流stderr 缓冲而非一刀切缓冲 选择性 flush 保留了诊断能力阻塞错误路径仍能让运维者看到前置日志同时保证成功路径静默即正确未知输入一律 no-op exit 0hook 是对宿主 Agent 的旁路增强任何不认识的输入都不应以非零退出或 stderr 噪音惩罚宿主用静态契约测试锁住纪律对关键源码做必须包含/不得包含的断言如禁止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),仅供参考