mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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
2404 lines
75 KiB
TypeScript
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(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/&/g, '&')
|
|
}
|
|
|
|
private readXmlTag(xml: string, tag: string): string | undefined {
|
|
const match = xml.match(new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'i'))
|
|
return match?.[1] ? this.decodeXmlText(match[1].trim()) : undefined
|
|
}
|
|
|
|
private parseTaskNotificationContent(
|
|
content: unknown,
|
|
timestamp?: string,
|
|
): SessionTaskNotification | null {
|
|
const xml = this.extractTextBlocks(content)
|
|
.map((text) => this.extractTaskNotificationXml(text))
|
|
.find((value): value is string => value !== null)
|
|
if (!xml) return null
|
|
|
|
const toolUseId = this.readXmlTag(xml, 'tool-use-id')
|
|
const status = this.readXmlTag(xml, 'status')
|
|
if (
|
|
!toolUseId ||
|
|
(status !== 'completed' && status !== 'failed' && status !== 'stopped')
|
|
) {
|
|
return null
|
|
}
|
|
|
|
const taskId = this.readXmlTag(xml, 'task-id') || toolUseId
|
|
const summary = this.readXmlTag(xml, 'summary')
|
|
const result = this.readXmlTag(xml, 'result')
|
|
const outputFile = this.readXmlTag(xml, 'output-file')
|
|
return {
|
|
taskId,
|
|
toolUseId,
|
|
status,
|
|
...(summary ? { summary } : {}),
|
|
...(result ? { result } : {}),
|
|
...(outputFile ? { outputFile } : {}),
|
|
...(timestamp ? { timestamp } : {}),
|
|
}
|
|
}
|
|
|
|
private shouldHideTranscriptEntry(entry: RawEntry): boolean {
|
|
const role = entry.message?.role
|
|
const content = entry.message?.content
|
|
|
|
if (role === 'user') {
|
|
return (
|
|
this.isInternalCommandBreadcrumb(content) ||
|
|
this.isSyntheticUserInterruption(content) ||
|
|
this.isTaskNotificationContent(content)
|
|
)
|
|
}
|
|
|
|
if (role === 'assistant') {
|
|
return this.isSyntheticNoResponseAssistant(content)
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
private isGoalLocalCommandOutput(output: string): boolean {
|
|
const trimmed = output.trim()
|
|
return (
|
|
trimmed.startsWith('Goal set:') ||
|
|
trimmed.startsWith('Goal cleared:') ||
|
|
trimmed === 'Goal cleared.' ||
|
|
trimmed === 'Goal marked complete.' ||
|
|
trimmed === 'No active goal.'
|
|
)
|
|
}
|
|
|
|
private isGoalLocalCommandEntry(entry: RawEntry): boolean {
|
|
if (
|
|
entry.type !== 'system' ||
|
|
entry.subtype !== 'local_command' ||
|
|
typeof entry.content !== 'string'
|
|
) {
|
|
return false
|
|
}
|
|
|
|
const commandName = this.readXmlTag(entry.content, 'command-name')?.replace(/^\//, '')
|
|
if (commandName) return commandName === 'goal'
|
|
|
|
const output =
|
|
this.readXmlTag(entry.content, 'local-command-stdout') ??
|
|
this.readXmlTag(entry.content, 'local-command-stderr')
|
|
return output ? this.isGoalLocalCommandOutput(output) : false
|
|
}
|
|
|
|
private goalLocalCommandEntryToMessage(entry: RawEntry): MessageEntry | null {
|
|
if (!this.isGoalLocalCommandEntry(entry)) return null
|
|
return {
|
|
id: entry.uuid || crypto.randomUUID(),
|
|
type: 'system',
|
|
content: entry.content,
|
|
timestamp: entry.timestamp || new Date().toISOString(),
|
|
parentUuid: entry.parentUuid ?? undefined,
|
|
isSidechain: entry.isSidechain,
|
|
}
|
|
}
|
|
|
|
private goalCreationCommandTitle(entry: RawEntry): string | null {
|
|
if (
|
|
entry.type !== 'system' ||
|
|
entry.subtype !== 'local_command' ||
|
|
typeof entry.content !== 'string'
|
|
) {
|
|
return null
|
|
}
|
|
|
|
const commandName = this.readXmlTag(entry.content, 'command-name')?.replace(/^\//, '')
|
|
if (commandName !== 'goal') return null
|
|
|
|
const args = this.readXmlTag(entry.content, 'command-args')?.trim()
|
|
if (!args || /^clear\b/i.test(args)) return null
|
|
|
|
const title = cleanSessionTitleSource(`/goal ${args}`)
|
|
return title ? title.length > 80 ? title.slice(0, 80) + '...' : title : null
|
|
}
|
|
|
|
private extractAgentToolUseId(entry: RawEntry): string | undefined {
|
|
const content = entry.message?.content
|
|
if (!Array.isArray(content)) return undefined
|
|
|
|
for (const block of content as Array<Record<string, unknown>>) {
|
|
if (
|
|
block.type === 'tool_use' &&
|
|
block.name === 'Agent' &&
|
|
typeof block.id === 'string'
|
|
) {
|
|
return block.id
|
|
}
|
|
}
|
|
|
|
return undefined
|
|
}
|
|
|
|
private extractAgentToolUseIdsFromMessage(message: MessageEntry): string[] {
|
|
if (message.type !== 'tool_use' || !Array.isArray(message.content)) {
|
|
return []
|
|
}
|
|
|
|
return (message.content as ContentBlock[])
|
|
.filter((block) => block.type === 'tool_use' && block.name === 'Agent')
|
|
.flatMap((block) => (typeof block.id === 'string' ? [block.id] : []))
|
|
}
|
|
|
|
private extractTextFromContent(content: unknown): string {
|
|
if (typeof content === 'string') return content
|
|
if (!Array.isArray(content)) return ''
|
|
|
|
return (content as ContentBlock[])
|
|
.flatMap((block) => (typeof block.text === 'string' ? [block.text] : []))
|
|
.join('\n')
|
|
}
|
|
|
|
private extractAgentIdFromResultText(text: string): string | undefined {
|
|
const match = text.match(/(?:^|\n)\s*agentId:\s*([A-Za-z0-9_-]+)/)
|
|
return match?.[1]
|
|
}
|
|
|
|
private extractAgentResultLinks(messages: MessageEntry[]): Map<string, string> {
|
|
const agentToolUseIds = new Set(
|
|
messages.flatMap((message) => this.extractAgentToolUseIdsFromMessage(message)),
|
|
)
|
|
const resultLinks = new Map<string, string>()
|
|
|
|
for (const message of messages) {
|
|
if (message.type !== 'tool_result' || !Array.isArray(message.content)) {
|
|
continue
|
|
}
|
|
|
|
for (const block of message.content as ContentBlock[]) {
|
|
if (block.type !== 'tool_result' || typeof block.tool_use_id !== 'string') {
|
|
continue
|
|
}
|
|
if (!agentToolUseIds.has(block.tool_use_id)) {
|
|
continue
|
|
}
|
|
|
|
const agentId = this.extractAgentIdFromResultText(
|
|
this.extractTextFromContent(block.content),
|
|
)
|
|
if (agentId) {
|
|
resultLinks.set(block.tool_use_id, agentId)
|
|
}
|
|
}
|
|
}
|
|
|
|
return resultLinks
|
|
}
|
|
|
|
private namespaceSubagentContentIds(content: unknown, namespace: string): unknown {
|
|
if (!Array.isArray(content)) return content
|
|
|
|
return (content as ContentBlock[]).map((block) => {
|
|
if (!block || typeof block !== 'object') return block
|
|
if (block.type === 'tool_use' && typeof block.id === 'string') {
|
|
return { ...block, id: `${namespace}/${block.id}` }
|
|
}
|
|
if (block.type === 'tool_result' && typeof block.tool_use_id === 'string') {
|
|
return { ...block, tool_use_id: `${namespace}/${block.tool_use_id}` }
|
|
}
|
|
return block
|
|
})
|
|
}
|
|
|
|
private subagentTranscriptPath(
|
|
projectDir: string,
|
|
sessionId: string,
|
|
agentId: string,
|
|
): string {
|
|
const normalizedAgentId = agentId.startsWith('agent-') ? agentId : `agent-${agentId}`
|
|
return path.join(
|
|
this.getProjectsDir(),
|
|
projectDir,
|
|
sessionId,
|
|
'subagents',
|
|
`${normalizedAgentId}.jsonl`,
|
|
)
|
|
}
|
|
|
|
private async loadSubagentToolMessages(
|
|
projectDir: string,
|
|
sessionId: string,
|
|
parentToolUseId: string,
|
|
agentId: string,
|
|
): Promise<MessageEntry[]> {
|
|
const filePath = this.subagentTranscriptPath(projectDir, sessionId, agentId)
|
|
const entries = await this.readJsonlFile(filePath)
|
|
const namespace = `${parentToolUseId}/${agentId}`
|
|
const messages: MessageEntry[] = []
|
|
|
|
for (const entry of entries) {
|
|
if (!entry.message?.role || entry.isMeta) continue
|
|
if (this.shouldHideTranscriptEntry(entry)) continue
|
|
if (entry.type !== 'user' && entry.type !== 'assistant' && entry.type !== 'system') {
|
|
continue
|
|
}
|
|
|
|
const message = this.entryToMessage(
|
|
{
|
|
...entry,
|
|
message: {
|
|
...entry.message,
|
|
content: this.namespaceSubagentContentIds(entry.message.content, namespace),
|
|
},
|
|
},
|
|
parentToolUseId,
|
|
)
|
|
if (message && (message.type === 'tool_use' || message.type === 'tool_result')) {
|
|
messages.push(message)
|
|
}
|
|
}
|
|
|
|
return messages
|
|
}
|
|
|
|
private async appendSubagentToolMessages(
|
|
projectDir: string,
|
|
sessionId: string,
|
|
messages: MessageEntry[],
|
|
): Promise<MessageEntry[]> {
|
|
const resultLinks = this.extractAgentResultLinks(messages)
|
|
if (resultLinks.size === 0) {
|
|
return messages
|
|
}
|
|
|
|
const childMessages = await Promise.all(
|
|
[...resultLinks.entries()].map(([parentToolUseId, agentId]) =>
|
|
this.loadSubagentToolMessages(projectDir, sessionId, parentToolUseId, agentId),
|
|
),
|
|
)
|
|
return [...messages, ...childMessages.flat()]
|
|
}
|
|
|
|
private resolveParentToolUseId(
|
|
entry: RawEntry,
|
|
entriesByUuid: Map<string, RawEntry>,
|
|
cache: Map<string, string | undefined>,
|
|
): string | undefined {
|
|
if (
|
|
typeof entry.parent_tool_use_id === 'string' &&
|
|
entry.parent_tool_use_id.length > 0
|
|
) {
|
|
return entry.parent_tool_use_id
|
|
}
|
|
|
|
if (entry.isSidechain !== true) {
|
|
return undefined
|
|
}
|
|
|
|
const cacheKey = entry.uuid
|
|
if (cacheKey && cache.has(cacheKey)) {
|
|
return cache.get(cacheKey)
|
|
}
|
|
|
|
let resolved: string | undefined
|
|
let currentParentUuid =
|
|
typeof entry.parentUuid === 'string' ? entry.parentUuid : undefined
|
|
const visited = new Set<string>()
|
|
|
|
while (currentParentUuid && !visited.has(currentParentUuid)) {
|
|
visited.add(currentParentUuid)
|
|
const parentEntry = entriesByUuid.get(currentParentUuid)
|
|
if (!parentEntry) break
|
|
|
|
const directAgentToolUseId = this.extractAgentToolUseId(parentEntry)
|
|
if (directAgentToolUseId) {
|
|
resolved = directAgentToolUseId
|
|
break
|
|
}
|
|
|
|
if (parentEntry.uuid && cache.has(parentEntry.uuid)) {
|
|
resolved = cache.get(parentEntry.uuid)
|
|
break
|
|
}
|
|
|
|
currentParentUuid =
|
|
typeof parentEntry.parentUuid === 'string'
|
|
? parentEntry.parentUuid
|
|
: undefined
|
|
}
|
|
|
|
if (cacheKey) {
|
|
cache.set(cacheKey, resolved)
|
|
}
|
|
|
|
return resolved
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Title extraction
|
|
// --------------------------------------------------------------------------
|
|
|
|
private extractTitle(entries: RawEntry[]): string {
|
|
// 1. Look for custom title entry (appended by renameSession) — highest priority
|
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
const e = entries[i]!
|
|
if (e.type === 'custom-title' && e.customTitle) {
|
|
return e.customTitle
|
|
}
|
|
}
|
|
|
|
// 2. Goal sessions should keep the original objective as the stable title.
|
|
for (const e of entries) {
|
|
const goalTitle = this.goalCreationCommandTitle(e)
|
|
if (goalTitle) return goalTitle
|
|
}
|
|
|
|
// 3. Look for AI-generated title (written by titleService)
|
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
const e = entries[i]!
|
|
if (e.type === 'ai-title' && e.aiTitle) {
|
|
const title = cleanSessionTitleSource(String(e.aiTitle))
|
|
if (title) return title
|
|
}
|
|
}
|
|
|
|
// 4. Look for first non-meta user message as title
|
|
for (const e of entries) {
|
|
if (e.type === 'user' && !e.isMeta && e.message?.role === 'user') {
|
|
const content = e.message.content
|
|
let text: string | undefined
|
|
if (typeof content === 'string') {
|
|
text = content
|
|
} else if (Array.isArray(content)) {
|
|
const textBlock = content.find(
|
|
(block: Record<string, unknown>) => block.type === 'text' && typeof block.text === 'string'
|
|
)
|
|
if (textBlock) text = textBlock.text as string
|
|
}
|
|
if (text) {
|
|
const title = cleanSessionTitleSource(text)
|
|
if (title) return title.length > 80 ? title.slice(0, 80) + '...' : title
|
|
}
|
|
}
|
|
}
|
|
|
|
return 'Untitled Session'
|
|
}
|
|
|
|
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()
|