/** * 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' import { sanitizePath as sanitizePortablePath } from '../../utils/sessionStoragePortable.js' import type { FileHistorySnapshot } from '../../utils/fileHistory.js' import { calculateUSDCost, MODEL_COSTS } from '../../utils/modelCost.js' import { calculateCurrentContextTokenTotal, MODEL_CONTEXT_WINDOW_DEFAULT, getContextWindowForModel, getModelMaxOutputTokens, } from '../../utils/context.js' import { getCanonicalName } from '../../utils/model/model.js' import { resolveSessionWorkspaceLaunch, type CreateSessionRepositoryOptions, type PreparedSessionWorkspace, } from './repositoryLaunchService.js' // ============================================================================ // Types // ============================================================================ export type SessionListItem = { id: string title: string createdAt: string modifiedAt: string messageCount: number projectPath: string workDir: string | null workDirExists: boolean } export type SessionDetail = SessionListItem & { messages: MessageEntry[] } export type SessionLaunchInfo = { filePath: string projectDir: string workDir: string repository?: PreparedSessionWorkspace['repository'] transcriptMessageCount: number customTitle: string | null } export type TrimSessionResult = { removedCount: number removedMessageIds: string[] } export type MessageEntry = { id: string type: 'user' | 'assistant' | 'system' | 'tool_use' | 'tool_result' content: unknown timestamp: string model?: string parentUuid?: string parentToolUseId?: string isSidechain?: boolean } export type SessionTaskNotification = { taskId: string toolUseId: string status: 'completed' | 'failed' | 'stopped' summary?: string outputFile?: string } export type TranscriptUsageSnapshot = { source: 'transcript' totalCostUSD: number costDisplay: string hasUnknownModelCost: boolean totalAPIDuration: number totalDuration: number totalLinesAdded: number totalLinesRemoved: number totalInputTokens: number totalOutputTokens: number totalCacheReadInputTokens: number totalCacheCreationInputTokens: number totalWebSearchRequests: number models: Array<{ model: string displayName: string inputTokens: number outputTokens: number cacheReadInputTokens: number cacheCreationInputTokens: number webSearchRequests: number costUSD: number costDisplay: string contextWindow: number maxOutputTokens: number }> } export type TranscriptMetadataSnapshot = { model?: string cwd?: string version?: string } export type TranscriptContextEstimate = { categories: Array<{ name: string tokens: number color: string isDeferred?: boolean }> totalTokens: number maxTokens: number rawMaxTokens: number percentage: number gridRows: Array> model: string memoryFiles: Array<{ path: string; type: string; tokens: number }> mcpTools: Array<{ name: string; serverName: string; tokens: number; isLoaded?: boolean }> agents: Array<{ agentType: string; source: string; tokens: number }> apiUsage: { input_tokens: number output_tokens: number cache_creation_input_tokens: number cache_read_input_tokens: number } } /** Raw entry parsed from a single JSONL line */ type RawEntry = { type?: string uuid?: string messageId?: string parentUuid?: string | null parent_tool_use_id?: string | null isSidechain?: boolean isMeta?: boolean cwd?: string message?: { role?: string content?: unknown model?: string id?: string type?: string usage?: { input_tokens?: number output_tokens?: number cache_read_input_tokens?: number cache_creation_input_tokens?: number server_tool_use?: { web_search_requests?: number } speed?: string } } timestamp?: string version?: string snapshot?: { messageId?: string trackedFileBackups?: Record timestamp?: string } customTitle?: string title?: string [key: string]: unknown } type ContentBlock = Record const USER_INTERRUPTION_TEXTS = new Set([ '[Request interrupted by user]', '[Request interrupted by user for tool use]', ]) const NO_RESPONSE_REQUESTED_TEXT = 'No response requested.' const TASK_NOTIFICATION_RE = /^\s*[\s\S]*<\/task-notification>$/i const TASK_NOTIFICATION_BLOCK_RE = /\s*[\s\S]*?<\/task-notification>/i // ============================================================================ // 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 shared session storage does. * This must remain Windows-safe, so reserved characters such as ':' are normalized too. */ private sanitizePath(dirPath: string): string { return sanitizePortablePath(dirPath) } // -------------------------------------------------------------------------- // 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') } private resolveWorkDirFromEntries( entries: RawEntry[], fallbackProjectDir?: string, ): string | null { for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i] if (entry.type === 'session-meta' && typeof (entry as Record).workDir === 'string') { return (entry as Record).workDir as string } } for (let i = entries.length - 1; i >= 0; i--) { const cwd = entries[i]?.cwd if (typeof cwd === 'string' && cwd.trim()) { return cwd } } return fallbackProjectDir ? this.desanitizePath(fallbackProjectDir) : null } private resolveRepositoryFromEntries(entries: RawEntry[]): PreparedSessionWorkspace['repository'] | undefined { for (let i = entries.length - 1; i >= 0; i--) { const repository = (entries[i] as Record)?.repository if (repository && typeof repository === 'object') { return repository as PreparedSessionWorkspace['repository'] } } return undefined } private countTranscriptMessages(entries: RawEntry[]): number { return entries.filter((entry) => !entry.isMeta && !!entry.message?.role && (entry.type === 'user' || entry.type === 'assistant' || entry.type === 'system') ).length } // -------------------------------------------------------------------------- // Entry → MessageEntry conversion // -------------------------------------------------------------------------- private entryToMessage( entry: RawEntry, parentToolUseId?: string, ): 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, parentToolUseId, isSidechain: entry.isSidechain, } } private extractTextBlocks(content: unknown): string[] { if (typeof content === 'string') return [content] if (!Array.isArray(content)) return [] return content .flatMap((block) => { if (!block || typeof block !== 'object') return [] const record = block as Record return record.type === 'text' && typeof record.text === 'string' ? [record.text] : [] }) .map((text) => text.trim()) .filter(Boolean) } private isInternalCommandBreadcrumb(content: unknown): boolean { if (typeof content !== 'string') return false return ( content.includes('') || content.includes('') || content.includes('') || content.includes('') ) } private isSyntheticUserInterruption(content: unknown): boolean { const textBlocks = this.extractTextBlocks(content) return ( textBlocks.length > 0 && textBlocks.every((text) => USER_INTERRUPTION_TEXTS.has(text)) ) } private isSyntheticNoResponseAssistant(content: unknown): boolean { const textBlocks = this.extractTextBlocks(content) return ( textBlocks.length > 0 && textBlocks.every((text) => text === NO_RESPONSE_REQUESTED_TEXT) ) } private isToolResultContent(content: unknown): boolean { return ( Array.isArray(content) && content.some((block) => block && typeof block === 'object' && (block as Record).type === 'tool_result' ) ) } private isTaskNotificationContent(content: unknown): boolean { const textBlocks = this.extractTextBlocks(content) return ( textBlocks.length > 0 && textBlocks.every((text) => this.extractTaskNotificationXml(text) !== null) ) } private extractTaskNotificationXml(text: string): string | null { const trimmed = text.trim() if (TASK_NOTIFICATION_RE.test(trimmed)) return trimmed return trimmed.match(TASK_NOTIFICATION_BLOCK_RE)?.[0] ?? null } private decodeXmlText(text: string): string { return text .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/&/g, '&') } private readXmlTag(xml: string, tag: string): string | undefined { const match = xml.match(new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'i')) return match?.[1] ? this.decodeXmlText(match[1].trim()) : undefined } private parseTaskNotificationContent(content: unknown): SessionTaskNotification | null { const xml = this.extractTextBlocks(content) .map((text) => this.extractTaskNotificationXml(text)) .find((value): value is string => value !== null) if (!xml) return null const toolUseId = this.readXmlTag(xml, 'tool-use-id') const status = this.readXmlTag(xml, 'status') if ( !toolUseId || (status !== 'completed' && status !== 'failed' && status !== 'stopped') ) { return null } const taskId = this.readXmlTag(xml, 'task-id') || toolUseId const summary = this.readXmlTag(xml, 'summary') const outputFile = this.readXmlTag(xml, 'output-file') return { taskId, toolUseId, status, ...(summary ? { summary } : {}), ...(outputFile ? { outputFile } : {}), } } private shouldHideTranscriptEntry(entry: RawEntry): boolean { const role = entry.message?.role const content = entry.message?.content if (role === 'user') { return ( this.isInternalCommandBreadcrumb(content) || this.isSyntheticUserInterruption(content) || this.isTaskNotificationContent(content) ) } if (role === 'assistant') { return this.isSyntheticNoResponseAssistant(content) } return false } private extractAgentToolUseId(entry: RawEntry): string | undefined { const content = entry.message?.content if (!Array.isArray(content)) return undefined for (const block of content as Array>) { if ( block.type === 'tool_use' && block.name === 'Agent' && typeof block.id === 'string' ) { return block.id } } return undefined } private extractAgentToolUseIdsFromMessage(message: MessageEntry): string[] { if (message.type !== 'tool_use' || !Array.isArray(message.content)) { return [] } return (message.content as ContentBlock[]) .filter((block) => block.type === 'tool_use' && block.name === 'Agent') .flatMap((block) => (typeof block.id === 'string' ? [block.id] : [])) } private extractTextFromContent(content: unknown): string { if (typeof content === 'string') return content if (!Array.isArray(content)) return '' return (content as ContentBlock[]) .flatMap((block) => (typeof block.text === 'string' ? [block.text] : [])) .join('\n') } private extractAgentIdFromResultText(text: string): string | undefined { const match = text.match(/(?:^|\n)\s*agentId:\s*([A-Za-z0-9_-]+)/) return match?.[1] } private extractAgentResultLinks(messages: MessageEntry[]): Map { const agentToolUseIds = new Set( messages.flatMap((message) => this.extractAgentToolUseIdsFromMessage(message)), ) const resultLinks = new Map() for (const message of messages) { if (message.type !== 'tool_result' || !Array.isArray(message.content)) { continue } for (const block of message.content as ContentBlock[]) { if (block.type !== 'tool_result' || typeof block.tool_use_id !== 'string') { continue } if (!agentToolUseIds.has(block.tool_use_id)) { continue } const agentId = this.extractAgentIdFromResultText( this.extractTextFromContent(block.content), ) if (agentId) { resultLinks.set(block.tool_use_id, agentId) } } } return resultLinks } private namespaceSubagentContentIds(content: unknown, namespace: string): unknown { if (!Array.isArray(content)) return content return (content as ContentBlock[]).map((block) => { if (!block || typeof block !== 'object') return block if (block.type === 'tool_use' && typeof block.id === 'string') { return { ...block, id: `${namespace}/${block.id}` } } if (block.type === 'tool_result' && typeof block.tool_use_id === 'string') { return { ...block, tool_use_id: `${namespace}/${block.tool_use_id}` } } return block }) } private subagentTranscriptPath( projectDir: string, sessionId: string, agentId: string, ): string { const normalizedAgentId = agentId.startsWith('agent-') ? agentId : `agent-${agentId}` return path.join( this.getProjectsDir(), projectDir, sessionId, 'subagents', `${normalizedAgentId}.jsonl`, ) } private async loadSubagentToolMessages( projectDir: string, sessionId: string, parentToolUseId: string, agentId: string, ): Promise { const filePath = this.subagentTranscriptPath(projectDir, sessionId, agentId) const entries = await this.readJsonlFile(filePath) const namespace = `${parentToolUseId}/${agentId}` const messages: MessageEntry[] = [] for (const entry of entries) { if (!entry.message?.role || entry.isMeta) continue if (this.shouldHideTranscriptEntry(entry)) continue if (entry.type !== 'user' && entry.type !== 'assistant' && entry.type !== 'system') { continue } const message = this.entryToMessage( { ...entry, message: { ...entry.message, content: this.namespaceSubagentContentIds(entry.message.content, namespace), }, }, parentToolUseId, ) if (message && (message.type === 'tool_use' || message.type === 'tool_result')) { messages.push(message) } } return messages } private async appendSubagentToolMessages( projectDir: string, sessionId: string, messages: MessageEntry[], ): Promise { const resultLinks = this.extractAgentResultLinks(messages) if (resultLinks.size === 0) { return messages } const childMessages = await Promise.all( [...resultLinks.entries()].map(([parentToolUseId, agentId]) => this.loadSubagentToolMessages(projectDir, sessionId, parentToolUseId, agentId), ), ) return [...messages, ...childMessages.flat()] } private resolveParentToolUseId( entry: RawEntry, entriesByUuid: Map, cache: Map, ): string | undefined { if ( typeof entry.parent_tool_use_id === 'string' && entry.parent_tool_use_id.length > 0 ) { return entry.parent_tool_use_id } if (entry.isSidechain !== true) { return undefined } const cacheKey = entry.uuid if (cacheKey && cache.has(cacheKey)) { return cache.get(cacheKey) } let resolved: string | undefined let currentParentUuid = typeof entry.parentUuid === 'string' ? entry.parentUuid : undefined const visited = new Set() while (currentParentUuid && !visited.has(currentParentUuid)) { visited.add(currentParentUuid) const parentEntry = entriesByUuid.get(currentParentUuid) if (!parentEntry) break const directAgentToolUseId = this.extractAgentToolUseId(parentEntry) if (directAgentToolUseId) { resolved = directAgentToolUseId break } if (parentEntry.uuid && cache.has(parentEntry.uuid)) { resolved = cache.get(parentEntry.uuid) break } currentParentUuid = typeof parentEntry.parentUuid === 'string' ? parentEntry.parentUuid : undefined } if (cacheKey) { cache.set(cacheKey, resolved) } return resolved } // -------------------------------------------------------------------------- // Title extraction // -------------------------------------------------------------------------- private extractTitle(entries: RawEntry[]): string { // 1. Look for custom title entry (appended by renameSession) — highest priority 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 AI-generated title (written by titleService) for (let i = entries.length - 1; i >= 0; i--) { const e = entries[i]! if (e.type === 'ai-title' && e.aiTitle) { return e.aiTitle as string } } // 3. 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)) { 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. */ private async findSessionFiles( sessionId: string ): Promise> { if (!this.isValidSessionId(sessionId)) { return [] } const projectsDir = this.getProjectsDir() let projectDirs: string[] try { projectDirs = await fs.readdir(projectsDir) } catch { return [] } const matches: Array<{ filePath: string; projectDir: string }> = [] for (const dir of projectDirs) { const filePath = path.join(projectsDir, dir, `${sessionId}.jsonl`) try { await fs.access(filePath) matches.push({ filePath, projectDir: dir }) } catch { continue } } return matches } async findSessionFile( sessionId: string ): Promise<{ filePath: string; projectDir: string } | null> { return (await this.findSessionFiles(sessionId))[0] ?? 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) } private formatCost(cost: number): string { return `$${cost > 0.5 ? (Math.round(cost * 100) / 100).toFixed(2) : cost.toFixed(4)}` } private getTranscriptContextWindow(model: string): number { try { return getContextWindowForModel(model) } catch (err) { if ( err instanceof Error && err.message.includes('Config accessed before allowed') ) { return MODEL_CONTEXT_WINDOW_DEFAULT } throw err } } async getTranscriptMetadata(sessionId: string): Promise { const found = await this.findSessionFile(sessionId) if (!found) return null const entries = await this.readJsonlFile(found.filePath) const metadata: TranscriptMetadataSnapshot = {} for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i]! if (!metadata.model && typeof entry.message?.model === 'string') { metadata.model = entry.message.model } if (!metadata.cwd && typeof entry.cwd === 'string') { metadata.cwd = entry.cwd } if (!metadata.version && typeof entry.version === 'string') { metadata.version = entry.version } if (metadata.model && metadata.cwd && metadata.version) break } return metadata } async getTranscriptContextEstimate(sessionId: string): Promise { const found = await this.findSessionFile(sessionId) if (!found) return null const entries = await this.readJsonlFile(found.filePath) let latest: { model: string inputTokens: number outputTokens: number cacheReadInputTokens: number cacheCreationInputTokens: number } | null = null for (const entry of entries) { const usage = entry.message?.usage const model = entry.message?.model if (!usage || typeof model !== 'string') continue const inputTokens = typeof usage.input_tokens === 'number' ? usage.input_tokens : 0 const outputTokens = typeof usage.output_tokens === 'number' ? usage.output_tokens : 0 const cacheReadInputTokens = typeof usage.cache_read_input_tokens === 'number' ? usage.cache_read_input_tokens : 0 const cacheCreationInputTokens = typeof usage.cache_creation_input_tokens === 'number' ? usage.cache_creation_input_tokens : 0 const promptTokens = inputTokens + cacheReadInputTokens + cacheCreationInputTokens if (promptTokens === 0 && outputTokens === 0) continue latest = { model, inputTokens, outputTokens, cacheReadInputTokens, cacheCreationInputTokens, } } if (!latest) return null const rawMaxTokens = this.getTranscriptContextWindow(latest.model) const promptTokens = latest.inputTokens + latest.cacheReadInputTokens + latest.cacheCreationInputTokens const totalTokens = calculateCurrentContextTokenTotal(promptTokens, { input_tokens: latest.inputTokens, output_tokens: latest.outputTokens, cache_read_input_tokens: latest.cacheReadInputTokens, cache_creation_input_tokens: latest.cacheCreationInputTokens, }) const percentage = rawMaxTokens > 0 ? Math.round((totalTokens / rawMaxTokens) * 100) : 0 const categories: TranscriptContextEstimate['categories'] = [ { name: 'Input tokens', tokens: latest.inputTokens, color: '#8f3217' }, { name: 'Cache read', tokens: latest.cacheReadInputTokens, color: '#0f5c8f' }, { name: 'Cache write', tokens: latest.cacheCreationInputTokens, color: '#7c3aed' }, { name: 'Output tokens', tokens: latest.outputTokens, color: '#2f7d32' }, { name: 'Free space', tokens: Math.max(0, rawMaxTokens - totalTokens), color: '#a1a1aa', isDeferred: true }, ].filter((category) => category.tokens > 0) const filledSquares = Math.max(0, Math.min(100, Math.round((totalTokens / Math.max(1, rawMaxTokens)) * 100))) const gridRows = Array.from({ length: 10 }, (_, row) => Array.from({ length: 10 }, (_, col) => { const index = row * 10 + col const isFilled = index < filledSquares return { color: isFilled ? '#8f3217' : '#a1a1aa', isFilled, categoryName: isFilled ? 'Input context' : 'Free space', tokens: Math.round(rawMaxTokens / 100), percentage: 1, squareFullness: isFilled ? 1 : 0, } }), ) return { categories, totalTokens, maxTokens: rawMaxTokens, rawMaxTokens, percentage, gridRows, model: latest.model, memoryFiles: [], mcpTools: [], agents: [], apiUsage: { input_tokens: latest.inputTokens, output_tokens: latest.outputTokens, cache_creation_input_tokens: latest.cacheCreationInputTokens, cache_read_input_tokens: latest.cacheReadInputTokens, }, } } async getTranscriptUsage(sessionId: string): Promise { const found = await this.findSessionFile(sessionId) if (!found) return null const entries = await this.readJsonlFile(found.filePath) const models = new Map() let totalCostUSD = 0 let totalInputTokens = 0 let totalOutputTokens = 0 let totalCacheReadInputTokens = 0 let totalCacheCreationInputTokens = 0 let totalWebSearchRequests = 0 let hasUnknownModelCost = false let firstUsageAt: number | null = null let lastUsageAt: number | null = null for (const entry of entries) { const usage = entry.message?.usage const model = entry.message?.model if (!usage || typeof model !== 'string') continue const inputTokens = typeof usage.input_tokens === 'number' ? usage.input_tokens : 0 const outputTokens = typeof usage.output_tokens === 'number' ? usage.output_tokens : 0 const cacheReadInputTokens = typeof usage.cache_read_input_tokens === 'number' ? usage.cache_read_input_tokens : 0 const cacheCreationInputTokens = typeof usage.cache_creation_input_tokens === 'number' ? usage.cache_creation_input_tokens : 0 const webSearchRequests = typeof usage.server_tool_use?.web_search_requests === 'number' ? usage.server_tool_use.web_search_requests : 0 if ( inputTokens === 0 && outputTokens === 0 && cacheReadInputTokens === 0 && cacheCreationInputTokens === 0 && webSearchRequests === 0 ) { continue } const canonical = getCanonicalName(model) if (!Object.prototype.hasOwnProperty.call(MODEL_COSTS, canonical)) { hasUnknownModelCost = true } const costUsage = { input_tokens: inputTokens, output_tokens: outputTokens, cache_read_input_tokens: cacheReadInputTokens, cache_creation_input_tokens: cacheCreationInputTokens, server_tool_use: { web_search_requests: webSearchRequests }, speed: usage.speed, } as Parameters[1] const costUSD = calculateUSDCost(model, costUsage) let modelUsage = models.get(model) if (!modelUsage) { modelUsage = { model, displayName: canonical, inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, cacheCreationInputTokens: 0, webSearchRequests: 0, costUSD: 0, costDisplay: '$0.0000', contextWindow: this.getTranscriptContextWindow(model), maxOutputTokens: getModelMaxOutputTokens(model).default, } models.set(model, modelUsage) } modelUsage.inputTokens += inputTokens modelUsage.outputTokens += outputTokens modelUsage.cacheReadInputTokens += cacheReadInputTokens modelUsage.cacheCreationInputTokens += cacheCreationInputTokens modelUsage.webSearchRequests += webSearchRequests modelUsage.costUSD += costUSD modelUsage.costDisplay = this.formatCost(modelUsage.costUSD) totalCostUSD += costUSD totalInputTokens += inputTokens totalOutputTokens += outputTokens totalCacheReadInputTokens += cacheReadInputTokens totalCacheCreationInputTokens += cacheCreationInputTokens totalWebSearchRequests += webSearchRequests if (entry.timestamp) { const time = Date.parse(entry.timestamp) if (!Number.isNaN(time)) { firstUsageAt = firstUsageAt === null ? time : Math.min(firstUsageAt, time) lastUsageAt = lastUsageAt === null ? time : Math.max(lastUsageAt, time) } } } if (models.size === 0) return null return { source: 'transcript', totalCostUSD, costDisplay: this.formatCost(totalCostUSD), hasUnknownModelCost, totalAPIDuration: 0, totalDuration: firstUsageAt !== null && lastUsageAt !== null ? Math.max(0, Math.round((lastUsageAt - firstUsageAt) / 1000)) : 0, totalLinesAdded: 0, totalLinesRemoved: 0, totalInputTokens, totalOutputTokens, totalCacheReadInputTokens, totalCacheCreationInputTokens, totalWebSearchRequests, models: Array.from(models.values()), } } // -------------------------------------------------------------------------- // 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) const filesWithStats = (await Promise.all(sessionFiles.map(async (sessionFile) => { try { return { ...sessionFile, stat: await fs.stat(sessionFile.filePath), } } catch { return null } }))).filter((item): item is NonNullable => item !== null) filesWithStats.sort((a, b) => b.stat.mtime.getTime() - a.stat.mtime.getTime()) const total = filesWithStats.length const offset = options?.offset ?? 0 const limit = options?.limit ?? 50 const paginatedFiles = filesWithStats.slice(offset, offset + limit) // Build session list items with metadata from file stats & first entries const items = (await Promise.all(paginatedFiles.map(async ({ filePath, projectDir, sessionId, stat }) => { try { const entries = await this.readJsonlFile(filePath) const workDir = this.resolveWorkDirFromEntries(entries, projectDir) const workDirExists = await this.pathExists(workDir) // 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 } } return { id: sessionId, title, createdAt, modifiedAt: stat.mtime.toISOString(), messageCount, projectPath: projectDir, workDir, workDirExists, } } catch { // Skip unreadable files return null } }))).filter((item): item is SessionListItem => item !== null) return { sessions: items, 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 = await this.appendSubagentToolMessages( projectDir, sessionId, this.entriesToMessages(entries), ) const title = this.extractTitle(entries) const workDir = this.resolveWorkDirFromEntries(entries, projectDir) const workDirExists = await this.pathExists(workDir) 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, workDir, workDirExists, 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 await this.appendSubagentToolMessages( found.projectDir, sessionId, this.entriesToMessages(entries), ) } /** * Create a new session file for the given working directory. */ async createSession( workDir?: string, repositoryOptions?: CreateSessionRepositoryOptions, ): Promise<{ sessionId: string; workDir: string }> { // Default to user home directory when no workDir specified const resolvedWorkDir = workDir || os.homedir() const sessionId = crypto.randomUUID() // Resolve to absolute path. NOTE: path.resolve() uses process.cwd() to // expand relative paths — in bundled sidecar mode the server's cwd is // typically '/'. Callers (IM adapters) already send absolute realPath, // but we log here so cwd regressions are caught early. const preparedWorkspace = await resolveSessionWorkspaceLaunch( resolvedWorkDir, repositoryOptions, sessionId, ) const absWorkDir = preparedWorkspace.workDir console.log( `[SessionService] createSession: requested workDir=${JSON.stringify( workDir, )}, resolved=${absWorkDir}, repository=${JSON.stringify( preparedWorkspace.repository ?? null, )} (process.cwd()=${process.cwd()})`, ) const sanitized = this.sanitizePath(absWorkDir) 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, } // Store actual workDir for later retrieval const metaEntry = { type: 'session-meta', isMeta: true, workDir: absWorkDir, repository: preparedWorkspace.repository, timestamp: now, } await fs.writeFile(filePath, JSON.stringify(initialEntry) + '\n' + JSON.stringify(metaEntry) + '\n', 'utf-8') return { sessionId, workDir: absWorkDir } } /** * 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) } /** * Append an AI-generated title entry to a session's JSONL file. */ async appendAiTitle(sessionId: string, title: string): Promise { const found = await this.findSessionFile(sessionId) if (!found) return await this.appendJsonlEntry(found.filePath, { type: 'ai-title', aiTitle: title, timestamp: new Date().toISOString(), }) } async getCustomTitle(sessionId: string): Promise { const found = await this.findSessionFile(sessionId) if (!found) return null const entries = await this.readJsonlFile(found.filePath) let customTitle: string | null = null for (const entry of entries) { if (entry.type === 'custom-title' && typeof entry.customTitle === 'string' && entry.customTitle.trim()) { customTitle = entry.customTitle } } return customTitle } /** * Get the actual working directory for a session. * First checks for stored session-meta entry, then falls back to desanitizePath. */ async getSessionWorkDir(sessionId: string): Promise { const found = await this.findSessionFile(sessionId) if (!found) return null const entries = await this.readJsonlFile(found.filePath) return this.resolveWorkDirFromEntries(entries, found.projectDir) } async getSessionMessageCwd( sessionId: string, messageId: string, ): Promise { const found = await this.findSessionFile(sessionId) if (!found) return null const entries = await this.readJsonlFile(found.filePath) const entry = entries.find((candidate) => candidate.uuid === messageId) return typeof entry?.cwd === 'string' && entry.cwd.trim() ? entry.cwd : null } /** * Inspect how a session should be launched. * Placeholder desktop-created sessions have zero transcript messages. */ async getSessionLaunchInfo(sessionId: string): Promise { const found = await this.findSessionFile(sessionId) if (!found) return null const entries = await this.readJsonlFile(found.filePath) const workDir = this.resolveWorkDirFromEntries(entries, found.projectDir) || process.cwd() const repository = this.resolveRepositoryFromEntries(entries) let customTitle: string | null = null for (const entry of entries) { if (entry.type === 'custom-title' && typeof entry.customTitle === 'string') { customTitle = entry.customTitle } } const transcriptMessageCount = this.countTranscriptMessages(entries) return { filePath: found.filePath, projectDir: found.projectDir, workDir, repository, transcriptMessageCount, customTitle, } } async deleteSessionFile(sessionId: string): Promise { const found = await this.findSessionFile(sessionId) if (!found) return await fs.unlink(found.filePath) } async clearSessionTranscript(sessionId: string, fallbackWorkDir?: string): Promise { let found = await this.findSessionFile(sessionId) if (!found && fallbackWorkDir) { const resolvedPath = path.resolve(fallbackWorkDir) const absWorkDir = await fs.realpath(resolvedPath).catch(() => resolvedPath) const dirPath = path.join(this.getProjectsDir(), this.sanitizePath(absWorkDir)) await fs.mkdir(dirPath, { recursive: true }) found = { filePath: path.join(dirPath, `${sessionId}.jsonl`), projectDir: this.sanitizePath(absWorkDir), } } if (!found) { throw ApiError.notFound(`Session not found: ${sessionId}`) } const entries = await this.readJsonlFile(found.filePath) const workDir = this.resolveWorkDirFromEntries(entries, found.projectDir) || fallbackWorkDir || process.cwd() const repository = this.resolveRepositoryFromEntries(entries) const now = new Date().toISOString() const initialEntry = { type: 'file-history-snapshot', messageId: crypto.randomUUID(), snapshot: { messageId: crypto.randomUUID(), trackedFileBackups: {}, timestamp: now, }, isSnapshotUpdate: false, } const metaEntry = { type: 'session-meta', isMeta: true, workDir, repository, timestamp: now, } await fs.writeFile( found.filePath, `${JSON.stringify(initialEntry)}\n${JSON.stringify(metaEntry)}\n`, 'utf-8', ) } async appendSessionMetadata( sessionId: string, metadata: { workDir: string customTitle?: string | null repository?: PreparedSessionWorkspace['repository'] } ): Promise { const matches = await this.findSessionFiles(sessionId) if (matches.length === 0) return let repository = metadata.repository if (!repository) { for (const match of matches) { const candidate = this.resolveRepositoryFromEntries(await this.readJsonlFile(match.filePath)) if (candidate) { repository = candidate break } } } const targetProjectDir = this.sanitizePath(metadata.workDir) const targetFilePath = path.join(this.getProjectsDir(), targetProjectDir, `${sessionId}.jsonl`) await fs.mkdir(path.dirname(targetFilePath), { recursive: true }) await this.appendJsonlEntry(targetFilePath, { type: 'session-meta', isMeta: true, workDir: metadata.workDir, repository, timestamp: new Date().toISOString(), }) if (metadata.customTitle) { await this.appendJsonlEntry(targetFilePath, { type: 'custom-title', customTitle: metadata.customTitle, timestamp: new Date().toISOString(), }) } } async deletePlaceholderSessionFiles( sessionId: string, keepWorkDir: string, ): Promise { if (!this.isValidSessionId(sessionId)) return 0 const projectsDir = this.getProjectsDir() let projectDirs: import('node:fs').Dirent[] try { projectDirs = await fs.readdir(projectsDir, { withFileTypes: true }) } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') return 0 throw err } const keepProjectDir = this.sanitizePath(keepWorkDir) let removed = 0 for (const projectDir of projectDirs) { if (!projectDir.isDirectory()) continue if (projectDir.name === keepProjectDir) continue const filePath = path.join(projectsDir, projectDir.name, `${sessionId}.jsonl`) const entries = await this.readJsonlFile(filePath) if (entries.length === 0) continue if (this.countTranscriptMessages(entries) > 0) continue await fs.rm(filePath, { force: true }) removed += 1 } return removed } async trimSessionMessagesFrom( sessionId: string, startMessageId: string, ): Promise { const found = await this.findSessionFile(sessionId) if (!found) { throw ApiError.notFound(`Session not found: ${sessionId}`) } const entries = await this.readJsonlFile(found.filePath) const activeMessages = this.entriesToMessages(entries) const startIndex = activeMessages.findIndex((message) => message.id === startMessageId) if (startIndex < 0) { throw ApiError.badRequest(`Message not found in active session chain: ${startMessageId}`) } const removedMessageIds = activeMessages .slice(startIndex) .map((message) => message.id) const remainingMessageIds = new Set( activeMessages .slice(0, startIndex) .map((message) => message.id), ) if (removedMessageIds.length === 0) { return { removedCount: 0, removedMessageIds: [] } } const removedIds = new Set(removedMessageIds) const filteredEntries = entries.filter( (entry) => { if (typeof entry.uuid !== 'string') return true if (removedIds.has(entry.uuid)) return false if ( entry.message?.role && (entry.type === 'user' || entry.type === 'assistant' || entry.type === 'system') ) { return remainingMessageIds.has(entry.uuid) } return true }, ) const content = filteredEntries.length > 0 ? filteredEntries.map((entry) => JSON.stringify(entry)).join('\n') + '\n' : '' await fs.writeFile(found.filePath, content, 'utf-8') return { removedCount: removedMessageIds.length, removedMessageIds, } } async getSessionFileHistorySnapshots( 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) const snapshotsByMessageId = new Map() for (const entry of entries) { if (entry.type !== 'file-history-snapshot' || !entry.snapshot) continue const snapshotMessageId = typeof entry.snapshot.messageId === 'string' ? entry.snapshot.messageId : typeof entry.messageId === 'string' ? entry.messageId : null if (!snapshotMessageId) continue snapshotsByMessageId.set(snapshotMessageId, { messageId: snapshotMessageId as FileHistorySnapshot['messageId'], trackedFileBackups: entry.snapshot.trackedFileBackups && typeof entry.snapshot.trackedFileBackups === 'object' ? (entry.snapshot.trackedFileBackups as FileHistorySnapshot['trackedFileBackups']) : {}, timestamp: new Date( entry.snapshot.timestamp || entry.timestamp || new Date().toISOString(), ), }) } return [...snapshotsByMessageId.values()] } async getSessionTaskNotifications( 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) const notifications: SessionTaskNotification[] = [] for (const entry of entries) { if (entry.message?.role !== 'user') continue const notification = this.parseTaskNotificationContent(entry.message.content) if (notification) notifications.push(notification) } return notifications } // -------------------------------------------------------------------------- // Private helpers // -------------------------------------------------------------------------- private entriesToMessages(entries: RawEntry[]): MessageEntry[] { const messages: MessageEntry[] = [] const entriesByUuid = new Map() const parentToolUseIdCache = new Map() let suppressTaskNotificationResponse = false for (const entry of entries) { if (typeof entry.uuid === 'string' && entry.uuid.length > 0) { entriesByUuid.set(entry.uuid, entry) } } 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 const isTaskNotification = entry.message.role === 'user' && this.isTaskNotificationContent(entry.message.content) if (isTaskNotification) { suppressTaskNotificationResponse = true continue } if ( entry.message.role === 'user' && !this.isToolResultContent(entry.message.content) ) { suppressTaskNotificationResponse = false } else if (suppressTaskNotificationResponse) { continue } if (this.shouldHideTranscriptEntry(entry)) continue // Skip non-transcript entry types const entryType = entry.type if ( entryType !== 'user' && entryType !== 'assistant' && entryType !== 'system' ) { continue } const parentToolUseId = this.resolveParentToolUseId( entry, entriesByUuid, parentToolUseIdCache, ) const msg = this.entryToMessage(entry, parentToolUseId) if (msg) { messages.push(msg) } } return messages } private async pathExists(targetPath: string | null): Promise { if (!targetPath) return false try { const stat = await fs.stat(targetPath) return stat.isDirectory() } catch { return false } } } // Singleton instance for shared use across API handlers export const sessionService = new SessionService()