cc-haha/src/server/services/sessionService.ts
moyu12-ae 1b52da3587 fix: session branch visibility and parent-child tree display (#561)
- Fix branch button hidden when AI is responding by decoupling
  branchActionsDisabled from chatState (MessageList.tsx)
- Add sourceSessionId/sourceMessageId to SessionListItem type
  (both server and desktop)
- Scan forkedFrom in listSessions to expose parent-child relationships
- Preserve sourceSessionId/sourceMessageId in branchSession optimistic store
- Add buildSessionTree utility for tree-flattened session list with
  depth and isLastChild markers
- Render forked sessions indented with GitBranch icon in sidebar
- Add comprehensive tests (187 total: 81 desktop + 106 server)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 23:40:04 +08:00

1968 lines
60 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 { findCanonicalGitRoot } from '../../utils/git.js'
import { calculateUSDCost, MODEL_COSTS } from '../../utils/modelCost.js'
import {
calculateCurrentContextTokenTotal,
MODEL_CONTEXT_WINDOW_DEFAULT,
getContextWindowForModel,
getModelMaxOutputTokens,
} from '../../utils/context.js'
import { getCanonicalName } from '../../utils/model/model.js'
import {
resolveSessionWorkspaceLaunch,
type CreateSessionRepositoryOptions,
type PreparedSessionWorkspace,
} from './repositoryLaunchService.js'
import { registerFilesystemAccessRoot } from './filesystemAccessRoots.js'
import { cleanSessionTitleSource } from '../../utils/sessionTitleText.js'
// ============================================================================
// Types
// ============================================================================
export type SessionListItem = {
id: string
title: string
createdAt: string
modifiedAt: string
messageCount: number
projectPath: string
projectRoot: string | null
workDir: string | null
workDirExists: boolean
sourceSessionId?: string
sourceMessageId?: 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
}
export type TrimSessionResult = {
removedCount: number
removedMessageIds: string[]
}
export type MessageEntry = {
id: string
type: 'user' | 'assistant' | 'system' | 'tool_use' | 'tool_result'
content: unknown
toolUseResult?: unknown
timestamp: string
model?: string
parentUuid?: string
parentToolUseId?: string
isSidechain?: boolean
}
export type SessionTaskNotification = {
taskId: string
toolUseId: string
status: 'completed' | 'failed' | 'stopped'
summary?: string
result?: string
outputFile?: string
timestamp?: string
}
export type TranscriptUsageSnapshot = {
source: 'transcript'
totalCostUSD: number
costDisplay: string
hasUnknownModelCost: boolean
totalAPIDuration: number
totalDuration: number
totalLinesAdded: number
totalLinesRemoved: number
totalInputTokens: number
totalOutputTokens: number
totalCacheReadInputTokens: number
totalCacheCreationInputTokens: number
totalWebSearchRequests: number
models: Array<{
model: string
displayName: string
inputTokens: number
outputTokens: number
cacheReadInputTokens: number
cacheCreationInputTokens: number
webSearchRequests: number
costUSD: number
costDisplay: string
contextWindow: number
maxOutputTokens: number
}>
}
export type TranscriptMetadataSnapshot = {
model?: string
cwd?: string
version?: string
}
export type TranscriptContextEstimate = {
categories: Array<{
name: string
tokens: number
color: string
isDeferred?: boolean
}>
totalTokens: number
maxTokens: number
rawMaxTokens: number
percentage: number
gridRows: Array<Array<{
color: string
isFilled: boolean
categoryName: string
tokens: number
percentage: number
squareFullness: number
}>>
model: string
memoryFiles: Array<{ path: string; type: string; tokens: number }>
mcpTools: Array<{ name: string; serverName: string; tokens: number; isLoaded?: boolean }>
agents: Array<{ agentType: string; source: string; tokens: number }>
apiUsage: {
input_tokens: number
output_tokens: number
cache_creation_input_tokens: number
cache_read_input_tokens: number
}
}
/** 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
worktreeSession?: PersistedWorktreeSession | null
title?: string
[key: string]: unknown
}
type PersistedWorktreeSession = {
originalCwd: string
worktreePath: string
worktreeName: string
worktreeBranch?: string
originalBranch?: string
originalHeadCommit?: string
sessionId: string
tmuxSessionName?: string
hookBased?: boolean
}
type ContentBlock = Record<string, unknown>
const USER_INTERRUPTION_TEXTS = new Set([
'[Request interrupted by user]',
'[Request interrupted by user for tool use]',
])
const NO_RESPONSE_REQUESTED_TEXT = 'No response requested.'
const TASK_NOTIFICATION_RE = /^<task-notification>\s*[\s\S]*<\/task-notification>$/i
const TASK_NOTIFICATION_BLOCK_RE = /<task-notification>\s*[\s\S]*?<\/task-notification>/i
// ============================================================================
// 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 resolveWorktreeSessionFromEntries(entries: RawEntry[]): PersistedWorktreeSession | null | undefined {
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i]
if (entry?.type !== 'worktree-state') continue
const worktreeSession = entry.worktreeSession
if (worktreeSession === null) return null
if (
worktreeSession &&
typeof worktreeSession === 'object' &&
typeof worktreeSession.worktreePath === 'string' &&
typeof worktreeSession.worktreeName === 'string'
) {
return worktreeSession
}
}
return undefined
}
private async resolveProjectRootFromEntries(
entries: RawEntry[],
workDir: string | null,
fallbackProjectDir?: string,
): Promise<string | null> {
const worktreeSession = this.resolveWorktreeSessionFromEntries(entries)
const repository = this.resolveRepositoryFromEntries(entries)
const candidate = worktreeSession?.originalCwd ||
repository?.repoRoot ||
workDir ||
(fallbackProjectDir ? this.desanitizePath(fallbackProjectDir) : null)
if (!candidate) return null
const canonicalCandidate = await this.canonicalizeProjectPath(candidate)
const gitRoot = findCanonicalGitRoot(canonicalCandidate)
if (gitRoot) return gitRoot
if (workDir) {
const marker = `${path.sep}.claude${path.sep}worktrees${path.sep}`
const markerIndex = canonicalCandidate.indexOf(marker)
if (markerIndex > 0) return canonicalCandidate.slice(0, markerIndex)
}
return canonicalCandidate
}
private async canonicalizeProjectPath(projectPath: string): Promise<string> {
try {
return (await fs.realpath(projectPath)).normalize('NFC')
} catch {
return projectPath.normalize('NFC')
}
}
private countTranscriptMessages(entries: RawEntry[]): number {
return entries.filter((entry) =>
!entry.isMeta &&
!!entry.message?.role &&
(entry.type === 'user' || entry.type === 'assistant' || entry.type === 'system')
).length
}
// --------------------------------------------------------------------------
// 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,
...(entry.toolUseResult !== undefined ? { toolUseResult: entry.toolUseResult } : {}),
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,
timestamp?: string,
): SessionTaskNotification | null {
const xml = this.extractTextBlocks(content)
.map((text) => this.extractTaskNotificationXml(text))
.find((value): value is string => value !== null)
if (!xml) return null
const toolUseId = this.readXmlTag(xml, 'tool-use-id')
const status = this.readXmlTag(xml, 'status')
if (
!toolUseId ||
(status !== 'completed' && status !== 'failed' && status !== 'stopped')
) {
return null
}
const taskId = this.readXmlTag(xml, 'task-id') || toolUseId
const summary = this.readXmlTag(xml, 'summary')
const result = this.readXmlTag(xml, 'result')
const outputFile = this.readXmlTag(xml, 'output-file')
return {
taskId,
toolUseId,
status,
...(summary ? { summary } : {}),
...(result ? { result } : {}),
...(outputFile ? { outputFile } : {}),
...(timestamp ? { timestamp } : {}),
}
}
private shouldHideTranscriptEntry(entry: RawEntry): boolean {
const role = entry.message?.role
const content = entry.message?.content
if (role === 'user') {
return (
this.isInternalCommandBreadcrumb(content) ||
this.isSyntheticUserInterruption(content) ||
this.isTaskNotificationContent(content)
)
}
if (role === 'assistant') {
return this.isSyntheticNoResponseAssistant(content)
}
return false
}
private isGoalLocalCommandOutput(output: string): boolean {
const trimmed = output.trim()
return (
trimmed.startsWith('Goal set:') ||
trimmed.startsWith('Goal cleared:') ||
trimmed === 'Goal cleared.' ||
trimmed === 'Goal marked complete.' ||
trimmed === 'No active goal.'
)
}
private isGoalLocalCommandEntry(entry: RawEntry): boolean {
if (
entry.type !== 'system' ||
entry.subtype !== 'local_command' ||
typeof entry.content !== 'string'
) {
return false
}
const commandName = this.readXmlTag(entry.content, 'command-name')?.replace(/^\//, '')
if (commandName) return commandName === 'goal'
const output =
this.readXmlTag(entry.content, 'local-command-stdout') ??
this.readXmlTag(entry.content, 'local-command-stderr')
return output ? this.isGoalLocalCommandOutput(output) : false
}
private goalLocalCommandEntryToMessage(entry: RawEntry): MessageEntry | null {
if (!this.isGoalLocalCommandEntry(entry)) return null
return {
id: entry.uuid || crypto.randomUUID(),
type: 'system',
content: entry.content,
timestamp: entry.timestamp || new Date().toISOString(),
parentUuid: entry.parentUuid ?? undefined,
isSidechain: entry.isSidechain,
}
}
private goalCreationCommandTitle(entry: RawEntry): string | null {
if (
entry.type !== 'system' ||
entry.subtype !== 'local_command' ||
typeof entry.content !== 'string'
) {
return null
}
const commandName = this.readXmlTag(entry.content, 'command-name')?.replace(/^\//, '')
if (commandName !== 'goal') return null
const args = this.readXmlTag(entry.content, 'command-args')?.trim()
if (!args || /^clear\b/i.test(args)) return null
const title = cleanSessionTitleSource(`/goal ${args}`)
return title ? title.length > 80 ? title.slice(0, 80) + '...' : title : null
}
private extractAgentToolUseId(entry: RawEntry): string | undefined {
const content = entry.message?.content
if (!Array.isArray(content)) return undefined
for (const block of content as Array<Record<string, unknown>>) {
if (
block.type === 'tool_use' &&
block.name === 'Agent' &&
typeof block.id === 'string'
) {
return block.id
}
}
return undefined
}
private extractAgentToolUseIdsFromMessage(message: MessageEntry): string[] {
if (message.type !== 'tool_use' || !Array.isArray(message.content)) {
return []
}
return (message.content as ContentBlock[])
.filter((block) => block.type === 'tool_use' && block.name === 'Agent')
.flatMap((block) => (typeof block.id === 'string' ? [block.id] : []))
}
private extractTextFromContent(content: unknown): string {
if (typeof content === 'string') return content
if (!Array.isArray(content)) return ''
return (content as ContentBlock[])
.flatMap((block) => (typeof block.text === 'string' ? [block.text] : []))
.join('\n')
}
private extractAgentIdFromResultText(text: string): string | undefined {
const match = text.match(/(?:^|\n)\s*agentId:\s*([A-Za-z0-9_-]+)/)
return match?.[1]
}
private extractAgentResultLinks(messages: MessageEntry[]): Map<string, string> {
const agentToolUseIds = new Set(
messages.flatMap((message) => this.extractAgentToolUseIdsFromMessage(message)),
)
const resultLinks = new Map<string, string>()
for (const message of messages) {
if (message.type !== 'tool_result' || !Array.isArray(message.content)) {
continue
}
for (const block of message.content as ContentBlock[]) {
if (block.type !== 'tool_result' || typeof block.tool_use_id !== 'string') {
continue
}
if (!agentToolUseIds.has(block.tool_use_id)) {
continue
}
const agentId = this.extractAgentIdFromResultText(
this.extractTextFromContent(block.content),
)
if (agentId) {
resultLinks.set(block.tool_use_id, agentId)
}
}
}
return resultLinks
}
private namespaceSubagentContentIds(content: unknown, namespace: string): unknown {
if (!Array.isArray(content)) return content
return (content as ContentBlock[]).map((block) => {
if (!block || typeof block !== 'object') return block
if (block.type === 'tool_use' && typeof block.id === 'string') {
return { ...block, id: `${namespace}/${block.id}` }
}
if (block.type === 'tool_result' && typeof block.tool_use_id === 'string') {
return { ...block, tool_use_id: `${namespace}/${block.tool_use_id}` }
}
return block
})
}
private subagentTranscriptPath(
projectDir: string,
sessionId: string,
agentId: string,
): string {
const normalizedAgentId = agentId.startsWith('agent-') ? agentId : `agent-${agentId}`
return path.join(
this.getProjectsDir(),
projectDir,
sessionId,
'subagents',
`${normalizedAgentId}.jsonl`,
)
}
private async loadSubagentToolMessages(
projectDir: string,
sessionId: string,
parentToolUseId: string,
agentId: string,
): Promise<MessageEntry[]> {
const filePath = this.subagentTranscriptPath(projectDir, sessionId, agentId)
const entries = await this.readJsonlFile(filePath)
const namespace = `${parentToolUseId}/${agentId}`
const messages: MessageEntry[] = []
for (const entry of entries) {
if (!entry.message?.role || entry.isMeta) continue
if (this.shouldHideTranscriptEntry(entry)) continue
if (entry.type !== 'user' && entry.type !== 'assistant' && entry.type !== 'system') {
continue
}
const message = this.entryToMessage(
{
...entry,
message: {
...entry.message,
content: this.namespaceSubagentContentIds(entry.message.content, namespace),
},
},
parentToolUseId,
)
if (message && (message.type === 'tool_use' || message.type === 'tool_result')) {
messages.push(message)
}
}
return messages
}
private async appendSubagentToolMessages(
projectDir: string,
sessionId: string,
messages: MessageEntry[],
): Promise<MessageEntry[]> {
const resultLinks = this.extractAgentResultLinks(messages)
if (resultLinks.size === 0) {
return messages
}
const childMessages = await Promise.all(
[...resultLinks.entries()].map(([parentToolUseId, agentId]) =>
this.loadSubagentToolMessages(projectDir, sessionId, parentToolUseId, agentId),
),
)
return [...messages, ...childMessages.flat()]
}
private resolveParentToolUseId(
entry: RawEntry,
entriesByUuid: Map<string, RawEntry>,
cache: Map<string, string | undefined>,
): string | undefined {
if (
typeof entry.parent_tool_use_id === 'string' &&
entry.parent_tool_use_id.length > 0
) {
return entry.parent_tool_use_id
}
if (entry.isSidechain !== true) {
return undefined
}
const cacheKey = entry.uuid
if (cacheKey && cache.has(cacheKey)) {
return cache.get(cacheKey)
}
let resolved: string | undefined
let currentParentUuid =
typeof entry.parentUuid === 'string' ? entry.parentUuid : undefined
const visited = new Set<string>()
while (currentParentUuid && !visited.has(currentParentUuid)) {
visited.add(currentParentUuid)
const parentEntry = entriesByUuid.get(currentParentUuid)
if (!parentEntry) break
const directAgentToolUseId = this.extractAgentToolUseId(parentEntry)
if (directAgentToolUseId) {
resolved = directAgentToolUseId
break
}
if (parentEntry.uuid && cache.has(parentEntry.uuid)) {
resolved = cache.get(parentEntry.uuid)
break
}
currentParentUuid =
typeof parentEntry.parentUuid === 'string'
? parentEntry.parentUuid
: undefined
}
if (cacheKey) {
cache.set(cacheKey, resolved)
}
return resolved
}
// --------------------------------------------------------------------------
// Title extraction
// --------------------------------------------------------------------------
private extractTitle(entries: RawEntry[]): string {
// 1. Look for custom title entry (appended by renameSession) — highest priority
for (let i = entries.length - 1; i >= 0; i--) {
const e = entries[i]!
if (e.type === 'custom-title' && e.customTitle) {
return e.customTitle
}
}
// 2. Goal sessions should keep the original objective as the stable title.
for (const e of entries) {
const goalTitle = this.goalCreationCommandTitle(e)
if (goalTitle) return goalTitle
}
// 3. Look for AI-generated title (written by titleService)
for (let i = entries.length - 1; i >= 0; i--) {
const e = entries[i]!
if (e.type === 'ai-title' && e.aiTitle) {
const title = cleanSessionTitleSource(String(e.aiTitle))
if (title) return title
}
}
// 4. Look for first non-meta user message as title
for (const e of entries) {
if (e.type === 'user' && !e.isMeta && e.message?.role === 'user') {
const content = e.message.content
let text: string | undefined
if (typeof content === 'string') {
text = content
} else if (Array.isArray(content)) {
const textBlock = content.find(
(block: Record<string, unknown>) => block.type === 'text' && typeof block.text === 'string'
)
if (textBlock) text = textBlock.text as string
}
if (text) {
const title = cleanSessionTitleSource(text)
if (title) return title.length > 80 ? title.slice(0, 80) + '...' : title
}
}
}
return 'Untitled Session'
}
// --------------------------------------------------------------------------
// Session file discovery
// --------------------------------------------------------------------------
/**
* Find all .jsonl session files across all project directories.
* Returns an array of { filePath, projectDir, sessionId }.
*/
private async discoverSessionFiles(projectFilter?: string): Promise<
Array<{ filePath: string; projectDir: string; sessionId: string }>
> {
const projectsDir = this.getProjectsDir()
let projectDirs: string[]
try {
projectDirs = await fs.readdir(projectsDir)
} catch {
return []
}
// Optionally filter to a specific project
if (projectFilter) {
const sanitized = this.sanitizePath(projectFilter)
projectDirs = projectDirs.filter((d) => d === sanitized)
}
const results: Array<{ filePath: string; projectDir: string; sessionId: string }> = []
for (const dir of projectDirs) {
const dirPath = path.join(projectsDir, dir)
// Ensure it's a directory
try {
const stat = await fs.stat(dirPath)
if (!stat.isDirectory()) continue
} catch {
continue
}
let files: string[]
try {
files = await fs.readdir(dirPath)
} catch {
continue
}
for (const file of files) {
if (!file.endsWith('.jsonl')) continue
const sessionId = file.replace('.jsonl', '')
results.push({
filePath: path.join(dirPath, file),
projectDir: dir,
sessionId,
})
}
}
return results
}
/**
* Convert a sanitized directory name back to the original absolute path.
* Reverses sanitizePath(): `-Users-nanmi-workspace` → `/Users/nanmi/workspace`.
*/
desanitizePath(sanitized: string): string {
// The sanitized form replaces all non-alphanumeric characters with '-'.
// This fallback is necessarily lossy, but old Windows transcripts without
// session-meta still need the drive separator restored well enough to resume.
const windowsDrivePath = sanitized.match(/^([a-zA-Z])--(.+)$/)
if (windowsDrivePath) {
return `${windowsDrivePath[1]}:${path.win32.sep}${windowsDrivePath[2].replace(/-/g, path.win32.sep)}`
}
// On POSIX the original path starts with '/', so the sanitized form starts with '-'.
// UNC-style Windows paths also recover to a leading double separator on Windows.
return sanitized.replace(/-/g, path.sep)
}
/**
* Find the .jsonl file for a given session ID.
* Searches across all project directories since sessions may belong to any project.
*/
private async findSessionFiles(
sessionId: string
): Promise<Array<{ filePath: string; projectDir: string }>> {
if (!this.isValidSessionId(sessionId)) {
return []
}
const projectsDir = this.getProjectsDir()
let projectDirs: string[]
try {
projectDirs = await fs.readdir(projectsDir)
} catch {
return []
}
const matches: Array<{ filePath: string; projectDir: string; mtimeMs: number }> = []
for (const dir of projectDirs) {
const filePath = path.join(projectsDir, dir, `${sessionId}.jsonl`)
try {
const stat = await fs.stat(filePath)
matches.push({ filePath, projectDir: dir, mtimeMs: stat.mtimeMs })
} catch {
continue
}
}
return matches
.sort((a, b) => b.mtimeMs - a.mtimeMs)
.map(({ filePath, projectDir }) => ({ filePath, projectDir }))
}
async findSessionFile(
sessionId: string
): Promise<{ filePath: string; projectDir: string } | null> {
return (await this.findSessionFiles(sessionId))[0] ?? null
}
private isValidSessionId(id: string): boolean {
// UUID v4 format
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)
}
private formatCost(cost: number): string {
return `$${cost > 0.5 ? (Math.round(cost * 100) / 100).toFixed(2) : cost.toFixed(4)}`
}
private getTranscriptContextWindow(model: string): number {
try {
return getContextWindowForModel(model)
} catch (err) {
if (
err instanceof Error &&
err.message.includes('Config accessed before allowed')
) {
return MODEL_CONTEXT_WINDOW_DEFAULT
}
throw err
}
}
async getTranscriptMetadata(sessionId: string): Promise<TranscriptMetadataSnapshot | null> {
const found = await this.findSessionFile(sessionId)
if (!found) return null
const entries = await this.readJsonlFile(found.filePath)
const metadata: TranscriptMetadataSnapshot = {}
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i]!
if (!metadata.model && typeof entry.message?.model === 'string') {
metadata.model = entry.message.model
}
if (!metadata.cwd && typeof entry.cwd === 'string') {
metadata.cwd = entry.cwd
}
if (!metadata.version && typeof entry.version === 'string') {
metadata.version = entry.version
}
if (metadata.model && metadata.cwd && metadata.version) break
}
return metadata
}
async getTranscriptContextEstimate(sessionId: string): Promise<TranscriptContextEstimate | null> {
const found = await this.findSessionFile(sessionId)
if (!found) return null
const entries = await this.readJsonlFile(found.filePath)
let latest: {
model: string
inputTokens: number
outputTokens: number
cacheReadInputTokens: number
cacheCreationInputTokens: number
} | null = null
for (const entry of entries) {
const usage = entry.message?.usage
const model = entry.message?.model
if (!usage || typeof model !== 'string') continue
const inputTokens = typeof usage.input_tokens === 'number' ? usage.input_tokens : 0
const outputTokens = typeof usage.output_tokens === 'number' ? usage.output_tokens : 0
const cacheReadInputTokens = typeof usage.cache_read_input_tokens === 'number' ? usage.cache_read_input_tokens : 0
const cacheCreationInputTokens = typeof usage.cache_creation_input_tokens === 'number' ? usage.cache_creation_input_tokens : 0
const promptTokens = inputTokens + cacheReadInputTokens + cacheCreationInputTokens
if (promptTokens === 0 && outputTokens === 0) continue
latest = {
model,
inputTokens,
outputTokens,
cacheReadInputTokens,
cacheCreationInputTokens,
}
}
if (!latest) return null
const rawMaxTokens = this.getTranscriptContextWindow(latest.model)
const promptTokens = latest.inputTokens + latest.cacheReadInputTokens + latest.cacheCreationInputTokens
const totalTokens = calculateCurrentContextTokenTotal(promptTokens, {
input_tokens: latest.inputTokens,
output_tokens: latest.outputTokens,
cache_read_input_tokens: latest.cacheReadInputTokens,
cache_creation_input_tokens: latest.cacheCreationInputTokens,
}, rawMaxTokens)
const percentage = rawMaxTokens > 0 ? Math.round((totalTokens / rawMaxTokens) * 100) : 0
const categories: TranscriptContextEstimate['categories'] = [
{ name: 'Input tokens', tokens: latest.inputTokens, color: '#8f3217' },
{ name: 'Cache read', tokens: latest.cacheReadInputTokens, color: '#0f5c8f' },
{ name: 'Cache write', tokens: latest.cacheCreationInputTokens, color: '#7c3aed' },
{ name: 'Output tokens', tokens: latest.outputTokens, color: '#2f7d32' },
{ name: 'Free space', tokens: Math.max(0, rawMaxTokens - totalTokens), color: '#a1a1aa', isDeferred: true },
].filter((category) => category.tokens > 0)
const filledSquares = Math.max(0, Math.min(100, Math.round((totalTokens / Math.max(1, rawMaxTokens)) * 100)))
const gridRows = Array.from({ length: 10 }, (_, row) =>
Array.from({ length: 10 }, (_, col) => {
const index = row * 10 + col
const isFilled = index < filledSquares
return {
color: isFilled ? '#8f3217' : '#a1a1aa',
isFilled,
categoryName: isFilled ? 'Input context' : 'Free space',
tokens: Math.round(rawMaxTokens / 100),
percentage: 1,
squareFullness: isFilled ? 1 : 0,
}
}),
)
return {
categories,
totalTokens,
maxTokens: rawMaxTokens,
rawMaxTokens,
percentage,
gridRows,
model: latest.model,
memoryFiles: [],
mcpTools: [],
agents: [],
apiUsage: {
input_tokens: latest.inputTokens,
output_tokens: latest.outputTokens,
cache_creation_input_tokens: latest.cacheCreationInputTokens,
cache_read_input_tokens: latest.cacheReadInputTokens,
},
}
}
async getTranscriptUsage(sessionId: string): Promise<TranscriptUsageSnapshot | null> {
const found = await this.findSessionFile(sessionId)
if (!found) return null
const entries = await this.readJsonlFile(found.filePath)
const models = new Map<string, TranscriptUsageSnapshot['models'][number]>()
let totalCostUSD = 0
let totalInputTokens = 0
let totalOutputTokens = 0
let totalCacheReadInputTokens = 0
let totalCacheCreationInputTokens = 0
let totalWebSearchRequests = 0
let hasUnknownModelCost = false
let firstUsageAt: number | null = null
let lastUsageAt: number | null = null
for (const entry of entries) {
const usage = entry.message?.usage
const model = entry.message?.model
if (!usage || typeof model !== 'string') continue
const inputTokens = typeof usage.input_tokens === 'number' ? usage.input_tokens : 0
const outputTokens = typeof usage.output_tokens === 'number' ? usage.output_tokens : 0
const cacheReadInputTokens = typeof usage.cache_read_input_tokens === 'number' ? usage.cache_read_input_tokens : 0
const cacheCreationInputTokens = typeof usage.cache_creation_input_tokens === 'number' ? usage.cache_creation_input_tokens : 0
const webSearchRequests = typeof usage.server_tool_use?.web_search_requests === 'number'
? usage.server_tool_use.web_search_requests
: 0
if (
inputTokens === 0 &&
outputTokens === 0 &&
cacheReadInputTokens === 0 &&
cacheCreationInputTokens === 0 &&
webSearchRequests === 0
) {
continue
}
const canonical = getCanonicalName(model)
if (!Object.prototype.hasOwnProperty.call(MODEL_COSTS, canonical)) {
hasUnknownModelCost = true
}
const costUsage = {
input_tokens: inputTokens,
output_tokens: outputTokens,
cache_read_input_tokens: cacheReadInputTokens,
cache_creation_input_tokens: cacheCreationInputTokens,
server_tool_use: { web_search_requests: webSearchRequests },
speed: usage.speed,
} as Parameters<typeof calculateUSDCost>[1]
const costUSD = calculateUSDCost(model, costUsage)
let modelUsage = models.get(model)
if (!modelUsage) {
modelUsage = {
model,
displayName: canonical,
inputTokens: 0,
outputTokens: 0,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
webSearchRequests: 0,
costUSD: 0,
costDisplay: '$0.0000',
contextWindow: this.getTranscriptContextWindow(model),
maxOutputTokens: getModelMaxOutputTokens(model).default,
}
models.set(model, modelUsage)
}
modelUsage.inputTokens += inputTokens
modelUsage.outputTokens += outputTokens
modelUsage.cacheReadInputTokens += cacheReadInputTokens
modelUsage.cacheCreationInputTokens += cacheCreationInputTokens
modelUsage.webSearchRequests += webSearchRequests
modelUsage.costUSD += costUSD
modelUsage.costDisplay = this.formatCost(modelUsage.costUSD)
totalCostUSD += costUSD
totalInputTokens += inputTokens
totalOutputTokens += outputTokens
totalCacheReadInputTokens += cacheReadInputTokens
totalCacheCreationInputTokens += cacheCreationInputTokens
totalWebSearchRequests += webSearchRequests
if (entry.timestamp) {
const time = Date.parse(entry.timestamp)
if (!Number.isNaN(time)) {
firstUsageAt = firstUsageAt === null ? time : Math.min(firstUsageAt, time)
lastUsageAt = lastUsageAt === null ? time : Math.max(lastUsageAt, time)
}
}
}
if (models.size === 0) return null
return {
source: 'transcript',
totalCostUSD,
costDisplay: this.formatCost(totalCostUSD),
hasUnknownModelCost,
totalAPIDuration: 0,
totalDuration:
firstUsageAt !== null && lastUsageAt !== null
? Math.max(0, Math.round((lastUsageAt - firstUsageAt) / 1000))
: 0,
totalLinesAdded: 0,
totalLinesRemoved: 0,
totalInputTokens,
totalOutputTokens,
totalCacheReadInputTokens,
totalCacheCreationInputTokens,
totalWebSearchRequests,
models: Array.from(models.values()),
}
}
// --------------------------------------------------------------------------
// Public API
// --------------------------------------------------------------------------
/**
* List all sessions, optionally filtered by project path.
*/
async listSessions(options?: {
project?: string
limit?: number
offset?: number
}): Promise<{ sessions: SessionListItem[]; total: number }> {
const sessionFiles = await this.discoverSessionFiles(options?.project)
const filesWithStats = (await Promise.all(sessionFiles.map(async (sessionFile) => {
try {
return {
...sessionFile,
stat: await fs.stat(sessionFile.filePath),
}
} catch {
return null
}
}))).filter((item): item is NonNullable<typeof item> => item !== null)
filesWithStats.sort((a, b) => b.stat.mtime.getTime() - a.stat.mtime.getTime())
const total = filesWithStats.length
const offset = options?.offset ?? 0
const limit = options?.limit ?? 50
const paginatedFiles = filesWithStats.slice(offset, offset + limit)
// Build session list items with metadata from file stats & first entries
const items = (await Promise.all(paginatedFiles.map(async ({ filePath, projectDir, sessionId, stat }) => {
try {
const entries = await this.readJsonlFile(filePath)
const workDir = this.resolveWorkDirFromEntries(entries, projectDir)
const projectRoot = await this.resolveProjectRootFromEntries(entries, workDir, projectDir)
const workDirExists = await this.pathExists(workDir)
// Count transcript messages only (user + assistant)
const messageCount = entries.filter(
(e) => (e.type === 'user' || e.type === 'assistant') && e.message?.role
).length
const title = this.extractTitle(entries)
// Find the earliest timestamp from entries, fallback to file birthtime
let createdAt = stat.birthtime.toISOString()
for (const e of entries) {
if (e.timestamp) {
createdAt = e.timestamp
break
}
}
// Extract fork parent relationship from transcript entries
let sourceSessionId: string | undefined
let sourceMessageId: string | undefined
for (const e of entries) {
const fork = (e as Record<string, unknown>).forkedFrom as
| { sessionId?: string; messageUuid?: string }
| undefined
if (fork?.sessionId) {
sourceSessionId = fork.sessionId
sourceMessageId = fork.messageUuid
break
}
}
return {
id: sessionId,
title,
createdAt,
modifiedAt: stat.mtime.toISOString(),
messageCount,
projectPath: projectDir,
projectRoot,
workDir,
workDirExists,
sourceSessionId,
sourceMessageId,
}
} catch {
// Skip unreadable files
return null
}
}))).filter((item): item is SessionListItem => item !== null)
return { sessions: items, total }
}
/**
* Get full session detail including all messages.
*/
async getSession(sessionId: string): Promise<SessionDetail | null> {
const found = await this.findSessionFile(sessionId)
if (!found) return null
const { filePath, projectDir } = found
const stat = await fs.stat(filePath)
const entries = await this.readJsonlFile(filePath)
const messages = await this.appendSubagentToolMessages(
projectDir,
sessionId,
this.entriesToMessages(entries),
)
const title = this.extractTitle(entries)
const workDir = this.resolveWorkDirFromEntries(entries, projectDir)
const projectRoot = await this.resolveProjectRootFromEntries(entries, workDir, projectDir)
const workDirExists = await this.pathExists(workDir)
let createdAt = stat.birthtime.toISOString()
for (const e of entries) {
if (e.timestamp) {
createdAt = e.timestamp
break
}
}
return {
id: sessionId,
title,
createdAt,
modifiedAt: stat.mtime.toISOString(),
messageCount: messages.length,
projectPath: projectDir,
projectRoot,
workDir,
workDirExists,
messages,
}
}
/**
* Get only the messages for a session (lighter than full detail).
*/
async getSessionMessages(sessionId: string): Promise<MessageEntry[]> {
const found = await this.findSessionFile(sessionId)
if (!found) {
throw ApiError.notFound(`Session not found: ${sessionId}`)
}
const entries = await this.readJsonlFile(found.filePath)
return await this.appendSubagentToolMessages(
found.projectDir,
sessionId,
this.entriesToMessages(entries),
)
}
/**
* Create a new session file for the given working directory.
*/
async createSession(
workDir?: string,
repositoryOptions?: CreateSessionRepositoryOptions,
): Promise<{ sessionId: string; workDir: string }> {
// Default to user home directory when no workDir specified
const resolvedWorkDir = workDir || os.homedir()
const sessionId = crypto.randomUUID()
// Resolve to absolute path. NOTE: path.resolve() uses process.cwd() to
// expand relative paths — in bundled sidecar mode the server's cwd is
// typically '/'. Callers (IM adapters) already send absolute realPath,
// but we log here so cwd regressions are caught early.
const preparedWorkspace = await resolveSessionWorkspaceLaunch(
resolvedWorkDir,
repositoryOptions,
sessionId,
)
const absWorkDir = preparedWorkspace.workDir
registerFilesystemAccessRoot(absWorkDir)
console.log(
`[SessionService] createSession: requested workDir=${JSON.stringify(
workDir,
)}, resolved=${absWorkDir}, repository=${JSON.stringify(
preparedWorkspace.repository ?? null,
)} (process.cwd()=${process.cwd()})`,
)
const sanitized = this.sanitizePath(absWorkDir)
const dirPath = path.join(this.getProjectsDir(), sanitized)
// Ensure the project directory exists
await fs.mkdir(dirPath, { recursive: true })
const filePath = path.join(dirPath, `${sessionId}.jsonl`)
const now = new Date().toISOString()
// Write an initial file-history-snapshot entry (matches CLI behavior)
const initialEntry = {
type: 'file-history-snapshot',
messageId: crypto.randomUUID(),
snapshot: {
messageId: crypto.randomUUID(),
trackedFileBackups: {},
timestamp: now,
},
isSnapshotUpdate: false,
}
// Store actual workDir for later retrieval
const metaEntry = {
type: 'session-meta',
isMeta: true,
workDir: absWorkDir,
repository: preparedWorkspace.repository,
timestamp: now,
}
await fs.writeFile(filePath, JSON.stringify(initialEntry) + '\n' + JSON.stringify(metaEntry) + '\n', 'utf-8')
return { sessionId, workDir: absWorkDir }
}
/**
* Delete a session's JSONL file.
*/
async deleteSession(sessionId: string): Promise<void> {
const found = await this.findSessionFile(sessionId)
if (!found) {
throw ApiError.notFound(`Session not found: ${sessionId}`)
}
await fs.unlink(found.filePath)
}
async deleteSessions(sessionIds: string[]): Promise<DeleteSessionsResult> {
const successes: string[] = []
const failures: DeleteSessionFailure[] = []
const results = await Promise.all(sessionIds.map(async (sessionId) => {
try {
await this.deleteSession(sessionId)
return { type: 'success' as const, sessionId }
} catch (error) {
return {
type: 'failure' as const,
sessionId,
message: error instanceof Error ? error.message : 'Unknown delete failure',
code: error instanceof ApiError ? error.code : undefined,
}
}
}))
for (const result of results) {
if (result.type === 'success') {
successes.push(result.sessionId)
} else {
failures.push({
sessionId: result.sessionId,
message: result.message,
code: result.code,
})
}
}
return { successes, failures }
}
/**
* Rename a session by appending a custom-title entry to its JSONL file.
*/
async renameSession(sessionId: string, title: string): Promise<void> {
if (!title || typeof title !== 'string') {
throw ApiError.badRequest('title is required')
}
const found = await this.findSessionFile(sessionId)
if (!found) {
throw ApiError.notFound(`Session not found: ${sessionId}`)
}
const entry = {
type: 'custom-title',
customTitle: title,
timestamp: new Date().toISOString(),
}
await this.appendJsonlEntry(found.filePath, entry)
}
/**
* Append an AI-generated title entry to a session's JSONL file.
*/
async appendAiTitle(sessionId: string, title: string): Promise<void> {
const found = await this.findSessionFile(sessionId)
if (!found) return
await this.appendJsonlEntry(found.filePath, {
type: 'ai-title',
aiTitle: title,
timestamp: new Date().toISOString(),
})
}
async getCustomTitle(sessionId: string): Promise<string | null> {
const found = await this.findSessionFile(sessionId)
if (!found) return null
const entries = await this.readJsonlFile(found.filePath)
let customTitle: string | null = null
for (const entry of entries) {
if (entry.type === 'custom-title' && typeof entry.customTitle === 'string' && entry.customTitle.trim()) {
customTitle = entry.customTitle
}
}
return customTitle
}
/**
* Get the actual working directory for a session.
* First checks for stored session-meta entry, then falls back to desanitizePath.
*/
async getSessionWorkDir(sessionId: string): Promise<string | null> {
const found = await this.findSessionFile(sessionId)
if (!found) return null
const entries = await this.readJsonlFile(found.filePath)
return this.resolveWorkDirFromEntries(entries, found.projectDir)
}
async getSessionMessageCwd(
sessionId: string,
messageId: string,
): Promise<string | null> {
const found = await this.findSessionFile(sessionId)
if (!found) return null
const entries = await this.readJsonlFile(found.filePath)
const entry = entries.find((candidate) => candidate.uuid === messageId)
return typeof entry?.cwd === 'string' && entry.cwd.trim() ? entry.cwd : null
}
/**
* Inspect how a session should be launched.
* Placeholder desktop-created sessions have zero transcript messages.
*/
async getSessionLaunchInfo(sessionId: string): Promise<SessionLaunchInfo | null> {
const found = await this.findSessionFile(sessionId)
if (!found) return null
const entries = await this.readJsonlFile(found.filePath)
const workDir = this.resolveWorkDirFromEntries(entries, found.projectDir) || process.cwd()
const repository = this.resolveRepositoryFromEntries(entries)
const worktreeSession = this.resolveWorktreeSessionFromEntries(entries)
let customTitle: string | null = null
for (const entry of entries) {
if (entry.type === 'custom-title' && typeof entry.customTitle === 'string') {
customTitle = entry.customTitle
}
}
const transcriptMessageCount = this.countTranscriptMessages(entries)
return {
filePath: found.filePath,
projectDir: found.projectDir,
workDir,
repository,
worktreeSession,
transcriptMessageCount,
customTitle,
}
}
async deleteSessionFile(sessionId: string): Promise<void> {
const found = await this.findSessionFile(sessionId)
if (!found) return
await fs.unlink(found.filePath)
}
async clearSessionTranscript(sessionId: string, fallbackWorkDir?: string): Promise<void> {
let found = await this.findSessionFile(sessionId)
if (!found && fallbackWorkDir) {
const resolvedPath = path.resolve(fallbackWorkDir)
const absWorkDir = await fs.realpath(resolvedPath).catch(() => resolvedPath)
const dirPath = path.join(this.getProjectsDir(), this.sanitizePath(absWorkDir))
await fs.mkdir(dirPath, { recursive: true })
found = {
filePath: path.join(dirPath, `${sessionId}.jsonl`),
projectDir: this.sanitizePath(absWorkDir),
}
}
if (!found) {
throw ApiError.notFound(`Session not found: ${sessionId}`)
}
const entries = await this.readJsonlFile(found.filePath)
const workDir = this.resolveWorkDirFromEntries(entries, found.projectDir) || fallbackWorkDir || process.cwd()
const repository = this.resolveRepositoryFromEntries(entries)
const now = new Date().toISOString()
const initialEntry = {
type: 'file-history-snapshot',
messageId: crypto.randomUUID(),
snapshot: {
messageId: crypto.randomUUID(),
trackedFileBackups: {},
timestamp: now,
},
isSnapshotUpdate: false,
}
const metaEntry = {
type: 'session-meta',
isMeta: true,
workDir,
repository,
timestamp: now,
}
await fs.writeFile(
found.filePath,
`${JSON.stringify(initialEntry)}\n${JSON.stringify(metaEntry)}\n`,
'utf-8',
)
}
async appendSessionMetadata(
sessionId: string,
metadata: {
workDir: string
customTitle?: string | null
repository?: PreparedSessionWorkspace['repository']
}
): Promise<void> {
const matches = await this.findSessionFiles(sessionId)
if (matches.length === 0) return
let repository = metadata.repository
if (!repository) {
for (const match of matches) {
const candidate = this.resolveRepositoryFromEntries(await this.readJsonlFile(match.filePath))
if (candidate) {
repository = candidate
break
}
}
}
const targetProjectDir = this.sanitizePath(metadata.workDir)
const targetFilePath = path.join(this.getProjectsDir(), targetProjectDir, `${sessionId}.jsonl`)
await fs.mkdir(path.dirname(targetFilePath), { recursive: true })
await this.appendJsonlEntry(targetFilePath, {
type: 'session-meta',
isMeta: true,
workDir: metadata.workDir,
repository,
timestamp: new Date().toISOString(),
})
if (metadata.customTitle) {
await this.appendJsonlEntry(targetFilePath, {
type: 'custom-title',
customTitle: metadata.customTitle,
timestamp: new Date().toISOString(),
})
}
}
async deletePlaceholderSessionFiles(
sessionId: string,
keepWorkDir: string,
): Promise<number> {
if (!this.isValidSessionId(sessionId)) return 0
const projectsDir = this.getProjectsDir()
let projectDirs: import('node:fs').Dirent[]
try {
projectDirs = await fs.readdir(projectsDir, { withFileTypes: true })
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return 0
throw err
}
const keepProjectDir = this.sanitizePath(keepWorkDir)
let removed = 0
for (const projectDir of projectDirs) {
if (!projectDir.isDirectory()) continue
if (projectDir.name === keepProjectDir) continue
const filePath = path.join(projectsDir, projectDir.name, `${sessionId}.jsonl`)
const entries = await this.readJsonlFile(filePath)
if (entries.length === 0) continue
if (this.countTranscriptMessages(entries) > 0) continue
await fs.rm(filePath, { force: true })
removed += 1
}
return removed
}
async trimSessionMessagesFrom(
sessionId: string,
startMessageId: string,
): Promise<TrimSessionResult> {
const found = await this.findSessionFile(sessionId)
if (!found) {
throw ApiError.notFound(`Session not found: ${sessionId}`)
}
const entries = await this.readJsonlFile(found.filePath)
const activeMessages = this.entriesToMessages(entries)
const startIndex = activeMessages.findIndex((message) => message.id === startMessageId)
if (startIndex < 0) {
throw ApiError.badRequest(`Message not found in active session chain: ${startMessageId}`)
}
const removedMessageIds = activeMessages
.slice(startIndex)
.map((message) => message.id)
const remainingMessageIds = new Set(
activeMessages
.slice(0, startIndex)
.map((message) => message.id),
)
if (removedMessageIds.length === 0) {
return { removedCount: 0, removedMessageIds: [] }
}
const removedIds = new Set(removedMessageIds)
const filteredEntries = entries.filter(
(entry) => {
if (typeof entry.uuid !== 'string') return true
if (removedIds.has(entry.uuid)) return false
if (
entry.message?.role &&
(entry.type === 'user' || entry.type === 'assistant' || entry.type === 'system')
) {
return remainingMessageIds.has(entry.uuid)
}
return true
},
)
const content =
filteredEntries.length > 0
? filteredEntries.map((entry) => JSON.stringify(entry)).join('\n') + '\n'
: ''
await fs.writeFile(found.filePath, content, 'utf-8')
return {
removedCount: removedMessageIds.length,
removedMessageIds,
}
}
async getSessionFileHistorySnapshots(
sessionId: string,
): Promise<FileHistorySnapshot[]> {
const found = await this.findSessionFile(sessionId)
if (!found) {
throw ApiError.notFound(`Session not found: ${sessionId}`)
}
const entries = await this.readJsonlFile(found.filePath)
const snapshotsByMessageId = new Map<string, FileHistorySnapshot>()
for (const entry of entries) {
if (entry.type !== 'file-history-snapshot' || !entry.snapshot) continue
const snapshotMessageId =
typeof entry.snapshot.messageId === 'string'
? entry.snapshot.messageId
: typeof entry.messageId === 'string'
? entry.messageId
: null
if (!snapshotMessageId) continue
snapshotsByMessageId.set(snapshotMessageId, {
messageId: snapshotMessageId as FileHistorySnapshot['messageId'],
trackedFileBackups:
entry.snapshot.trackedFileBackups &&
typeof entry.snapshot.trackedFileBackups === 'object'
? (entry.snapshot.trackedFileBackups as FileHistorySnapshot['trackedFileBackups'])
: {},
timestamp: new Date(
entry.snapshot.timestamp || entry.timestamp || new Date().toISOString(),
),
})
}
return [...snapshotsByMessageId.values()]
}
async getSessionTaskNotifications(
sessionId: string,
): Promise<SessionTaskNotification[]> {
const found = await this.findSessionFile(sessionId)
if (!found) {
throw ApiError.notFound(`Session not found: ${sessionId}`)
}
const entries = await this.readJsonlFile(found.filePath)
const notifications: SessionTaskNotification[] = []
for (const entry of entries) {
if (entry.message?.role !== 'user') continue
const notification = this.parseTaskNotificationContent(
entry.message.content,
entry.timestamp,
)
if (notification) notifications.push(notification)
}
return notifications
}
// --------------------------------------------------------------------------
// Private helpers
// --------------------------------------------------------------------------
private entriesToMessages(entries: RawEntry[]): MessageEntry[] {
const messages: MessageEntry[] = []
const entriesByUuid = new Map<string, RawEntry>()
const parentToolUseIdCache = new Map<string, string | undefined>()
let suppressTaskNotificationResponse = false
for (const entry of entries) {
if (typeof entry.uuid === 'string' && entry.uuid.length > 0) {
entriesByUuid.set(entry.uuid, entry)
}
}
for (const entry of entries) {
const goalLocalCommandMessage = this.goalLocalCommandEntryToMessage(entry)
if (goalLocalCommandMessage) {
messages.push(goalLocalCommandMessage)
continue
}
// Only process transcript entries (user / assistant / system with messages)
if (!entry.message?.role) continue
// Skip meta entries (CLI internal bookkeeping)
if (entry.isMeta) continue
const isTaskNotification =
entry.message.role === 'user' &&
this.isTaskNotificationContent(entry.message.content)
if (isTaskNotification) {
suppressTaskNotificationResponse = true
continue
}
if (
entry.message.role === 'user' &&
!this.isToolResultContent(entry.message.content)
) {
suppressTaskNotificationResponse = false
} else if (suppressTaskNotificationResponse) {
continue
}
if (this.shouldHideTranscriptEntry(entry)) continue
// Skip non-transcript entry types
const entryType = entry.type
if (
entryType !== 'user' &&
entryType !== 'assistant' &&
entryType !== 'system'
) {
continue
}
const parentToolUseId = this.resolveParentToolUseId(
entry,
entriesByUuid,
parentToolUseIdCache,
)
const msg = this.entryToMessage(entry, parentToolUseId)
if (msg) {
messages.push(msg)
}
}
return messages
}
private async pathExists(targetPath: string | null): Promise<boolean> {
if (!targetPath) return false
try {
const stat = await fs.stat(targetPath)
return stat.isDirectory()
} catch {
return false
}
}
}
// Singleton instance for shared use across API handlers
export const sessionService = new SessionService()