/** * Session Service — 会话文件的读写操作封装 * * 读写 CLI 持久化在 ~/.claude/projects/{sanitized_path}/{sessionId}.jsonl 的会话数据, * 确保 Desktop App 与 CLI 的数据完全互通。 */ import { createReadStream } from 'node:fs' import * as fs from 'node:fs/promises' import * as path from 'node:path' import * as os from 'node:os' import { createInterface } from 'node:readline' import { ApiError } from '../middleware/errorHandler.js' import { sanitizePath as sanitizePortablePath } from '../../utils/sessionStoragePortable.js' import type { FileHistorySnapshot } from '../../utils/fileHistory.js' import { findCanonicalGitRoot } from '../../utils/git.js' import { calculateUSDCost, MODEL_COSTS } from '../../utils/modelCost.js' import { MODEL_CONTEXT_WINDOW_DEFAULT, getContextWindowForModel, getModelMaxOutputTokens, is1mContextDisabled, } from '../../utils/context.js' import { MODEL_CONTEXT_WINDOWS_ENV_KEY, getModelContextWindowFromEnvValue, } from '../../utils/model/modelContextWindows.js' import { calculateContextBudget, getProviderUsageTrust, hasMediaInput, } from '../../utils/contextBudget.js' import { getCanonicalName } from '../../utils/model/model.js' import { isFirstPartyAnthropicBaseUrl } from '../../utils/model/providers.js' import { resolveSessionWorkspaceLaunch, type CreateSessionRepositoryOptions, type PreparedSessionWorkspace, } from './repositoryLaunchService.js' import { registerFilesystemAccessRoot } from './filesystemAccessRoots.js' import { normalizeDriveRootPathForPlatform } from './windowsDrivePath.js' import { cleanSessionTitleSource } from '../../utils/sessionTitleText.js' import { roughTokenCountEstimationForMessages } from '../../services/tokenEstimation.js' import { ProviderService } from './providerService.js' // ============================================================================ // Types // ============================================================================ export type SessionListItem = { id: string title: string createdAt: string modifiedAt: string messageCount: number projectPath: string projectRoot: string | null workDir: string | null workDirExists: boolean permissionMode?: string } export type DeleteSessionFailure = { sessionId: string message: string code?: string } export type DeleteSessionsResult = { successes: string[] failures: DeleteSessionFailure[] } export type SessionDetail = SessionListItem & { messages: MessageEntry[] } export type SessionLaunchInfo = { filePath: string projectDir: string workDir: string repository?: PreparedSessionWorkspace['repository'] worktreeSession?: PersistedWorktreeSession | null transcriptMessageCount: number customTitle: string | null permissionMode?: string runtimeProviderId?: string | null runtimeModelId?: string effortLevel?: string } export type TrimSessionResult = { removedCount: number removedMessageIds: string[] } export type MessageUsage = { input_tokens?: number output_tokens?: number cache_read_input_tokens?: number cache_creation_input_tokens?: number } export type MessageEntry = { id: string type: 'user' | 'assistant' | 'system' | 'tool_use' | 'tool_result' content: unknown toolUseResult?: unknown timestamp: string model?: string usage?: MessageUsage parentUuid?: string parentToolUseId?: string isSidechain?: boolean } export type SessionTaskNotification = { taskId: string toolUseId: string status: 'completed' | 'failed' | 'stopped' summary?: string result?: string outputFile?: string timestamp?: 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 subtype?: string content?: unknown 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 permissionMode?: string worktreeSession?: PersistedWorktreeSession | null title?: string [key: string]: unknown } type RawMessageUsage = NonNullable['usage'] function normalizeMessageUsage(usage: RawMessageUsage): MessageUsage | undefined { if (!usage) return undefined const normalized: MessageUsage = {} if (typeof usage.input_tokens === 'number' && Number.isFinite(usage.input_tokens)) { normalized.input_tokens = usage.input_tokens } if (typeof usage.output_tokens === 'number' && Number.isFinite(usage.output_tokens)) { normalized.output_tokens = usage.output_tokens } if ( typeof usage.cache_read_input_tokens === 'number' && Number.isFinite(usage.cache_read_input_tokens) ) { normalized.cache_read_input_tokens = usage.cache_read_input_tokens } if ( typeof usage.cache_creation_input_tokens === 'number' && Number.isFinite(usage.cache_creation_input_tokens) ) { normalized.cache_creation_input_tokens = usage.cache_creation_input_tokens } return Object.keys(normalized).length > 0 ? normalized : undefined } type PersistedWorktreeSession = { originalCwd: string worktreePath: string worktreeName: string worktreeBranch?: string originalBranch?: string originalHeadCommit?: string sessionId: string tmuxSessionName?: string hookBased?: boolean } type SessionListSummary = { title: string createdAt: string messageCount: number workDir: string | null permissionMode?: string runtimeProviderId?: string | null runtimeModelId?: string effortLevel?: string repository?: PreparedSessionWorkspace['repository'] worktreeSession?: PersistedWorktreeSession | null } const VALID_SESSION_PERMISSION_MODES = new Set([ 'default', 'acceptEdits', 'plan', 'bypassPermissions', 'dontAsk', ]) const VALID_SESSION_EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'max']) 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 { private providerService = new ProviderService() private readonly sessionListCacheTtlMs = 5_000 private readonly sessionListCache = new Map() private sessionListCacheKey(options?: { project?: string limit?: number offset?: number }): string { return JSON.stringify({ project: options?.project ?? null, limit: options?.limit ?? 50, offset: options?.offset ?? 0, }) } private cloneSessionListResult(result: { sessions: SessionListItem[]; total: number }): { sessions: SessionListItem[]; total: number } { return { total: result.total, sessions: result.sessions.map((session) => ({ ...session })), } } private invalidateSessionListCache(): void { this.sessionListCache.clear() } // -------------------------------------------------------------------------- // 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 scanSessionListSummary( filePath: string, projectDir: string, stat: { birthtime: Date }, ): Promise { let createdAt = stat.birthtime.toISOString() let hasCreatedAt = false let messageCount = 0 let firstUserTitle: string | null = null let goalTitle: string | null = null let aiTitle: string | null = null let customTitle: string | null = null let latestWorkDir: string | null = null let latestCwd: string | null = null let permissionMode: string | undefined let runtimeProviderId: string | null | undefined let runtimeModelId: string | undefined let effortLevel: string | undefined let repository: PreparedSessionWorkspace['repository'] | undefined let worktreeSession: PersistedWorktreeSession | null | undefined const stream = createReadStream(filePath, { encoding: 'utf8' }) const lines = createInterface({ input: stream, crlfDelay: Infinity, }) try { for await (const line of lines) { const trimmed = line.trim() if (!trimmed) continue let entry: RawEntry try { entry = JSON.parse(trimmed) as RawEntry } catch { continue } if (!hasCreatedAt && entry.timestamp) { createdAt = entry.timestamp hasCreatedAt = true } if ( (entry.type === 'user' || entry.type === 'assistant') && entry.message?.role ) { messageCount += 1 } if (entry.type === 'session-meta') { if (typeof (entry as Record).workDir === 'string') { latestWorkDir = normalizeDriveRootPathForPlatform( (entry as Record).workDir as string, ) } if ( typeof entry.permissionMode === 'string' && VALID_SESSION_PERMISSION_MODES.has(entry.permissionMode) ) { permissionMode = entry.permissionMode } if ( (entry as Record).runtimeProviderId === null || typeof (entry as Record).runtimeProviderId === 'string' ) { runtimeProviderId = (entry as Record).runtimeProviderId as string | null } if (typeof (entry as Record).runtimeModelId === 'string') { runtimeModelId = (entry as Record).runtimeModelId as string } if ( typeof (entry as Record).effortLevel === 'string' && VALID_SESSION_EFFORT_LEVELS.has((entry as Record).effortLevel as string) ) { effortLevel = (entry as Record).effortLevel as string } } if (typeof entry.cwd === 'string' && entry.cwd.trim()) { latestCwd = normalizeDriveRootPathForPlatform(entry.cwd) } const candidateRepository = (entry as Record)?.repository if (candidateRepository && typeof candidateRepository === 'object') { repository = candidateRepository as PreparedSessionWorkspace['repository'] } if (entry.type === 'worktree-state') { if (entry.worktreeSession === null) { worktreeSession = null } else if ( entry.worktreeSession && typeof entry.worktreeSession === 'object' && typeof entry.worktreeSession.worktreePath === 'string' && typeof entry.worktreeSession.worktreeName === 'string' ) { worktreeSession = entry.worktreeSession } } if (entry.type === 'custom-title' && entry.customTitle) { customTitle = String(entry.customTitle) } if (!goalTitle) { goalTitle = this.goalCreationCommandTitle(entry) } if (entry.type === 'ai-title' && entry.aiTitle) { const title = cleanSessionTitleSource(String(entry.aiTitle)) if (title) aiTitle = title } if ( !firstUserTitle && entry.type === 'user' && !entry.isMeta && entry.message?.role === 'user' ) { firstUserTitle = this.extractUserMessageTitle(entry.message.content) } } } finally { lines.close() stream.destroy() } return { title: customTitle || goalTitle || aiTitle || firstUserTitle || 'Untitled Session', createdAt, messageCount, workDir: latestWorkDir || latestCwd || this.desanitizePath(projectDir), ...(permissionMode ? { permissionMode } : {}), ...(runtimeProviderId !== undefined ? { runtimeProviderId } : {}), ...(runtimeModelId ? { runtimeModelId } : {}), ...(effortLevel ? { effortLevel } : {}), ...(repository ? { repository } : {}), ...(worktreeSession !== undefined ? { worktreeSession } : {}), } } /** * Resolve a session's display title + lightweight metadata from its JSONL file. * * Reuses the same title precedence as the session list (custom-title > goal > * ai-title > first user message), so global session search shows real titles * instead of the raw UUID file name. */ async getSessionTitleAndMeta(filePath: string): Promise<{ title: string modifiedAt: string workDir: string | null projectPath: string }> { const stat = await fs.stat(filePath) const projectPath = path.basename(path.dirname(filePath)) const summary = await this.scanSessionListSummary(filePath, projectPath, stat) return { title: summary.title, modifiedAt: stat.mtime.toISOString(), workDir: summary.workDir ?? null, projectPath, } } 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 normalizeDriveRootPathForPlatform((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 normalizeDriveRootPathForPlatform(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 resolvePermissionModeFromEntries(entries: RawEntry[]): string | undefined { for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i] if (entry?.type !== 'session-meta') continue const permissionMode = entry.permissionMode if ( typeof permissionMode === 'string' && VALID_SESSION_PERMISSION_MODES.has(permissionMode) ) { return permissionMode } } return undefined } private resolveWorktreeSessionFromEntries(entries: RawEntry[]): PersistedWorktreeSession | null | undefined { for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i] if (entry?.type !== 'worktree-state') continue const worktreeSession = entry.worktreeSession if (worktreeSession === null) return null if ( worktreeSession && typeof worktreeSession === 'object' && typeof worktreeSession.worktreePath === 'string' && typeof worktreeSession.worktreeName === 'string' ) { return worktreeSession } } return undefined } private async resolveProjectRootFromEntries( entries: RawEntry[], workDir: string | null, fallbackProjectDir?: string, ): Promise { const worktreeSession = this.resolveWorktreeSessionFromEntries(entries) const repository = this.resolveRepositoryFromEntries(entries) return this.resolveProjectRootFromSessionMetadata({ worktreeSession, repository, workDir, fallbackProjectDir, }) } private async resolveProjectRootFromSessionMetadata({ worktreeSession, repository, workDir, fallbackProjectDir, }: { worktreeSession?: PersistedWorktreeSession | null repository?: PreparedSessionWorkspace['repository'] workDir: string | null fallbackProjectDir?: string }): Promise { const candidate = worktreeSession?.originalCwd || repository?.repoRoot || workDir || (fallbackProjectDir ? this.desanitizePath(fallbackProjectDir) : null) if (!candidate) return null const canonicalCandidate = await this.canonicalizeProjectPath(candidate) const gitRoot = findCanonicalGitRoot(canonicalCandidate) if (gitRoot) return gitRoot if (workDir) { const marker = `${path.sep}.claude${path.sep}worktrees${path.sep}` const markerIndex = canonicalCandidate.indexOf(marker) if (markerIndex > 0) return canonicalCandidate.slice(0, markerIndex) } return canonicalCandidate } private async canonicalizeProjectPath(projectPath: string): Promise { try { return normalizeDriveRootPathForPlatform(await fs.realpath(projectPath)).normalize('NFC') } catch { return projectPath.normalize('NFC') } } 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' } const usage = normalizeMessageUsage(msg.usage) return { id: entry.uuid || crypto.randomUUID(), type, content: msg.content, ...(entry.toolUseResult !== undefined ? { toolUseResult: entry.toolUseResult } : {}), timestamp: entry.timestamp || new Date().toISOString(), model: msg.model, ...(usage ? { usage } : {}), 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 { const textBlocks = this.extractTextBlocks(content) return ( textBlocks.length > 0 && textBlocks.every((text) => text.includes('') || text.includes('') || text.includes('') || text.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, timestamp?: string, ): 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 result = this.readXmlTag(xml, 'result') const outputFile = this.readXmlTag(xml, 'output-file') return { taskId, toolUseId, status, ...(summary ? { summary } : {}), ...(result ? { result } : {}), ...(outputFile ? { outputFile } : {}), ...(timestamp ? { timestamp } : {}), } } 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 isGoalLocalCommandOutput(output: string): boolean { const trimmed = output.trim() return ( trimmed.startsWith('Goal set:') || trimmed.startsWith('Goal cleared:') || trimmed === 'Goal cleared.' || trimmed === 'Goal marked complete.' || trimmed === 'No active goal.' ) } private isGoalLocalCommandEntry(entry: RawEntry): boolean { if ( entry.type !== 'system' || entry.subtype !== 'local_command' || typeof entry.content !== 'string' ) { return false } const commandName = this.readXmlTag(entry.content, 'command-name')?.replace(/^\//, '') if (commandName) return commandName === 'goal' const output = this.readXmlTag(entry.content, 'local-command-stdout') ?? this.readXmlTag(entry.content, 'local-command-stderr') return output ? this.isGoalLocalCommandOutput(output) : false } private goalLocalCommandEntryToMessage(entry: RawEntry): MessageEntry | null { if (!this.isGoalLocalCommandEntry(entry)) return null return { id: entry.uuid || crypto.randomUUID(), type: 'system', content: entry.content, timestamp: entry.timestamp || new Date().toISOString(), parentUuid: entry.parentUuid ?? undefined, isSidechain: entry.isSidechain, } } private goalCreationCommandTitle(entry: RawEntry): string | null { if ( entry.type !== 'system' || entry.subtype !== 'local_command' || typeof entry.content !== 'string' ) { return null } const commandName = this.readXmlTag(entry.content, 'command-name')?.replace(/^\//, '') if (commandName !== 'goal') return null const args = this.readXmlTag(entry.content, 'command-args')?.trim() if (!args || /^clear\b/i.test(args)) return null const title = cleanSessionTitleSource(`/goal ${args}`) return title ? title.length > 80 ? title.slice(0, 80) + '...' : title : null } 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. Goal sessions should keep the original objective as the stable title. for (const e of entries) { const goalTitle = this.goalCreationCommandTitle(e) if (goalTitle) return goalTitle } // 3. 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) { const title = cleanSessionTitleSource(String(e.aiTitle)) if (title) return title } } // 4. 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) { const title = cleanSessionTitleSource(text) if (title) return title.length > 80 ? title.slice(0, 80) + '...' : title } } } return 'Untitled Session' } private extractUserMessageTitle(content: unknown): string | null { 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 null const title = cleanSessionTitleSource(text) if (!title) return null return title.length > 80 ? `${title.slice(0, 80)}...` : title } // -------------------------------------------------------------------------- // 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(normalizeDriveRootPathForPlatform(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 non-alphanumeric characters with '-'. // This fallback is necessarily lossy, but old Windows transcripts without // session-meta still need the drive separator restored well enough to resume. const windowsDrivePath = sanitized.match(/^([a-zA-Z])--(.+)$/) if (windowsDrivePath) { return `${windowsDrivePath[1]}:${path.win32.sep}${windowsDrivePath[2].replace(/-/g, path.win32.sep)}` } const windowsDriveRoot = sanitized.match(/^([a-zA-Z])--$/) if (windowsDriveRoot) { return `${windowsDriveRoot[1]}:${path.win32.sep}` } // On POSIX the original path starts with '/', so the sanitized form starts with '-'. // UNC-style Windows paths also recover to a leading double separator on Windows. 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; mtimeMs: number }> = [] for (const dir of projectDirs) { const filePath = path.join(projectsDir, dir, `${sessionId}.jsonl`) try { const stat = await fs.stat(filePath) matches.push({ filePath, projectDir: dir, mtimeMs: stat.mtimeMs }) } catch { continue } } return matches .sort((a, b) => b.mtimeMs - a.mtimeMs) .map(({ filePath, projectDir }) => ({ filePath, projectDir })) } 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 async getProviderContextWindowForSession( sessionId: string, model: string, ): Promise { const launchInfo = await this.getSessionLaunchInfo(sessionId).catch(() => null) const providerIds: string[] = [] if (typeof launchInfo?.runtimeProviderId === 'string') { providerIds.push(launchInfo.runtimeProviderId) } else if (launchInfo?.runtimeProviderId !== null) { const { activeId } = await this.providerService.listProviders().catch(() => ({ activeId: null })) if (activeId) providerIds.push(activeId) } for (const providerId of providerIds) { const env = await this.providerService.getProviderRuntimeEnv(providerId).catch(() => null) const contextWindow = getModelContextWindowFromEnvValue( model, env?.[MODEL_CONTEXT_WINDOWS_ENV_KEY], ) if (contextWindow !== undefined) { if (contextWindow > MODEL_CONTEXT_WINDOW_DEFAULT && is1mContextDisabled()) { return MODEL_CONTEXT_WINDOW_DEFAULT } return contextWindow } } return undefined } private async getTranscriptContextWindow(sessionId: string, model: string): Promise { const providerContextWindow = await this.getProviderContextWindowForSession(sessionId, model) if (providerContextWindow !== undefined) { return providerContextWindow } 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 = await this.getTranscriptContextWindow(sessionId, latest.model) const promptTokens = latest.inputTokens + latest.cacheReadInputTokens + latest.cacheCreationInputTokens const transcriptMessages = entries.filter(entry => entry.type === 'user' || entry.type === 'assistant' || entry.type === 'attachment', ) as Parameters[0] const estimatedTokens = roughTokenCountEstimationForMessages(transcriptMessages) || promptTokens const contextBudget = calculateContextBudget({ estimatedTokens, contextWindow: rawMaxTokens, currentUsage: { input_tokens: latest.inputTokens, output_tokens: latest.outputTokens, cache_read_input_tokens: latest.cacheReadInputTokens, cache_creation_input_tokens: latest.cacheCreationInputTokens, }, usageTrust: getProviderUsageTrust({ isFirstPartyAnthropic: isFirstPartyAnthropicBaseUrl(), }), hasMediaInput: hasMediaInput(transcriptMessages), }) const totalTokens = contextBudget.usedTokens const percentage = rawMaxTokens > 0 ? Math.round((totalTokens / rawMaxTokens) * 100) : 0 const usageCategories: 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' }, ] const contextCategories: TranscriptContextEstimate['categories'] = contextBudget.ignoredUsageReason === 'low_trust_media_usage' ? [{ name: 'Estimated context', tokens: totalTokens, color: '#8f3217' }] : usageCategories const categories: TranscriptContextEstimate['categories'] = [ ...contextCategories, { 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: await this.getTranscriptContextWindow(sessionId, 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 cacheKey = this.sessionListCacheKey(options) const cached = this.sessionListCache.get(cacheKey) if (cached && cached.expiresAt > Date.now()) { return this.cloneSessionListResult(cached.result) } 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 & a streaming // transcript summary. Keep this sequential so large JSONL files are not // loaded into memory concurrently by the sidebar's frequent refresh. const items: SessionListItem[] = [] for (const { filePath, projectDir, sessionId, stat } of paginatedFiles) { try { const summary = await this.scanSessionListSummary(filePath, projectDir, stat) const workDir = summary.workDir const projectRoot = await this.resolveProjectRootFromSessionMetadata({ worktreeSession: summary.worktreeSession, repository: summary.repository, workDir, fallbackProjectDir: projectDir, }) const workDirExists = await this.pathExists(workDir) items.push({ id: sessionId, title: summary.title, createdAt: summary.createdAt, modifiedAt: stat.mtime.toISOString(), messageCount: summary.messageCount, projectPath: projectDir, projectRoot, workDir, workDirExists, permissionMode: summary.permissionMode, }) } catch { // Skip unreadable files } } const result = { sessions: items, total } this.sessionListCache.set(cacheKey, { expiresAt: Date.now() + this.sessionListCacheTtlMs, result: this.cloneSessionListResult(result), }) return result } /** * 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 permissionMode = this.resolvePermissionModeFromEntries(entries) const projectRoot = await this.resolveProjectRootFromEntries(entries, workDir, 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, projectRoot, workDir, workDirExists, permissionMode, 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, permissionMode?: string, ): 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 registerFilesystemAccessRoot(absWorkDir) 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, ...(permissionMode && VALID_SESSION_PERMISSION_MODES.has(permissionMode) ? { permissionMode } : {}), timestamp: now, } await fs.writeFile(filePath, JSON.stringify(initialEntry) + '\n' + JSON.stringify(metaEntry) + '\n', 'utf-8') this.invalidateSessionListCache() 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) this.invalidateSessionListCache() } async deleteSessions(sessionIds: string[]): Promise { const successes: string[] = [] const failures: DeleteSessionFailure[] = [] const results = await Promise.all(sessionIds.map(async (sessionId) => { try { await this.deleteSession(sessionId) return { type: 'success' as const, sessionId } } catch (error) { return { type: 'failure' as const, sessionId, message: error instanceof Error ? error.message : 'Unknown delete failure', code: error instanceof ApiError ? error.code : undefined, } } })) for (const result of results) { if (result.type === 'success') { successes.push(result.sessionId) } else { failures.push({ sessionId: result.sessionId, message: result.message, code: result.code, }) } } return { successes, failures } } /** * 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) this.invalidateSessionListCache() } /** * 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(), }) this.invalidateSessionListCache() } 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) const worktreeSession = this.resolveWorktreeSessionFromEntries(entries) const permissionMode = this.resolvePermissionModeFromEntries(entries) let customTitle: string | null = null let runtimeProviderId: string | null | undefined let runtimeModelId: string | undefined let effortLevel: string | undefined for (const entry of entries) { if (entry.type === 'custom-title' && typeof entry.customTitle === 'string') { customTitle = entry.customTitle } if (entry.type === 'session-meta') { const record = entry as Record if (record.runtimeProviderId === null || typeof record.runtimeProviderId === 'string') { runtimeProviderId = record.runtimeProviderId as string | null } if (typeof record.runtimeModelId === 'string') { runtimeModelId = record.runtimeModelId } if ( typeof record.effortLevel === 'string' && VALID_SESSION_EFFORT_LEVELS.has(record.effortLevel) ) { effortLevel = record.effortLevel } } } const transcriptMessageCount = this.countTranscriptMessages(entries) return { filePath: found.filePath, projectDir: found.projectDir, workDir, repository, worktreeSession, transcriptMessageCount, customTitle, permissionMode, ...(runtimeProviderId !== undefined ? { runtimeProviderId } : {}), ...(runtimeModelId ? { runtimeModelId } : {}), ...(effortLevel ? { effortLevel } : {}), } } async deleteSessionFile(sessionId: string): Promise { const found = await this.findSessionFile(sessionId) if (!found) return await fs.unlink(found.filePath) this.invalidateSessionListCache() } async clearSessionTranscript(sessionId: string, fallbackWorkDir?: string): Promise { let found = await this.findSessionFile(sessionId) if (!found && fallbackWorkDir) { const resolvedPath = path.resolve(normalizeDriveRootPathForPlatform(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', ) this.invalidateSessionListCache() } async appendSessionMetadata( sessionId: string, metadata: { workDir: string customTitle?: string | null repository?: PreparedSessionWorkspace['repository'] permissionMode?: string runtimeProviderId?: string | null runtimeModelId?: string effortLevel?: string } ): 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 normalizedWorkDir = normalizeDriveRootPathForPlatform(metadata.workDir) const targetProjectDir = this.sanitizePath(normalizedWorkDir) 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: normalizedWorkDir, repository, ...(metadata.permissionMode && VALID_SESSION_PERMISSION_MODES.has(metadata.permissionMode) ? { permissionMode: metadata.permissionMode } : {}), ...(metadata.runtimeProviderId !== undefined ? { runtimeProviderId: metadata.runtimeProviderId } : {}), ...(metadata.runtimeModelId ? { runtimeModelId: metadata.runtimeModelId } : {}), ...(metadata.effortLevel && VALID_SESSION_EFFORT_LEVELS.has(metadata.effortLevel) ? { effortLevel: metadata.effortLevel } : {}), timestamp: new Date().toISOString(), }) if (metadata.customTitle) { await this.appendJsonlEntry(targetFilePath, { type: 'custom-title', customTitle: metadata.customTitle, timestamp: new Date().toISOString(), }) } this.invalidateSessionListCache() } 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(normalizeDriveRootPathForPlatform(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 } if (removed > 0) this.invalidateSessionListCache() 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') this.invalidateSessionListCache() 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, entry.timestamp, ) 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) { const goalLocalCommandMessage = this.goalLocalCommandEntryToMessage(entry) if (goalLocalCommandMessage) { messages.push(goalLocalCommandMessage) continue } // 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()