mission-control/src/lib/claude-sessions.ts

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
}
}