/** * Session Service — 会话文件的读写操作封装 * * 读写 CLI 持久化在 ~/.claude/projects/{sanitized_path}/{sessionId}.jsonl 的会话数据, * 确保 Desktop App 与 CLI 的数据完全互通。 */ import * as fs from 'node:fs/promises' import * as path from 'node:path' import * as os from 'node:os' import { ApiError } from '../middleware/errorHandler.js' // ============================================================================ // Types // ============================================================================ export type SessionListItem = { id: string title: string createdAt: string modifiedAt: string messageCount: number projectPath: string } export type SessionDetail = SessionListItem & { messages: MessageEntry[] } export type MessageEntry = { id: string type: 'user' | 'assistant' | 'system' | 'tool_use' | 'tool_result' content: unknown timestamp: string model?: string parentUuid?: string isSidechain?: boolean } /** Raw entry parsed from a single JSONL line */ type RawEntry = { type?: string uuid?: string parentUuid?: string | null isSidechain?: boolean isMeta?: boolean message?: { role?: string content?: unknown model?: string id?: string type?: string } timestamp?: string customTitle?: string title?: string [key: string]: unknown } // ============================================================================ // Service // ============================================================================ export class SessionService { // -------------------------------------------------------------------------- // Config helpers // -------------------------------------------------------------------------- private getConfigDir(): string { return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude') } private getProjectsDir(): string { return path.join(this.getConfigDir(), 'projects') } /** * Sanitize a path the same way the CLI does: replace path separators with '-'. * For example, `/Users/nanmi/workspace` becomes `-Users-nanmi-workspace`. */ private sanitizePath(dirPath: string): string { return dirPath.replace(/\//g, '-').replace(/\\/g, '-') } // -------------------------------------------------------------------------- // JSONL parsing // -------------------------------------------------------------------------- private async readJsonlFile(filePath: string): Promise { let content: string try { content = await fs.readFile(filePath, 'utf-8') } catch (err: unknown) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') { return [] } throw err } const entries: RawEntry[] = [] for (const line of content.split('\n')) { const trimmed = line.trim() if (!trimmed) continue try { entries.push(JSON.parse(trimmed) as RawEntry) } catch { // skip malformed lines } } return entries } private async appendJsonlEntry(filePath: string, entry: Record): Promise { const line = JSON.stringify(entry) + '\n' await fs.appendFile(filePath, line, 'utf-8') } // -------------------------------------------------------------------------- // Entry → MessageEntry conversion // -------------------------------------------------------------------------- private entryToMessage(entry: RawEntry): MessageEntry | null { const msg = entry.message if (!msg || !msg.role) return null // Determine our normalized type let type: MessageEntry['type'] const role = msg.role if (role === 'user') { // Check if the content is a tool_result array if (Array.isArray(msg.content)) { const hasToolResult = msg.content.some( (block: Record) => block.type === 'tool_result' ) if (hasToolResult) { type = 'tool_result' } else { type = 'user' } } else { type = 'user' } } else if (role === 'assistant') { // Check if the content contains tool_use blocks if (Array.isArray(msg.content)) { const hasToolUse = msg.content.some( (block: Record) => block.type === 'tool_use' ) type = hasToolUse ? 'tool_use' : 'assistant' } else { type = 'assistant' } } else { type = 'system' } return { id: entry.uuid || crypto.randomUUID(), type, content: msg.content, timestamp: entry.timestamp || new Date().toISOString(), model: msg.model, parentUuid: entry.parentUuid ?? undefined, isSidechain: entry.isSidechain, } } // -------------------------------------------------------------------------- // Title extraction // -------------------------------------------------------------------------- private extractTitle(entries: RawEntry[]): string { // 1. Look for custom title entry (appended by renameSession) for (let i = entries.length - 1; i >= 0; i--) { const e = entries[i]! if (e.type === 'custom-title' && e.customTitle) { return e.customTitle } } // 2. Look for first non-meta user message as title for (const e of entries) { if (e.type === 'user' && !e.isMeta && e.message?.role === 'user') { const content = e.message.content let text: string | undefined if (typeof content === 'string') { text = content } else if (Array.isArray(content)) { // Extract text from content blocks: [{ type: 'text', text: '...' }] const textBlock = content.find( (block: Record) => block.type === 'text' && typeof block.text === 'string' ) if (textBlock) text = textBlock.text as string } if (text) { return text.length > 80 ? text.slice(0, 80) + '...' : text } } } return 'Untitled Session' } // -------------------------------------------------------------------------- // Session file discovery // -------------------------------------------------------------------------- /** * Find all .jsonl session files across all project directories. * Returns an array of { filePath, projectDir, sessionId }. */ private async discoverSessionFiles(projectFilter?: string): Promise< Array<{ filePath: string; projectDir: string; sessionId: string }> > { const projectsDir = this.getProjectsDir() let projectDirs: string[] try { projectDirs = await fs.readdir(projectsDir) } catch { return [] } // Optionally filter to a specific project if (projectFilter) { const sanitized = this.sanitizePath(projectFilter) projectDirs = projectDirs.filter((d) => d === sanitized) } const results: Array<{ filePath: string; projectDir: string; sessionId: string }> = [] for (const dir of projectDirs) { const dirPath = path.join(projectsDir, dir) // Ensure it's a directory try { const stat = await fs.stat(dirPath) if (!stat.isDirectory()) continue } catch { continue } let files: string[] try { files = await fs.readdir(dirPath) } catch { continue } for (const file of files) { if (!file.endsWith('.jsonl')) continue const sessionId = file.replace('.jsonl', '') results.push({ filePath: path.join(dirPath, file), projectDir: dir, sessionId, }) } } return results } /** * Convert a sanitized directory name back to the original absolute path. * Reverses sanitizePath(): `-Users-nanmi-workspace` → `/Users/nanmi/workspace`. */ desanitizePath(sanitized: string): string { // The sanitized form replaces all '/' (and '\') with '-'. // On POSIX the original path starts with '/', so the sanitized form starts with '-'. // We restore by replacing every '-' with '/' (the platform separator). // On Windows the leading character would be a drive letter, but we handle POSIX here. return sanitized.replace(/-/g, path.sep) } /** * Find the .jsonl file for a given session ID. * Searches across all project directories since sessions may belong to any project. */ async findSessionFile( sessionId: string ): Promise<{ filePath: string; projectDir: string } | null> { // Validate sessionId format to prevent path traversal if (!this.isValidSessionId(sessionId)) { return null } const projectsDir = this.getProjectsDir() let projectDirs: string[] try { projectDirs = await fs.readdir(projectsDir) } catch { return null } for (const dir of projectDirs) { const filePath = path.join(projectsDir, dir, `${sessionId}.jsonl`) try { await fs.access(filePath) return { filePath, projectDir: dir } } catch { continue } } return null } private isValidSessionId(id: string): boolean { // UUID v4 format return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id) } // -------------------------------------------------------------------------- // Public API // -------------------------------------------------------------------------- /** * List all sessions, optionally filtered by project path. */ async listSessions(options?: { project?: string limit?: number offset?: number }): Promise<{ sessions: SessionListItem[]; total: number }> { const sessionFiles = await this.discoverSessionFiles(options?.project) // Build session list items with metadata from file stats & first entries const items: SessionListItem[] = [] for (const { filePath, projectDir, sessionId } of sessionFiles) { try { const stat = await fs.stat(filePath) const entries = await this.readJsonlFile(filePath) // Count transcript messages only (user + assistant) const messageCount = entries.filter( (e) => (e.type === 'user' || e.type === 'assistant') && e.message?.role ).length const title = this.extractTitle(entries) // Find the earliest timestamp from entries, fallback to file birthtime let createdAt = stat.birthtime.toISOString() for (const e of entries) { if (e.timestamp) { createdAt = e.timestamp break } } items.push({ id: sessionId, title, createdAt, modifiedAt: stat.mtime.toISOString(), messageCount, projectPath: projectDir, }) } catch { // Skip unreadable files } } // Sort by modifiedAt descending (most recent first) items.sort((a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime()) const total = items.length const offset = options?.offset ?? 0 const limit = options?.limit ?? 50 const paginated = items.slice(offset, offset + limit) return { sessions: paginated, total } } /** * Get full session detail including all messages. */ async getSession(sessionId: string): Promise { const found = await this.findSessionFile(sessionId) if (!found) return null const { filePath, projectDir } = found const stat = await fs.stat(filePath) const entries = await this.readJsonlFile(filePath) const messages = this.entriesToMessages(entries) const title = this.extractTitle(entries) let createdAt = stat.birthtime.toISOString() for (const e of entries) { if (e.timestamp) { createdAt = e.timestamp break } } return { id: sessionId, title, createdAt, modifiedAt: stat.mtime.toISOString(), messageCount: messages.length, projectPath: projectDir, messages, } } /** * Get only the messages for a session (lighter than full detail). */ async getSessionMessages(sessionId: string): Promise { const found = await this.findSessionFile(sessionId) if (!found) { throw ApiError.notFound(`Session not found: ${sessionId}`) } const entries = await this.readJsonlFile(found.filePath) return this.entriesToMessages(entries) } /** * Create a new session file for the given working directory. */ async createSession(workDir: string): Promise<{ sessionId: string }> { if (!workDir) { throw ApiError.badRequest('workDir is required') } const sessionId = crypto.randomUUID() const sanitized = this.sanitizePath(workDir) const dirPath = path.join(this.getProjectsDir(), sanitized) // Ensure the project directory exists await fs.mkdir(dirPath, { recursive: true }) const filePath = path.join(dirPath, `${sessionId}.jsonl`) const now = new Date().toISOString() // Write an initial file-history-snapshot entry (matches CLI behavior) const initialEntry = { type: 'file-history-snapshot', messageId: crypto.randomUUID(), snapshot: { messageId: crypto.randomUUID(), trackedFileBackups: {}, timestamp: now, }, isSnapshotUpdate: false, } await fs.writeFile(filePath, JSON.stringify(initialEntry) + '\n', 'utf-8') return { sessionId } } /** * Delete a session's JSONL file. */ async deleteSession(sessionId: string): Promise { const found = await this.findSessionFile(sessionId) if (!found) { throw ApiError.notFound(`Session not found: ${sessionId}`) } await fs.unlink(found.filePath) } /** * Rename a session by appending a custom-title entry to its JSONL file. */ async renameSession(sessionId: string, title: string): Promise { if (!title || typeof title !== 'string') { throw ApiError.badRequest('title is required') } const found = await this.findSessionFile(sessionId) if (!found) { throw ApiError.notFound(`Session not found: ${sessionId}`) } const entry = { type: 'custom-title', customTitle: title, timestamp: new Date().toISOString(), } await this.appendJsonlEntry(found.filePath, entry) } // -------------------------------------------------------------------------- // Private helpers // -------------------------------------------------------------------------- private entriesToMessages(entries: RawEntry[]): MessageEntry[] { const messages: MessageEntry[] = [] for (const entry of entries) { // Only process transcript entries (user / assistant / system with messages) if (!entry.message?.role) continue // Skip meta entries (CLI internal bookkeeping) if (entry.isMeta) continue // Skip non-transcript entry types const entryType = entry.type if ( entryType !== 'user' && entryType !== 'assistant' && entryType !== 'system' ) { continue } const msg = this.entryToMessage(entry) if (msg) { messages.push(msg) } } return messages } } // Singleton instance for shared use across API handlers export const sessionService = new SessionService()