181 lines
6.9 KiB
JavaScript
181 lines
6.9 KiB
JavaScript
|
|
#!/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);
|