/** * Claude Code Local Session Scanner * * Discovers and tracks local Claude Code sessions by scanning ~/.claude/projects/. * Each project directory contains JSONL session transcripts that record every * user message, assistant response, and tool call with timestamps and token usage. * * This module parses those JSONL files to extract: * - Session metadata (model, project, git branch, timestamps) * - Message counts (user, assistant, tool uses) * - Token usage (input, output, estimated cost) * - Activity status (active if last message < 5 minutes ago) */ import { readdirSync, readFileSync, statSync } from 'fs' import { join } from 'path' import { config } from './config' import { getDatabase } from './db' import { logger } from './logger' // Rough per-token pricing (USD) for cost estimation const MODEL_PRICING: Record = { 'claude-opus-4-6': { input: 15 / 1_000_000, output: 75 / 1_000_000 }, 'claude-sonnet-4-6': { input: 3 / 1_000_000, output: 15 / 1_000_000 }, 'claude-haiku-4-5': { input: 0.8 / 1_000_000, output: 4 / 1_000_000 }, } const DEFAULT_PRICING = { input: 3 / 1_000_000, output: 15 / 1_000_000 } // Session is "active" if last activity was within this window. // Local CLI sessions can remain interactive without emitting frequent logs. const ACTIVE_THRESHOLD_MS = 90 * 60 * 1000 const FUTURE_TOLERANCE_MS = 60 * 1000 interface SessionStats { sessionId: string projectSlug: string projectPath: string | null model: string | null gitBranch: string | null userMessages: number assistantMessages: number toolUses: number inputTokens: number outputTokens: number estimatedCost: number firstMessageAt: string | null lastMessageAt: string | null lastUserPrompt: string | null isActive: boolean } interface JSONLEntry { type?: string sessionId?: string timestamp?: string isSidechain?: boolean gitBranch?: string cwd?: string message?: { role?: string content?: string | Array<{ type: string; text?: string; id?: string }> model?: string usage?: { input_tokens?: number output_tokens?: number cache_read_input_tokens?: number cache_creation_input_tokens?: number } } } /** Parse a single JSONL file and extract session stats */ function clampTimestamp(ms: number): number { if (!Number.isFinite(ms) || ms <= 0) return 0 const now = Date.now() if (ms > now + FUTURE_TOLERANCE_MS) return now return ms } function parseSessionFile(filePath: string, projectSlug: string, fileMtimeMs: number): SessionStats | null { try { const content = readFileSync(filePath, 'utf-8') const lines = content.split('\n').filter(Boolean) if (lines.length === 0) return null let sessionId: string | null = null let model: string | null = null let gitBranch: string | null = null let projectPath: string | null = null let userMessages = 0 let assistantMessages = 0 let toolUses = 0 let inputTokens = 0 let outputTokens = 0 let cacheReadTokens = 0 let cacheCreationTokens = 0 let firstMessageAt: string | null = null let lastMessageAt: string | null = null let lastUserPrompt: string | null = null for (const line of lines) { let entry: JSONLEntry try { entry = JSON.parse(line) } catch { continue } // Extract session ID from first entry that has one if (!sessionId && entry.sessionId) { sessionId = entry.sessionId } // Extract git branch if (!gitBranch && entry.gitBranch) { gitBranch = entry.gitBranch } // Extract project working directory if (!projectPath && entry.cwd) { projectPath = entry.cwd } // Track timestamps if (entry.timestamp) { if (!firstMessageAt) firstMessageAt = entry.timestamp lastMessageAt = entry.timestamp } // Skip sidechain messages (subagent work) for counts if (entry.isSidechain) continue if (entry.type === 'user' && entry.message) { userMessages++ // Extract last user prompt text const msg = entry.message if (typeof msg.content === 'string' && msg.content.length > 0) { lastUserPrompt = msg.content.slice(0, 500) } } if (entry.type === 'assistant' && entry.message) { assistantMessages++ // Extract model if (entry.message.model) { model = entry.message.model } // Extract token usage const usage = entry.message.usage if (usage) { inputTokens += (usage.input_tokens || 0) cacheReadTokens += (usage.cache_read_input_tokens || 0) cacheCreationTokens += (usage.cache_creation_input_tokens || 0) outputTokens += (usage.output_tokens || 0) } // Count tool uses in assistant content if (Array.isArray(entry.message.content)) { for (const block of entry.message.content) { if (block.type === 'tool_use') toolUses++ } } } } if (!sessionId) return null // Estimate cost (cache reads = 10% of input, cache creation = 125% of input) const pricing = (model && MODEL_PRICING[model]) || DEFAULT_PRICING const estimatedCost = inputTokens * pricing.input + cacheReadTokens * pricing.input * 0.1 + cacheCreationTokens * pricing.input * 1.25 + outputTokens * pricing.output const parsedFirstMs = firstMessageAt ? clampTimestamp(new Date(firstMessageAt).getTime()) : 0 const parsedLastMs = lastMessageAt ? clampTimestamp(new Date(lastMessageAt).getTime()) : 0 const mtimeMs = clampTimestamp(fileMtimeMs) const effectiveLastMs = Math.max(parsedLastMs, mtimeMs) const effectiveFirstMs = parsedFirstMs || mtimeMs const isActive = effectiveLastMs > 0 && (Date.now() - effectiveLastMs) < ACTIVE_THRESHOLD_MS // Store total input tokens (including cache) for display const totalInputTokens = inputTokens + cacheReadTokens + cacheCreationTokens return { sessionId, projectSlug, projectPath, model, gitBranch, userMessages, assistantMessages, toolUses, inputTokens: totalInputTokens, outputTokens, estimatedCost: Math.round(estimatedCost * 10000) / 10000, firstMessageAt: effectiveFirstMs ? new Date(effectiveFirstMs).toISOString() : null, lastMessageAt: effectiveLastMs ? new Date(effectiveLastMs).toISOString() : null, lastUserPrompt, isActive, } } catch (err) { logger.warn({ err, filePath }, 'Failed to parse Claude session file') return null } } /** Scan all Claude Code projects and discover sessions */ export function scanClaudeSessions(): SessionStats[] { const claudeHome = config.claudeHome if (!claudeHome) return [] const projectsDir = join(claudeHome, 'projects') let projectDirs: string[] try { projectDirs = readdirSync(projectsDir) } catch { return [] // No projects directory — Claude Code not installed or never used } const sessions: SessionStats[] = [] for (const projectSlug of projectDirs) { const projectDir = join(projectsDir, projectSlug) let stat try { stat = statSync(projectDir) } catch { continue } if (!stat.isDirectory()) continue // Find JSONL files in this project let files: string[] try { files = readdirSync(projectDir).filter(f => f.endsWith('.jsonl')) } catch { continue } for (const file of files) { const filePath = join(projectDir, file) const parsed = parseSessionFile(filePath, projectSlug, statSync(filePath).mtimeMs) if (parsed) sessions.push(parsed) } } return sessions } // Throttle full disk scans — at most once per 30 seconds let lastSyncAt = 0 let lastSyncResult: { ok: boolean; message: string } = { ok: true, message: 'Not yet scanned' } const SYNC_THROTTLE_MS = 30_000 /** Scan and upsert sessions into the database (throttled to avoid repeated disk scans) */ export async function syncClaudeSessions(force = false): Promise<{ ok: boolean; message: string }> { const now = Date.now() if (!force && lastSyncAt > 0 && (now - lastSyncAt) < SYNC_THROTTLE_MS) { return lastSyncResult } try { const sessions = scanClaudeSessions() if (sessions.length === 0) { return { ok: true, message: 'No Claude sessions found' } } const db = getDatabase() const now = Math.floor(Date.now() / 1000) const upsert = db.prepare(` INSERT INTO claude_sessions ( session_id, project_slug, project_path, model, git_branch, user_messages, assistant_messages, tool_uses, input_tokens, output_tokens, estimated_cost, first_message_at, last_message_at, last_user_prompt, is_active, scanned_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(session_id) DO UPDATE SET model = excluded.model, git_branch = excluded.git_branch, user_messages = excluded.user_messages, assistant_messages = excluded.assistant_messages, tool_uses = excluded.tool_uses, input_tokens = excluded.input_tokens, output_tokens = excluded.output_tokens, estimated_cost = excluded.estimated_cost, last_message_at = excluded.last_message_at, last_user_prompt = excluded.last_user_prompt, is_active = excluded.is_active, scanned_at = excluded.scanned_at, updated_at = excluded.updated_at `) let upserted = 0 db.transaction(() => { // Mark all sessions inactive before scanning db.prepare('UPDATE claude_sessions SET is_active = 0').run() for (const s of sessions) { upsert.run( s.sessionId, s.projectSlug, s.projectPath, s.model, s.gitBranch, s.userMessages, s.assistantMessages, s.toolUses, s.inputTokens, s.outputTokens, s.estimatedCost, s.firstMessageAt, s.lastMessageAt, s.lastUserPrompt, s.isActive ? 1 : 0, now, now, ) upserted++ } })() const active = sessions.filter(s => s.isActive).length lastSyncAt = Date.now() lastSyncResult = { ok: true, message: `Scanned ${upserted} session(s), ${active} active` } return lastSyncResult } catch (err: any) { logger.error({ err }, 'Claude session sync failed') lastSyncAt = Date.now() lastSyncResult = { ok: false, message: `Scan failed: ${err.message}` } return lastSyncResult } }