cc-haha/src/server/services/sessionService.ts
程序员阿江(Relakkes) 5f0ddaecb2 fix: keep worktree session metadata after CLI init
Worktree sessions briefly showed the expected footer badge and then reverted because the CLI init path produced a second transcript under the actual worktree cwd. The desktop placeholder still held the repository launch metadata, but the startup cleanup removed that placeholder after writing metadata through a lookup that could target the stale source transcript. The runtime session also kept the launch cwd instead of the init cwd.

This moves session metadata writes to the transcript matching the CLI init cwd, carries repository launch metadata across duplicate session files, and updates the live ConversationService workDir when the init event reports the real cwd.

Constraint: Native CLI worktree creation happens inside the CLI after desktop has already created a placeholder session.

Rejected: Patch the footer to remember the first worktree badge | it would hide stale backend state and leave other session APIs on the source checkout.

Confidence: high

Scope-risk: narrow

Directive: Treat the CLI init cwd as authoritative for active desktop sessions; do not let placeholder cleanup discard repository launch metadata.

Tested: bun test src/server/__tests__/sessions.test.ts --test-name-pattern worktree|git-info|placeholder

Tested: cd desktop && bun run lint

Tested: cd desktop && bun run test -- src/components/shared/ProjectContextChip.test.tsx

Tested: git diff --check
2026-05-08 18:24:16 +08:00

1729 lines
52 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 {
calculateCurrentContextTokenTotal,
MODEL_CONTEXT_WINDOW_DEFAULT,
getContextWindowForModel,
getModelMaxOutputTokens,
} from '../../utils/context.js'
import { getCanonicalName } from '../../utils/model/model.js'
import {
resolveSessionWorkspaceLaunch,
type CreateSessionRepositoryOptions,
type PreparedSessionWorkspace,
} from './repositoryLaunchService.js'
// ============================================================================
// Types
// ============================================================================
export type SessionListItem = {
id: string
title: string
createdAt: string
modifiedAt: string
messageCount: number
projectPath: string
workDir: string | null
workDirExists: boolean
}
export type SessionDetail = SessionListItem & {
messages: MessageEntry[]
}
export type SessionLaunchInfo = {
filePath: string
projectDir: string
workDir: string
repository?: PreparedSessionWorkspace['repository']
transcriptMessageCount: number
customTitle: string | null
}
export type TrimSessionResult = {
removedCount: number
removedMessageIds: string[]
}
export type MessageEntry = {
id: string
type: 'user' | 'assistant' | 'system' | 'tool_use' | 'tool_result'
content: unknown
timestamp: string
model?: string
parentUuid?: string
parentToolUseId?: string
isSidechain?: boolean
}
export type SessionTaskNotification = {
taskId: string
toolUseId: string
status: 'completed' | 'failed' | 'stopped'
summary?: string
outputFile?: string
}
export type TranscriptUsageSnapshot = {
source: 'transcript'
totalCostUSD: number
costDisplay: string
hasUnknownModelCost: boolean
totalAPIDuration: number
totalDuration: number
totalLinesAdded: number
totalLinesRemoved: number
totalInputTokens: number
totalOutputTokens: number
totalCacheReadInputTokens: number
totalCacheCreationInputTokens: number
totalWebSearchRequests: number
models: Array<{
model: string
displayName: string
inputTokens: number
outputTokens: number
cacheReadInputTokens: number
cacheCreationInputTokens: number
webSearchRequests: number
costUSD: number
costDisplay: string
contextWindow: number
maxOutputTokens: number
}>
}
export type TranscriptMetadataSnapshot = {
model?: string
cwd?: string
version?: string
}
export type TranscriptContextEstimate = {
categories: Array<{
name: string
tokens: number
color: string
isDeferred?: boolean
}>
totalTokens: number
maxTokens: number
rawMaxTokens: number
percentage: number
gridRows: Array<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.'
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
// ============================================================================
// 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 (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 countTranscriptMessages(entries: RawEntry[]): number {
return entries.filter((entry) =>
!entry.isMeta &&
!!entry.message?.role &&
(entry.type === 'user' || entry.type === 'assistant' || entry.type === 'system')
).length
}
// --------------------------------------------------------------------------
// Entry → MessageEntry conversion
// --------------------------------------------------------------------------
private entryToMessage(
entry: RawEntry,
parentToolUseId?: string,
): MessageEntry | null {
const msg = entry.message
if (!msg || !msg.role) return null
// Determine our normalized type
let type: MessageEntry['type']
const role = msg.role
if (role === 'user') {
// Check if the content is a tool_result array
if (Array.isArray(msg.content)) {
const hasToolResult = msg.content.some(
(block: Record<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 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(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&')
}
private readXmlTag(xml: string, tag: string): string | undefined {
const match = xml.match(new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'i'))
return match?.[1] ? this.decodeXmlText(match[1].trim()) : undefined
}
private parseTaskNotificationContent(content: unknown): SessionTaskNotification | null {
const xml = this.extractTextBlocks(content)
.map((text) => this.extractTaskNotificationXml(text))
.find((value): value is string => value !== null)
if (!xml) return null
const toolUseId = this.readXmlTag(xml, 'tool-use-id')
const status = this.readXmlTag(xml, 'status')
if (
!toolUseId ||
(status !== 'completed' && status !== 'failed' && status !== 'stopped')
) {
return null
}
const taskId = this.readXmlTag(xml, 'task-id') || toolUseId
const summary = this.readXmlTag(xml, 'summary')
const outputFile = this.readXmlTag(xml, 'output-file')
return {
taskId,
toolUseId,
status,
...(summary ? { summary } : {}),
...(outputFile ? { outputFile } : {}),
}
}
private shouldHideTranscriptEntry(entry: RawEntry): boolean {
const role = entry.message?.role
const content = entry.message?.content
if (role === 'user') {
return (
this.isInternalCommandBreadcrumb(content) ||
this.isSyntheticUserInterruption(content) ||
this.isTaskNotificationContent(content)
)
}
if (role === 'assistant') {
return this.isSyntheticNoResponseAssistant(content)
}
return false
}
private extractAgentToolUseId(entry: RawEntry): string | undefined {
const content = entry.message?.content
if (!Array.isArray(content)) return undefined
for (const block of content as Array<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.
*/
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 }> = []
for (const dir of projectDirs) {
const filePath = path.join(projectsDir, dir, `${sessionId}.jsonl`)
try {
await fs.access(filePath)
matches.push({ filePath, projectDir: dir })
} catch {
continue
}
}
return matches
}
async findSessionFile(
sessionId: string
): Promise<{ filePath: string; projectDir: string } | null> {
return (await this.findSessionFiles(sessionId))[0] ?? null
}
private isValidSessionId(id: string): boolean {
// UUID v4 format
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)
}
private formatCost(cost: number): string {
return `$${cost > 0.5 ? (Math.round(cost * 100) / 100).toFixed(2) : cost.toFixed(4)}`
}
private getTranscriptContextWindow(model: string): number {
try {
return getContextWindowForModel(model)
} catch (err) {
if (
err instanceof Error &&
err.message.includes('Config accessed before allowed')
) {
return MODEL_CONTEXT_WINDOW_DEFAULT
}
throw err
}
}
async getTranscriptMetadata(sessionId: string): Promise<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,
})
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 workDirExists = await this.pathExists(workDir)
// Count transcript messages only (user + assistant)
const messageCount = entries.filter(
(e) => (e.type === 'user' || e.type === 'assistant') && e.message?.role
).length
const title = this.extractTitle(entries)
// Find the earliest timestamp from entries, fallback to file birthtime
let createdAt = stat.birthtime.toISOString()
for (const e of entries) {
if (e.timestamp) {
createdAt = e.timestamp
break
}
}
return {
id: sessionId,
title,
createdAt,
modifiedAt: stat.mtime.toISOString(),
messageCount,
projectPath: projectDir,
workDir,
workDirExists,
}
} catch {
// Skip unreadable files
return null
}
}))).filter((item): item is SessionListItem => item !== null)
return { sessions: items, total }
}
/**
* Get full session detail including all messages.
*/
async getSession(sessionId: string): Promise<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,
repositoryOptions?: CreateSessionRepositoryOptions,
): Promise<{ sessionId: string; workDir: string }> {
// Default to user home directory when no workDir specified
const resolvedWorkDir = workDir || os.homedir()
const sessionId = crypto.randomUUID()
// Resolve to absolute path. NOTE: path.resolve() uses process.cwd() to
// expand relative paths — in bundled sidecar mode the server's cwd is
// typically '/'. Callers (IM adapters) already send absolute realPath,
// but we log here so cwd regressions are caught early.
const preparedWorkspace = await resolveSessionWorkspaceLaunch(
resolvedWorkDir,
repositoryOptions,
sessionId,
)
const absWorkDir = preparedWorkspace.workDir
console.log(
`[SessionService] createSession: requested workDir=${JSON.stringify(
workDir,
)}, resolved=${absWorkDir}, repository=${JSON.stringify(
preparedWorkspace.repository ?? null,
)} (process.cwd()=${process.cwd()})`,
)
const sanitized = this.sanitizePath(absWorkDir)
const dirPath = path.join(this.getProjectsDir(), sanitized)
// Ensure the project directory exists
await fs.mkdir(dirPath, { recursive: true })
const filePath = path.join(dirPath, `${sessionId}.jsonl`)
const now = new Date().toISOString()
// Write an initial file-history-snapshot entry (matches CLI behavior)
const initialEntry = {
type: 'file-history-snapshot',
messageId: crypto.randomUUID(),
snapshot: {
messageId: crypto.randomUUID(),
trackedFileBackups: {},
timestamp: now,
},
isSnapshotUpdate: false,
}
// Store actual workDir for later retrieval
const metaEntry = {
type: 'session-meta',
isMeta: true,
workDir: absWorkDir,
repository: preparedWorkspace.repository,
timestamp: now,
}
await fs.writeFile(filePath, JSON.stringify(initialEntry) + '\n' + JSON.stringify(metaEntry) + '\n', 'utf-8')
return { sessionId, workDir: absWorkDir }
}
/**
* Delete a session's JSONL file.
*/
async deleteSession(sessionId: string): Promise<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(),
})
}
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)
let customTitle: string | null = null
for (const entry of entries) {
if (entry.type === 'custom-title' && typeof entry.customTitle === 'string') {
customTitle = entry.customTitle
}
}
const transcriptMessageCount = this.countTranscriptMessages(entries)
return {
filePath: found.filePath,
projectDir: found.projectDir,
workDir,
repository,
transcriptMessageCount,
customTitle,
}
}
async deleteSessionFile(sessionId: string): Promise<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)
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) {
// 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()