Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 17:59:29 +08:00
commit 681c2e46c0
31 changed files with 6218 additions and 0 deletions

210
hooks/stop.js Executable file
View File

@@ -0,0 +1,210 @@
#!/usr/bin/env node
// Stop Hook - Captures Claude's complete response for self-contained conversation logs
// This hook fires after Claude completes each response
//
// SAFETY: Includes graceful failure handling to avoid blocking Claude Code
// if plugin is uninstalled or dependencies are missing.
const fs = require('fs');
const path = require('path');
// Graceful failure wrapper - protect against plugin uninstallation
try {
// Check if critical dependencies exist (indicates plugin is installed)
const cliLibPath = path.join(__dirname, '../cli/lib');
if (!fs.existsSync(cliLibPath)) {
// Plugin likely uninstalled, exit silently
process.exit(0);
}
const LockManager = require('../cli/lib/lock-manager');
// Read stdin to get transcript path
let stdinData;
try {
const stdinInput = fs.readFileSync(0, 'utf8').trim();
if (!stdinInput) {
process.exit(0);
}
stdinData = JSON.parse(stdinInput);
} catch (stdinErr) {
// Cannot parse stdin, exit silently
process.exit(0);
}
const transcriptPath = stdinData.transcript_path;
if (!transcriptPath || !fs.existsSync(transcriptPath)) {
// No transcript path or file doesn't exist
process.exit(0);
}
// Read transcript file to get Claude's last response (with exponential backoff retry)
// Uses smart retry strategy: fast success path (0-50ms), patient for edge cases (750ms max)
const MAX_RETRIES = 5;
const RETRY_DELAYS = [0, 50, 100, 200, 400]; // Exponential backoff in milliseconds
function tryFindAssistantMessage() {
try {
const transcriptContent = fs.readFileSync(transcriptPath, 'utf8');
const lines = transcriptContent.trim().split('\n');
// Find last assistant message (search backwards for efficiency)
for (let i = lines.length - 1; i >= 0; i--) {
try {
const entry = JSON.parse(lines[i]);
// Claude Code transcript format: {type: 'assistant', message: {role, content}}
if (entry.type === 'assistant' && entry.message) {
return entry.message; // Return the message object which has role and content
}
} catch (parseErr) {
// Skip malformed lines
continue;
}
}
return null;
} catch (readErr) {
return null;
}
}
// Sleep function for retry delays
function sleep(ms) {
const start = Date.now();
while (Date.now() - start < ms) {
// Busy wait (acceptable for short delays in hooks)
}
}
// Try to find assistant message with exponential backoff retries
let lastAssistantMessage = null;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
lastAssistantMessage = tryFindAssistantMessage();
if (lastAssistantMessage) {
break;
}
// If not found and not last attempt, wait with exponential backoff
if (attempt < MAX_RETRIES) {
const nextDelay = RETRY_DELAYS[attempt]; // Next delay for next attempt
sleep(nextDelay);
}
}
if (!lastAssistantMessage) {
// No assistant message found after all retries
process.exit(0);
}
// Configuration
const SESSIONS_DIR = '.claude/sessions';
const ACTIVE_SESSION_FILE = path.join(SESSIONS_DIR, '.active-session');
// Exit early if no active session
if (!fs.existsSync(ACTIVE_SESSION_FILE)) {
process.exit(0);
}
// Read active session name
let activeSession;
try {
activeSession = fs.readFileSync(ACTIVE_SESSION_FILE, 'utf8').trim();
} catch (err) {
process.exit(0);
}
if (!activeSession) {
process.exit(0);
}
const sessionDir = path.join(SESSIONS_DIR, activeSession);
if (!fs.existsSync(sessionDir)) {
process.exit(0);
}
// Check if auto-capture is enabled
const sessionMd = path.join(sessionDir, 'session.md');
if (fs.existsSync(sessionMd)) {
try {
const content = fs.readFileSync(sessionMd, 'utf8');
if (content.includes('Auto-capture: disabled')) {
process.exit(0);
}
} catch (err) {
// Continue if we can't read the file
}
}
// Use lock to prevent race conditions
const lockManager = new LockManager(SESSIONS_DIR);
const lock = lockManager.acquireLock(`auto-capture-${activeSession}`, {
timeout: 1000,
wait: true
});
if (!lock.acquired) {
// Could not acquire lock - skip this update to avoid blocking
process.exit(0);
}
try {
// Extract tool uses from response
const toolUses = [];
if (lastAssistantMessage.content && Array.isArray(lastAssistantMessage.content)) {
lastAssistantMessage.content.forEach(block => {
if (block.type === 'tool_use') {
toolUses.push({
tool: block.name,
input: block.input,
id: block.id || null
});
}
});
}
// Helper to extract text from content blocks
function extractTextContent(content) {
if (typeof content === 'string') {
return content;
}
if (Array.isArray(content)) {
return content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('\n');
}
return '';
}
const responseText = extractTextContent(lastAssistantMessage.content);
// Log Claude's response
const ConversationLogger = require('../cli/lib/conversation-logger');
const logger = new ConversationLogger(sessionDir);
logger.logAssistantResponse({
timestamp: new Date().toISOString(),
response_text: responseText,
tools_used: toolUses,
message_id: lastAssistantMessage.id || null
});
} catch (error) {
// Silent failure - don't block hook execution
} finally {
// Always release lock
lock.release();
}
process.exit(0);
} catch (error) {
// Outer catch: Handle plugin missing/uninstalled
// Exit silently to avoid blocking Claude Code
process.exit(0);
}