2026-04-21 17:57:05 +08:00
|
|
|
|
#!/usr/bin/env node
|
|
|
|
|
|
/**
|
|
|
|
|
|
* UserPromptSubmit Hook - 会话启动恢复器
|
|
|
|
|
|
* 1. 检测 handoff.json 存在时注入恢复上下文并归档
|
|
|
|
|
|
* 2. 每次会话首次 prompt 时重置 heartbeat 计数器(解决 /clear 后计数不归零问题)
|
|
|
|
|
|
*/
|
|
|
|
|
|
const fs = require('fs');
|
|
|
|
|
|
const path = require('path');
|
|
|
|
|
|
|
|
|
|
|
|
const CLAUDE_ROOT = require('./lib/root.js');
|
|
|
|
|
|
const readStdin = require('./lib/read-stdin.js');
|
|
|
|
|
|
|
|
|
|
|
|
const SESSION_STATE_DIR = path.join(CLAUDE_ROOT, 'session-state');
|
|
|
|
|
|
const HANDOFF_PATH = path.join(SESSION_STATE_DIR, 'handoff.json');
|
|
|
|
|
|
const RESTORE_MARKER = path.join(SESSION_STATE_DIR, '.session-restored');
|
|
|
|
|
|
const HEARTBEAT_FILE = path.join(CLAUDE_ROOT, 'debug', 'session-heartbeat.json');
|
|
|
|
|
|
|
|
|
|
|
|
(async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
let hookData = {};
|
|
|
|
|
|
try { hookData = await readStdin(); } catch (_) {}
|
|
|
|
|
|
|
|
|
|
|
|
const sessionId = hookData.session_id || '';
|
|
|
|
|
|
let messages = [];
|
|
|
|
|
|
|
|
|
|
|
|
// === 1. 会话 ID 变化检测 → 重置 heartbeat ===
|
|
|
|
|
|
let lastSessionId = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (fs.existsSync(RESTORE_MARKER)) {
|
|
|
|
|
|
lastSessionId = fs.readFileSync(RESTORE_MARKER, 'utf8').trim();
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
|
|
|
|
|
|
|
if (sessionId && sessionId !== lastSessionId) {
|
2026-04-27 17:59:44 +08:00
|
|
|
|
// 新会话: 仅重置当前 session 的 heartbeat (X03 keyed 结构) // [PATCH-X05-KEYED-RESET]
|
2026-04-21 17:57:05 +08:00
|
|
|
|
if (fs.existsSync(HEARTBEAT_FILE)) {
|
2026-04-27 17:59:44 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const hbAll = JSON.parse(fs.readFileSync(HEARTBEAT_FILE, 'utf8'));
|
|
|
|
|
|
hbAll[sessionId] = { count: 0, lastActivity: Date.now(), notified: [] };
|
|
|
|
|
|
const tmp = HEARTBEAT_FILE + '.tmp.' + process.pid;
|
|
|
|
|
|
fs.writeFileSync(tmp, JSON.stringify(hbAll), 'utf8');
|
|
|
|
|
|
fs.renameSync(tmp, HEARTBEAT_FILE);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// 损坏: 初始化为新 keyed 结构
|
|
|
|
|
|
const init = {}; init[sessionId] = { count: 0, lastActivity: Date.now(), notified: [] };
|
|
|
|
|
|
fs.writeFileSync(HEARTBEAT_FILE, JSON.stringify(init), 'utf8');
|
|
|
|
|
|
}
|
2026-04-21 17:57:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
// 记录当前 session_id
|
|
|
|
|
|
if (!fs.existsSync(SESSION_STATE_DIR)) {
|
|
|
|
|
|
fs.mkdirSync(SESSION_STATE_DIR, { recursive: true });
|
|
|
|
|
|
}
|
|
|
|
|
|
fs.writeFileSync(RESTORE_MARKER, sessionId, 'utf8');
|
|
|
|
|
|
|
|
|
|
|
|
// === 1b. Auto Git Pull(新会话拉取最新代码)===
|
|
|
|
|
|
try {
|
|
|
|
|
|
const sync = require('../scripts/auto-git-sync.js');
|
|
|
|
|
|
const pullMsgs = sync.pullLatest();
|
|
|
|
|
|
if (pullMsgs && pullMsgs.length > 0) {
|
|
|
|
|
|
messages.push(pullMsgs.join('\n'));
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (_) {} // fail-open
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// === 2. Handoff 恢复 ===
|
|
|
|
|
|
if (fs.existsSync(HANDOFF_PATH)) {
|
|
|
|
|
|
let handoff = {};
|
|
|
|
|
|
try {
|
|
|
|
|
|
handoff = JSON.parse(fs.readFileSync(HANDOFF_PATH, 'utf8'));
|
|
|
|
|
|
} catch (_) {
|
|
|
|
|
|
fs.unlinkSync(HANDOFF_PATH);
|
|
|
|
|
|
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
|
|
|
|
|
return process.exit(0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 检查是否过期(24 小时)
|
|
|
|
|
|
const age = Date.now() - new Date(handoff.timestamp || 0).getTime();
|
|
|
|
|
|
if (age > 24 * 60 * 60 * 1000) {
|
|
|
|
|
|
const archiveName = `handoff-expired-${Date.now()}.json`;
|
|
|
|
|
|
fs.renameSync(HANDOFF_PATH, path.join(SESSION_STATE_DIR, archiveName));
|
|
|
|
|
|
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
|
|
|
|
|
return process.exit(0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 归档并注入恢复上下文
|
|
|
|
|
|
const archiveName = `handoff-${Date.now()}.json`;
|
|
|
|
|
|
fs.renameSync(HANDOFF_PATH, path.join(SESSION_STATE_DIR, archiveName));
|
|
|
|
|
|
|
2026-04-27 17:59:44 +08:00
|
|
|
|
// [PATCH-X10-ARCHIVE-CLEANUP]
|
|
|
|
|
|
try {
|
|
|
|
|
|
const archives = fs.readdirSync(SESSION_STATE_DIR)
|
|
|
|
|
|
.filter(f => /^handoff-(\d+|expired-\d+)\.json$/.test(f))
|
|
|
|
|
|
.sort();
|
|
|
|
|
|
if (archives.length > 20) {
|
|
|
|
|
|
for (const old of archives.slice(0, archives.length - 20)) {
|
|
|
|
|
|
try { fs.unlinkSync(path.join(SESSION_STATE_DIR, old)); } catch {}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
|
2026-04-21 17:57:05 +08:00
|
|
|
|
messages.push([
|
|
|
|
|
|
'[SESSION_RESTORE] 检测到上次会话的 handoff 记录:',
|
|
|
|
|
|
`- 时间: ${handoff.timestamp}`,
|
|
|
|
|
|
`- 工作目录: ${handoff.working_directory || 'unknown'}`,
|
|
|
|
|
|
`- 工具调用数: ${handoff.tool_call_count || 'unknown'}`,
|
|
|
|
|
|
`- 摘要: ${handoff.conversation_summary || '无'}`,
|
|
|
|
|
|
'',
|
|
|
|
|
|
'请检查 memory 和 task 列表以恢复上下文。'
|
|
|
|
|
|
].join('\n'));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (messages.length > 0) {
|
|
|
|
|
|
console.log(JSON.stringify({
|
|
|
|
|
|
continue: true,
|
|
|
|
|
|
suppressOutput: false,
|
|
|
|
|
|
systemMessage: messages.join('\n')
|
|
|
|
|
|
}));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
|
|
|
|
|
}
|
|
|
|
|
|
process.exit(0);
|
|
|
|
|
|
})();
|