#!/usr/bin/env node /** * Session Pin - 钉住已命名(renamed)的会话,防止 auto-cleanup 清理 * 创建: 2026-04-15 | 部署: deploy-session-pin.js */ 'use strict'; const fs = require('fs'); const path = require('path'); let ROOT; try { ROOT = require('../hooks/lib/root.js'); } catch { ROOT = path.join(process.env.USERPROFILE || process.env.HOME || '', '.claude'); } const PIN_FILE = path.join(ROOT, 'pinned-sessions.json'); const SESSIONS_DIR = path.join(ROOT, 'sessions'); const ARCHIVES_DIR = path.join(ROOT, 'archives'); const PIN_TTL_DAYS = 90; function readPins() { try { if (!fs.existsSync(PIN_FILE)) return { version: 1, pins: {} }; const raw = fs.readFileSync(PIN_FILE, 'utf8'); const data = JSON.parse(raw); if (!data || typeof data.pins !== 'object') throw new Error('invalid schema'); return data; } catch { // 备份损坏文件而非静默丢弃,防止覆盖导致数据丢失 try { fs.copyFileSync(PIN_FILE, PIN_FILE + '.corrupt.' + Date.now()); } catch {} return { version: 1, pins: {} }; } } function writePins(data) { const tmp = PIN_FILE + '.tmp.' + process.pid; fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8'); fs.renameSync(tmp, PIN_FILE); } function findSessionMeta(sessionId) { if (!fs.existsSync(SESSIONS_DIR)) return null; try { const files = fs.readdirSync(SESSIONS_DIR).filter(f => f.endsWith('.json')); for (const file of files) { try { const meta = JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, file), 'utf8')); if (meta.sessionId === sessionId && meta.name) { return { name: meta.name, startedAt: meta.startedAt, pid: meta.pid }; } } catch {} } } catch {} return null; } function extractSummary(transcriptPath) { const result = { messageCount: 0, firstPrompt: '', lastPrompt: '' }; try { if (!transcriptPath || !fs.existsSync(transcriptPath)) return result; const content = fs.readFileSync(transcriptPath, 'utf8'); const lines = content.split('\n').filter(l => l.trim()); result.messageCount = lines.length; const userMessages = []; for (const line of lines) { try { const obj = JSON.parse(line); const isHuman = (obj.type === 'human') || (obj.message && obj.message.role === 'human') || (obj.role === 'human'); if (isHuman) { let text = ''; if (typeof obj.message === 'string') text = obj.message; else if (obj.message && obj.message.content) { if (typeof obj.message.content === 'string') text = obj.message.content; else if (Array.isArray(obj.message.content)) { text = obj.message.content.filter(c => c.type === 'text').map(c => c.text).join(' '); } } else if (obj.content && typeof obj.content === 'string') text = obj.content; if (text.trim()) userMessages.push(text.trim().slice(0, 200)); } } catch {} } if (userMessages.length > 0) { result.firstPrompt = userMessages[0]; result.lastPrompt = userMessages[userMessages.length - 1]; } } catch {} return result; } function saveArchive(sessionId, name, summary, startedAt) { try { if (!fs.existsSync(ARCHIVES_DIR)) fs.mkdirSync(ARCHIVES_DIR, { recursive: true }); const date = new Date().toISOString().slice(0, 10); const safeName = (name .replace(/[\x00-\x1f<>:"\/\\|?*\[\]]/g, '_') .replace(/^(CON|PRN|AUX|NUL|COM\d|LPT\d)$/i, '_$1') .trim() .slice(0, 50)) || 'unnamed'; const archiveFile = path.join(ARCHIVES_DIR, date + '_' + safeName + '.json'); const archive = { sessionId, name, archivedAt: new Date().toISOString(), startedAt: startedAt ? new Date(startedAt).toISOString() : null, messageCount: summary.messageCount, firstPrompt: summary.firstPrompt, lastPrompt: summary.lastPrompt, }; fs.writeFileSync(archiveFile, JSON.stringify(archive, null, 2), 'utf8'); return archiveFile; } catch { return null; } } function cleanExpiredPins() { const data = readPins(); const now = Date.now(); let cleaned = 0; for (const [sid, entry] of Object.entries(data.pins)) { const expiry = entry.expiresAt ? new Date(entry.expiresAt).getTime() : 0; if (!entry.expiresAt || isNaN(expiry) || expiry < now) { delete data.pins[sid]; cleaned++; } } if (cleaned > 0) writePins(data); return cleaned; } function pinSession(input) { const sessionId = input && input.session_id; if (!sessionId) return; const meta = findSessionMeta(sessionId); if (!meta || !meta.name) return; const data = readPins(); if (data.pins[sessionId]) { data.pins[sessionId].name = meta.name; writePins(data); return; } const now = new Date(); const expiresAt = new Date(now.getTime() + PIN_TTL_DAYS * 24 * 60 * 60 * 1000); let transcriptRel = ''; if (input.transcript_path) { const normalized = input.transcript_path.replace(/\\/g, '/'); const idx = normalized.indexOf('projects/'); transcriptRel = idx >= 0 ? normalized.slice(idx) : normalized; } data.pins[sessionId] = { name: meta.name, pinnedAt: now.toISOString(), expiresAt: expiresAt.toISOString(), ttlDays: PIN_TTL_DAYS, transcriptRel, }; // 内联过期清理,避免双重写盘 [P2-1 fixed] const _now = Date.now(); for (const [_sid, _entry] of Object.entries(data.pins)) { if (_sid === sessionId) continue; // 刚添加的不清理 const _exp = _entry.expiresAt ? new Date(_entry.expiresAt).getTime() : 0; if (!_entry.expiresAt || isNaN(_exp) || _exp < _now) delete data.pins[_sid]; } writePins(data); const summary = extractSummary(input.transcript_path); saveArchive(sessionId, meta.name, summary, meta.startedAt); } module.exports = { pinSession, readPins, cleanExpiredPins, PIN_FILE, ARCHIVES_DIR };