cc-haha/src/server/services/sessionService.ts
程序员阿江(Relakkes) aaf25159af fix: keep workspace review surfaces usable across sessions
Workspace inspection now shares the same diff renderer between chat change cards and panel previews, supports scoped preview-tab closing, confirms checkpoint undo, resolves rewind checkpoints from the prompt cwd, and removes light-only workspace chrome so dark theme remains coherent.

Constraint: Workspace review must keep working for existing transcript sessions and non-git session-derived changes.
Rejected: Keep a separate chat diff preview | it drifted from panel rendering and dark theme behavior.
Confidence: high
Scope-risk: moderate
Directive: Keep chat and workspace diff rendering shared so theme and truncation behavior do not diverge again.
Tested: cd desktop && bun run test -- src/components/workspace/WorkspacePanel.test.tsx src/components/chat/MessageList.test.tsx src/stores/workspacePanelStore.test.ts
Tested: cd desktop && bun run lint
Tested: cd desktop && bun run build
Tested: bun test src/server/__tests__/sessions.test.ts
Tested: Browser dark workspace preview at http://127.0.0.1:59468/ with src/App.jsx diff
Not-tested: macOS packaged app build
2026-04-30 14:30:20 +08:00

1489 lines
44 KiB
TypeScript

/**
* Session Service — 会话文件的读写操作封装
*
* 读写 CLI 持久化在 ~/.claude/projects/{sanitized_path}/{sessionId}.jsonl 的会话数据,
* 确保 Desktop App 与 CLI 的数据完全互通。
*/
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import * as os from 'node:os'
import { ApiError } from '../middleware/errorHandler.js'
import { sanitizePath as sanitizePortablePath } from '../../utils/sessionStoragePortable.js'
import type { FileHistorySnapshot } from '../../utils/fileHistory.js'
import { calculateUSDCost, MODEL_COSTS } from '../../utils/modelCost.js'
import {
MODEL_CONTEXT_WINDOW_DEFAULT,
getContextWindowForModel,
getModelMaxOutputTokens,
} from '../../utils/context.js'
import { getCanonicalName } from '../../utils/model/model.js'
// ============================================================================
// Types
// ============================================================================
export type SessionListItem = {
id: string
title: string
createdAt: string
modifiedAt: string
messageCount: number
projectPath: string
workDir: string | null
workDirExists: boolean
}
export type SessionDetail = SessionListItem & {
messages: MessageEntry[]
}
export type SessionLaunchInfo = {
filePath: string
projectDir: string
workDir: string
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 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
}
}
/** Raw entry parsed from a single JSONL line */
type RawEntry = {
type?: string
uuid?: string
messageId?: string
parentUuid?: string | null
parent_tool_use_id?: string | null
isSidechain?: boolean
isMeta?: boolean
cwd?: string
message?: {
role?: string
content?: unknown
model?: string
id?: string
type?: string
usage?: {
input_tokens?: number
output_tokens?: number
cache_read_input_tokens?: number
cache_creation_input_tokens?: number
server_tool_use?: {
web_search_requests?: number
}
speed?: string
}
}
timestamp?: string
version?: string
snapshot?: {
messageId?: string
trackedFileBackups?: Record<string, unknown>
timestamp?: string
}
customTitle?: string
title?: string
[key: string]: unknown
}
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.'
// ============================================================================
// Service
// ============================================================================
export class SessionService {
// --------------------------------------------------------------------------
// Config helpers
// --------------------------------------------------------------------------
private getConfigDir(): string {
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
}
private getProjectsDir(): string {
return path.join(this.getConfigDir(), 'projects')
}
/**
* Sanitize a path the same way the shared session storage does.
* This must remain Windows-safe, so reserved characters such as ':' are normalized too.
*/
private sanitizePath(dirPath: string): string {
return sanitizePortablePath(dirPath)
}
// --------------------------------------------------------------------------
// JSONL parsing
// --------------------------------------------------------------------------
private async readJsonlFile(filePath: string): Promise<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 {
// skip malformed lines
}
}
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 (const entry of entries) {
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
}
// --------------------------------------------------------------------------
// 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<string, unknown>) => 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<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 shouldHideTranscriptEntry(entry: RawEntry): boolean {
const role = entry.message?.role
const content = entry.message?.content
if (role === 'user') {
return (
this.isInternalCommandBreadcrumb(content) ||
this.isSyntheticUserInterruption(content)
)
}
if (role === 'assistant') {
return this.isSyntheticNoResponseAssistant(content)
}
return false
}
private extractAgentToolUseId(entry: RawEntry): string | undefined {
const content = entry.message?.content
if (!Array.isArray(content)) return undefined
for (const block of content as Array<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. Look for AI-generated title (written by titleService)
for (let i = entries.length - 1; i >= 0; i--) {
const e = entries[i]!
if (e.type === 'ai-title' && e.aiTitle) {
return e.aiTitle as string
}
}
// 3. Look for first non-meta user message as title
for (const e of entries) {
if (e.type === 'user' && !e.isMeta && e.message?.role === 'user') {
const content = e.message.content
let text: string | undefined
if (typeof content === 'string') {
text = content
} else if (Array.isArray(content)) {
const textBlock = content.find(
(block: Record<string, unknown>) => block.type === 'text' && typeof block.text === 'string'
)
if (textBlock) text = textBlock.text as string
}
if (text) {
return text.length > 80 ? text.slice(0, 80) + '...' : text
}
}
}
return 'Untitled Session'
}
// --------------------------------------------------------------------------
// Session file discovery
// --------------------------------------------------------------------------
/**
* Find all .jsonl session files across all project directories.
* Returns an array of { filePath, projectDir, sessionId }.
*/
private async discoverSessionFiles(projectFilter?: string): Promise<
Array<{ filePath: string; projectDir: string; sessionId: string }>
> {
const projectsDir = this.getProjectsDir()
let projectDirs: string[]
try {
projectDirs = await fs.readdir(projectsDir)
} catch {
return []
}
// Optionally filter to a specific project
if (projectFilter) {
const sanitized = this.sanitizePath(projectFilter)
projectDirs = projectDirs.filter((d) => d === sanitized)
}
const results: Array<{ filePath: string; projectDir: string; sessionId: string }> = []
for (const dir of projectDirs) {
const dirPath = path.join(projectsDir, dir)
// Ensure it's a directory
try {
const stat = await fs.stat(dirPath)
if (!stat.isDirectory()) continue
} catch {
continue
}
let files: string[]
try {
files = await fs.readdir(dirPath)
} catch {
continue
}
for (const file of files) {
if (!file.endsWith('.jsonl')) continue
const sessionId = file.replace('.jsonl', '')
results.push({
filePath: path.join(dirPath, file),
projectDir: dir,
sessionId,
})
}
}
return results
}
/**
* Convert a sanitized directory name back to the original absolute path.
* Reverses sanitizePath(): `-Users-nanmi-workspace` → `/Users/nanmi/workspace`.
*/
desanitizePath(sanitized: string): string {
// The sanitized form replaces all '/' (and '\') with '-'.
// On POSIX the original path starts with '/', so the sanitized form starts with '-'.
// We restore by replacing every '-' with '/' (the platform separator).
// On Windows the leading character would be a drive letter, but we handle POSIX here.
return sanitized.replace(/-/g, path.sep)
}
/**
* Find the .jsonl file for a given session ID.
* Searches across all project directories since sessions may belong to any project.
*/
async findSessionFile(
sessionId: string
): Promise<{ filePath: string; projectDir: string } | null> {
// Validate sessionId format to prevent path traversal
if (!this.isValidSessionId(sessionId)) {
return null
}
const projectsDir = this.getProjectsDir()
let projectDirs: string[]
try {
projectDirs = await fs.readdir(projectsDir)
} catch {
return null
}
for (const dir of projectDirs) {
const filePath = path.join(projectsDir, dir, `${sessionId}.jsonl`)
try {
await fs.access(filePath)
return { filePath, projectDir: dir }
} catch {
continue
}
}
return null
}
private isValidSessionId(id: string): boolean {
// UUID v4 format
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)
}
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 totalTokens = latest.inputTokens + latest.cacheReadInputTokens + latest.cacheCreationInputTokens
const percentage = rawMaxTokens > 0 ? Math.round((totalTokens / rawMaxTokens) * 100) : 0
const categories: TranscriptContextEstimate['categories'] = [
{ name: 'Input tokens', tokens: latest.inputTokens, color: '#8f3217' },
{ name: 'Cache read', tokens: latest.cacheReadInputTokens, color: '#0f5c8f' },
{ name: 'Cache write', tokens: latest.cacheCreationInputTokens, color: '#7c3aed' },
{ name: '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)
// Build session list items with metadata from file stats & first entries
const items: SessionListItem[] = []
for (const { filePath, projectDir, sessionId } of sessionFiles) {
try {
const stat = await fs.stat(filePath)
const entries = await this.readJsonlFile(filePath)
const workDir = this.resolveWorkDirFromEntries(entries, projectDir)
const workDirExists = await this.pathExists(workDir)
// Count transcript messages only (user + assistant)
const messageCount = entries.filter(
(e) => (e.type === 'user' || e.type === 'assistant') && e.message?.role
).length
const title = this.extractTitle(entries)
// Find the earliest timestamp from entries, fallback to file birthtime
let createdAt = stat.birthtime.toISOString()
for (const e of entries) {
if (e.timestamp) {
createdAt = e.timestamp
break
}
}
items.push({
id: sessionId,
title,
createdAt,
modifiedAt: stat.mtime.toISOString(),
messageCount,
projectPath: projectDir,
workDir,
workDirExists,
})
} catch {
// Skip unreadable files
}
}
// Sort by modifiedAt descending (most recent first)
items.sort((a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime())
const total = items.length
const offset = options?.offset ?? 0
const limit = options?.limit ?? 50
const paginated = items.slice(offset, offset + limit)
return { sessions: paginated, total }
}
/**
* Get full session detail including all messages.
*/
async getSession(sessionId: string): Promise<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 workDirExists = await this.pathExists(workDir)
let createdAt = stat.birthtime.toISOString()
for (const e of entries) {
if (e.timestamp) {
createdAt = e.timestamp
break
}
}
return {
id: sessionId,
title,
createdAt,
modifiedAt: stat.mtime.toISOString(),
messageCount: messages.length,
projectPath: projectDir,
workDir,
workDirExists,
messages,
}
}
/**
* Get only the messages for a session (lighter than full detail).
*/
async getSessionMessages(sessionId: string): Promise<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): Promise<{ sessionId: string }> {
// Default to user home directory when no workDir specified
const resolvedWorkDir = workDir || os.homedir()
// 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 absWorkDir = path.resolve(resolvedWorkDir)
console.log(
`[SessionService] createSession: requested workDir=${JSON.stringify(
workDir,
)}, resolved=${absWorkDir} (process.cwd()=${process.cwd()})`,
)
let stat
try {
stat = await fs.stat(absWorkDir)
} catch {
throw ApiError.badRequest(`Working directory does not exist: ${absWorkDir}`)
}
if (!stat.isDirectory()) {
throw ApiError.badRequest(`Working directory is not a directory: ${absWorkDir}`)
}
const sessionId = crypto.randomUUID()
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,
timestamp: now,
}
await fs.writeFile(filePath, JSON.stringify(initialEntry) + '\n' + JSON.stringify(metaEntry) + '\n', 'utf-8')
return { sessionId }
}
/**
* 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)
}
/**
* 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(),
})
}
/**
* 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()
let customTitle: string | null = null
let transcriptMessageCount = 0
for (const entry of entries) {
if (entry.type === 'custom-title' && typeof entry.customTitle === 'string') {
customTitle = entry.customTitle
}
if (
!entry.isMeta &&
entry.message?.role &&
(entry.type === 'user' || entry.type === 'assistant' || entry.type === 'system')
) {
transcriptMessageCount++
}
}
return {
filePath: found.filePath,
projectDir: found.projectDir,
workDir,
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 absWorkDir = path.resolve(fallbackWorkDir)
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 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,
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 }
): Promise<void> {
const found = await this.findSessionFile(sessionId)
if (!found) return
await this.appendJsonlEntry(found.filePath, {
type: 'session-meta',
isMeta: true,
workDir: metadata.workDir,
timestamp: new Date().toISOString(),
})
if (metadata.customTitle) {
await this.appendJsonlEntry(found.filePath, {
type: 'custom-title',
customTitle: metadata.customTitle,
timestamp: new Date().toISOString(),
})
}
}
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)
if (removedMessageIds.length === 0) {
return { removedCount: 0, removedMessageIds: [] }
}
const removedIds = new Set(removedMessageIds)
const filteredEntries = entries.filter(
(entry) => !(typeof entry.uuid === 'string' && removedIds.has(entry.uuid)),
)
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()]
}
// --------------------------------------------------------------------------
// Private helpers
// --------------------------------------------------------------------------
private entriesToMessages(entries: RawEntry[]): MessageEntry[] {
const messages: MessageEntry[] = []
const entriesByUuid = new Map<string, RawEntry>()
const parentToolUseIdCache = new Map<string, string | undefined>()
for (const entry of entries) {
if (typeof entry.uuid === 'string' && entry.uuid.length > 0) {
entriesByUuid.set(entry.uuid, entry)
}
}
for (const entry of entries) {
// Only process transcript entries (user / assistant / system with messages)
if (!entry.message?.role) continue
// Skip meta entries (CLI internal bookkeeping)
if (entry.isMeta) continue
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()