claude-mem 会话生命周期修复实战:Stop Hook 端口冲突、Windows 路径损坏与上下文压缩崩溃
本文基于 claude-mem 仓库中的修复 Playbook TRIAGE-03-Stop-Hook-Session-Lifecycle,深入讲解 Stop Hook(会话结束钩子)三类高影响缺陷的根因与修复方案:多会话并发启动时的端口冲突、Windows 下 Hook 命令反斜杠路径导致的 MODULE_NOT_FOUND,以及上下文压缩(compaction)后转录文件消失引发的 Stop Hook 崩溃。读完本文,你将掌握 claude-mem 钩子链路的幂等启动、跨平台路径安全与优雅降级(graceful degradation)设计,并能对照仓库源码验证这些机制的实际实现。
背景:为什么 Stop Hook 是最高危的故障点
claude-mem 的核心机制是在 Agent 会话的关键节点挂载 Hook:会话开始时拉起 worker 服务并注入上下文,工具调用后捕获 observation,会话结束时(Stop Hook)请求生成会话摘要。这些 Hook 定义在 plugin/hooks/hooks.json 中,其中 Stop 事件执行 hook claude-code summarize 命令(timeout 120 秒,异步执行):
"Stop": [
{
"hooks": [
{
"type": "command",
"shell": "bash",
"command": "... node \"$_P/scripts/bun-runner.js\" \"$_P/scripts/worker-service.cjs\" hook claude-code summarize",
"timeout": 120,
"async": true
}
]
}
]
Playbook 开篇将 Stop Hook 故障定性为"第二高影响的缺陷类别"(second most impactful bug class),原因有三:
- 错误直接可见于用户:Stop Hook 运行在会话边界,正是用户注意力集中的时刻,任何报错都会形成"错误循环"体验;
- 阻断摘要生成:Stop Hook 是触发
summarize的唯一入口,一旦失败,会话记忆压缩与持久化整条链路断裂; - Windows 上可能整体崩溃:路径分隔符问题会让钩子脚本连加载都做不到。
该 Playbook 针对三个 Issue:#1346(端口冲突)、#1281(Windows MODULE_NOT_FOUND)、#1274(压缩后转录缺失崩溃),并以 Phase 01 的合并(PR #1330、#1291、#1326)为前置条件。下面逐一展开。
修复一:并发会话的端口冲突——worker 启动幂等化(Issue #1346)
问题现象
当两个 Claude Code 会话几乎同时启动时,第二个会话的 worker 启动尝试会失败,报出 port 37777 in use 错误。从源码结构看,worker 默认绑定固定端口(37777),两个 SessionStart Hook 并发触发时,后启动的一方尝试 bind 已被占用的端口,从而把"健康 worker 已存在"这一正常状态误判为错误并向用户暴露。
修复策略:先探测、再决定,绝不向健康 worker 报障
Playbook 给出的修复准则是"让 worker 启动幂等",核心决策树为:
- 在尝试 spawn 新 worker 之前,调用
isPortInUse()探测端口; - 若端口被占用 且 健康检查通过(
/api/health返回 200)——静默退出,退出码 0,因为 worker 本来就在运行; - 若端口被占用但健康检查失败——才进入清理与重启流程。
关键行为准则一句话:只要存在健康的 worker,第二个会话永远不应看到错误。
源码印证
isPortInUse() 的实现位于 src/services/infrastructure/HealthMonitor.ts,其设计本身就体现了上述决策树的双层探测思想:
export async function isPortInUse(port: number): Promise<boolean> {
if (process.platform === 'win32') {
// Fast path: HTTP health check. A live claude-mem worker responds to
// /api/health, so this is the cheapest non-disruptive probe for the
// common case (worker is running and healthy).
try {
const response = await fetch(`http://${formatHostForUrl(getWorkerHost())}:${port}/api/health`);
if (response.ok) return true;
// Non-ok response: port is reachable but the worker is unhealthy.
// Fall through to the net.createServer check below ...
} catch (error) {
// fetch threw (ECONNREFUSED, timeout, etc.): the port may still be in
// use by a non-HTTP process (zombie worker, foreign service, etc.).
// Fall through to the net.createServer probe ...
}
}
return new Promise((resolve) => {
const server = net.createServer();
server.once('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') resolve(true);
else resolve(false);
});
server.once('listening', () => server.close(() => resolve(false)));
server.listen(port, workerHost);
});
}
两个值得注意的细节:
- Windows 快速路径:在 win32 上先做
/api/healthHTTP 探测。因为 Windows 上 socket 状态查询手段受限,一个能应答 health 端点的进程几乎可以断定就是存活的 worker;HTTP 探测不确定时(超时、非 200),再退化到net.createServer的 bind 探测作为权威判定——只有"尝试绑定失败(EADDRINUSE)"才能确证端口被占用。 - host 格式化:
formatHostForUrl()会把CLAUDE_MEM_WORKER_HOST中的 IPv6 字面量(如::1)括起来生成合法的http://[::1]:portURL,避免 URL 构造错误造成误判。
同一文件还配套导出了 waitForHealth() / waitForReadiness()(轮询 /api/health、/api/readiness,默认 30 秒超时,500ms 间隔),供启动后确认服务就绪。
worker 侧对进程内 EADDRINUSE 的兜底在 src/services/worker-service.ts:
worker.start().catch(async (error) => {
const isPortConflict = error instanceof Error && (
(error as NodeJS.ErrnoException).code === 'EADDRINUSE' ||
/port.*in use|address.*in use/i.test(error.message)
);
if (isPortConflict && await waitForHealth(port, 3000)) {
logger.info('SYSTEM', 'Duplicate daemon exiting — another worker already claimed port', { port });
process.exit(0);
}
// ... removePidFileIfOwner(process.pid); process.exit(1);
});
这里实现了 Playbook 中"修复剩余边界情况"的描述:当进程内 bind 直接撞上 EADDRINUSE 时,先 waitForHealth(port, 3000) 确认占用者是一个健康的 worker,是则退出码 0 静默退出(转而通过 HTTP 复用已有 worker),而不是退出 1 把失败暴露给调用方。PID 文件清理也采用 owner-or-dead 守卫(removePidFileIfOwner)——输掉端口竞争的一方绝不清掉赢家的 PID 文件,防止把健康 worker 的元数据误删。
Playbook 记录的最终状态:Phase 01 的 PR #1341 已带入多层端口保护(PID 检查、端口检查、daemon 守卫),Phase 03 补齐了上述进程内 EADDRINUSE 回退路径,并把"端口占用"日志从 ERROR 降级为 INFO(这是正常去重而非错误),新增 12 个端口冲突测试,全部 1144 个测试通过。
修复二:Windows Stop Hook MODULE_NOT_FOUND——路径分隔符双保险(Issue #1281)
问题现象
在 Windows 上,Hook 命令中的脚本路径若包含反斜杠(Windows 原生分隔符),命令解析时会发生"反斜杠路径损坏"(backslash path corruption),导致 stop hook 脚本以 MODULE_NOT_FOUND 失败。根因在于:hook command 是一段 shell 文本,路径在其中被二次插值;Unix 风格的 shell 转义规则会把 \s、\p 之类的序列吞掉或转义掉,路径就此变形。
修复方案:两层防御
Playbook 记录的修复分两层:
第一层:hooks.json shell 前言(preamble)规范化。 在插值路径进命令之前,先用 POSIX 安全的 printf | tr 管道把 CLAUDE_PLUGIN_ROOT 中的反斜杠统一转换成正斜杠。这一层保证无论环境变量来自哪个平台、携带哪种分隔符,进入后续路径拼接的都是正斜杠形式。
第二层:执行器从 Unix-only 脚本切换到跨平台 Node 包装器。 hooks.json 的命令从原先的 bun-exec-runner.sh(纯 Unix shell 脚本,Windows 无对应 shell 时直接不可用)切换为 node bun-runner.js。跨平台路径规范化逻辑内置在 plugin/scripts/bun-runner.js 第 111-117 行附近——由 Node.js 而非 shell 负责解析与执行脚本路径,从根本上绕开 shell 转义差异。
验证手段
修复的验收标准是:hooks.json 的输出在所有平台上只含正斜杠路径。当前仓库中 plugin/hooks/hooks.json 的 8 个 hook command 全部形如 node "$_P/scripts/bun-runner.js" ...,配合命令前段的 cygpath -w 调用(Git Bash 环境下把正斜杠路径转回 Windows 形式,仅在 command -v cygpath 存在时执行)形成闭环:shell 文本层面恒用正斜杠,真正执行时由 cygpath/Node 做平台适配。Playbook 记录该修复后 25 个 plugin-distribution 测试与全部 90 个 hook 相关测试通过。
修复三:上下文压缩后 Stop Hook 崩溃——转录缺失优雅降级(Issue #1274)
问题现象
Claude Code 在会话中途压缩上下文(context compaction)时,转录(transcript)文件会移动或消失。此时 Stop Hook 仍会触发,处理代码尝试读取旧路径的转录文件,抛出 "Transcript path missing" 错误,并沿 transcript-parser.ts → hook-command.ts 一路传播,最终以退出码 2(BLOCKING_ERROR)结束——Stop Hook 崩溃,用户会话结束时看到报错。
修复方案:永不因缺失转录而抛错
Playbook 的修复准则:
- 读取转录前加
existsSync(transcriptPath)守卫; - 文件缺失时记录警告并按降级模式继续——用最后一条已知的 assistant 消息代替完整转录生成摘要;
- 同时检查
CLAUDE_CONVERSATION环境变量回退路径——压缩可能改写该变量,回退逻辑应同时尝试原始路径与压缩后路径; - 硬性约束:handler 不得在转录缺失时 throw,必须优雅降级。
源码印证:summarize handler 的降级实现
当前 src/cli/handlers/summarize.ts 中的转录提取正是按"绝不外抛"的原则实现的:
try {
// One read of the transcript yields both the text and the model.
const turn = extractLastAssistantTurn(transcriptPath, true);
lastAssistantMessage = stripMemoryTags(turn.text);
observedModel = turn.model;
} catch (err) {
logger.warn('HOOK', `Stop hook: failed to extract last assistant message for session ${sessionId}: ...`);
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
}
转录文件缺失、为空或不可读时,extractLastAssistantTurn 的异常被就地捕获,记录 warning 后以退出码 0 返回 { continue: true }——会话照常结束,不再向用户抛出 BLOCKING_ERROR。这与 Playbook 描述的演进路径一致:修复前 transcript-parser.ts 的错误会传播到 src/cli/hook-command.ts 的兜底 catch 分支,触发 emitBlockingError 并以 HOOK_EXIT_CODES.BLOCKING_ERROR(2)退出;修复后该路径被降级为"跳过摘要"而非"崩溃"。
值得注意的是,当前实现的错误路由还有一整套更细粒度的分类,见 src/cli/hook-command.ts:
isNonBlockingHookInputError()(L77-L83):消息含 "transcript path" 且 "missing/does not exist" 时,视为非阻断输入错误——记 warn、发 no-op 结果、退出码 0;isWorkerUnavailableError()(L43-L75):ECONNREFUSED、fetch failed、5xx/429 等传输层错误——worker 不可用属于瞬态,退出码 0,交给recordWorkerUnreachable()的失败计数达到阈值后才对外发声;- 只有上述都不匹配的真实编程错误,才走
emitBlockingError退出码 2 的路径。
这套分类保证了 Stop Hook 的退出码语义清晰:只有真正需要阻断的钩子错误才会以 2 退出并把信息回灌给模型,环境性、瞬态性、输入缺失类问题一律静默放行。
修复后的摘要请求流程
即便降级,会话清理仍应发生。summarize handler 提取(或获得降级后的)lastAssistantMessage 后,通过 executeWithWorkerFallback('/api/sessions/summarize', 'POST', ...) 把摘要请求投递给 worker(见 src/cli/handlers/summarize.ts);若运行在 server 运行时,则走 summarizeViaServer,通过 startSession → recordEvent → endSession 三步把最后一条 assistant 消息落入生成流水线。也就是说,"转录缺失"只影响摘要的素材完整度,不会中断 worker 接收摘要请求这条主链。
验证与回归:Phase 03 的验收基线
Playbook 将验证固化为四步 checklist,这也是对同类钩子问题回归测试的可复用模板:
- 全量测试通过:
npm test——Phase 03 完成时 1149 个测试 0 失败(3 skipped),其中针对本阶段新增了:- 12 个端口冲突测试(并发启动、EADDRINUSE 回退、日志级别);
- 5 个转录缺失测试,覆盖:文件缺失、空文件、合法转录、无路径、以及警告日志断言;
- 构建与分发:
npm run build-and-sync——worker service、MCP server、context generator 构建并同步到 marketplace; - hooks.json 路径格式检查:读取 plugin/hooks/hooks.json,断言所有
command值使用node "$_P/scripts/bun-runner.js"前缀且全为正斜杠,任何 command value 中不得出现反斜杠; - 关键行为断言:健康 worker 存在时,第二个会话启动路径必须退出码 0 且无用户可见错误。
对应仓库中可继续深入验证的测试包括 tests/infrastructure/health-monitor.test.ts、tests/infrastructure/plugin-distribution.test.ts 与 tests/hook-command.test.ts。
小结:会话边界健壮性的三条设计准则
从 TRIAGE-03 的修复中可以提炼出 claude-mem 钩子体系处理的三条通用准则,对任何"宿主 IDE/CLI + 后台服务 + 生命周期钩子"的架构都适用:
- 启动幂等先于启动尝试:任何端口绑定前做"占用 + 健康"双层探测,占用且健康即静默复用,杜绝把正常去重当错误报给用户(HealthMonitor.ts 的双层探测、worker-service.ts 的 EADDRINUSE 回退);
- shell 文本中的路径必须平台无关:跨平台钩子命令在 shell 层恒用正斜杠 + 前置
tr规范化,执行层交给 Node/cygpath 适配,避免 shell 转义吞噬反斜杠(hooks.json、bun-runner.js); - 钩子 handler 是纯函数,错误分级路由:handler 只返回结果不直接写 IO/退出(见 summarize.ts 头部注释的 IO discipline),由 hook-command.ts 统一把"输入缺失 / worker 瞬态不可用 / 真实错误"分流到退出码 0 / 0 / 2,保证会话边界永不因环境异常向用户报错。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00