329 lines
10 KiB
TypeScript
329 lines
10 KiB
TypeScript
/**
|
|
* 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<string, { input: number; output: number }> = {
|
|
'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
|
|
}
|
|
}
|