bookworm-smart-assistant/scripts/patches/patch-x03-heartbeat-session-isolation.js
Bookworm Admin b7a8e29d21 release: v6.7.0 - OTA E2E test release
- VERSION file as authoritative version source
- export.mjs reads VERSION with package.json fallback
- bw-ota.ps1 DryRun mode for safe testing
- auto-setup.ps1 bumped to v3.2.0 (Phase 8 OTA)
2026-04-27 17:59:44 +08:00

181 lines
6.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
// patch-x03-heartbeat-session-isolation.js
// P1: session-heartbeat.js 状态无 session_id 隔离, 多窗口互相干扰
// 修复: 改为 { [session_id]: {count,lastActivity,notified} } keyed 结构
// 连带: pre-compact-handoff.js heartbeat 重置改为只重置自身 session
'use strict';
const fs = require('fs');
const path = require('path');
const SENTINEL = '// [PATCH-X03-SESSION-ISOLATION]';
const heartbeatFile = path.join(__dirname, '..', '..', 'hooks', 'session-heartbeat.js');
const handoffFile = path.join(__dirname, '..', '..', 'hooks', 'pre-compact-handoff.js');
// ======================== Part 1: session-heartbeat.js ========================
if (!fs.existsSync(heartbeatFile)) {
process.stdout.write('[SKIP] session-heartbeat.js not found\n');
process.exit(0);
}
let hbContent = fs.readFileSync(heartbeatFile, 'utf8');
if (hbContent.includes(SENTINEL)) {
process.stdout.write('[SKIP] patch-x03 already applied to heartbeat\n');
} else {
const hbBak = heartbeatFile + '.bak.x03';
if (!fs.existsSync(hbBak)) fs.writeFileSync(hbBak, hbContent);
const NEW_HEARTBEAT = `#!/usr/bin/env node
${SENTINEL}
/**
* PostToolUse Hook: 会话心跳检测器 (session-isolated)
* Matcher: Edit|Write|Skill|Agent|Bash|mcp__.*
*
* 按 session_id 隔离计数, 多窗口不互相干扰.
* 阈值策略同原版: 20(INFO) / 30(WARNING) / 40(CRITICAL) / 50+ 每10次强提醒
* 退出码: 始终 0 (纯通知, 不阻断工作流)
*/
'use strict';
const fs = require('fs');
const path = require('path');
const readStdin = require('./lib/read-stdin.js');
const CLAUDE_ROOT = require('./lib/root.js');
const STATE_FILE = path.join(CLAUDE_ROOT, 'debug', 'session-heartbeat.json');
const SESSION_TIMEOUT_MS = 30 * 60 * 1000;
const THRESHOLDS = [
{ count: 20, level: 'INFO', msg: '当前会话已执行 {n} 次工具调用。如对话较长,可考虑 /clear 释放上下文。' },
{ count: 30, level: 'WARNING', msg: '当前会话已执行 {n} 次工具调用,上下文可能接近饱和。建议在合适时机 /clear 重置上下文窗口。' },
{ count: 40, level: 'CRITICAL', msg: '当前会话已执行 {n} 次工具调用,上下文压力较大。强烈建议用户 /clear 重置。如有重要任务进行中,可委托 Agent 子进程隔离执行。' },
];
(async () => {
try {
let hookData = {};
try { hookData = await readStdin(); } catch {}
const sid = hookData.session_id || 'default';
const debugDir = path.join(CLAUDE_ROOT, 'debug');
if (!fs.existsSync(debugDir)) fs.mkdirSync(debugDir, { recursive: true });
let allState = {};
try {
if (fs.existsSync(STATE_FILE)) {
allState = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
}
} catch { allState = {}; }
// 清理 2 小时无活动的其他会话 (防文件膨胀)
const now = Date.now();
const GC_MS = 2 * 3600 * 1000;
for (const k of Object.keys(allState)) {
if (k !== sid && now - (allState[k]?.lastActivity || 0) > GC_MS) delete allState[k];
}
let state = allState[sid] || { count: 0, lastActivity: now, notified: [] };
// 会话超时: 30 分钟无活动 → 重置该会话
if (now - (state.lastActivity || 0) > SESSION_TIMEOUT_MS) {
state = { count: 0, lastActivity: now, notified: [] };
}
state.count += 1;
state.lastActivity = now;
let notification = null;
for (const t of THRESHOLDS) {
if (state.count === t.count && !state.notified.includes(t.count)) {
notification = { level: t.level, msg: t.msg.replace('{n}', state.count) };
state.notified.push(t.count);
break;
}
}
if (!notification && state.count >= 50 && state.count % 10 === 0 && !state.notified.includes(state.count)) {
notification = {
level: 'CRITICAL',
msg: \`当前会话已执行 \${state.count} 次工具调用,上下文严重饱和。请立即建议用户 /clear 或将剩余任务委托给 Agent 子进程。\`
};
state.notified.push(state.count);
}
allState[sid] = state;
fs.writeFileSync(STATE_FILE, JSON.stringify(allState), 'utf8');
if (notification) {
console.log(JSON.stringify({
continue: true,
suppressOutput: false,
systemMessage: \`[session-heartbeat \${notification.level}] \${notification.msg}\`
}));
}
} catch {
// Fail-open
}
process.exit(0);
})();
`;
fs.writeFileSync(heartbeatFile, NEW_HEARTBEAT, 'utf8');
process.stdout.write('[DONE] patch-x03 part1: heartbeat session isolation applied\n');
}
// ======================== Part 2: pre-compact-handoff.js ========================
if (!fs.existsSync(handoffFile)) {
process.stdout.write('[SKIP] pre-compact-handoff.js not found\n');
process.exit(0);
}
let hoContentRaw = fs.readFileSync(handoffFile, 'utf8');
const useCRLF = hoContentRaw.includes('\r\n');
let hoContent = useCRLF ? hoContentRaw.replace(/\r\n/g, '\n') : hoContentRaw;
const HO_SENTINEL = '// [PATCH-X03-HANDOFF-RESET]';
if (hoContent.includes(HO_SENTINEL)) {
process.stdout.write('[SKIP] patch-x03 already applied to handoff\n');
} else {
const hoBak = handoffFile + '.bak.x03';
if (!fs.existsSync(hoBak)) fs.writeFileSync(hoBak, hoContentRaw);
const OLD_RESET = ` // 同时重置 heartbeat 计数器compact 相当于新会话起点)
const heartbeatFile = path.join(CLAUDE_ROOT, 'debug', 'session-heartbeat.json');
if (fs.existsSync(heartbeatFile)) {
fs.writeFileSync(heartbeatFile, JSON.stringify({
count: 0, lastActivity: Date.now(), notified: []
}), 'utf8');
}`;
const NEW_RESET = ` // 重置当前 session 的 heartbeat 计数器 (compact = 新起点) ${HO_SENTINEL}
const heartbeatPath = path.join(CLAUDE_ROOT, 'debug', 'session-heartbeat.json');
if (fs.existsSync(heartbeatPath)) {
try {
const hbAll = JSON.parse(fs.readFileSync(heartbeatPath, 'utf8'));
const hbSid = hookData.session_id || 'default';
if (hbAll[hbSid]) {
hbAll[hbSid] = { count: 0, lastActivity: Date.now(), notified: [] };
fs.writeFileSync(heartbeatPath, JSON.stringify(hbAll), 'utf8');
}
} catch { /* 损坏则跳过, heartbeat 超时会自然重置 */ }
}`;
if (!hoContent.includes(OLD_RESET)) {
process.stdout.write('[WARN] handoff old reset block not found, skipping part2\n');
} else {
hoContent = hoContent.replace(OLD_RESET, NEW_RESET);
const finalContent = useCRLF ? hoContent.replace(/\n/g, '\r\n') : hoContent;
fs.writeFileSync(handoffFile, finalContent, 'utf8');
process.stdout.write('[DONE] patch-x03 part2: handoff session-scoped reset applied\n');
}
}
// 验证
const v1 = fs.readFileSync(heartbeatFile, 'utf8');
const v2 = fs.readFileSync(handoffFile, 'utf8');
const ok1 = v1.includes(SENTINEL) && v1.includes('allState[sid]');
const ok2 = v2.includes(HO_SENTINEL) && v2.includes('hbAll[hbSid]');
process.stdout.write(ok1 && ok2 ? '[VERIFY] both files patched correctly\n' : '[WARN] partial verification — check manually\n');
process.exit(0);