From 7fac3e3bb622ab7ac8d3537c41852b4ffc76bab0 Mon Sep 17 00:00:00 2001 From: Alex Newman Date: Wed, 15 Oct 2025 15:51:25 -0400 Subject: [PATCH] Add comprehensive documentation for Claude Code hooks and streaming input modes - Introduced a detailed reference for implementing hooks in Claude Code, covering configuration, project-specific scripts, plugin hooks, and various hook events. - Explained the input modes available in the Claude Agent SDK, emphasizing the benefits of streaming input mode and providing implementation examples for both streaming and single message input. - Highlighted security considerations and best practices for writing hooks, along with debugging tips and execution details. --- REFACTOR-PLAN.md | 1346 +++++++++++++++++++++++++ context/claude-code/hooks.md | 837 +++++++++++++++ context/claude-code/streaming.md | 295 ++++++ hook-templates/post-tool-use.js | 2 +- hook-templates/session-start.js | 12 +- hook-templates/shared/hook-helpers.js | 2 +- hook-templates/stop.js | 2 +- hook-templates/user-prompt-submit.js | 2 +- src/commands/install.ts | 5 - 9 files changed, 2487 insertions(+), 16 deletions(-) create mode 100644 REFACTOR-PLAN.md create mode 100644 context/claude-code/hooks.md create mode 100644 context/claude-code/streaming.md diff --git a/REFACTOR-PLAN.md b/REFACTOR-PLAN.md new file mode 100644 index 00000000..9b9dc0c9 --- /dev/null +++ b/REFACTOR-PLAN.md @@ -0,0 +1,1346 @@ +# Claude-Mem v4.0 Architecture Specification + +## Vision + +A clean, hook-driven memory system where Claude Code hooks call CLI commands directly. All business logic lives in TypeScript, no separate hook files needed. + +--- + +## System Overview + +``` +┌─────────────────┐ +│ Claude Code │ +│ Hooks │ +└────────┬────────┘ + │ JSON via stdin + ▼ +┌─────────────────┐ +│ CLI Commands │ +│ (TypeScript) │ +└────────┬────────┘ + │ + ├──► SQLite Database + │ • streaming_sessions table + │ • session_locks table + │ + └──► Claude SDK + • Streaming memory agent + • Real-time processing +``` + +**Core Principles:** +- ✅ CLI commands are the only interface +- ✅ All state stored in SQLite +- ✅ SDK agent processes in-memory, writes to SQLite +- ✅ No command-to-command calls (no CLI calling CLI) +- ✅ No hook files to distribute + +--- + +## Database Schema + +### Table: `streaming_sessions` + +Tracks all memory sessions with their metadata and final summaries. + +```sql +CREATE TABLE streaming_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + claude_session_id TEXT UNIQUE NOT NULL, -- From Claude Code + sdk_session_id TEXT, -- From SDK init + project TEXT NOT NULL, -- Project name + title TEXT, -- Generated title + subtitle TEXT, -- Generated subtitle + user_prompt TEXT, -- Initial prompt + started_at TEXT NOT NULL, -- ISO timestamp + started_at_epoch INTEGER NOT NULL, -- Unix ms + updated_at TEXT, -- Last update + updated_at_epoch INTEGER, + completed_at TEXT, -- Session end + completed_at_epoch INTEGER, + status TEXT NOT NULL CHECK(status IN ('active', 'completed', 'failed')) +); + +CREATE INDEX idx_sessions_claude_id ON streaming_sessions(claude_session_id); +CREATE INDEX idx_sessions_sdk_id ON streaming_sessions(sdk_session_id); +CREATE INDEX idx_sessions_project_status ON streaming_sessions(project, status); +``` + +### Table: `session_locks` + +Prevents concurrent SDK session access. + +```sql +CREATE TABLE session_locks ( + sdk_session_id TEXT PRIMARY KEY, + locked_by TEXT NOT NULL, -- Command name: 'save' or 'summary' + locked_at TEXT NOT NULL, -- ISO timestamp + locked_at_epoch INTEGER NOT NULL -- Unix ms +); +``` + +**Lock lifecycle:** +- Acquired before resuming SDK session +- Released after SDK stream completes +- Auto-cleaned if older than 5 minutes (stale lock) + +--- + +## The Four Commands + +All commands accept JSON from stdin and output hook-appropriate responses to stdout. + +### 1. `claude-mem context` + +**Purpose:** Load recent session history for context injection + +**Hook:** SessionStart (matcher: "startup") + +**Input:** +```json +{ + "hook_event_name": "SessionStart", + "session_id": "abc123", + "transcript_path": "/path/to/transcript.jsonl", + "cwd": "/path/to/project", + "source": "startup" +} +``` + +**Flow:** +1. Check `source` field → only process if "startup" or "clear" +2. Extract project name from `cwd` (e.g., `/Users/alex/myapp` → `myapp`) +3. Open SQLite database at `~/.claude-mem/claude-mem.db` +4. Query recent completed sessions: + ```sql + SELECT title, subtitle, user_prompt, started_at + FROM streaming_sessions + WHERE project = ? AND status = 'completed' + ORDER BY started_at_epoch DESC + LIMIT 10 + ``` +5. Format as human-readable text +6. Output to stdout (plain text, NOT JSON) + +**Output:** +``` +=============================================================================== +What's new | Wednesday, October 15, 2025 at 03:18 PM EDT +=============================================================================== +Recent sessions for myapp: + +• 2025-10-15 14:30: User Authentication Implementation + Added JWT tokens and refresh logic + +• 2025-10-14 10:15: Database Schema Refactor + Migrated to normalized structure + +• 2025-10-13 16:45: API Documentation + Created OpenAPI specs for all endpoints + +=============================================================================== +``` + +**Exit code:** 0 + +**Special behavior:** Claude Code injects stdout into conversation context automatically for SessionStart hooks. + +--- + +### 2. `claude-mem new` + +**Purpose:** Start new streaming memory session + +**Hook:** UserPromptSubmit + +**Input:** +```json +{ + "hook_event_name": "UserPromptSubmit", + "session_id": "abc123", + "transcript_path": "/path/to/transcript.jsonl", + "cwd": "/path/to/project", + "prompt": "User's question or task", + "timestamp": "2025-10-15T19:30:00Z" +} +``` + +**Flow:** + +1. **Cleanup orphaned sessions** + ```sql + UPDATE streaming_sessions + SET status = 'failed' + WHERE project = ? AND status = 'active' + ``` + +2. **Create session record** + ```sql + INSERT INTO streaming_sessions ( + claude_session_id, project, user_prompt, + started_at, started_at_epoch, status + ) VALUES (?, ?, ?, ?, ?, 'active') + ``` + +3. **Build SDK system prompt** + ```typescript + const systemPrompt = ` + You are a memory assistant for project "${project}". + Session: ${session_id} + Date: ${date} + + The user said: "${user_prompt}" + + Your job: Analyze the work being done and remember important details. + You will receive tool outputs. Extract what matters: + - Key decisions made + - Patterns discovered + - Problems solved + - Technical insights + + Store memories directly to SQLite using your available functions. + `; + ``` + +4. **Start SDK session** + ```typescript + const response = query({ + prompt: systemPrompt, + options: { + model: 'claude-sonnet-4-5-20250929', + allowedTools: ['Bash'], // SDK can write directly to SQLite + maxTokens: 4096, + cwd: payload.cwd + } + }); + ``` + +5. **Wait for SDK init and extract session ID** + ```typescript + for await (const msg of response) { + if (msg.type === 'system' && msg.subtype === 'init') { + sdkSessionId = msg.session_id; + break; + } + } + ``` + +6. **Update database with SDK session ID** + ```sql + UPDATE streaming_sessions + SET sdk_session_id = ?, updated_at = ?, updated_at_epoch = ? + WHERE id = ? + ``` + +7. **Set activity flag** (for UI indicators) + ```typescript + fs.writeFileSync( + '~/.claude-mem/activity.flag', + JSON.stringify({ active: true, project, timestamp: Date.now() }) + ); + ``` + +8. **Generate title asynchronously** (non-blocking background task) + ```typescript + // Use Claude SDK to generate a short title and subtitle + // Store results back to streaming_sessions table + // Runs detached, doesn't block the hook + generateTitleInBackground(session_id, user_prompt, project); + ``` + +9. **Output success response** + +**Output:** +```json +{"continue": true, "suppressOutput": true} +``` + +**Exit code:** 0 + +--- + +### 3. `claude-mem save` + +**Purpose:** Feed tool results to streaming memory agent + +**Hook:** PostToolUse (matcher: "*") + +**Input:** +```json +{ + "hook_event_name": "PostToolUse", + "session_id": "abc123", + "transcript_path": "/path/to/transcript.jsonl", + "cwd": "/path/to/project", + "tool_name": "Read", + "tool_input": {"file_path": "/path/to/file.ts"}, + "tool_response": {"content": "file contents..."}, + "timestamp": "2025-10-15T19:30:05Z" +} +``` + +**Flow:** + +**CRITICAL:** This is an async operation to avoid blocking the main Claude Code session. + +1. **Immediately return async response** + ```typescript + console.log(JSON.stringify({ async: true, asyncTimeout: 180000 })); + ``` + +2. **Then process in background:** + + a. **Find active session** + ```sql + SELECT id, sdk_session_id, claude_session_id + FROM streaming_sessions + WHERE project = ? AND status = 'active' + ORDER BY started_at_epoch DESC + LIMIT 1 + ``` + + b. **Validate SDK session exists** + - If no `sdk_session_id` yet, skip (session still initializing) + + c. **Clean stale locks** + ```sql + DELETE FROM session_locks + WHERE locked_at_epoch < ? -- 5 minutes ago + ``` + + d. **Attempt to acquire lock** + ```sql + INSERT INTO session_locks (sdk_session_id, locked_by, locked_at, locked_at_epoch) + VALUES (?, 'save', ?, ?) + ``` + - If insert fails (UNIQUE constraint), skip this tool + - Lock prevents concurrent SDK access + + e. **Build tool observation message** + ```typescript + const message = ` + TOOL OBSERVATION + =============== + Tool: ${tool_name} + Input: ${JSON.stringify(tool_input, null, 2)} + Output: ${JSON.stringify(tool_response, null, 2)} + + Analyze this result. If it contains important information, update the session record. + You have access to Bash to run SQL commands against ~/.claude-mem/claude-mem.db + `; + ``` + + f. **Resume SDK session** + ```typescript + const response = query({ + prompt: message, + options: { + model: 'claude-sonnet-4-5-20250929', + resume: sdkSessionId, // Continue existing session + allowedTools: ['Bash'], + maxTokens: 2048, + cwd: payload.cwd + } + }); + ``` + + g. **Consume SDK stream** + ```typescript + for await (const msg of response) { + // SDK processes the tool result + // May run Bash commands to update SQLite + // E.g., INSERT INTO session_notes (session_id, note, timestamp) VALUES (...) + } + ``` + + h. **Release lock** + ```sql + DELETE FROM session_locks + WHERE sdk_session_id = ? + ``` + +**Output:** +```json +{"async": true, "asyncTimeout": 180000} +``` + +**Exit code:** 0 + +**Notes:** +- Non-critical: If locked or session not ready, just skip +- Next tool will catch up +- SDK decides what's worth remembering + +--- + +### 4. `claude-mem summary` + +**Purpose:** Generate and store final session overview + +**Hook:** Stop + +**Input:** +```json +{ + "hook_event_name": "Stop", + "session_id": "abc123", + "transcript_path": "/path/to/transcript.jsonl", + "cwd": "/path/to/project" +} +``` + +**Flow:** + +**CRITICAL:** This is an async operation but MUST complete successfully. + +1. **Immediately return async response** + ```typescript + console.log(JSON.stringify({ async: true, asyncTimeout: 180000 })); + ``` + +2. **Clear activity flag** + ```typescript + fs.writeFileSync( + '~/.claude-mem/activity.flag', + JSON.stringify({ active: false, timestamp: Date.now() }) + ); + ``` + +3. **Then process in background:** + + a. **Find active session** + ```sql + SELECT id, sdk_session_id, claude_session_id, title, subtitle + FROM streaming_sessions + WHERE project = ? AND status = 'active' + ORDER BY started_at_epoch DESC + LIMIT 1 + ``` + + b. **Validate SDK session exists** + - If no `sdk_session_id`, exit (session never fully initialized) + + c. **Clean stale locks** + ```sql + DELETE FROM session_locks + WHERE locked_at_epoch < ? -- 5 minutes ago + ``` + + d. **Acquire lock (with retry)** + ```typescript + // Wait up to 10 seconds for 'save' to finish + for (let i = 0; i < 20; i++) { + try { + // INSERT INTO session_locks ... + lockAcquired = true; + break; + } catch { + await sleep(500); + } + } + if (!lockAcquired) throw new Error('Could not acquire lock'); + ``` + + e. **Build finalization message** + ```typescript + const message = ` + SESSION ENDING + ============= + Project: ${project} + Session: ${claude_session_id} + Title: ${title || 'Untitled'} + Subtitle: ${subtitle || ''} + + Generate a comprehensive overview of this session. + + Required format: + - One-line title (if not already set) + - One-line subtitle (if not already set) + - Key accomplishments + - Technical decisions + - Problems solved + + Store the overview by updating the streaming_sessions record in SQLite. + Use Bash to run the SQL UPDATE command. + `; + ``` + + f. **Resume SDK session** + ```typescript + const response = query({ + prompt: message, + options: { + model: 'claude-sonnet-4-5-20250929', + resume: sdkSessionId, + allowedTools: ['Bash'], + maxTokens: 4096, + cwd: payload.cwd + } + }); + ``` + + g. **Consume SDK stream** + ```typescript + for await (const msg of response) { + // SDK generates overview and updates database + // Runs SQL like: + // UPDATE streaming_sessions + // SET title = ?, subtitle = ? + // WHERE id = ? + } + ``` + + h. **Mark session complete** + ```sql + UPDATE streaming_sessions + SET status = 'completed', + completed_at = ?, + completed_at_epoch = ? + WHERE id = ? + ``` + + i. **Delete SDK transcript** (keep UI clean) + ```typescript + const transcriptPath = `~/.claude/projects/${sanitizedCwd}/${sdkSessionId}.jsonl`; + if (fs.existsSync(transcriptPath)) { + fs.unlinkSync(transcriptPath); + } + ``` + + j. **Release lock** + ```sql + DELETE FROM session_locks + WHERE sdk_session_id = ? + ``` + +**Output:** +```json +{"async": true, "asyncTimeout": 180000} +``` + +**Exit code:** 0 + +**Notes:** +- MUST acquire lock (waits up to 10s) +- MUST complete successfully +- Cleanup is important for user experience + +--- + +## TypeScript Module Structure + +### `src/lib/stdin-reader.ts` + +```typescript +export async function readStdinJson(): Promise { + const chunks: string[] = []; + for await (const chunk of process.stdin) { + chunks.push(chunk.toString()); + } + const input = chunks.join(''); + return input.trim() ? JSON.parse(input) : {}; +} +``` + +### `src/lib/database.ts` + +```typescript +import { Database } from 'bun:sqlite'; +import path from 'path'; +import os from 'os'; +import fs from 'fs'; + +export interface StreamingSession { + id: number; + claude_session_id: string; + sdk_session_id: string | null; + project: string; + title: string | null; + subtitle: string | null; + user_prompt: string | null; + started_at: string; + started_at_epoch: number; + updated_at: string | null; + updated_at_epoch: number | null; + completed_at: string | null; + completed_at_epoch: number | null; + status: 'active' | 'completed' | 'failed'; +} + +function getDataDirectory(): string { + return path.join(os.homedir(), '.claude-mem'); +} + +export function initializeDatabase(): Database { + const dataDir = getDataDirectory(); + const dbPath = path.join(dataDir, 'claude-mem.db'); + + if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); + } + + const db = new Database(dbPath); + + // Optimize SQLite settings + db.pragma('journal_mode = WAL'); + db.pragma('synchronous = NORMAL'); + db.pragma('foreign_keys = ON'); + db.pragma('temp_store = memory'); + + ensureTables(db); + + return db; +} + +function ensureTables(db: Database): void { + // Create streaming_sessions table + db.exec(` + CREATE TABLE IF NOT EXISTS streaming_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + claude_session_id TEXT UNIQUE NOT NULL, + sdk_session_id TEXT, + project TEXT NOT NULL, + title TEXT, + subtitle TEXT, + user_prompt TEXT, + started_at TEXT NOT NULL, + started_at_epoch INTEGER NOT NULL, + updated_at TEXT, + updated_at_epoch INTEGER, + completed_at TEXT, + completed_at_epoch INTEGER, + status TEXT NOT NULL CHECK(status IN ('active', 'completed', 'failed')) + ) + `); + + // Create indices + db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_claude_id ON streaming_sessions(claude_session_id)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_sdk_id ON streaming_sessions(sdk_session_id)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_project_status ON streaming_sessions(project, status)`); + + // Create session_locks table + db.exec(` + CREATE TABLE IF NOT EXISTS session_locks ( + sdk_session_id TEXT PRIMARY KEY, + locked_by TEXT NOT NULL, + locked_at TEXT NOT NULL, + locked_at_epoch INTEGER NOT NULL + ) + `); +} + +export function createStreamingSession( + db: Database, + data: { + claude_session_id: string; + project: string; + user_prompt: string | null; + started_at: string; + } +): StreamingSession { + const epoch = new Date(data.started_at).getTime(); + + const stmt = db.prepare(` + INSERT INTO streaming_sessions ( + claude_session_id, project, user_prompt, started_at, started_at_epoch, status + ) VALUES (?, ?, ?, ?, ?, 'active') + `); + + const result = stmt.run( + data.claude_session_id, + data.project, + data.user_prompt, + data.started_at, + epoch + ); + + return db.prepare('SELECT * FROM streaming_sessions WHERE id = ?') + .get(result.lastInsertRowid) as StreamingSession; +} + +export function updateStreamingSession( + db: Database, + id: number, + updates: Partial +): void { + const timestamp = new Date().toISOString(); + const epoch = Date.now(); + + const fields: string[] = []; + const values: any[] = []; + + if (updates.sdk_session_id !== undefined) { + fields.push('sdk_session_id = ?'); + values.push(updates.sdk_session_id); + } + if (updates.title !== undefined) { + fields.push('title = ?'); + values.push(updates.title); + } + if (updates.subtitle !== undefined) { + fields.push('subtitle = ?'); + values.push(updates.subtitle); + } + if (updates.status !== undefined) { + fields.push('status = ?'); + values.push(updates.status); + } + + fields.push('updated_at = ?', 'updated_at_epoch = ?'); + values.push(timestamp, epoch); + + values.push(id); + + const stmt = db.prepare(` + UPDATE streaming_sessions + SET ${fields.join(', ')} + WHERE id = ? + `); + + stmt.run(...values); +} + +export function getActiveStreamingSessionsForProject( + db: Database, + project: string +): StreamingSession[] { + const stmt = db.prepare(` + SELECT * FROM streaming_sessions + WHERE project = ? AND status = 'active' + ORDER BY started_at_epoch DESC + `); + + return stmt.all(project) as StreamingSession[]; +} + +export function getRecentCompletedSessions( + db: Database, + project: string, + limit: number = 10 +): StreamingSession[] { + const stmt = db.prepare(` + SELECT * FROM streaming_sessions + WHERE project = ? AND status = 'completed' + ORDER BY started_at_epoch DESC + LIMIT ? + `); + + return stmt.all(project, limit) as StreamingSession[]; +} + +export function markStreamingSessionCompleted( + db: Database, + id: number +): void { + const timestamp = new Date().toISOString(); + const epoch = Date.now(); + + const stmt = db.prepare(` + UPDATE streaming_sessions + SET status = 'completed', + completed_at = ?, + completed_at_epoch = ?, + updated_at = ?, + updated_at_epoch = ? + WHERE id = ? + `); + + stmt.run(timestamp, epoch, timestamp, epoch, id); +} + +export function markOrphanedSessionsFailed( + db: Database, + project: string +): void { + const stmt = db.prepare(` + UPDATE streaming_sessions + SET status = 'failed' + WHERE project = ? AND status = 'active' + `); + + stmt.run(project); +} + +export function acquireSessionLock( + db: Database, + sdkSessionId: string, + lockOwner: string +): boolean { + try { + const timestamp = new Date().toISOString(); + const epoch = Date.now(); + + const stmt = db.prepare(` + INSERT INTO session_locks (sdk_session_id, locked_by, locked_at, locked_at_epoch) + VALUES (?, ?, ?, ?) + `); + + stmt.run(sdkSessionId, lockOwner, timestamp, epoch); + return true; + } catch { + return false; // UNIQUE constraint violation = already locked + } +} + +export function releaseSessionLock( + db: Database, + sdkSessionId: string +): void { + const stmt = db.prepare(` + DELETE FROM session_locks + WHERE sdk_session_id = ? + `); + + stmt.run(sdkSessionId); +} + +export function cleanupStaleLocks(db: Database): void { + const fiveMinutesAgo = Date.now() - (5 * 60 * 1000); + + const stmt = db.prepare(` + DELETE FROM session_locks + WHERE locked_at_epoch < ? + `); + + stmt.run(fiveMinutesAgo); +} +``` + +### `src/lib/path-resolver.ts` + +```typescript +import path from 'path'; + +export function getProjectName(cwd: string): string { + return path.basename(cwd); +} +``` + +### `src/lib/prompt-builder.ts` + +```typescript +export function buildSystemPrompt(params: { + project: string; + sessionId: string; + date: string; + userPrompt: string; +}): string { + return `You are a memory assistant for project "${params.project}". + +Session ID: ${params.sessionId} +Date: ${params.date} + +The user said: "${params.userPrompt}" + +Your job is to analyze the work being done and remember important details. +You will receive tool outputs as the session progresses. + +Extract what matters: +- Key decisions made +- Patterns discovered +- Problems solved +- Technical insights + +You have access to Bash to write directly to the SQLite database at ~/.claude-mem/claude-mem.db +Store important observations as you see them.`; +} + +export function buildToolMessage(params: { + toolName: string; + toolInput: any; + toolResponse: any; + timestamp: string; +}): string { + return `TOOL OBSERVATION +=============== +Time: ${params.timestamp} +Tool: ${params.toolName} + +Input: +${JSON.stringify(params.toolInput, null, 2)} + +Output: +${JSON.stringify(params.toolResponse, null, 2)} + +Analyze this result. If it contains important information worth remembering, use Bash to update the database.`; +} + +export function buildEndMessage(params: { + project: string; + sessionId: string; + title: string | null; + subtitle: string | null; +}): string { + return `SESSION ENDING +============= +Project: ${params.project} +Session: ${params.sessionId} +Current Title: ${params.title || 'Not set'} +Current Subtitle: ${params.subtitle || 'Not set'} + +Generate a comprehensive overview of this session. + +Required: +1. A concise title (if not already set) +2. A brief subtitle (if not already set) +3. Key accomplishments +4. Technical decisions made +5. Problems solved + +Use Bash to UPDATE the streaming_sessions record with the title and subtitle if they're not already set.`; +} +``` + +### `src/commands/hook-handlers.ts` + +```typescript +import { query } from '@anthropic-ai/claude-agent-sdk'; +import { initializeDatabase, Database } from '../lib/database'; +import * as db from '../lib/database'; +import { getProjectName } from '../lib/path-resolver'; +import { buildSystemPrompt, buildToolMessage, buildEndMessage } from '../lib/prompt-builder'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +export async function handleContext(payload: any): Promise { + // Only process startup/clear + if (payload.source !== 'startup' && payload.source !== 'clear') { + return; + } + + const project = getProjectName(payload.cwd); + const database = initializeDatabase(); + + const sessions = db.getRecentCompletedSessions(database, project, 10); + + if (sessions.length === 0) { + console.log(`=============================================================================== +What's new | ${new Date().toLocaleString('en-US', { + weekday: 'long', month: 'long', day: 'numeric', year: 'numeric', + hour: 'numeric', minute: '2-digit', timeZoneName: 'short' +})} +=============================================================================== +No previous sessions found for this project. +Start working and claude-mem will automatically capture context for future sessions. +===============================================================================`); + database.close(); + return; + } + + console.log(`=============================================================================== +What's new | ${new Date().toLocaleString('en-US', { + weekday: 'long', month: 'long', day: 'numeric', year: 'numeric', + hour: 'numeric', minute: '2-digit', timeZoneName: 'short' +})} +=============================================================================== +Recent sessions for ${project}: +`); + + for (const session of sessions) { + const date = new Date(session.started_at).toISOString().split('T')[0]; + const title = session.title || 'Untitled'; + const subtitle = session.subtitle || ''; + console.log(`• ${date}: ${title}`); + if (subtitle) { + console.log(` ${subtitle}`); + } + console.log(); + } + + console.log(`===============================================================================`); + database.close(); +} + +export async function handleNew(payload: any): Promise { + const project = getProjectName(payload.cwd); + const database = initializeDatabase(); + + // Mark any orphaned sessions as failed + db.markOrphanedSessionsFailed(database, project); + + // Create new session + const session = db.createStreamingSession(database, { + claude_session_id: payload.session_id, + project, + user_prompt: payload.prompt || null, + started_at: payload.timestamp || new Date().toISOString() + }); + + // Build system prompt + const date = new Date().toISOString().split('T')[0]; + const systemPrompt = buildSystemPrompt({ + project, + sessionId: payload.session_id, + date, + userPrompt: payload.prompt || '' + }); + + // Start SDK session + const response = query({ + prompt: systemPrompt, + options: { + model: 'claude-sonnet-4-5-20250929', + allowedTools: ['Bash'], + maxTokens: 4096, + cwd: payload.cwd + } + }); + + // Extract SDK session ID + let sdkSessionId: string | null = null; + for await (const msg of response) { + if (msg.type === 'system' && msg.subtype === 'init') { + sdkSessionId = msg.session_id; + break; + } + } + + // Update with SDK session ID + if (sdkSessionId) { + db.updateStreamingSession(database, session.id, { sdk_session_id: sdkSessionId }); + } + + // Set activity flag + const activityFlagPath = path.join(os.homedir(), '.claude-mem', 'activity.flag'); + fs.writeFileSync(activityFlagPath, JSON.stringify({ + active: true, + project, + timestamp: Date.now() + })); + + database.close(); + + // Output hook response + console.log(JSON.stringify({ continue: true, suppressOutput: true })); +} + +export async function handleSave(payload: any): Promise { + // Return immediately + console.log(JSON.stringify({ async: true, asyncTimeout: 180000 })); + + // Process async + const project = getProjectName(payload.cwd); + const database = initializeDatabase(); + + try { + db.cleanupStaleLocks(database); + + const sessions = db.getActiveStreamingSessionsForProject(database, project); + if (sessions.length === 0) { + database.close(); + return; + } + + const session = sessions[0]; + if (!session.sdk_session_id) { + database.close(); + return; + } + + // Try to acquire lock + const lockAcquired = db.acquireSessionLock(database, session.sdk_session_id, 'save'); + if (!lockAcquired) { + database.close(); + return; + } + + // Build tool message + const message = buildToolMessage({ + toolName: payload.tool_name, + toolInput: payload.tool_input, + toolResponse: payload.tool_response, + timestamp: payload.timestamp || new Date().toISOString() + }); + + // Resume SDK session + const response = query({ + prompt: message, + options: { + model: 'claude-sonnet-4-5-20250929', + resume: session.sdk_session_id, + allowedTools: ['Bash'], + maxTokens: 2048, + cwd: payload.cwd + } + }); + + // Consume stream + for await (const msg of response) { + // SDK processes + } + + db.releaseSessionLock(database, session.sdk_session_id); + } catch (error) { + console.error('Error in save:', error); + } finally { + database.close(); + } +} + +export async function handleSummary(payload: any): Promise { + // Return immediately + console.log(JSON.stringify({ async: true, asyncTimeout: 180000 })); + + // Clear activity flag + const activityFlagPath = path.join(os.homedir(), '.claude-mem', 'activity.flag'); + fs.writeFileSync(activityFlagPath, JSON.stringify({ + active: false, + timestamp: Date.now() + })); + + const project = getProjectName(payload.cwd); + const database = initializeDatabase(); + + try { + db.cleanupStaleLocks(database); + + const sessions = db.getActiveStreamingSessionsForProject(database, project); + if (sessions.length === 0) { + database.close(); + return; + } + + const session = sessions[0]; + if (!session.sdk_session_id) { + database.close(); + return; + } + + // Acquire lock with retry + let lockAcquired = false; + for (let i = 0; i < 20; i++) { + lockAcquired = db.acquireSessionLock(database, session.sdk_session_id, 'summary'); + if (lockAcquired) break; + await new Promise(resolve => setTimeout(resolve, 500)); + } + + if (!lockAcquired) { + throw new Error('Could not acquire session lock'); + } + + // Build end message + const message = buildEndMessage({ + project, + sessionId: session.claude_session_id, + title: session.title, + subtitle: session.subtitle + }); + + // Resume SDK session + const response = query({ + prompt: message, + options: { + model: 'claude-sonnet-4-5-20250929', + resume: session.sdk_session_id, + allowedTools: ['Bash'], + maxTokens: 4096, + cwd: payload.cwd + } + }); + + // Consume stream + for await (const msg of response) { + // SDK generates and stores overview + } + + // Mark completed + db.markStreamingSessionCompleted(database, session.id); + + // Delete SDK transcript + const sanitizedCwd = payload.cwd.replace(/\//g, '-'); + const transcriptPath = path.join( + os.homedir(), + '.claude', + 'projects', + sanitizedCwd, + `${session.sdk_session_id}.jsonl` + ); + + if (fs.existsSync(transcriptPath)) { + fs.unlinkSync(transcriptPath); + } + + db.releaseSessionLock(database, session.sdk_session_id); + } catch (error) { + console.error('Error in summary:', error); + } finally { + database.close(); + } +} +``` + +### `src/cli.ts` + +```typescript +import { Command } from 'commander'; +import { readStdinJson } from './lib/stdin-reader'; +import { handleContext, handleNew, handleSave, handleSummary } from './commands/hook-handlers'; + +const program = new Command(); + +program + .name('claude-mem') + .description('Memory management for Claude Code') + .version('4.0.0'); + +program + .command('context') + .description('Load context from previous sessions') + .action(async () => { + const payload = await readStdinJson(); + await handleContext(payload); + }); + +program + .command('new') + .description('Start new memory session') + .action(async () => { + const payload = await readStdinJson(); + await handleNew(payload); + }); + +program + .command('save') + .description('Save tool observation to memory') + .action(async () => { + const payload = await readStdinJson(); + await handleSave(payload); + }); + +program + .command('summary') + .description('Generate and store session summary') + .action(async () => { + const payload = await readStdinJson(); + await handleSummary(payload); + }); + +program.parse(); +``` + +--- + +## Installation & Configuration + +### User Installation + +```bash +npm install -g claude-mem +``` + +### Claude Code Configuration + +Add to `.claude/settings.json`: + +```json +{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup", + "hooks": [ + { + "type": "command", + "command": "claude-mem context" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "claude-mem new" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "claude-mem save" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "claude-mem summary" + } + ] + } + ] + } +} +``` + +That's it! No hook files needed. + +--- + +## Testing Strategy + +### Unit Tests + +Test each command independently: + +```bash +# Test context +echo '{"hook_event_name":"SessionStart","source":"startup","cwd":"'$(pwd)'"}' | claude-mem context + +# Test new +echo '{"hook_event_name":"UserPromptSubmit","session_id":"test","prompt":"hello","cwd":"'$(pwd)'","timestamp":"'$(date -Iseconds)'"}' | claude-mem new + +# Test save +echo '{"hook_event_name":"PostToolUse","session_id":"test","tool_name":"Read","tool_input":{},"tool_response":{"content":"test"},"cwd":"'$(pwd)'"}' | claude-mem save + +# Test summary +echo '{"hook_event_name":"Stop","session_id":"test","cwd":"'$(pwd)'"}' | claude-mem summary +``` + +### Integration Tests + +Full lifecycle test: +1. Create session +2. Feed several tools +3. Generate summary +4. Verify database state + +### Database Verification + +```bash +sqlite3 ~/.claude-mem/claude-mem.db "SELECT * FROM streaming_sessions;" +``` + +--- + +## Success Criteria + +✅ All four commands work with stdin JSON +✅ All database operations are type-safe +✅ No hook files to distribute +✅ No CLI-to-CLI calls +✅ Clean separation of concerns +✅ Comprehensive test coverage +✅ Simple installation process + +--- + +## Timeline + +- **Database layer**: 2-3 hours +- **Command handlers**: 4-6 hours +- **CLI integration**: 1-2 hours +- **Testing**: 2-3 hours +- **Documentation**: 1-2 hours + +**Total: 10-16 hours of focused development** diff --git a/context/claude-code/hooks.md b/context/claude-code/hooks.md new file mode 100644 index 00000000..20e51fcd --- /dev/null +++ b/context/claude-code/hooks.md @@ -0,0 +1,837 @@ +# Hooks reference + +> This page provides reference documentation for implementing hooks in Claude Code. + + + For a quickstart guide with examples, see [Get started with Claude Code hooks](/en/docs/claude-code/hooks-guide). + + +## Configuration + +Claude Code hooks are configured in your [settings files](/en/docs/claude-code/settings): + +* `~/.claude/settings.json` - User settings +* `.claude/settings.json` - Project settings +* `.claude/settings.local.json` - Local project settings (not committed) +* Enterprise managed policy settings + +### Structure + +Hooks are organized by matchers, where each matcher can have multiple hooks: + +```json theme={null} +{ + "hooks": { + "EventName": [ + { + "matcher": "ToolPattern", + "hooks": [ + { + "type": "command", + "command": "your-command-here" + } + ] + } + ] + } +} +``` + +* **matcher**: Pattern to match tool names, case-sensitive (only applicable for + `PreToolUse` and `PostToolUse`) + * Simple strings match exactly: `Write` matches only the Write tool + * Supports regex: `Edit|Write` or `Notebook.*` + * Use `*` to match all tools. You can also use empty string (`""`) or leave + `matcher` blank. +* **hooks**: Array of commands to execute when the pattern matches + * `type`: Currently only `"command"` is supported + * `command`: The bash command to execute (can use `$CLAUDE_PROJECT_DIR` + environment variable) + * `timeout`: (Optional) How long a command should run, in seconds, before + canceling that specific command. + +For events like `UserPromptSubmit`, `Notification`, `Stop`, and `SubagentStop` +that don't use matchers, you can omit the matcher field: + +```json theme={null} +{ + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "/path/to/prompt-validator.py" + } + ] + } + ] + } +} +``` + +### Project-Specific Hook Scripts + +You can use the environment variable `CLAUDE_PROJECT_DIR` (only available when +Claude Code spawns the hook command) to reference scripts stored in your project, +ensuring they work regardless of Claude's current directory: + +```json theme={null} +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Write|Edit", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/check-style.sh" + } + ] + } + ] + } +} +``` + +### Plugin hooks + +[Plugins](/en/docs/claude-code/plugins) can provide hooks that integrate seamlessly with your user and project hooks. Plugin hooks are automatically merged with your configuration when plugins are enabled. + +**How plugin hooks work**: + +* Plugin hooks are defined in the plugin's `hooks/hooks.json` file or in a file given by a custom path to the `hooks` field. +* When a plugin is enabled, its hooks are merged with user and project hooks +* Multiple hooks from different sources can respond to the same event +* Plugin hooks use the `${CLAUDE_PLUGIN_ROOT}` environment variable to reference plugin files + +**Example plugin hook configuration**: + +```json theme={null} +{ + "description": "Automatic code formatting", + "hooks": { + "PostToolUse": [ + { + "matcher": "Write|Edit", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/scripts/format.sh", + "timeout": 30 + } + ] + } + ] + } +} +``` + + + Plugin hooks use the same format as regular hooks with an optional `description` field to explain the hook's purpose. + + + + Plugin hooks run alongside your custom hooks. If multiple hooks match an event, they all execute in parallel. + + +**Environment variables for plugins**: + +* `${CLAUDE_PLUGIN_ROOT}`: Absolute path to the plugin directory +* `${CLAUDE_PROJECT_DIR}`: Project root directory (same as for project hooks) +* All standard environment variables are available + +See the [plugin components reference](/en/docs/claude-code/plugins-reference#hooks) for details on creating plugin hooks. + +## Hook Events + +### PreToolUse + +Runs after Claude creates tool parameters and before processing the tool call. + +**Common matchers:** + +* `Task` - Subagent tasks (see [subagents documentation](/en/docs/claude-code/sub-agents)) +* `Bash` - Shell commands +* `Glob` - File pattern matching +* `Grep` - Content search +* `Read` - File reading +* `Edit` - File editing +* `Write` - File writing +* `WebFetch`, `WebSearch` - Web operations + +### PostToolUse + +Runs immediately after a tool completes successfully. + +Recognizes the same matcher values as PreToolUse. + +### Notification + +Runs when Claude Code sends notifications. Notifications are sent when: + +1. Claude needs your permission to use a tool. Example: "Claude needs your + permission to use Bash" +2. The prompt input has been idle for at least 60 seconds. "Claude is waiting + for your input" + +### UserPromptSubmit + +Runs when the user submits a prompt, before Claude processes it. This allows you +to add additional context based on the prompt/conversation, validate prompts, or +block certain types of prompts. + +### Stop + +Runs when the main Claude Code agent has finished responding. Does not run if +the stoppage occurred due to a user interrupt. + +### SubagentStop + +Runs when a Claude Code subagent (Task tool call) has finished responding. + +### PreCompact + +Runs before Claude Code is about to run a compact operation. + +**Matchers:** + +* `manual` - Invoked from `/compact` +* `auto` - Invoked from auto-compact (due to full context window) + +### SessionStart + +Runs when Claude Code starts a new session or resumes an existing session (which +currently does start a new session under the hood). Useful for loading in +development context like existing issues or recent changes to your codebase. + +**Matchers:** + +* `startup` - Invoked from startup +* `resume` - Invoked from `--resume`, `--continue`, or `/resume` +* `clear` - Invoked from `/clear` +* `compact` - Invoked from auto or manual compact. + +### SessionEnd + +Runs when a Claude Code session ends. Useful for cleanup tasks, logging session +statistics, or saving session state. + +The `reason` field in the hook input will be one of: + +* `clear` - Session cleared with /clear command +* `logout` - User logged out +* `prompt_input_exit` - User exited while prompt input was visible +* `other` - Other exit reasons + +## Hook Input + +Hooks receive JSON data via stdin containing session information and +event-specific data: + +```typescript theme={null} +{ + // Common fields + session_id: string + transcript_path: string // Path to conversation JSON + cwd: string // The current working directory when the hook is invoked + + // Event-specific fields + hook_event_name: string + ... +} +``` + +### PreToolUse Input + +The exact schema for `tool_input` depends on the tool. + +```json theme={null} +{ + "session_id": "abc123", + "transcript_path": "/Users/.../.claude/projects/.../00893aaf-19fa-41d2-8238-13269b9b3ca0.jsonl", + "cwd": "/Users/...", + "hook_event_name": "PreToolUse", + "tool_name": "Write", + "tool_input": { + "file_path": "/path/to/file.txt", + "content": "file content" + } +} +``` + +### PostToolUse Input + +The exact schema for `tool_input` and `tool_response` depends on the tool. + +```json theme={null} +{ + "session_id": "abc123", + "transcript_path": "/Users/.../.claude/projects/.../00893aaf-19fa-41d2-8238-13269b9b3ca0.jsonl", + "cwd": "/Users/...", + "hook_event_name": "PostToolUse", + "tool_name": "Write", + "tool_input": { + "file_path": "/path/to/file.txt", + "content": "file content" + }, + "tool_response": { + "filePath": "/path/to/file.txt", + "success": true + } +} +``` + +### Notification Input + +```json theme={null} +{ + "session_id": "abc123", + "transcript_path": "/Users/.../.claude/projects/.../00893aaf-19fa-41d2-8238-13269b9b3ca0.jsonl", + "cwd": "/Users/...", + "hook_event_name": "Notification", + "message": "Task completed successfully" +} +``` + +### UserPromptSubmit Input + +```json theme={null} +{ + "session_id": "abc123", + "transcript_path": "/Users/.../.claude/projects/.../00893aaf-19fa-41d2-8238-13269b9b3ca0.jsonl", + "cwd": "/Users/...", + "hook_event_name": "UserPromptSubmit", + "prompt": "Write a function to calculate the factorial of a number" +} +``` + +### Stop and SubagentStop Input + +`stop_hook_active` is true when Claude Code is already continuing as a result of +a stop hook. Check this value or process the transcript to prevent Claude Code +from running indefinitely. + +```json theme={null} +{ + "session_id": "abc123", + "transcript_path": "~/.claude/projects/.../00893aaf-19fa-41d2-8238-13269b9b3ca0.jsonl", + "hook_event_name": "Stop", + "stop_hook_active": true +} +``` + +### PreCompact Input + +For `manual`, `custom_instructions` comes from what the user passes into +`/compact`. For `auto`, `custom_instructions` is empty. + +```json theme={null} +{ + "session_id": "abc123", + "transcript_path": "~/.claude/projects/.../00893aaf-19fa-41d2-8238-13269b9b3ca0.jsonl", + "hook_event_name": "PreCompact", + "trigger": "manual", + "custom_instructions": "" +} +``` + +### SessionStart Input + +```json theme={null} +{ + "session_id": "abc123", + "transcript_path": "~/.claude/projects/.../00893aaf-19fa-41d2-8238-13269b9b3ca0.jsonl", + "hook_event_name": "SessionStart", + "source": "startup" +} +``` + +### SessionEnd Input + +```json theme={null} +{ + "session_id": "abc123", + "transcript_path": "~/.claude/projects/.../00893aaf-19fa-41d2-8238-13269b9b3ca0.jsonl", + "cwd": "/Users/...", + "hook_event_name": "SessionEnd", + "reason": "exit" +} +``` + +## Hook Output + +There are two ways for hooks to return output back to Claude Code. The output +communicates whether to block and any feedback that should be shown to Claude +and the user. + +### Simple: Exit Code + +Hooks communicate status through exit codes, stdout, and stderr: + +* **Exit code 0**: Success. `stdout` is shown to the user in transcript mode + (CTRL-R), except for `UserPromptSubmit` and `SessionStart`, where stdout is + added to the context. +* **Exit code 2**: Blocking error. `stderr` is fed back to Claude to process + automatically. See per-hook-event behavior below. +* **Other exit codes**: Non-blocking error. `stderr` is shown to the user and + execution continues. + + + Reminder: Claude Code does not see stdout if the exit code is 0, except for + the `UserPromptSubmit` hook where stdout is injected as context. + + +#### Exit Code 2 Behavior + +| Hook Event | Behavior | +| ------------------ | ------------------------------------------------------------------ | +| `PreToolUse` | Blocks the tool call, shows stderr to Claude | +| `PostToolUse` | Shows stderr to Claude (tool already ran) | +| `Notification` | N/A, shows stderr to user only | +| `UserPromptSubmit` | Blocks prompt processing, erases prompt, shows stderr to user only | +| `Stop` | Blocks stoppage, shows stderr to Claude | +| `SubagentStop` | Blocks stoppage, shows stderr to Claude subagent | +| `PreCompact` | N/A, shows stderr to user only | +| `SessionStart` | N/A, shows stderr to user only | +| `SessionEnd` | N/A, shows stderr to user only | + +### Advanced: JSON Output + +Hooks can return structured JSON in `stdout` for more sophisticated control: + +#### Common JSON Fields + +All hook types can include these optional fields: + +```json theme={null} +{ + "continue": true, // Whether Claude should continue after hook execution (default: true) + "stopReason": "string", // Message shown when continue is false + + "suppressOutput": true, // Hide stdout from transcript mode (default: false) + "systemMessage": "string" // Optional warning message shown to the user +} +``` + +If `continue` is false, Claude stops processing after the hooks run. + +* For `PreToolUse`, this is different from `"permissionDecision": "deny"`, which + only blocks a specific tool call and provides automatic feedback to Claude. +* For `PostToolUse`, this is different from `"decision": "block"`, which + provides automated feedback to Claude. +* For `UserPromptSubmit`, this prevents the prompt from being processed. +* For `Stop` and `SubagentStop`, this takes precedence over any + `"decision": "block"` output. +* In all cases, `"continue" = false` takes precedence over any + `"decision": "block"` output. + +`stopReason` accompanies `continue` with a reason shown to the user, not shown +to Claude. + +#### `PreToolUse` Decision Control + +`PreToolUse` hooks can control whether a tool call proceeds. + +* `"allow"` bypasses the permission system. `permissionDecisionReason` is shown + to the user but not to Claude. +* `"deny"` prevents the tool call from executing. `permissionDecisionReason` is + shown to Claude. +* `"ask"` asks the user to confirm the tool call in the UI. + `permissionDecisionReason` is shown to the user but not to Claude. + +```json theme={null} +{ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow" | "deny" | "ask", + "permissionDecisionReason": "My reason here" + } +} +``` + + + The `decision` and `reason` fields are deprecated for PreToolUse hooks. + Use `hookSpecificOutput.permissionDecision` and + `hookSpecificOutput.permissionDecisionReason` instead. The deprecated fields + `"approve"` and `"block"` map to `"allow"` and `"deny"` respectively. + + +#### `PostToolUse` Decision Control + +`PostToolUse` hooks can provide feedback to Claude after tool execution. + +* `"block"` automatically prompts Claude with `reason`. +* `undefined` does nothing. `reason` is ignored. +* `"hookSpecificOutput.additionalContext"` adds context for Claude to consider. + +```json theme={null} +{ + "decision": "block" | undefined, + "reason": "Explanation for decision", + "hookSpecificOutput": { + "hookEventName": "PostToolUse", + "additionalContext": "Additional information for Claude" + } +} +``` + +#### `UserPromptSubmit` Decision Control + +`UserPromptSubmit` hooks can control whether a user prompt is processed. + +* `"block"` prevents the prompt from being processed. The submitted prompt is + erased from context. `"reason"` is shown to the user but not added to context. +* `undefined` allows the prompt to proceed normally. `"reason"` is ignored. +* `"hookSpecificOutput.additionalContext"` adds the string to the context if not + blocked. + +```json theme={null} +{ + "decision": "block" | undefined, + "reason": "Explanation for decision", + "hookSpecificOutput": { + "hookEventName": "UserPromptSubmit", + "additionalContext": "My additional context here" + } +} +``` + +#### `Stop`/`SubagentStop` Decision Control + +`Stop` and `SubagentStop` hooks can control whether Claude must continue. + +* `"block"` prevents Claude from stopping. You must populate `reason` for Claude + to know how to proceed. +* `undefined` allows Claude to stop. `reason` is ignored. + +```json theme={null} +{ + "decision": "block" | undefined, + "reason": "Must be provided when Claude is blocked from stopping" +} +``` + +#### `SessionStart` Decision Control + +`SessionStart` hooks allow you to load in context at the start of a session. + +* `"hookSpecificOutput.additionalContext"` adds the string to the context. +* Multiple hooks' `additionalContext` values are concatenated. + +```json theme={null} +{ + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": "My additional context here" + } +} +``` + +#### `SessionEnd` Decision Control + +`SessionEnd` hooks run when a session ends. They cannot block session termination +but can perform cleanup tasks. + +#### Exit Code Example: Bash Command Validation + +```python theme={null} +#!/usr/bin/env python3 +import json +import re +import sys + +# Define validation rules as a list of (regex pattern, message) tuples +VALIDATION_RULES = [ + ( + r"\bgrep\b(?!.*\|)", + "Use 'rg' (ripgrep) instead of 'grep' for better performance and features", + ), + ( + r"\bfind\s+\S+\s+-name\b", + "Use 'rg --files | rg pattern' or 'rg --files -g pattern' instead of 'find -name' for better performance", + ), +] + + +def validate_command(command: str) -> list[str]: + issues = [] + for pattern, message in VALIDATION_RULES: + if re.search(pattern, command): + issues.append(message) + return issues + + +try: + input_data = json.load(sys.stdin) +except json.JSONDecodeError as e: + print(f"Error: Invalid JSON input: {e}", file=sys.stderr) + sys.exit(1) + +tool_name = input_data.get("tool_name", "") +tool_input = input_data.get("tool_input", {}) +command = tool_input.get("command", "") + +if tool_name != "Bash" or not command: + sys.exit(1) + +# Validate the command +issues = validate_command(command) + +if issues: + for message in issues: + print(f"• {message}", file=sys.stderr) + # Exit code 2 blocks tool call and shows stderr to Claude + sys.exit(2) +``` + +#### JSON Output Example: UserPromptSubmit to Add Context and Validation + + + For `UserPromptSubmit` hooks, you can inject context using either method: + + * Exit code 0 with stdout: Claude sees the context (special case for `UserPromptSubmit`) + * JSON output: Provides more control over the behavior + + +```python theme={null} +#!/usr/bin/env python3 +import json +import sys +import re +import datetime + +# Load input from stdin +try: + input_data = json.load(sys.stdin) +except json.JSONDecodeError as e: + print(f"Error: Invalid JSON input: {e}", file=sys.stderr) + sys.exit(1) + +prompt = input_data.get("prompt", "") + +# Check for sensitive patterns +sensitive_patterns = [ + (r"(?i)\b(password|secret|key|token)\s*[:=]", "Prompt contains potential secrets"), +] + +for pattern, message in sensitive_patterns: + if re.search(pattern, prompt): + # Use JSON output to block with a specific reason + output = { + "decision": "block", + "reason": f"Security policy violation: {message}. Please rephrase your request without sensitive information." + } + print(json.dumps(output)) + sys.exit(0) + +# Add current time to context +context = f"Current time: {datetime.datetime.now()}" +print(context) + +""" +The following is also equivalent: +print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "UserPromptSubmit", + "additionalContext": context, + }, +})) +""" + +# Allow the prompt to proceed with the additional context +sys.exit(0) +``` + +#### JSON Output Example: PreToolUse with Approval + +```python theme={null} +#!/usr/bin/env python3 +import json +import sys + +# Load input from stdin +try: + input_data = json.load(sys.stdin) +except json.JSONDecodeError as e: + print(f"Error: Invalid JSON input: {e}", file=sys.stderr) + sys.exit(1) + +tool_name = input_data.get("tool_name", "") +tool_input = input_data.get("tool_input", {}) + +# Example: Auto-approve file reads for documentation files +if tool_name == "Read": + file_path = tool_input.get("file_path", "") + if file_path.endswith((".md", ".mdx", ".txt", ".json")): + # Use JSON output to auto-approve the tool call + output = { + "decision": "approve", + "reason": "Documentation file auto-approved", + "suppressOutput": True # Don't show in transcript mode + } + print(json.dumps(output)) + sys.exit(0) + +# For other cases, let the normal permission flow proceed +sys.exit(0) +``` + +## Working with MCP Tools + +Claude Code hooks work seamlessly with +[Model Context Protocol (MCP) tools](/en/docs/claude-code/mcp). When MCP servers +provide tools, they appear with a special naming pattern that you can match in +your hooks. + +### MCP Tool Naming + +MCP tools follow the pattern `mcp____`, for example: + +* `mcp__memory__create_entities` - Memory server's create entities tool +* `mcp__filesystem__read_file` - Filesystem server's read file tool +* `mcp__github__search_repositories` - GitHub server's search tool + +### Configuring Hooks for MCP Tools + +You can target specific MCP tools or entire MCP servers: + +```json theme={null} +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "mcp__memory__.*", + "hooks": [ + { + "type": "command", + "command": "echo 'Memory operation initiated' >> ~/mcp-operations.log" + } + ] + }, + { + "matcher": "mcp__.*__write.*", + "hooks": [ + { + "type": "command", + "command": "/home/user/scripts/validate-mcp-write.py" + } + ] + } + ] + } +} +``` + +## Examples + + + For practical examples including code formatting, notifications, and file protection, see [More Examples](/en/docs/claude-code/hooks-guide#more-examples) in the get started guide. + + +## Security Considerations + +### Disclaimer + +**USE AT YOUR OWN RISK**: Claude Code hooks execute arbitrary shell commands on +your system automatically. By using hooks, you acknowledge that: + +* You are solely responsible for the commands you configure +* Hooks can modify, delete, or access any files your user account can access +* Malicious or poorly written hooks can cause data loss or system damage +* Anthropic provides no warranty and assumes no liability for any damages + resulting from hook usage +* You should thoroughly test hooks in a safe environment before production use + +Always review and understand any hook commands before adding them to your +configuration. + +### Security Best Practices + +Here are some key practices for writing more secure hooks: + +1. **Validate and sanitize inputs** - Never trust input data blindly +2. **Always quote shell variables** - Use `"$VAR"` not `$VAR` +3. **Block path traversal** - Check for `..` in file paths +4. **Use absolute paths** - Specify full paths for scripts (use + "\$CLAUDE\_PROJECT\_DIR" for the project path) +5. **Skip sensitive files** - Avoid `.env`, `.git/`, keys, etc. + +### Configuration Safety + +Direct edits to hooks in settings files don't take effect immediately. Claude +Code: + +1. Captures a snapshot of hooks at startup +2. Uses this snapshot throughout the session +3. Warns if hooks are modified externally +4. Requires review in `/hooks` menu for changes to apply + +This prevents malicious hook modifications from affecting your current session. + +## Hook Execution Details + +* **Timeout**: 60-second execution limit by default, configurable per command. + * A timeout for an individual command does not affect the other commands. +* **Parallelization**: All matching hooks run in parallel +* **Deduplication**: Multiple identical hook commands are deduplicated automatically +* **Environment**: Runs in current directory with Claude Code's environment + * The `CLAUDE_PROJECT_DIR` environment variable is available and contains the + absolute path to the project root directory (where Claude Code was started) +* **Input**: JSON via stdin +* **Output**: + * PreToolUse/PostToolUse/Stop/SubagentStop: Progress shown in transcript (Ctrl-R) + * Notification/SessionEnd: Logged to debug only (`--debug`) + * UserPromptSubmit/SessionStart: stdout added as context for Claude + +## Debugging + +### Basic Troubleshooting + +If your hooks aren't working: + +1. **Check configuration** - Run `/hooks` to see if your hook is registered +2. **Verify syntax** - Ensure your JSON settings are valid +3. **Test commands** - Run hook commands manually first +4. **Check permissions** - Make sure scripts are executable +5. **Review logs** - Use `claude --debug` to see hook execution details + +Common issues: + +* **Quotes not escaped** - Use `\"` inside JSON strings +* **Wrong matcher** - Check tool names match exactly (case-sensitive) +* **Command not found** - Use full paths for scripts + +### Advanced Debugging + +For complex hook issues: + +1. **Inspect hook execution** - Use `claude --debug` to see detailed hook + execution +2. **Validate JSON schemas** - Test hook input/output with external tools +3. **Check environment variables** - Verify Claude Code's environment is correct +4. **Test edge cases** - Try hooks with unusual file paths or inputs +5. **Monitor system resources** - Check for resource exhaustion during hook + execution +6. **Use structured logging** - Implement logging in your hook scripts + +### Debug Output Example + +Use `claude --debug` to see hook execution details: + +``` +[DEBUG] Executing hooks for PostToolUse:Write +[DEBUG] Getting matching hook commands for PostToolUse with query: Write +[DEBUG] Found 1 hook matchers in settings +[DEBUG] Matched 1 hooks for query "Write" +[DEBUG] Found 1 hook commands to execute +[DEBUG] Executing hook command: with timeout 60000ms +[DEBUG] Hook command completed with status 0: +``` + +Progress messages appear in transcript mode (Ctrl-R) showing: + +* Which hook is running +* Command being executed +* Success/failure status +* Output or error messages diff --git a/context/claude-code/streaming.md b/context/claude-code/streaming.md new file mode 100644 index 00000000..dde14f98 --- /dev/null +++ b/context/claude-code/streaming.md @@ -0,0 +1,295 @@ +# Streaming Input + +> Understanding the two input modes for Claude Agent SDK and when to use each + +## Overview + +The Claude Agent SDK supports two distinct input modes for interacting with agents: + +* **Streaming Input Mode** (Default & Recommended) - A persistent, interactive session +* **Single Message Input** - One-shot queries that use session state and resuming + +This guide explains the differences, benefits, and use cases for each mode to help you choose the right approach for your application. + +## Streaming Input Mode (Recommended) + +Streaming input mode is the **preferred** way to use the Claude Agent SDK. It provides full access to the agent's capabilities and enables rich, interactive experiences. + +It allows the agent to operate as a long lived process that takes in user input, handles interruptions, surfaces permission requests, and handles session management. + +### How It Works + +```mermaid theme={null} +%%{init: {"theme": "base", "themeVariables": {"edgeLabelBackground": "#F0F0EB", "lineColor": "#91918D", "primaryColor": "#F0F0EB", "primaryTextColor": "#191919", "primaryBorderColor": "#D9D8D5", "secondaryColor": "#F5E6D8", "tertiaryColor": "#CC785C", "noteBkgColor": "#FAF0E6", "noteBorderColor": "#91918D"}, "sequence": {"actorMargin": 50, "width": 150, "height": 65, "boxMargin": 10, "boxTextMargin": 5, "noteMargin": 10, "messageMargin": 35}}}%% +sequenceDiagram + participant App as Your Application + participant Agent as Claude Agent + participant Tools as Tools/Hooks + participant FS as Environment/
File System + + App->>Agent: Initialize with AsyncGenerator + activate Agent + + App->>Agent: Yield Message 1 + Agent->>Tools: Execute tools + Tools->>FS: Read files + FS-->>Tools: File contents + Tools->>FS: Write/Edit files + FS-->>Tools: Success/Error + Agent-->>App: Stream partial response + Agent-->>App: Stream more content... + Agent->>App: Complete Message 1 + + App->>Agent: Yield Message 2 + Image + Agent->>Tools: Process image & execute + Tools->>FS: Access filesystem + FS-->>Tools: Operation results + Agent-->>App: Stream response 2 + + App->>Agent: Queue Message 3 + App->>Agent: Interrupt/Cancel + Agent->>App: Handle interruption + + Note over App,Agent: Session stays alive + Note over Tools,FS: Persistent file system
state maintained + + deactivate Agent +``` + +### Benefits + + + + Attach images directly to messages for visual analysis and understanding + + + + Send multiple messages that process sequentially, with ability to interrupt + + + + Full access to all tools and custom MCP servers during the session + + + + Use lifecycle hooks to customize behavior at various points + + + + See responses as they're generated, not just final results + + + + Maintain conversation context across multiple turns naturally + + + +### Implementation Example + + + ```typescript TypeScript theme={null} + import { query } from "@anthropic-ai/claude-agent-sdk"; + import { readFileSync } from "fs"; + + async function* generateMessages() { + // First message + yield { + type: "user" as const, + message: { + role: "user" as const, + content: "Analyze this codebase for security issues" + } + }; + + // Wait for conditions or user input + await new Promise(resolve => setTimeout(resolve, 2000)); + + // Follow-up with image + yield { + type: "user" as const, + message: { + role: "user" as const, + content: [ + { + type: "text", + text: "Review this architecture diagram" + }, + { + type: "image", + source: { + type: "base64", + media_type: "image/png", + data: readFileSync("diagram.png", "base64") + } + } + ] + } + }; + } + + // Process streaming responses + for await (const message of query({ + prompt: generateMessages(), + options: { + maxTurns: 10, + allowedTools: ["Read", "Grep"] + } + })) { + if (message.type === "result") { + console.log(message.result); + } + } + ``` + + ```python Python theme={null} + from claude_agent_sdk import ClaudeSDKClient, ClaudeAgentOptions, AssistantMessage, TextBlock + import asyncio + import base64 + + async def streaming_analysis(): + async def message_generator(): + # First message + yield { + "type": "user", + "message": { + "role": "user", + "content": "Analyze this codebase for security issues" + } + } + + # Wait for conditions + await asyncio.sleep(2) + + # Follow-up with image + with open("diagram.png", "rb") as f: + image_data = base64.b64encode(f.read()).decode() + + yield { + "type": "user", + "message": { + "role": "user", + "content": [ + { + "type": "text", + "text": "Review this architecture diagram" + }, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": image_data + } + } + ] + } + } + + # Use ClaudeSDKClient for streaming input + options = ClaudeAgentOptions( + max_turns=10, + allowed_tools=["Read", "Grep"] + ) + + async with ClaudeSDKClient(options) as client: + # Send streaming input + await client.query(message_generator()) + + # Process responses + async for message in client.receive_response(): + if isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + print(block.text) + + asyncio.run(streaming_analysis()) + ``` + + +## Single Message Input + +Single message input is simpler but more limited. + +### When to Use Single Message Input + +Use single message input when: + +* You need a one-shot response +* You do not need image attachments, hooks, etc. +* You need to operate in a stateless environment, such as a lambda function + +### Limitations + + + Single message input mode does **not** support: + + * Direct image attachments in messages + * Dynamic message queueing + * Real-time interruption + * Hook integration + * Natural multi-turn conversations + + +### Implementation Example + + + ```typescript TypeScript theme={null} + import { query } from "@anthropic-ai/claude-agent-sdk"; + + // Simple one-shot query + for await (const message of query({ + prompt: "Explain the authentication flow", + options: { + maxTurns: 1, + allowedTools: ["Read", "Grep"] + } + })) { + if (message.type === "result") { + console.log(message.result); + } + } + + // Continue conversation with session management + for await (const message of query({ + prompt: "Now explain the authorization process", + options: { + continue: true, + maxTurns: 1 + } + })) { + if (message.type === "result") { + console.log(message.result); + } + } + ``` + + ```python Python theme={null} + from claude_agent_sdk import query, ClaudeAgentOptions, ResultMessage + import asyncio + + async def single_message_example(): + # Simple one-shot query using query() function + async for message in query( + prompt="Explain the authentication flow", + options=ClaudeAgentOptions( + max_turns=1, + allowed_tools=["Read", "Grep"] + ) + ): + if isinstance(message, ResultMessage): + print(message.result) + + # Continue conversation with session management + async for message in query( + prompt="Now explain the authorization process", + options=ClaudeAgentOptions( + continue_conversation=True, + max_turns=1 + ) + ): + if isinstance(message, ResultMessage): + print(message.result) + + asyncio.run(single_message_example()) + ``` + diff --git a/hook-templates/post-tool-use.js b/hook-templates/post-tool-use.js index 075bb993..20910878 100755 --- a/hook-templates/post-tool-use.js +++ b/hook-templates/post-tool-use.js @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun /** * Post Tool Use Hook - Streaming SDK Version diff --git a/hook-templates/session-start.js b/hook-templates/session-start.js index a44e8ae2..39cf1742 100755 --- a/hook-templates/session-start.js +++ b/hook-templates/session-start.js @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun /** * Session Start Hook (SDK Version) @@ -36,14 +36,12 @@ process.stdin.on('end', async () => { const result = await executeCliCommand('claude-mem', ['load-context', '--format', 'session-start']); if (result.success && result.stdout) { - // Use the CLI output directly as context (it's already formatted) - const response = createHookResponse('SessionStart', true, { - context: result.stdout - }); - console.log(JSON.stringify(response)); + // Per Claude Code docs: for SessionStart, stdout with exit code 0 is added to context + // Use plain stdout instead of JSON to ensure it appears in Claude's context + console.log(result.stdout); process.exit(0); } else { - // Return without context + // Return without context - use JSON with suppressOutput to avoid empty context const response = createHookResponse('SessionStart', true); console.log(JSON.stringify(response)); process.exit(0); diff --git a/hook-templates/shared/hook-helpers.js b/hook-templates/shared/hook-helpers.js index 13e619bf..6dfd905c 100644 --- a/hook-templates/shared/hook-helpers.js +++ b/hook-templates/shared/hook-helpers.js @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun /** * Hook Helper Functions diff --git a/hook-templates/stop.js b/hook-templates/stop.js index 3371c69b..24cb94eb 100755 --- a/hook-templates/stop.js +++ b/hook-templates/stop.js @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun /** * Stop Hook - Simple Orchestrator diff --git a/hook-templates/user-prompt-submit.js b/hook-templates/user-prompt-submit.js index c6e4b54b..87bf00e2 100755 --- a/hook-templates/user-prompt-submit.js +++ b/hook-templates/user-prompt-submit.js @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun /** * User Prompt Submit Hook - Streaming SDK Version diff --git a/src/commands/install.ts b/src/commands/install.ts index 7448c536..718576a3 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -56,8 +56,6 @@ function animatedRainbow(text: string, speed: number = 100): Promise { }); } -// Sleep utility for smooth animations -const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); // Fast rainbow gradient preset with tighter color transitions const fastRainbow = gradient(['#ff0000', '#ff4500', '#ffa500', '#ffff00', '#00ff00', '#00ffff', '#0000ff', '#8b00ff']); @@ -488,8 +486,6 @@ export async function install(options: OptionValues = {}): Promise { textAlignment: 'center' })); - await sleep(500); - installUv(); const isNonInteractive = options.user || options.project || options.local || options.force; @@ -540,7 +536,6 @@ export async function install(options: OptionValues = {}): Promise { step.fn(); loader.stop(`${chalk.gray(progress)} ${step.name} ${vibrantRainbow('completed! ✨')}`); - await sleep(150); }