cc-haha/src/server/services/sessionService.ts
程序员阿江(Relakkes) 572eba46f4 fix(desktop): restore session token usage after reopen (#820)
Persist transcript usage through session history so reopened desktop tabs can restore header token counts, and resume elapsed timers when reconnected sessions report active status.

Tested: cd desktop && bun run test -- --run src/stores/chatStore.test.ts
Tested: bun test src/server/__tests__/sessions.test.ts
Tested: cd desktop && bun run lint
Tested: bun run check:server
Tested: git diff --check
Not-tested: bun run verify and coverage; scoped local handoff, not PR-ready validation.
Confidence: high
Scope-risk: narrow
2026-06-15 23:02:19 +08:00

2404 lines
75 KiB
TypeScript

/**
* Session Service — 会话文件的读写操作封装
*
* 读写 CLI 持久化在 ~/.claude/projects/{sanitized_path}/{sessionId}.jsonl 的会话数据,
* 确保 Desktop App 与 CLI 的数据完全互通。
*/
import { createReadStream } from 'node:fs'
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import * as os from 'node:os'
import { createInterface } from 'node:readline'
import { ApiError } from '../middleware/errorHandler.js'
import { sanitizePath as sanitizePortablePath } from '../../utils/sessionStoragePortable.js'
import type { FileHistorySnapshot } from '../../utils/fileHistory.js'
import { findCanonicalGitRoot } from '../../utils/git.js'
import { calculateUSDCost, MODEL_COSTS } from '../../utils/modelCost.js'
import {
MODEL_CONTEXT_WINDOW_DEFAULT,
getContextWindowForModel,
getModelMaxOutputTokens,
is1mContextDisabled,
} from '../../utils/context.js'
import {
MODEL_CONTEXT_WINDOWS_ENV_KEY,
getModelContextWindowFromEnvValue,
} from '../../utils/model/modelContextWindows.js'
import {
calculateContextBudget,
getProviderUsageTrust,
hasMediaInput,
} from '../../utils/contextBudget.js'
import { getCanonicalName } from '../../utils/model/model.js'
import { isFirstPartyAnthropicBaseUrl } from '../../utils/model/providers.js'
import {
resolveSessionWorkspaceLaunch,
type CreateSessionRepositoryOptions,
type PreparedSessionWorkspace,
} from './repositoryLaunchService.js'
import { registerFilesystemAccessRoot } from './filesystemAccessRoots.js'
import { normalizeDriveRootPathForPlatform } from './windowsDrivePath.js'
import { cleanSessionTitleSource } from '../../utils/sessionTitleText.js'
import { roughTokenCountEstimationForMessages } from '../../services/tokenEstimation.js'
import { ProviderService } from './providerService.js'
// ============================================================================
// Types
// ============================================================================
export type SessionListItem = {
id: string
title: string
createdAt: string
modifiedAt: string
messageCount: number
projectPath: string
projectRoot: string | null
workDir: string | null
workDirExists: boolean
permissionMode?: string
}
export type DeleteSessionFailure = {
sessionId: string
message: string
code?: string
}
export type DeleteSessionsResult = {
successes: string[]
failures: DeleteSessionFailure[]
}
export type SessionDetail = SessionListItem & {
messages: MessageEntry[]
}
export type SessionLaunchInfo = {
filePath: string
projectDir: string
workDir: string
repository?: PreparedSessionWorkspace['repository']
worktreeSession?: PersistedWorktreeSession | null
transcriptMessageCount: number
customTitle: string | null
permissionMode?: string
runtimeProviderId?: string | null
runtimeModelId?: string
effortLevel?: string
}
export type TrimSessionResult = {
removedCount: number
removedMessageIds: string[]
}
export type MessageUsage = {
input_tokens?: number
output_tokens?: number
cache_read_input_tokens?: number
cache_creation_input_tokens?: number
}
export type MessageEntry = {
id: string
type: 'user' | 'assistant' | 'system' | 'tool_use' | 'tool_result'
content: unknown
toolUseResult?: unknown
timestamp: string
model?: string
usage?: MessageUsage
parentUuid?: string
parentToolUseId?: string
isSidechain?: boolean
}
export type SessionTaskNotification = {
taskId: string
toolUseId: string
status: 'completed' | 'failed' | 'stopped'
summary?: string
result?: string
outputFile?: string
timestamp?: string
}
export type TranscriptUsageSnapshot = {
source: 'transcript'
totalCostUSD: number
costDisplay: string
hasUnknownModelCost: boolean
totalAPIDuration: number
totalDuration: number
totalLinesAdded: number
totalLinesRemoved: number
totalInputTokens: number
totalOutputTokens: number
totalCacheReadInputTokens: number
totalCacheCreationInputTokens: number
totalWebSearchRequests: number
models: Array<{
model: string
displayName: string
inputTokens: number
outputTokens: number
cacheReadInputTokens: number
cacheCreationInputTokens: number
webSearchRequests: number
costUSD: number
costDisplay: string
contextWindow: number
maxOutputTokens: number
}>
}
export type TranscriptMetadataSnapshot = {
model?: string
cwd?: string
version?: string
}
export type TranscriptContextEstimate = {
categories: Array<{
name: string
tokens: number
color: string
isDeferred?: boolean
}>
totalTokens: number
maxTokens: number
rawMaxTokens: number
percentage: number
gridRows: Array<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
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
permissionMode?: string
worktreeSession?: PersistedWorktreeSession | null
title?: string
[key: string]: unknown
}
type RawMessageUsage = NonNullable<RawEntry['message']>['usage']
function normalizeMessageUsage(usage: RawMessageUsage): MessageUsage | undefined {
if (!usage) return undefined
const normalized: MessageUsage = {}
if (typeof usage.input_tokens === 'number' && Number.isFinite(usage.input_tokens)) {
normalized.input_tokens = usage.input_tokens
}
if (typeof usage.output_tokens === 'number' && Number.isFinite(usage.output_tokens)) {
normalized.output_tokens = usage.output_tokens
}
if (
typeof usage.cache_read_input_tokens === 'number' &&
Number.isFinite(usage.cache_read_input_tokens)
) {
normalized.cache_read_input_tokens = usage.cache_read_input_tokens
}
if (
typeof usage.cache_creation_input_tokens === 'number' &&
Number.isFinite(usage.cache_creation_input_tokens)
) {
normalized.cache_creation_input_tokens = usage.cache_creation_input_tokens
}
return Object.keys(normalized).length > 0 ? normalized : undefined
}
type PersistedWorktreeSession = {
originalCwd: string
worktreePath: string
worktreeName: string
worktreeBranch?: string
originalBranch?: string
originalHeadCommit?: string
sessionId: string
tmuxSessionName?: string
hookBased?: boolean
}
type SessionListSummary = {
title: string
createdAt: string
messageCount: number
workDir: string | null
permissionMode?: string
runtimeProviderId?: string | null
runtimeModelId?: string
effortLevel?: string
repository?: PreparedSessionWorkspace['repository']
worktreeSession?: PersistedWorktreeSession | null
}
const VALID_SESSION_PERMISSION_MODES = new Set([
'default',
'acceptEdits',
'plan',
'bypassPermissions',
'dontAsk',
])
const VALID_SESSION_EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'max'])
type ContentBlock = Record<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 {
private providerService = new ProviderService()
private readonly sessionListCacheTtlMs = 5_000
private readonly sessionListCache = new Map<string, {
expiresAt: number
result: { sessions: SessionListItem[]; total: number }
}>()
private sessionListCacheKey(options?: {
project?: string
limit?: number
offset?: number
}): string {
return JSON.stringify({
project: options?.project ?? null,
limit: options?.limit ?? 50,
offset: options?.offset ?? 0,
})
}
private cloneSessionListResult(result: { sessions: SessionListItem[]; total: number }): { sessions: SessionListItem[]; total: number } {
return {
total: result.total,
sessions: result.sessions.map((session) => ({ ...session })),
}
}
private invalidateSessionListCache(): void {
this.sessionListCache.clear()
}
// --------------------------------------------------------------------------
// Config helpers
// --------------------------------------------------------------------------
private getConfigDir(): string {
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
}
private getProjectsDir(): string {
return path.join(this.getConfigDir(), 'projects')
}
/**
* Sanitize a path the same way the shared session storage does.
* This must remain Windows-safe, so reserved characters such as ':' are normalized too.
*/
private sanitizePath(dirPath: string): string {
return sanitizePortablePath(dirPath)
}
// --------------------------------------------------------------------------
// JSONL parsing
// --------------------------------------------------------------------------
private async readJsonlFile(filePath: string): Promise<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 scanSessionListSummary(
filePath: string,
projectDir: string,
stat: { birthtime: Date },
): Promise<SessionListSummary> {
let createdAt = stat.birthtime.toISOString()
let hasCreatedAt = false
let messageCount = 0
let firstUserTitle: string | null = null
let goalTitle: string | null = null
let aiTitle: string | null = null
let customTitle: string | null = null
let latestWorkDir: string | null = null
let latestCwd: string | null = null
let permissionMode: string | undefined
let runtimeProviderId: string | null | undefined
let runtimeModelId: string | undefined
let effortLevel: string | undefined
let repository: PreparedSessionWorkspace['repository'] | undefined
let worktreeSession: PersistedWorktreeSession | null | undefined
const stream = createReadStream(filePath, { encoding: 'utf8' })
const lines = createInterface({
input: stream,
crlfDelay: Infinity,
})
try {
for await (const line of lines) {
const trimmed = line.trim()
if (!trimmed) continue
let entry: RawEntry
try {
entry = JSON.parse(trimmed) as RawEntry
} catch {
continue
}
if (!hasCreatedAt && entry.timestamp) {
createdAt = entry.timestamp
hasCreatedAt = true
}
if (
(entry.type === 'user' || entry.type === 'assistant') &&
entry.message?.role
) {
messageCount += 1
}
if (entry.type === 'session-meta') {
if (typeof (entry as Record<string, unknown>).workDir === 'string') {
latestWorkDir = normalizeDriveRootPathForPlatform(
(entry as Record<string, unknown>).workDir as string,
)
}
if (
typeof entry.permissionMode === 'string' &&
VALID_SESSION_PERMISSION_MODES.has(entry.permissionMode)
) {
permissionMode = entry.permissionMode
}
if (
(entry as Record<string, unknown>).runtimeProviderId === null ||
typeof (entry as Record<string, unknown>).runtimeProviderId === 'string'
) {
runtimeProviderId = (entry as Record<string, unknown>).runtimeProviderId as string | null
}
if (typeof (entry as Record<string, unknown>).runtimeModelId === 'string') {
runtimeModelId = (entry as Record<string, unknown>).runtimeModelId as string
}
if (
typeof (entry as Record<string, unknown>).effortLevel === 'string' &&
VALID_SESSION_EFFORT_LEVELS.has((entry as Record<string, unknown>).effortLevel as string)
) {
effortLevel = (entry as Record<string, unknown>).effortLevel as string
}
}
if (typeof entry.cwd === 'string' && entry.cwd.trim()) {
latestCwd = normalizeDriveRootPathForPlatform(entry.cwd)
}
const candidateRepository = (entry as Record<string, unknown>)?.repository
if (candidateRepository && typeof candidateRepository === 'object') {
repository = candidateRepository as PreparedSessionWorkspace['repository']
}
if (entry.type === 'worktree-state') {
if (entry.worktreeSession === null) {
worktreeSession = null
} else if (
entry.worktreeSession &&
typeof entry.worktreeSession === 'object' &&
typeof entry.worktreeSession.worktreePath === 'string' &&
typeof entry.worktreeSession.worktreeName === 'string'
) {
worktreeSession = entry.worktreeSession
}
}
if (entry.type === 'custom-title' && entry.customTitle) {
customTitle = String(entry.customTitle)
}
if (!goalTitle) {
goalTitle = this.goalCreationCommandTitle(entry)
}
if (entry.type === 'ai-title' && entry.aiTitle) {
const title = cleanSessionTitleSource(String(entry.aiTitle))
if (title) aiTitle = title
}
if (
!firstUserTitle &&
entry.type === 'user' &&
!entry.isMeta &&
entry.message?.role === 'user'
) {
firstUserTitle = this.extractUserMessageTitle(entry.message.content)
}
}
} finally {
lines.close()
stream.destroy()
}
return {
title: customTitle ||
goalTitle ||
aiTitle ||
firstUserTitle ||
'Untitled Session',
createdAt,
messageCount,
workDir: latestWorkDir || latestCwd || this.desanitizePath(projectDir),
...(permissionMode ? { permissionMode } : {}),
...(runtimeProviderId !== undefined ? { runtimeProviderId } : {}),
...(runtimeModelId ? { runtimeModelId } : {}),
...(effortLevel ? { effortLevel } : {}),
...(repository ? { repository } : {}),
...(worktreeSession !== undefined ? { worktreeSession } : {}),
}
}
/**
* Resolve a session's display title + lightweight metadata from its JSONL file.
*
* Reuses the same title precedence as the session list (custom-title > goal >
* ai-title > first user message), so global session search shows real titles
* instead of the raw UUID file name.
*/
async getSessionTitleAndMeta(filePath: string): Promise<{
title: string
modifiedAt: string
workDir: string | null
projectPath: string
}> {
const stat = await fs.stat(filePath)
const projectPath = path.basename(path.dirname(filePath))
const summary = await this.scanSessionListSummary(filePath, projectPath, stat)
return {
title: summary.title,
modifiedAt: stat.mtime.toISOString(),
workDir: summary.workDir ?? null,
projectPath,
}
}
private async appendJsonlEntry(filePath: string, entry: Record<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 normalizeDriveRootPathForPlatform((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 normalizeDriveRootPathForPlatform(cwd)
}
}
return fallbackProjectDir ? this.desanitizePath(fallbackProjectDir) : null
}
private resolveRepositoryFromEntries(entries: RawEntry[]): PreparedSessionWorkspace['repository'] | undefined {
for (let i = entries.length - 1; i >= 0; i--) {
const repository = (entries[i] as Record<string, unknown>)?.repository
if (repository && typeof repository === 'object') {
return repository as PreparedSessionWorkspace['repository']
}
}
return undefined
}
private resolvePermissionModeFromEntries(entries: RawEntry[]): string | undefined {
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i]
if (entry?.type !== 'session-meta') continue
const permissionMode = entry.permissionMode
if (
typeof permissionMode === 'string' &&
VALID_SESSION_PERMISSION_MODES.has(permissionMode)
) {
return permissionMode
}
}
return undefined
}
private resolveWorktreeSessionFromEntries(entries: RawEntry[]): PersistedWorktreeSession | null | undefined {
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i]
if (entry?.type !== 'worktree-state') continue
const worktreeSession = entry.worktreeSession
if (worktreeSession === null) return null
if (
worktreeSession &&
typeof worktreeSession === 'object' &&
typeof worktreeSession.worktreePath === 'string' &&
typeof worktreeSession.worktreeName === 'string'
) {
return worktreeSession
}
}
return undefined
}
private async resolveProjectRootFromEntries(
entries: RawEntry[],
workDir: string | null,
fallbackProjectDir?: string,
): Promise<string | null> {
const worktreeSession = this.resolveWorktreeSessionFromEntries(entries)
const repository = this.resolveRepositoryFromEntries(entries)
return this.resolveProjectRootFromSessionMetadata({
worktreeSession,
repository,
workDir,
fallbackProjectDir,
})
}
private async resolveProjectRootFromSessionMetadata({
worktreeSession,
repository,
workDir,
fallbackProjectDir,
}: {
worktreeSession?: PersistedWorktreeSession | null
repository?: PreparedSessionWorkspace['repository']
workDir: string | null
fallbackProjectDir?: string
}): Promise<string | null> {
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 normalizeDriveRootPathForPlatform(await fs.realpath(projectPath)).normalize('NFC')
} catch {
return projectPath.normalize('NFC')
}
}
private countTranscriptMessages(entries: RawEntry[]): number {
return entries.filter((entry) =>
!entry.isMeta &&
!!entry.message?.role &&
(entry.type === 'user' || entry.type === 'assistant' || entry.type === 'system')
).length
}
// --------------------------------------------------------------------------
// Entry → MessageEntry conversion
// --------------------------------------------------------------------------
private entryToMessage(
entry: RawEntry,
parentToolUseId?: string,
): MessageEntry | null {
const msg = entry.message
if (!msg || !msg.role) return null
// Determine our normalized type
let type: MessageEntry['type']
const role = msg.role
if (role === 'user') {
// Check if the content is a tool_result array
if (Array.isArray(msg.content)) {
const hasToolResult = msg.content.some(
(block: Record<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'
}
const usage = normalizeMessageUsage(msg.usage)
return {
id: entry.uuid || crypto.randomUUID(),
type,
content: msg.content,
...(entry.toolUseResult !== undefined ? { toolUseResult: entry.toolUseResult } : {}),
timestamp: entry.timestamp || new Date().toISOString(),
model: msg.model,
...(usage ? { usage } : {}),
parentUuid: entry.parentUuid ?? undefined,
parentToolUseId,
isSidechain: entry.isSidechain,
}
}
private extractTextBlocks(content: unknown): string[] {
if (typeof content === 'string') return [content]
if (!Array.isArray(content)) return []
return content
.flatMap((block) => {
if (!block || typeof block !== 'object') return []
const record = block as Record<string, unknown>
return record.type === 'text' && typeof record.text === 'string'
? [record.text]
: []
})
.map((text) => text.trim())
.filter(Boolean)
}
private isInternalCommandBreadcrumb(content: unknown): boolean {
const textBlocks = this.extractTextBlocks(content)
return (
textBlocks.length > 0 &&
textBlocks.every((text) =>
text.includes('<command-name>') ||
text.includes('<command-message>') ||
text.includes('<command-args>') ||
text.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,
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'
}
private extractUserMessageTitle(content: unknown): string | null {
let text: string | undefined
if (typeof content === 'string') {
text = content
} else if (Array.isArray(content)) {
const textBlock = content.find(
(block: Record<string, unknown>) => block.type === 'text' && typeof block.text === 'string'
)
if (textBlock) text = textBlock.text as string
}
if (!text) return null
const title = cleanSessionTitleSource(text)
if (!title) return null
return title.length > 80 ? `${title.slice(0, 80)}...` : title
}
// --------------------------------------------------------------------------
// Session file discovery
// --------------------------------------------------------------------------
/**
* Find all .jsonl session files across all project directories.
* Returns an array of { filePath, projectDir, sessionId }.
*/
private async discoverSessionFiles(projectFilter?: string): Promise<
Array<{ filePath: string; projectDir: string; sessionId: string }>
> {
const projectsDir = this.getProjectsDir()
let projectDirs: string[]
try {
projectDirs = await fs.readdir(projectsDir)
} catch {
return []
}
// Optionally filter to a specific project
if (projectFilter) {
const sanitized = this.sanitizePath(normalizeDriveRootPathForPlatform(projectFilter))
projectDirs = projectDirs.filter((d) => d === sanitized)
}
const results: Array<{ filePath: string; projectDir: string; sessionId: string }> = []
for (const dir of projectDirs) {
const dirPath = path.join(projectsDir, dir)
// Ensure it's a directory
try {
const stat = await fs.stat(dirPath)
if (!stat.isDirectory()) continue
} catch {
continue
}
let files: string[]
try {
files = await fs.readdir(dirPath)
} catch {
continue
}
for (const file of files) {
if (!file.endsWith('.jsonl')) continue
const sessionId = file.replace('.jsonl', '')
results.push({
filePath: path.join(dirPath, file),
projectDir: dir,
sessionId,
})
}
}
return results
}
/**
* Convert a sanitized directory name back to the original absolute path.
* Reverses sanitizePath(): `-Users-nanmi-workspace` → `/Users/nanmi/workspace`.
*/
desanitizePath(sanitized: string): string {
// The sanitized form replaces all non-alphanumeric characters with '-'.
// This fallback is necessarily lossy, but old Windows transcripts without
// session-meta still need the drive separator restored well enough to resume.
const windowsDrivePath = sanitized.match(/^([a-zA-Z])--(.+)$/)
if (windowsDrivePath) {
return `${windowsDrivePath[1]}:${path.win32.sep}${windowsDrivePath[2].replace(/-/g, path.win32.sep)}`
}
const windowsDriveRoot = sanitized.match(/^([a-zA-Z])--$/)
if (windowsDriveRoot) {
return `${windowsDriveRoot[1]}:${path.win32.sep}`
}
// On POSIX the original path starts with '/', so the sanitized form starts with '-'.
// UNC-style Windows paths also recover to a leading double separator on Windows.
return sanitized.replace(/-/g, path.sep)
}
/**
* Find the .jsonl file for a given session ID.
* Searches across all project directories since sessions may belong to any project.
*/
private async findSessionFiles(
sessionId: string
): Promise<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 async getProviderContextWindowForSession(
sessionId: string,
model: string,
): Promise<number | undefined> {
const launchInfo = await this.getSessionLaunchInfo(sessionId).catch(() => null)
const providerIds: string[] = []
if (typeof launchInfo?.runtimeProviderId === 'string') {
providerIds.push(launchInfo.runtimeProviderId)
} else if (launchInfo?.runtimeProviderId !== null) {
const { activeId } = await this.providerService.listProviders().catch(() => ({ activeId: null }))
if (activeId) providerIds.push(activeId)
}
for (const providerId of providerIds) {
const env = await this.providerService.getProviderRuntimeEnv(providerId).catch(() => null)
const contextWindow = getModelContextWindowFromEnvValue(
model,
env?.[MODEL_CONTEXT_WINDOWS_ENV_KEY],
)
if (contextWindow !== undefined) {
if (contextWindow > MODEL_CONTEXT_WINDOW_DEFAULT && is1mContextDisabled()) {
return MODEL_CONTEXT_WINDOW_DEFAULT
}
return contextWindow
}
}
return undefined
}
private async getTranscriptContextWindow(sessionId: string, model: string): Promise<number> {
const providerContextWindow = await this.getProviderContextWindowForSession(sessionId, model)
if (providerContextWindow !== undefined) {
return providerContextWindow
}
try {
return getContextWindowForModel(model)
} catch (err) {
if (
err instanceof Error &&
err.message.includes('Config accessed before allowed')
) {
return MODEL_CONTEXT_WINDOW_DEFAULT
}
throw err
}
}
async getTranscriptMetadata(sessionId: string): Promise<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 = await this.getTranscriptContextWindow(sessionId, latest.model)
const promptTokens = latest.inputTokens + latest.cacheReadInputTokens + latest.cacheCreationInputTokens
const transcriptMessages = entries.filter(entry =>
entry.type === 'user' || entry.type === 'assistant' || entry.type === 'attachment',
) as Parameters<typeof roughTokenCountEstimationForMessages>[0]
const estimatedTokens =
roughTokenCountEstimationForMessages(transcriptMessages) || promptTokens
const contextBudget = calculateContextBudget({
estimatedTokens,
contextWindow: rawMaxTokens,
currentUsage: {
input_tokens: latest.inputTokens,
output_tokens: latest.outputTokens,
cache_read_input_tokens: latest.cacheReadInputTokens,
cache_creation_input_tokens: latest.cacheCreationInputTokens,
},
usageTrust: getProviderUsageTrust({
isFirstPartyAnthropic: isFirstPartyAnthropicBaseUrl(),
}),
hasMediaInput: hasMediaInput(transcriptMessages),
})
const totalTokens = contextBudget.usedTokens
const percentage = rawMaxTokens > 0 ? Math.round((totalTokens / rawMaxTokens) * 100) : 0
const usageCategories: TranscriptContextEstimate['categories'] = [
{ name: 'Input tokens', tokens: latest.inputTokens, color: '#8f3217' },
{ name: 'Cache read', tokens: latest.cacheReadInputTokens, color: '#0f5c8f' },
{ name: 'Cache write', tokens: latest.cacheCreationInputTokens, color: '#7c3aed' },
{ name: 'Output tokens', tokens: latest.outputTokens, color: '#2f7d32' },
]
const contextCategories: TranscriptContextEstimate['categories'] =
contextBudget.ignoredUsageReason === 'low_trust_media_usage'
? [{ name: 'Estimated context', tokens: totalTokens, color: '#8f3217' }]
: usageCategories
const categories: TranscriptContextEstimate['categories'] = [
...contextCategories,
{ name: 'Free space', tokens: Math.max(0, rawMaxTokens - totalTokens), color: '#a1a1aa', isDeferred: true },
].filter((category) => category.tokens > 0)
const filledSquares = Math.max(0, Math.min(100, Math.round((totalTokens / Math.max(1, rawMaxTokens)) * 100)))
const gridRows = Array.from({ length: 10 }, (_, row) =>
Array.from({ length: 10 }, (_, col) => {
const index = row * 10 + col
const isFilled = index < filledSquares
return {
color: isFilled ? '#8f3217' : '#a1a1aa',
isFilled,
categoryName: isFilled ? 'Input context' : 'Free space',
tokens: Math.round(rawMaxTokens / 100),
percentage: 1,
squareFullness: isFilled ? 1 : 0,
}
}),
)
return {
categories,
totalTokens,
maxTokens: rawMaxTokens,
rawMaxTokens,
percentage,
gridRows,
model: latest.model,
memoryFiles: [],
mcpTools: [],
agents: [],
apiUsage: {
input_tokens: latest.inputTokens,
output_tokens: latest.outputTokens,
cache_creation_input_tokens: latest.cacheCreationInputTokens,
cache_read_input_tokens: latest.cacheReadInputTokens,
},
}
}
async getTranscriptUsage(sessionId: string): Promise<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: await this.getTranscriptContextWindow(sessionId, model),
maxOutputTokens: getModelMaxOutputTokens(model).default,
}
models.set(model, modelUsage)
}
modelUsage.inputTokens += inputTokens
modelUsage.outputTokens += outputTokens
modelUsage.cacheReadInputTokens += cacheReadInputTokens
modelUsage.cacheCreationInputTokens += cacheCreationInputTokens
modelUsage.webSearchRequests += webSearchRequests
modelUsage.costUSD += costUSD
modelUsage.costDisplay = this.formatCost(modelUsage.costUSD)
totalCostUSD += costUSD
totalInputTokens += inputTokens
totalOutputTokens += outputTokens
totalCacheReadInputTokens += cacheReadInputTokens
totalCacheCreationInputTokens += cacheCreationInputTokens
totalWebSearchRequests += webSearchRequests
if (entry.timestamp) {
const time = Date.parse(entry.timestamp)
if (!Number.isNaN(time)) {
firstUsageAt = firstUsageAt === null ? time : Math.min(firstUsageAt, time)
lastUsageAt = lastUsageAt === null ? time : Math.max(lastUsageAt, time)
}
}
}
if (models.size === 0) return null
return {
source: 'transcript',
totalCostUSD,
costDisplay: this.formatCost(totalCostUSD),
hasUnknownModelCost,
totalAPIDuration: 0,
totalDuration:
firstUsageAt !== null && lastUsageAt !== null
? Math.max(0, Math.round((lastUsageAt - firstUsageAt) / 1000))
: 0,
totalLinesAdded: 0,
totalLinesRemoved: 0,
totalInputTokens,
totalOutputTokens,
totalCacheReadInputTokens,
totalCacheCreationInputTokens,
totalWebSearchRequests,
models: Array.from(models.values()),
}
}
// --------------------------------------------------------------------------
// Public API
// --------------------------------------------------------------------------
/**
* List all sessions, optionally filtered by project path.
*/
async listSessions(options?: {
project?: string
limit?: number
offset?: number
}): Promise<{ sessions: SessionListItem[]; total: number }> {
const cacheKey = this.sessionListCacheKey(options)
const cached = this.sessionListCache.get(cacheKey)
if (cached && cached.expiresAt > Date.now()) {
return this.cloneSessionListResult(cached.result)
}
const sessionFiles = await this.discoverSessionFiles(options?.project)
const filesWithStats = (await Promise.all(sessionFiles.map(async (sessionFile) => {
try {
return {
...sessionFile,
stat: await fs.stat(sessionFile.filePath),
}
} catch {
return null
}
}))).filter((item): item is NonNullable<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 & a streaming
// transcript summary. Keep this sequential so large JSONL files are not
// loaded into memory concurrently by the sidebar's frequent refresh.
const items: SessionListItem[] = []
for (const { filePath, projectDir, sessionId, stat } of paginatedFiles) {
try {
const summary = await this.scanSessionListSummary(filePath, projectDir, stat)
const workDir = summary.workDir
const projectRoot = await this.resolveProjectRootFromSessionMetadata({
worktreeSession: summary.worktreeSession,
repository: summary.repository,
workDir,
fallbackProjectDir: projectDir,
})
const workDirExists = await this.pathExists(workDir)
items.push({
id: sessionId,
title: summary.title,
createdAt: summary.createdAt,
modifiedAt: stat.mtime.toISOString(),
messageCount: summary.messageCount,
projectPath: projectDir,
projectRoot,
workDir,
workDirExists,
permissionMode: summary.permissionMode,
})
} catch {
// Skip unreadable files
}
}
const result = { sessions: items, total }
this.sessionListCache.set(cacheKey, {
expiresAt: Date.now() + this.sessionListCacheTtlMs,
result: this.cloneSessionListResult(result),
})
return result
}
/**
* Get full session detail including all messages.
*/
async getSession(sessionId: string): Promise<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 permissionMode = this.resolvePermissionModeFromEntries(entries)
const projectRoot = await this.resolveProjectRootFromEntries(entries, workDir, projectDir)
const workDirExists = await this.pathExists(workDir)
let createdAt = stat.birthtime.toISOString()
for (const e of entries) {
if (e.timestamp) {
createdAt = e.timestamp
break
}
}
return {
id: sessionId,
title,
createdAt,
modifiedAt: stat.mtime.toISOString(),
messageCount: messages.length,
projectPath: projectDir,
projectRoot,
workDir,
workDirExists,
permissionMode,
messages,
}
}
/**
* Get only the messages for a session (lighter than full detail).
*/
async getSessionMessages(sessionId: string): Promise<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,
permissionMode?: string,
): Promise<{ sessionId: string; workDir: string }> {
// Default to user home directory when no workDir specified
const resolvedWorkDir = workDir || os.homedir()
const sessionId = crypto.randomUUID()
// Resolve to absolute path. NOTE: path.resolve() uses process.cwd() to
// expand relative paths — in bundled sidecar mode the server's cwd is
// typically '/'. Callers (IM adapters) already send absolute realPath,
// but we log here so cwd regressions are caught early.
const preparedWorkspace = await resolveSessionWorkspaceLaunch(
resolvedWorkDir,
repositoryOptions,
sessionId,
)
const absWorkDir = preparedWorkspace.workDir
registerFilesystemAccessRoot(absWorkDir)
console.log(
`[SessionService] createSession: requested workDir=${JSON.stringify(
workDir,
)}, resolved=${absWorkDir}, repository=${JSON.stringify(
preparedWorkspace.repository ?? null,
)} (process.cwd()=${process.cwd()})`,
)
const sanitized = this.sanitizePath(absWorkDir)
const dirPath = path.join(this.getProjectsDir(), sanitized)
// Ensure the project directory exists
await fs.mkdir(dirPath, { recursive: true })
const filePath = path.join(dirPath, `${sessionId}.jsonl`)
const now = new Date().toISOString()
// Write an initial file-history-snapshot entry (matches CLI behavior)
const initialEntry = {
type: 'file-history-snapshot',
messageId: crypto.randomUUID(),
snapshot: {
messageId: crypto.randomUUID(),
trackedFileBackups: {},
timestamp: now,
},
isSnapshotUpdate: false,
}
// Store actual workDir for later retrieval
const metaEntry = {
type: 'session-meta',
isMeta: true,
workDir: absWorkDir,
repository: preparedWorkspace.repository,
...(permissionMode && VALID_SESSION_PERMISSION_MODES.has(permissionMode)
? { permissionMode }
: {}),
timestamp: now,
}
await fs.writeFile(filePath, JSON.stringify(initialEntry) + '\n' + JSON.stringify(metaEntry) + '\n', 'utf-8')
this.invalidateSessionListCache()
return { sessionId, workDir: absWorkDir }
}
/**
* Delete a session's JSONL file.
*/
async deleteSession(sessionId: string): Promise<void> {
const found = await this.findSessionFile(sessionId)
if (!found) {
throw ApiError.notFound(`Session not found: ${sessionId}`)
}
await fs.unlink(found.filePath)
this.invalidateSessionListCache()
}
async deleteSessions(sessionIds: string[]): Promise<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)
this.invalidateSessionListCache()
}
/**
* 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(),
})
this.invalidateSessionListCache()
}
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)
const permissionMode = this.resolvePermissionModeFromEntries(entries)
let customTitle: string | null = null
let runtimeProviderId: string | null | undefined
let runtimeModelId: string | undefined
let effortLevel: string | undefined
for (const entry of entries) {
if (entry.type === 'custom-title' && typeof entry.customTitle === 'string') {
customTitle = entry.customTitle
}
if (entry.type === 'session-meta') {
const record = entry as Record<string, unknown>
if (record.runtimeProviderId === null || typeof record.runtimeProviderId === 'string') {
runtimeProviderId = record.runtimeProviderId as string | null
}
if (typeof record.runtimeModelId === 'string') {
runtimeModelId = record.runtimeModelId
}
if (
typeof record.effortLevel === 'string' &&
VALID_SESSION_EFFORT_LEVELS.has(record.effortLevel)
) {
effortLevel = record.effortLevel
}
}
}
const transcriptMessageCount = this.countTranscriptMessages(entries)
return {
filePath: found.filePath,
projectDir: found.projectDir,
workDir,
repository,
worktreeSession,
transcriptMessageCount,
customTitle,
permissionMode,
...(runtimeProviderId !== undefined ? { runtimeProviderId } : {}),
...(runtimeModelId ? { runtimeModelId } : {}),
...(effortLevel ? { effortLevel } : {}),
}
}
async deleteSessionFile(sessionId: string): Promise<void> {
const found = await this.findSessionFile(sessionId)
if (!found) return
await fs.unlink(found.filePath)
this.invalidateSessionListCache()
}
async clearSessionTranscript(sessionId: string, fallbackWorkDir?: string): Promise<void> {
let found = await this.findSessionFile(sessionId)
if (!found && fallbackWorkDir) {
const resolvedPath = path.resolve(normalizeDriveRootPathForPlatform(fallbackWorkDir))
const absWorkDir = await fs.realpath(resolvedPath).catch(() => resolvedPath)
const dirPath = path.join(this.getProjectsDir(), this.sanitizePath(absWorkDir))
await fs.mkdir(dirPath, { recursive: true })
found = {
filePath: path.join(dirPath, `${sessionId}.jsonl`),
projectDir: this.sanitizePath(absWorkDir),
}
}
if (!found) {
throw ApiError.notFound(`Session not found: ${sessionId}`)
}
const entries = await this.readJsonlFile(found.filePath)
const workDir = this.resolveWorkDirFromEntries(entries, found.projectDir) || fallbackWorkDir || process.cwd()
const repository = this.resolveRepositoryFromEntries(entries)
const now = new Date().toISOString()
const initialEntry = {
type: 'file-history-snapshot',
messageId: crypto.randomUUID(),
snapshot: {
messageId: crypto.randomUUID(),
trackedFileBackups: {},
timestamp: now,
},
isSnapshotUpdate: false,
}
const metaEntry = {
type: 'session-meta',
isMeta: true,
workDir,
repository,
timestamp: now,
}
await fs.writeFile(
found.filePath,
`${JSON.stringify(initialEntry)}\n${JSON.stringify(metaEntry)}\n`,
'utf-8',
)
this.invalidateSessionListCache()
}
async appendSessionMetadata(
sessionId: string,
metadata: {
workDir: string
customTitle?: string | null
repository?: PreparedSessionWorkspace['repository']
permissionMode?: string
runtimeProviderId?: string | null
runtimeModelId?: string
effortLevel?: string
}
): Promise<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 normalizedWorkDir = normalizeDriveRootPathForPlatform(metadata.workDir)
const targetProjectDir = this.sanitizePath(normalizedWorkDir)
const targetFilePath = path.join(this.getProjectsDir(), targetProjectDir, `${sessionId}.jsonl`)
await fs.mkdir(path.dirname(targetFilePath), { recursive: true })
await this.appendJsonlEntry(targetFilePath, {
type: 'session-meta',
isMeta: true,
workDir: normalizedWorkDir,
repository,
...(metadata.permissionMode && VALID_SESSION_PERMISSION_MODES.has(metadata.permissionMode)
? { permissionMode: metadata.permissionMode }
: {}),
...(metadata.runtimeProviderId !== undefined
? { runtimeProviderId: metadata.runtimeProviderId }
: {}),
...(metadata.runtimeModelId ? { runtimeModelId: metadata.runtimeModelId } : {}),
...(metadata.effortLevel && VALID_SESSION_EFFORT_LEVELS.has(metadata.effortLevel)
? { effortLevel: metadata.effortLevel }
: {}),
timestamp: new Date().toISOString(),
})
if (metadata.customTitle) {
await this.appendJsonlEntry(targetFilePath, {
type: 'custom-title',
customTitle: metadata.customTitle,
timestamp: new Date().toISOString(),
})
}
this.invalidateSessionListCache()
}
async deletePlaceholderSessionFiles(
sessionId: string,
keepWorkDir: string,
): Promise<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(normalizeDriveRootPathForPlatform(keepWorkDir))
let removed = 0
for (const projectDir of projectDirs) {
if (!projectDir.isDirectory()) continue
if (projectDir.name === keepProjectDir) continue
const filePath = path.join(projectsDir, projectDir.name, `${sessionId}.jsonl`)
const entries = await this.readJsonlFile(filePath)
if (entries.length === 0) continue
if (this.countTranscriptMessages(entries) > 0) continue
await fs.rm(filePath, { force: true })
removed += 1
}
if (removed > 0) this.invalidateSessionListCache()
return removed
}
async trimSessionMessagesFrom(
sessionId: string,
startMessageId: string,
): Promise<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')
this.invalidateSessionListCache()
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()