Initial commit
This commit is contained in:
71
hooks/hooks.json
Normal file
71
hooks/hooks.json
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/session-start.js"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/user-prompt-submit.js"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/post-tool-use.js"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/post-tool-use.js"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "NotebookEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/post-tool-use.js"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/stop.js"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionEnd": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/session-end.js"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
188
hooks/post-tool-use.js
Executable file
188
hooks/post-tool-use.js
Executable file
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env node
|
||||
// Intelligent Post-Tool-Use Hook - Tracks file modifications for smart analysis
|
||||
// Works with async analysis system for intelligent snapshots
|
||||
//
|
||||
// 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 tool operation data from stdin (Claude Code provides this as JSON)
|
||||
// NOTE: Do NOT use process.env.CLAUDE_TOOL_NAME - it's always "unknown" due to Claude Code bug
|
||||
let toolName = 'unknown';
|
||||
let filePath = null;
|
||||
|
||||
try {
|
||||
const input = fs.readFileSync(0, 'utf8').trim();
|
||||
if (input) {
|
||||
const eventData = JSON.parse(input);
|
||||
|
||||
// Get tool name from stdin data (NOT environment variable)
|
||||
toolName = eventData.tool_name || 'unknown';
|
||||
|
||||
// Extract file path from tool input
|
||||
// Different tools use different field names for file paths
|
||||
if (eventData.tool_input) {
|
||||
if (eventData.tool_input.file_path) {
|
||||
filePath = eventData.tool_input.file_path;
|
||||
} else if (eventData.tool_input.notebook_path) {
|
||||
// NotebookEdit uses notebook_path instead of file_path
|
||||
filePath = eventData.tool_input.notebook_path;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// If we can't read stdin or parse it, exit early
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Only track file modifications (Write, Edit, and NotebookEdit tools)
|
||||
if (toolName !== 'Write' && toolName !== 'Edit' && toolName !== 'NotebookEdit') {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Configuration
|
||||
const SESSIONS_DIR = '.claude/sessions';
|
||||
const ACTIVE_SESSION_FILE = path.join(SESSIONS_DIR, '.active-session');
|
||||
const lockManager = new LockManager(SESSIONS_DIR);
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// State file
|
||||
const stateFile = path.join(sessionDir, '.auto-capture-state');
|
||||
|
||||
// Use lock to prevent race conditions with user-prompt-submit.js hook
|
||||
const lock = lockManager.acquireLock(`auto-capture-${activeSession}`, {
|
||||
timeout: 1000,
|
||||
wait: true
|
||||
});
|
||||
|
||||
if (!lock.acquired) {
|
||||
// Could not acquire lock - skip this update to avoid blocking
|
||||
// The file will be captured on next tool use
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
try {
|
||||
// Initialize state if doesn't exist
|
||||
let state = {
|
||||
file_count: 0,
|
||||
interaction_count: 0,
|
||||
interactions_since_last_analysis: 0,
|
||||
last_snapshot: '',
|
||||
last_reason: '',
|
||||
last_analysis_timestamp: '',
|
||||
modified_files: [] // Track actual file paths
|
||||
};
|
||||
|
||||
if (fs.existsSync(stateFile)) {
|
||||
try {
|
||||
state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
||||
} catch (err) {
|
||||
// Use default state if parse fails
|
||||
}
|
||||
}
|
||||
|
||||
// Increment file count
|
||||
state.file_count++;
|
||||
|
||||
// Track the modified file path if we captured it
|
||||
if (filePath) {
|
||||
// Initialize modified_files array if it doesn't exist (backward compatibility)
|
||||
if (!state.modified_files) {
|
||||
state.modified_files = [];
|
||||
}
|
||||
|
||||
// Add file to list if not already tracked
|
||||
const fileEntry = {
|
||||
path: filePath,
|
||||
operation: toolName.toLowerCase(),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Avoid duplicates - check if file already in list
|
||||
const existingIndex = state.modified_files.findIndex(f => f.path === filePath);
|
||||
if (existingIndex >= 0) {
|
||||
// Update existing entry with latest operation
|
||||
state.modified_files[existingIndex] = fileEntry;
|
||||
} else {
|
||||
// Add new file
|
||||
state.modified_files.push(fileEntry);
|
||||
}
|
||||
}
|
||||
|
||||
// Update state atomically
|
||||
// Note: We no longer immediately create snapshot markers here
|
||||
// Instead, the file_count contributes to analysis queue logic in user-prompt-submit.js
|
||||
const tempPath = `${stateFile}.tmp.${Date.now()}`;
|
||||
try {
|
||||
fs.writeFileSync(tempPath, JSON.stringify(state, null, 2));
|
||||
fs.renameSync(tempPath, stateFile);
|
||||
} catch (writeError) {
|
||||
// Clean up temp file
|
||||
if (fs.existsSync(tempPath)) {
|
||||
try {
|
||||
fs.unlinkSync(tempPath);
|
||||
} catch (cleanupError) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
throw writeError;
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
111
hooks/session-end.js
Executable file
111
hooks/session-end.js
Executable file
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* SessionEnd Hook
|
||||
*
|
||||
* Handles cleanup when Claude Code session terminates.
|
||||
* Ensures .active-session file is removed and index is updated.
|
||||
*
|
||||
* Hook receives:
|
||||
* - session_id: string
|
||||
* - transcript_path: string
|
||||
* - cwd: string
|
||||
* - permission_mode: string
|
||||
* - hook_event_name: "SessionEnd"
|
||||
* - reason: "exit" | "clear" | "logout" | "prompt_input_exit" | "other"
|
||||
*
|
||||
* SAFETY: Includes graceful failure handling to avoid blocking Claude Code shutdown
|
||||
* 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 IndexManager = require('../cli/lib/index-manager');
|
||||
|
||||
// Constants
|
||||
const SESSIONS_DIR = '.claude/sessions';
|
||||
const ACTIVE_SESSION_FILE = path.join(SESSIONS_DIR, '.active-session');
|
||||
const indexManager = new IndexManager(SESSIONS_DIR);
|
||||
|
||||
try {
|
||||
// Read input from stdin (provided by Claude Code)
|
||||
const input = fs.readFileSync(0, 'utf8').trim();
|
||||
|
||||
// Parse the JSON input
|
||||
let eventData;
|
||||
try {
|
||||
eventData = JSON.parse(input);
|
||||
} catch (parseError) {
|
||||
// If parsing fails, exit silently (no input provided)
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Clean up active session marker
|
||||
let sessionName = null;
|
||||
|
||||
if (fs.existsSync(ACTIVE_SESSION_FILE)) {
|
||||
try {
|
||||
sessionName = fs.readFileSync(ACTIVE_SESSION_FILE, 'utf8').trim();
|
||||
} catch (readError) {
|
||||
// Continue even if read fails
|
||||
}
|
||||
|
||||
// Delete the .active-session file
|
||||
try {
|
||||
fs.unlinkSync(ACTIVE_SESSION_FILE);
|
||||
} catch (unlinkError) {
|
||||
// File may already be deleted, continue
|
||||
}
|
||||
}
|
||||
|
||||
// Mark session as closed in .auto-capture-state (fast, atomic)
|
||||
if (sessionName) {
|
||||
const sessionDir = path.join(SESSIONS_DIR, sessionName);
|
||||
const statePath = path.join(sessionDir, '.auto-capture-state');
|
||||
|
||||
if (fs.existsSync(statePath)) {
|
||||
try {
|
||||
let state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
||||
state.session_status = 'closed';
|
||||
state.session_closed = new Date().toISOString();
|
||||
fs.writeFileSync(statePath, JSON.stringify(state, null, 2));
|
||||
} catch (stateError) {
|
||||
// Silent fail - hook must be safe
|
||||
// Session status update failed but continue with other cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update the index.json to clear activeSession using IndexManager
|
||||
// This uses proper locking and atomic writes to prevent corruption
|
||||
try {
|
||||
const index = indexManager.read({ skipValidation: true });
|
||||
index.activeSession = null;
|
||||
indexManager.write(index);
|
||||
} catch (indexError) {
|
||||
// Continue even if index update fails
|
||||
// This prevents blocking the hook if index is temporarily locked
|
||||
}
|
||||
|
||||
// Exit successfully
|
||||
process.exit(0);
|
||||
|
||||
} catch (error) {
|
||||
// Exit silently on any errors to avoid blocking Claude Code shutdown
|
||||
process.exit(0);
|
||||
}
|
||||
} catch (error) {
|
||||
// Outer catch: Handle plugin missing/uninstalled
|
||||
// Exit silently to avoid blocking Claude Code
|
||||
process.exit(0);
|
||||
}
|
||||
110
hooks/session-start.js
Executable file
110
hooks/session-start.js
Executable file
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* SessionStart Hook
|
||||
*
|
||||
* Handles session state cleanup when /clear is executed.
|
||||
* Clears active session markers to prevent confusion when context is lost.
|
||||
*
|
||||
* Hook receives:
|
||||
* - source: "startup" | "resume" | "clear" | "compact"
|
||||
* - session_id: string
|
||||
* - cwd: string
|
||||
* - permission_mode: string
|
||||
*
|
||||
* 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);
|
||||
}
|
||||
|
||||
// Import IndexManager for safe index updates
|
||||
const IndexManager = require('../cli/lib/index-manager');
|
||||
|
||||
// Constants
|
||||
const SESSIONS_DIR = '.claude/sessions';
|
||||
const ACTIVE_SESSION_FILE = path.join(SESSIONS_DIR, '.active-session');
|
||||
const indexManager = new IndexManager(SESSIONS_DIR);
|
||||
|
||||
try {
|
||||
// Read input from stdin (provided by Claude Code)
|
||||
const input = fs.readFileSync(0, 'utf8').trim();
|
||||
|
||||
// Parse the JSON input
|
||||
let eventData;
|
||||
try {
|
||||
eventData = JSON.parse(input);
|
||||
} catch (parseError) {
|
||||
// If parsing fails, exit silently (no input provided)
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const { source } = eventData;
|
||||
|
||||
// Only process when source is "clear"
|
||||
// Other sources ("startup", "resume", "compact") should allow normal auto-resume
|
||||
if (source === 'clear') {
|
||||
let sessionName = null;
|
||||
|
||||
// Read the active session name before clearing (for the message)
|
||||
if (fs.existsSync(ACTIVE_SESSION_FILE)) {
|
||||
try {
|
||||
sessionName = fs.readFileSync(ACTIVE_SESSION_FILE, 'utf8').trim();
|
||||
} catch (readError) {
|
||||
// Continue even if read fails
|
||||
}
|
||||
|
||||
// Clear the .active-session file
|
||||
try {
|
||||
fs.unlinkSync(ACTIVE_SESSION_FILE);
|
||||
} catch (unlinkError) {
|
||||
// File may already be deleted, continue
|
||||
}
|
||||
}
|
||||
|
||||
// Update the index.json to clear activeSession using IndexManager
|
||||
// This uses proper locking and atomic writes to prevent corruption
|
||||
try {
|
||||
const index = indexManager.read({ skipValidation: true });
|
||||
index.activeSession = null;
|
||||
indexManager.write(index);
|
||||
} catch (indexError) {
|
||||
// Continue even if index update fails
|
||||
// This prevents blocking the hook if index is temporarily locked
|
||||
}
|
||||
|
||||
// Inject helpful context message to Claude
|
||||
const output = {
|
||||
hookSpecificOutput: {
|
||||
additionalContext: sessionName
|
||||
? `📋 Session '${sessionName}' was auto-closed due to /clear command. The conversation context has been cleared.\n\nTo resume your work on this session, use: /session:continue ${sessionName}\nTo view all sessions, use: /session:list`
|
||||
: 'Previous session was auto-closed due to /clear command. Use /session:list to view available sessions.'
|
||||
}
|
||||
};
|
||||
|
||||
// Output the context injection
|
||||
console.log(JSON.stringify(output));
|
||||
}
|
||||
|
||||
// Exit successfully
|
||||
process.exit(0);
|
||||
|
||||
} catch (error) {
|
||||
// Exit silently on any errors to avoid blocking Claude Code startup
|
||||
process.exit(0);
|
||||
}
|
||||
} catch (error) {
|
||||
// Outer catch: Handle plugin missing/uninstalled
|
||||
// Exit silently to avoid blocking Claude Code
|
||||
process.exit(0);
|
||||
}
|
||||
210
hooks/stop.js
Executable file
210
hooks/stop.js
Executable 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);
|
||||
}
|
||||
226
hooks/user-prompt-submit.js
Executable file
226
hooks/user-prompt-submit.js
Executable file
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env node
|
||||
// Truly Automatic Auto-Capture Hook - Creates snapshots automatically
|
||||
// This hook creates snapshot files directly via CLI with NO manual intervention
|
||||
// Snapshots are created every N interactions or when file thresholds are met
|
||||
//
|
||||
// SAFETY: Includes graceful failure handling to avoid blocking Claude Code
|
||||
// if plugin is uninstalled or dependencies are missing.
|
||||
//
|
||||
// ORPHAN DETECTION: Periodically checks for orphaned hooks and auto-cleans them
|
||||
|
||||
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');
|
||||
|
||||
// Configuration
|
||||
const SESSIONS_DIR = '.claude/sessions';
|
||||
const ACTIVE_SESSION_FILE = path.join(SESSIONS_DIR, '.active-session');
|
||||
const lockManager = new LockManager(SESSIONS_DIR);
|
||||
|
||||
// Living Context Configuration
|
||||
const CONTEXT_UPDATE_THRESHOLD = 2; // Update context every 2 interactions (lightweight)
|
||||
const SNAPSHOT_THRESHOLD = 5; // Full snapshot every 5 interactions (heavier)
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// File paths
|
||||
const stateFile = path.join(sessionDir, '.auto-capture-state');
|
||||
|
||||
// Use lock to prevent race conditions during state read-modify-write
|
||||
const lock = lockManager.acquireLock(`auto-capture-${activeSession}`, {
|
||||
timeout: 1000,
|
||||
wait: true
|
||||
});
|
||||
|
||||
if (!lock.acquired) {
|
||||
// Could not acquire lock - skip this update to avoid blocking
|
||||
// The next interaction will pick up the count
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
try {
|
||||
// Initialize state if doesn't exist
|
||||
let state = {
|
||||
file_count: 0,
|
||||
interaction_count: 0,
|
||||
interactions_since_context_update: 0,
|
||||
interactions_since_snapshot: 0,
|
||||
last_context_update: '',
|
||||
last_snapshot_timestamp: ''
|
||||
};
|
||||
|
||||
if (fs.existsSync(stateFile)) {
|
||||
try {
|
||||
state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
||||
// Ensure Living Context fields exist
|
||||
state.interactions_since_context_update = state.interactions_since_context_update || 0;
|
||||
state.interactions_since_snapshot = state.interactions_since_snapshot || 0;
|
||||
state.last_context_update = state.last_context_update || '';
|
||||
state.last_snapshot_timestamp = state.last_snapshot_timestamp || '';
|
||||
} catch (err) {
|
||||
// Use default state if parse fails
|
||||
}
|
||||
}
|
||||
|
||||
// Increment interaction count
|
||||
state.interaction_count++;
|
||||
state.interactions_since_context_update++;
|
||||
state.interactions_since_snapshot++;
|
||||
|
||||
// INCREMENTAL LOGGING SYSTEM: Log conversation for later consolidation
|
||||
// This replaces the blocking snapshot system with fast (~1-2ms) logging
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
// Read stdin to get transcript path and user prompt
|
||||
let transcriptPath = null;
|
||||
let userPrompt = null;
|
||||
try {
|
||||
const stdinData = fs.readFileSync(0, 'utf8').trim();
|
||||
if (stdinData) {
|
||||
const eventData = JSON.parse(stdinData);
|
||||
transcriptPath = eventData.transcript_path || null;
|
||||
userPrompt = eventData.prompt || null;
|
||||
}
|
||||
} catch (stdinErr) {
|
||||
// If we can't read stdin, continue without transcript path
|
||||
// This ensures hook doesn't fail if stdin format changes
|
||||
}
|
||||
|
||||
// Log interaction incrementally (non-blocking, ~1-2ms)
|
||||
try {
|
||||
const ConversationLogger = require('../cli/lib/conversation-logger');
|
||||
const logger = new ConversationLogger(sessionDir);
|
||||
|
||||
logger.logInteraction({
|
||||
num: state.interaction_count,
|
||||
timestamp: timestamp,
|
||||
transcript_path: transcriptPath,
|
||||
user_prompt: userPrompt,
|
||||
state: state,
|
||||
modified_files: state.modified_files || []
|
||||
});
|
||||
} catch (err) {
|
||||
// Silent failure - don't block hook execution
|
||||
// Consolidation will work with whatever data is available
|
||||
}
|
||||
|
||||
// Note: Snapshot consolidation now happens at session start/continue
|
||||
// via consolidate-worker.js running in background
|
||||
// This eliminates the 10-15 second blocking issue
|
||||
|
||||
// Update state file atomically
|
||||
const tempPath = `${stateFile}.tmp.${Date.now()}`;
|
||||
try {
|
||||
fs.writeFileSync(tempPath, JSON.stringify(state, null, 2));
|
||||
fs.renameSync(tempPath, stateFile);
|
||||
} catch (writeError) {
|
||||
// Clean up temp file
|
||||
if (fs.existsSync(tempPath)) {
|
||||
try {
|
||||
fs.unlinkSync(tempPath);
|
||||
} catch (cleanupError) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
throw writeError;
|
||||
}
|
||||
} finally {
|
||||
// Always release lock
|
||||
lock.release();
|
||||
}
|
||||
|
||||
// ORPHAN DETECTION: Check for orphaned hooks every 20 prompts
|
||||
// This auto-cleans up hooks if plugin was uninstalled without cleanup
|
||||
try {
|
||||
const orphanCheckFile = path.join(SESSIONS_DIR, '.orphan-check-counter');
|
||||
let checkCounter = 0;
|
||||
|
||||
if (fs.existsSync(orphanCheckFile)) {
|
||||
try {
|
||||
checkCounter = parseInt(fs.readFileSync(orphanCheckFile, 'utf8').trim(), 10) || 0;
|
||||
} catch (e) {
|
||||
// Use default
|
||||
}
|
||||
}
|
||||
|
||||
checkCounter++;
|
||||
|
||||
// Check every 20 prompts
|
||||
if (checkCounter >= 20) {
|
||||
const HooksManager = require('../cli/lib/hooks-manager');
|
||||
const pluginRoot = path.dirname(__dirname);
|
||||
const manager = new HooksManager(process.cwd());
|
||||
|
||||
// Detect orphaned hooks
|
||||
const settings = manager.readSettings();
|
||||
const orphaned = manager.detectOrphanedHooks(settings, pluginRoot);
|
||||
|
||||
if (orphaned.length > 0) {
|
||||
// Auto-cleanup orphaned hooks
|
||||
const cleaned = manager.removePluginHooks(settings, pluginRoot);
|
||||
manager.createBackup();
|
||||
manager.writeSettings(cleaned);
|
||||
|
||||
// NOTE: We cannot inject context here as this is UserPromptSubmit hook
|
||||
// The cleanup happens silently in the background
|
||||
}
|
||||
|
||||
checkCounter = 0; // Reset counter
|
||||
}
|
||||
|
||||
// Write counter back
|
||||
fs.writeFileSync(orphanCheckFile, checkCounter.toString());
|
||||
} catch (orphanError) {
|
||||
// Silent failure - don't block hook execution
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
|
||||
} catch (error) {
|
||||
// Outer catch: Handle plugin missing/uninstalled
|
||||
// Exit silently to avoid blocking Claude Code
|
||||
process.exit(0);
|
||||
}
|
||||
Reference in New Issue
Block a user