| |
| |
| |
| |
| |
| |
|
|
| 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 { findCanonicalGitRoot } from '../../utils/git.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' |
| import { registerFilesystemAccessRoot } from './filesystemAccessRoots.js' |
| import { cleanSessionTitleSource } from '../../utils/sessionTitleText.js' |
|
|
| |
| |
| |
|
|
| export type SessionListItem = { |
| id: string |
| title: string |
| createdAt: string |
| modifiedAt: string |
| messageCount: number |
| projectPath: string |
| projectRoot: string | null |
| workDir: string | null |
| workDirExists: boolean |
| } |
|
|
| 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 |
| } |
|
|
| 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 |
| 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<Array<{ |
| color: string |
| isFilled: boolean |
| categoryName: string |
| tokens: number |
| percentage: number |
| squareFullness: number |
| }>> |
| 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 |
| } |
| } |
|
|
| |
| 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<string, unknown> |
| timestamp?: string |
| } |
| customTitle?: string |
| worktreeSession?: PersistedWorktreeSession | null |
| title?: string |
| [key: string]: unknown |
| } |
|
|
| type PersistedWorktreeSession = { |
| originalCwd: string |
| worktreePath: string |
| worktreeName: string |
| worktreeBranch?: string |
| originalBranch?: string |
| originalHeadCommit?: string |
| sessionId: string |
| tmuxSessionName?: string |
| hookBased?: boolean |
| } |
|
|
| type ContentBlock = Record<string, unknown> |
|
|
| 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 = /^<task-notification>\s*[\s\S]*<\/task-notification>$/i |
| const TASK_NOTIFICATION_BLOCK_RE = /<task-notification>\s*[\s\S]*?<\/task-notification>/i |
|
|
| |
| |
| |
|
|
| export class SessionService { |
| |
| |
| |
|
|
| private getConfigDir(): string { |
| return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude') |
| } |
|
|
| private getProjectsDir(): string { |
| return path.join(this.getConfigDir(), 'projects') |
| } |
|
|
| |
| |
| |
| |
| private sanitizePath(dirPath: string): string { |
| return sanitizePortablePath(dirPath) |
| } |
|
|
| |
| |
| |
|
|
| private async readJsonlFile(filePath: string): Promise<RawEntry[]> { |
| 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 { |
| |
| } |
| } |
| return entries |
| } |
|
|
| private async appendJsonlEntry(filePath: string, entry: Record<string, unknown>): Promise<void> { |
| 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<string, unknown>).workDir === 'string') { |
| return (entry as Record<string, unknown>).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<string, unknown>)?.repository |
| if (repository && typeof repository === 'object') { |
| return repository as PreparedSessionWorkspace['repository'] |
| } |
| } |
| 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<string | null> { |
| const worktreeSession = this.resolveWorktreeSessionFromEntries(entries) |
| const repository = this.resolveRepositoryFromEntries(entries) |
|
|
| 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<string> { |
| try { |
| return (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 |
| } |
|
|
| |
| |
| |
|
|
| private entryToMessage( |
| entry: RawEntry, |
| parentToolUseId?: string, |
| ): MessageEntry | null { |
| const msg = entry.message |
| if (!msg || !msg.role) return null |
|
|
| |
| let type: MessageEntry['type'] |
| const role = msg.role |
|
|
| if (role === 'user') { |
| |
| if (Array.isArray(msg.content)) { |
| const hasToolResult = msg.content.some( |
| (block: Record<string, unknown>) => block.type === 'tool_result' |
| ) |
| if (hasToolResult) { |
| type = 'tool_result' |
| } else { |
| type = 'user' |
| } |
| } else { |
| type = 'user' |
| } |
| } else if (role === 'assistant') { |
| |
| if (Array.isArray(msg.content)) { |
| const hasToolUse = msg.content.some( |
| (block: Record<string, unknown>) => 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<string, unknown> |
| 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('<command-name>') || |
| content.includes('<command-message>') || |
| content.includes('<command-args>') || |
| content.includes('<local-command-caveat>') |
| ) |
| } |
|
|
| 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<string, unknown>).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<Record<string, unknown>>) { |
| 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<string, string> { |
| const agentToolUseIds = new Set( |
| messages.flatMap((message) => this.extractAgentToolUseIdsFromMessage(message)), |
| ) |
| const resultLinks = new Map<string, string>() |
|
|
| 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<MessageEntry[]> { |
| 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<MessageEntry[]> { |
| 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<string, RawEntry>, |
| cache: Map<string, string | undefined>, |
| ): 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<string>() |
| |
| 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<string, unknown>) => 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' |
| } |
| |
| // -------------------------------------------------------------------------- |
| // 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 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)}` |
| } |
| |
| // 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<Array<{ filePath: string; projectDir: string }>> { |
| 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 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<TranscriptMetadataSnapshot | null> { |
| 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<TranscriptContextEstimate | null> { |
| 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, |
| }, rawMaxTokens) |
| 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<TranscriptUsageSnapshot | null> { |
| const found = await this.findSessionFile(sessionId) |
| if (!found) return null |
| |
| const entries = await this.readJsonlFile(found.filePath) |
| const models = new Map<string, TranscriptUsageSnapshot['models'][number]>() |
| 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<typeof calculateUSDCost>[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<typeof item> => 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 projectRoot = await this.resolveProjectRootFromEntries(entries, workDir, 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, |
| projectRoot, |
| 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<SessionDetail | null> { |
| 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 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, |
| messages, |
| } |
| } |
| |
| /** |
| * Get only the messages for a session (lighter than full detail). |
| */ |
| async getSessionMessages(sessionId: string): Promise<MessageEntry[]> { |
| 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 |
| 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, |
| 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<void> { |
| const found = await this.findSessionFile(sessionId) |
| if (!found) { |
| throw ApiError.notFound(`Session not found: ${sessionId}`) |
| } |
| |
| await fs.unlink(found.filePath) |
| } |
| |
| async deleteSessions(sessionIds: string[]): Promise<DeleteSessionsResult> { |
| 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<void> { |
| 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<void> { |
| 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<string | null> { |
| 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<string | null> { |
| 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<string | null> { |
| 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<SessionLaunchInfo | null> { |
| 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) |
| 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, |
| worktreeSession, |
| transcriptMessageCount, |
| customTitle, |
| } |
| } |
| |
| async deleteSessionFile(sessionId: string): Promise<void> { |
| const found = await this.findSessionFile(sessionId) |
| if (!found) return |
| await fs.unlink(found.filePath) |
| } |
| |
| async clearSessionTranscript(sessionId: string, fallbackWorkDir?: string): Promise<void> { |
| 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<void> { |
| 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<number> { |
| 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<TrimSessionResult> { |
| 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<FileHistorySnapshot[]> { |
| 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<string, FileHistorySnapshot>() |
| |
| 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<SessionTaskNotification[]> { |
| 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<string, RawEntry>() |
| const parentToolUseIdCache = new Map<string, string | undefined>() |
| 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<boolean> { |
| 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() |
| |