cc-haha/src/server/api/sessions.ts
2026-07-14 21:31:39 +08:00

1187 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Session REST API Routes
*
* 提供会话的 CRUD 操作接口,数据来自 CLI 共享的 JSONL 文件。
*
* Routes:
* GET /api/sessions — 列出会话
* GET /api/sessions/:id — 获取会话详情
* GET /api/sessions/:id/messages — 获取会话消息
* GET /api/sessions/:id/subagents/by-tool/:toolUseId — 获取 SubAgent 运行详情
* GET /api/sessions/:id/trace — 获取会话级模型调用 tracebody preview 裁剪后的列表视图)
* GET /api/sessions/:id/trace/calls/:callId — 获取单次调用的完整 trace 记录
* GET /api/sessions/:id/turn-checkpoints — 获取按轮次保留的 checkpoint 预览
* GET /api/sessions/:id/turn-checkpoints/diff — 获取绑定到指定 checkpoint 的 diff
* POST /api/sessions — 创建新会话
* POST /api/sessions/batch-delete — 批量删除会话
* DELETE /api/sessions/:id — 删除会话
* PATCH /api/sessions/:id — 重命名会话
*/
import * as path from 'node:path'
import { sessionService } from '../services/sessionService.js'
import { conversationService } from '../services/conversationService.js'
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
import { closeSessionConnection, getSlashCommands } from '../ws/handler.js'
import { listSkillSlashCommands, type SkillSlashCommand } from './skills.js'
import { WorkspaceService } from '../services/workspaceService.js'
import {
getRepositoryContext,
type CreateSessionRepositoryOptions,
} from '../services/repositoryLaunchService.js'
import {
executeSessionRewind,
getSessionTurnCheckpointDiff,
listSessionTurnCheckpoints,
previewSessionRewind,
type RewindTargetSelector,
} from '../services/sessionRewindService.js'
import { SessionStore } from '../../../adapters/common/session-store.js'
import {
createSessionBranch,
SessionBranchingError,
} from '../../utils/sessionBranching.js'
import { registerChangedFileAccessRoot, registerFilesystemAccessRoot } from '../services/filesystemAccessRoots.js'
import { findGitRoot } from '../../utils/git.js'
import { traceCaptureService, trimTraceCallPreviews } from '../services/traceCaptureService.js'
import { getSubagentRunByTool } from '../services/subagentRunService.js'
import { isValidPermissionMode } from '../services/settingsService.js'
import { handleWorkspaceSearchRoute } from './workspaceSearch.js'
const DEFAULT_GIT_INFO_COMMAND_TIMEOUT_MS = 3_000
const workspaceService = new WorkspaceService(
async (sessionId) => (
conversationService.getSessionWorkDir(sessionId) ||
await sessionService.getSessionWorkDir(sessionId)
),
async (sessionId) => sessionService.getSessionMessages(sessionId),
async (sessionId) => sessionService.getSessionFileHistorySnapshots(sessionId),
)
export async function handleSessionsApi(
req: Request,
url: URL,
segments: string[]
): Promise<Response> {
try {
// segments: ['api', 'sessions', ...rest]
const sessionId = segments[2] // may be undefined
const subResource = segments[3] // e.g. 'messages'
// -----------------------------------------------------------------------
// Collection routes: /api/sessions
// -----------------------------------------------------------------------
if (!sessionId) {
switch (req.method) {
case 'GET':
return await listSessions(url)
case 'POST':
return await createSession(req)
default:
return Response.json(
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
{ status: 405 }
)
}
}
// Special collection route: /api/sessions/batch-delete
if (sessionId === 'batch-delete') {
if (req.method !== 'POST') {
return Response.json(
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
{ status: 405 }
)
}
return await batchDeleteSessions(req)
}
// Special collection route: /api/sessions/recent-projects
if (sessionId === 'recent-projects' && req.method === 'GET') {
return await getRecentProjects(url)
}
// Special collection route: /api/sessions/repository-context
if (sessionId === 'repository-context' && req.method === 'GET') {
return await getSessionRepositoryContext(url)
}
// -----------------------------------------------------------------------
// Sub-resource routes: /api/sessions/:id/messages
// -----------------------------------------------------------------------
if (subResource === 'messages') {
if (req.method !== 'GET') {
return Response.json(
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
{ status: 405 }
)
}
return await getSessionMessages(sessionId)
}
if (subResource === 'trace') {
if (req.method !== 'GET') {
return Response.json(
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
{ status: 405 }
)
}
return segments[4] === 'calls'
? await getSessionTraceCall(sessionId, segments[5])
: await getSessionTrace(sessionId)
}
if (subResource === 'git-info') {
if (req.method !== 'GET') {
return Response.json(
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
{ status: 405 }
)
}
return await getGitInfo(sessionId)
}
if (subResource === 'rewind') {
if (req.method !== 'POST') {
return Response.json(
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
{ status: 405 }
)
}
return await rewindSession(req, sessionId)
}
if (subResource === 'branch') {
if (req.method !== 'POST') {
return Response.json(
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
{ status: 405 }
)
}
return await branchSession(req, sessionId)
}
if (subResource === 'turn-checkpoints') {
if (req.method !== 'GET') {
return Response.json(
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
{ status: 405 }
)
}
return segments[4] === 'diff'
? await getTurnCheckpointDiff(sessionId, url)
: await getTurnCheckpoints(sessionId)
}
if (subResource === 'slash-commands') {
if (req.method !== 'GET') {
return Response.json(
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
{ status: 405 }
)
}
return await getSessionSlashCommands(sessionId)
}
if (subResource === 'inspection') {
if (req.method !== 'GET') {
return Response.json(
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
{ status: 405 }
)
}
return await getSessionInspection(sessionId, url)
}
if (subResource === 'workspace') {
if (req.method !== 'GET') {
return Response.json(
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
{ status: 405 }
)
}
return await handleSessionWorkspaceRoute(sessionId, url, segments[4])
}
if (subResource === 'subagents') {
if (req.method !== 'GET') {
return Response.json(
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
{ status: 405 }
)
}
if (segments[4] !== 'by-tool' || !segments[5] || segments.length !== 6) {
return Response.json(
{ error: 'NOT_FOUND', message: 'SubAgent route not found' },
{ status: 404 }
)
}
let toolUseId: string
try {
toolUseId = decodeURIComponent(segments[5])
} catch {
return Response.json(
{ error: 'NOT_FOUND', message: 'SubAgent route not found' },
{ status: 404 }
)
}
const result = await getSubagentRunByTool(sessionId, toolUseId)
if (!result) {
throw ApiError.notFound(`SubAgent run not found: ${toolUseId}`)
}
return Response.json(result)
}
// Route to conversations handler if sub-resource is 'chat'
if (subResource === 'chat') {
// This is handled by the conversations API, but in case the router
// forwards it here, we delegate to the conversations module.
// Normally the router should route /api/sessions/:id/chat/* to conversations.
return Response.json(
{ error: 'NOT_FOUND', message: 'Use /api/sessions/:id/chat via conversations API' },
{ status: 404 }
)
}
// -----------------------------------------------------------------------
// Item routes: /api/sessions/:id
// -----------------------------------------------------------------------
switch (req.method) {
case 'GET':
return await getSession(sessionId)
case 'DELETE':
return await deleteSession(sessionId)
case 'PATCH':
return await patchSession(req, sessionId)
default:
return Response.json(
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
{ status: 405 }
)
}
} catch (error) {
return errorResponse(error)
}
}
// ============================================================================
// Handler implementations
// ============================================================================
async function listSessions(url: URL): Promise<Response> {
const project = url.searchParams.get('project') || undefined
const limit = parseInt(url.searchParams.get('limit') || '20', 10)
const offset = parseInt(url.searchParams.get('offset') || '0', 10)
if (isNaN(limit) || limit < 0) {
throw ApiError.badRequest('Invalid limit parameter')
}
if (isNaN(offset) || offset < 0) {
throw ApiError.badRequest('Invalid offset parameter')
}
const result = await sessionService.listSessions({ project, limit, offset })
return Response.json(result)
}
async function getSession(sessionId: string): Promise<Response> {
const detail = await sessionService.getSession(sessionId)
if (!detail) {
throw ApiError.notFound(`Session not found: ${sessionId}`)
}
return Response.json(detail)
}
async function getSessionMessages(sessionId: string): Promise<Response> {
const [messages, taskNotifications] = await Promise.all([
sessionService.getSessionMessages(sessionId),
sessionService.getSessionTaskNotifications(sessionId),
])
return Response.json({ messages, taskNotifications })
}
async function getSessionTrace(sessionId: string): Promise<Response> {
const [trace, sessionMeta, messageSignature] = await Promise.all([
traceCaptureService.getSessionTrace(sessionId),
getSessionTraceMeta(sessionId),
sessionService.getSessionMessagesSignature(sessionId),
])
return Response.json({
...trace,
calls: trace.calls.map((call) => trimTraceCallPreviews(call)),
messageSignature,
session: sessionMeta
? {
id: sessionId,
title: sessionMeta.title,
projectPath: sessionMeta.projectPath,
workDir: sessionMeta.workDir,
}
: null,
})
}
async function getSessionTraceMeta(sessionId: string): Promise<{
title: string
projectPath: string
workDir: string | null
} | null> {
const found = await sessionService.findSessionFile(sessionId)
if (!found) return null
const meta = await sessionService.getSessionTitleAndMeta(found.filePath)
return {
title: meta.title,
projectPath: meta.projectPath,
workDir: meta.workDir,
}
}
async function getSessionTraceCall(sessionId: string, callId: string | undefined): Promise<Response> {
if (!callId || callId.trim().length === 0) {
throw ApiError.badRequest('callId is required')
}
const call = await traceCaptureService.getSessionTraceCall(sessionId, callId)
if (!call) {
throw ApiError.notFound(`Trace call not found: ${callId}`)
}
return Response.json({ call })
}
async function handleSessionWorkspaceRoute(
sessionId: string,
url: URL,
workspaceResource?: string,
): Promise<Response> {
const workDir = await requireSessionWorkspace(sessionId)
switch (workspaceResource) {
case 'status':
return Response.json(await workspaceService.getStatus(sessionId))
case 'tree':
return await runWorkspaceRequest(() => workspaceService.readTree(
sessionId,
url.searchParams.get('path') || '',
))
case 'search':
return handleWorkspaceSearchRoute(workDir, url)
case 'file':
return await runWorkspaceRequest(() => workspaceService.readFile(
sessionId,
requireWorkspacePath(url, 'file'),
))
case 'diff':
return await runWorkspaceDiffRequest(() => workspaceService.getDiff(
sessionId,
requireWorkspacePath(url, 'diff'),
))
default:
throw ApiError.notFound(`Unknown workspace resource: ${workspaceResource || 'workspace'}`)
}
}
async function createSession(req: Request): Promise<Response> {
let body: { workDir?: string; repository?: CreateSessionRepositoryOptions; permissionMode?: string }
try {
body = (await req.json()) as { workDir?: string; repository?: CreateSessionRepositoryOptions; permissionMode?: string }
} catch {
throw ApiError.badRequest('Invalid JSON body')
}
if (body.workDir && typeof body.workDir !== 'string') {
throw ApiError.badRequest('workDir must be a string')
}
if (body.permissionMode !== undefined && typeof body.permissionMode !== 'string') {
throw ApiError.badRequest('permissionMode must be a string')
}
if (body.permissionMode !== undefined && !isValidPermissionMode(body.permissionMode)) {
throw ApiError.badRequest(`Invalid permission mode: "${body.permissionMode}"`)
}
if (body.repository !== undefined) {
if (!body.repository || typeof body.repository !== 'object' || Array.isArray(body.repository)) {
throw ApiError.badRequest('repository must be an object')
}
if (body.repository.branch !== undefined && body.repository.branch !== null && typeof body.repository.branch !== 'string') {
throw ApiError.badRequest('repository.branch must be a string')
}
if (body.repository.worktree !== undefined && typeof body.repository.worktree !== 'boolean') {
throw ApiError.badRequest('repository.worktree must be a boolean')
}
}
const result = await sessionService.createSession(body.workDir, body.repository, body.permissionMode)
recentProjectsCache = null
return Response.json(result, { status: 201 })
}
async function getSessionRepositoryContext(url: URL): Promise<Response> {
const workDir = url.searchParams.get('workDir')
if (!workDir) {
throw ApiError.badRequest('workDir query parameter is required')
}
const context = await getRepositoryContext(workDir)
registerFilesystemAccessRoot(workDir)
registerFilesystemAccessRoot(context.workDir)
registerFilesystemAccessRoot(context.repoRoot)
return Response.json(context)
}
async function requireSessionWorkspace(sessionId: string): Promise<string> {
const workDir =
conversationService.getSessionWorkDir(sessionId) ||
await sessionService.getSessionWorkDir(sessionId)
if (!workDir) {
throw ApiError.notFound(`Session not found: ${sessionId}`)
}
return workDir
}
function requireWorkspacePath(url: URL, route: 'file' | 'diff'): string {
const filePath = url.searchParams.get('path')
if (!filePath) {
throw ApiError.badRequest(`path query parameter is required for workspace ${route}`)
}
return filePath
}
async function runWorkspaceRequest<T>(operation: () => Promise<T>): Promise<Response> {
try {
return Response.json(await operation())
} catch (error) {
if (isOutsideWorkspaceError(error)) {
throw new ApiError(403, error.message, 'FORBIDDEN')
}
if (isSessionNotFoundError(error)) {
throw ApiError.notFound(error.message)
}
throw error
}
}
async function runWorkspaceDiffRequest<T extends { state?: string; error?: string }>(
operation: () => Promise<T>,
): Promise<Response> {
const result = await runWorkspaceRequest(operation)
const body = await result.clone().json() as T
if (body.state === 'error' && typeof body.error === 'string' && body.error.includes('outside workspace')) {
throw new ApiError(403, body.error, 'FORBIDDEN')
}
return result
}
function isOutsideWorkspaceError(error: unknown): error is Error {
return error instanceof Error && error.message.includes('outside workspace')
}
function isSessionNotFoundError(error: unknown): error is Error {
return error instanceof Error && error.message.startsWith('Session not found:')
}
async function deleteSession(sessionId: string): Promise<Response> {
conversationService.markSessionDeleted(sessionId)
try {
await sessionService.deleteSession(sessionId)
} catch (error) {
conversationService.unmarkSessionDeleted(sessionId)
throw error
}
closeSessionConnection(sessionId, 'session deleted')
cleanupAdapterSessionMappings(sessionId)
recentProjectsCache = null
return Response.json({ ok: true })
}
async function batchDeleteSessions(req: Request): Promise<Response> {
let body: { sessionIds?: unknown }
try {
body = (await req.json()) as { sessionIds?: unknown }
} catch {
throw ApiError.badRequest('Invalid JSON body')
}
const sessionIds = normalizeSessionIds(body.sessionIds)
conversationService.markSessionsDeleted(sessionIds)
const result = await sessionService.deleteSessions(sessionIds)
if (result.failures.length > 0) {
conversationService.unmarkSessionsDeleted(result.failures.map((failure) => failure.sessionId))
}
for (const sessionId of result.successes) {
closeSessionConnection(sessionId, 'session deleted')
cleanupAdapterSessionMappings(sessionId)
}
if (result.successes.length > 0) {
recentProjectsCache = null
}
return Response.json({
ok: result.failures.length === 0,
successes: result.successes,
failures: result.failures,
})
}
function normalizeSessionIds(value: unknown): string[] {
if (!Array.isArray(value)) {
throw ApiError.badRequest('sessionIds must be an array')
}
const sessionIds: string[] = []
for (const sessionId of value) {
if (typeof sessionId !== 'string' || sessionId.trim().length === 0) {
throw ApiError.badRequest('sessionIds must contain only non-empty strings')
}
sessionIds.push(sessionId.trim())
}
if (sessionIds.length === 0) {
throw ApiError.badRequest('sessionIds must include at least one session id')
}
return [...new Set(sessionIds)]
}
function cleanupAdapterSessionMappings(sessionId: string): void {
const removedChatIds = new SessionStore().deleteBySessionId(sessionId)
if (removedChatIds.length > 0) {
console.log(`[Sessions API] Removed ${removedChatIds.length} adapter session mapping(s) for ${sessionId}`)
}
}
function mergeSessionSlashCommands(
preferred: Array<{ name: string; description?: string; argumentHint?: string }>,
fallback: SkillSlashCommand[],
): Array<{ name: string; description: string; argumentHint?: string }> {
const merged = new Map<string, { name: string; description: string; argumentHint?: string }>()
for (const command of preferred) {
if (!command.name) continue
merged.set(command.name, {
name: command.name,
description: command.description || '',
...(command.argumentHint ? { argumentHint: command.argumentHint } : {}),
})
}
for (const command of fallback) {
if (!command.name || merged.has(command.name)) continue
merged.set(command.name, {
name: command.name,
description: command.description || '',
...(command.argumentHint ? { argumentHint: command.argumentHint } : {}),
})
}
return [...merged.values()]
}
async function getSessionSlashCommands(sessionId: string): Promise<Response> {
const cachedCommands = getSlashCommands(sessionId)
const workDir = await sessionService.getSessionWorkDir(sessionId)
if (!workDir) {
throw ApiError.notFound(`Session not found: ${sessionId}`)
}
const skillCommands = await listSkillSlashCommands(workDir)
const slashCommands = cachedCommands.length > 0
? mergeSessionSlashCommands(cachedCommands, skillCommands)
: skillCommands
return Response.json({ commands: slashCommands })
}
async function getSessionInspection(sessionId: string, url: URL): Promise<Response> {
const includeContext = url.searchParams.get('includeContext') !== '0'
const contextOnly = includeContext && url.searchParams.get('contextOnly') === '1'
let transcriptSnapshot: Awaited<ReturnType<typeof sessionService.getInspectionTranscriptSnapshot>> | undefined
const getTranscriptSnapshot = async () => {
if (transcriptSnapshot !== undefined) return transcriptSnapshot
transcriptSnapshot = await sessionService.getInspectionTranscriptSnapshot(sessionId).catch(() => null)
return transcriptSnapshot
}
const active = conversationService.hasSession(sessionId)
const workDir =
conversationService.getSessionWorkDir(sessionId) ||
(await getTranscriptSnapshot())?.launchInfo.workDir
if (!workDir) {
throw ApiError.notFound(`Session not found: ${sessionId}`)
}
const launchInfo = !active ? (await getTranscriptSnapshot())?.launchInfo ?? null : null
const permissionMode = active
? conversationService.getSessionPermissionMode(sessionId)
: launchInfo?.permissionMode ?? 'default'
const initMessage = conversationService.getSessionInitMessage(sessionId) ??
[...conversationService.getRecentSdkMessages(sessionId)]
.reverse()
.find((message) => message?.type === 'system' && message.subtype === 'init')
const transcriptMetadata = !active || !initMessage
? (await getTranscriptSnapshot())?.metadata ?? null
: null
const cachedSlashCommands = getSlashCommands(sessionId)
const skillSlashCommands = await listSkillSlashCommands(workDir)
const fallbackSlashCommands = cachedSlashCommands.length > 0
? mergeSessionSlashCommands(cachedSlashCommands, skillSlashCommands)
: skillSlashCommands
const slashCommandCount = Array.isArray(initMessage?.slash_commands)
? initMessage.slash_commands.length
: fallbackSlashCommands.length
const response: Record<string, unknown> = {
active,
status: {
sessionId,
workDir,
permissionMode,
version: typeof initMessage?.claude_code_version === 'string' ? initMessage.claude_code_version : transcriptMetadata?.version,
cwd: typeof initMessage?.cwd === 'string' ? initMessage.cwd : transcriptMetadata?.cwd ?? workDir,
model: typeof initMessage?.model === 'string' ? initMessage.model : transcriptMetadata?.model,
apiKeySource: typeof initMessage?.apiKeySource === 'string' ? initMessage.apiKeySource : undefined,
outputStyle: typeof initMessage?.output_style === 'string' ? initMessage.output_style : undefined,
tools: Array.isArray(initMessage?.tools) ? initMessage.tools : [],
mcpServers: Array.isArray(initMessage?.mcp_servers) ? initMessage.mcp_servers : [],
slashCommandCount,
skillCount: Array.isArray(initMessage?.skills) ? initMessage.skills.length : 0,
},
errors: {},
}
if (!active) {
const snapshot = await getTranscriptSnapshot()
const transcriptUsage = snapshot?.usage ?? null
const transcriptContextEstimate = snapshot?.contextEstimate ?? null
if (transcriptContextEstimate) {
response.contextEstimate = transcriptContextEstimate
}
if (transcriptUsage) {
response.usage = transcriptUsage
}
response.errors = {
...(transcriptUsage ? {} : { usage: 'CLI session is not running' }),
...(includeContext ? { context: 'CLI session is not running' } : {}),
}
return Response.json(response)
}
const errors: Record<string, string> = {}
if (contextOnly) {
try {
response.context = await conversationService.requestControl(
sessionId,
{ subtype: 'get_context_usage', estimateOnly: true },
20_000,
)
} catch (error) {
errors.context = error instanceof Error ? error.message : String(error)
}
if (!response.context) {
const transcriptContextEstimate = (await getTranscriptSnapshot())?.contextEstimate ?? null
if (transcriptContextEstimate) {
response.contextEstimate = transcriptContextEstimate
}
}
} else {
const basicControlTimeoutMs = includeContext ? 10_000 : 4_000
const [usageResult, contextResult, mcpResult] = await Promise.allSettled([
conversationService.requestControl(sessionId, { subtype: 'get_session_usage' }, basicControlTimeoutMs),
includeContext
? conversationService.requestControl(
sessionId,
{ subtype: 'get_context_usage', estimateOnly: true },
20_000,
)
: Promise.resolve(null),
conversationService.requestControl(sessionId, { subtype: 'mcp_status' }, basicControlTimeoutMs),
])
if (usageResult.status === 'fulfilled') {
const transcriptUsage = (await getTranscriptSnapshot())?.usage ?? null
response.usage = chooseRicherUsage(
{ ...usageResult.value, source: 'current_process' },
transcriptUsage,
)
} else {
const transcriptUsage = (await getTranscriptSnapshot())?.usage ?? null
if (transcriptUsage) {
response.usage = transcriptUsage
} else {
errors.usage = usageResult.reason instanceof Error ? usageResult.reason.message : String(usageResult.reason)
}
}
if (!includeContext) {
// Context can be expensive on large live sessions. The desktop UI loads it
// separately when the context tab is actually selected.
} else if (contextResult.status === 'fulfilled' && contextResult.value) {
response.context = contextResult.value
} else {
errors.context = contextResult.reason instanceof Error ? contextResult.reason.message : String(contextResult.reason)
const transcriptContextEstimate = (await getTranscriptSnapshot())?.contextEstimate ?? null
if (transcriptContextEstimate) {
response.contextEstimate = transcriptContextEstimate
}
}
if (mcpResult.status === 'fulfilled' && response.status && typeof response.status === 'object') {
response.status = {
...response.status,
mcpServers: Array.isArray(mcpResult.value.mcpServers) ? mcpResult.value.mcpServers : (response.status as Record<string, unknown>).mcpServers,
}
}
}
response.errors = errors
return Response.json(response)
}
function usageTokenTotal(usage: unknown): number {
if (!usage || typeof usage !== 'object') return 0
const record = usage as Record<string, unknown>
return [
record.totalInputTokens,
record.totalOutputTokens,
record.totalCacheReadInputTokens,
record.totalCacheCreationInputTokens,
].reduce((sum, value) => sum + (typeof value === 'number' ? value : 0), 0)
}
function chooseRicherUsage(
currentUsage: Record<string, unknown>,
transcriptUsage: Record<string, unknown> | null,
): Record<string, unknown> {
if (!transcriptUsage) return currentUsage
return usageTokenTotal(transcriptUsage) > usageTokenTotal(currentUsage)
? transcriptUsage
: currentUsage
}
function sameResolvedPath(left: string | null | undefined, right: string | null | undefined): boolean {
if (!left || !right) return false
return path.resolve(left) === path.resolve(right)
}
function getGitInfoCommandTimeoutMs(): number {
const raw = process.env.CC_HAHA_GIT_INFO_TIMEOUT_MS
if (!raw) return DEFAULT_GIT_INFO_COMMAND_TIMEOUT_MS
const parsed = Number(raw)
return Number.isFinite(parsed) && parsed > 0
? parsed
: DEFAULT_GIT_INFO_COMMAND_TIMEOUT_MS
}
async function runGitInfoCommand(workDir: string, args: string[]): Promise<string | null> {
let proc: Bun.Subprocess<'ignore', 'pipe', 'ignore'> | null = null
let timeout: ReturnType<typeof setTimeout> | null = null
try {
proc = Bun.spawn(['git', ...args], {
cwd: workDir,
stdin: 'ignore',
stdout: 'pipe',
stderr: 'ignore',
})
const output = new Response(proc.stdout).text()
.then(async (text) => (await proc!.exited) === 0 ? text.trim() : null)
.catch(() => null)
const timedOut = new Promise<null>((resolve) => {
timeout = setTimeout(() => {
try {
proc?.kill()
} catch {
// Process may already have exited.
}
resolve(null)
}, getGitInfoCommandTimeoutMs())
})
return await Promise.race([output, timedOut])
} catch {
return null
} finally {
if (timeout) clearTimeout(timeout)
}
}
function repoNameFromRemote(remote: string | null): string {
if (!remote) return ''
const match = remote.match(/\/([^/]+?)(?:\.git)?$/) || remote.match(/:([^/]+\/[^/]+?)(?:\.git)?$/)
return match ? match[1]! : ''
}
function ownerRepoNameFromRemote(remote: string | null): string | null {
if (!remote) return null
const match = remote.match(/:([^/]+\/[^/]+?)(?:\.git)?$/) || remote.match(/\/([^/]+\/[^/]+?)(?:\.git)?$/)
return match ? match[1]! : null
}
function repoNameFromWorkDir(workDir: string): string {
return path.basename(workDir) || workDir.split(/[\\/]/).filter(Boolean).at(-1) || ''
}
async function getGitInfo(sessionId: string): Promise<Response> {
const workDir = conversationService.getSessionWorkDir(sessionId) || await sessionService.getSessionWorkDir(sessionId)
if (!workDir) {
throw ApiError.notFound(`Session not found: ${sessionId}`)
}
registerFilesystemAccessRoot(workDir)
const launchInfo = await sessionService.getSessionLaunchInfo(sessionId).catch(() => null)
const repository = launchInfo?.repository
const worktreeSession = launchInfo?.worktreeSession
// The visible business branch comes from Desktop's launch choice when present.
// CLI originalBranch is the source checkout before creating the worktree, which
// can differ from the selected base ref.
const sessionBranch = repository?.branch || worktreeSession?.originalBranch || null
const worktree = repository?.worktree || worktreeSession
? {
enabled: true,
path: worktreeSession?.worktreePath || workDir,
plannedPath: worktreeSession?.worktreePath || repository?.worktreePath || null,
sourceWorkDir: worktreeSession?.originalCwd || repository?.requestedWorkDir || repository?.repoRoot || null,
slug: worktreeSession?.worktreeName || repository?.worktreeSlug || null,
branch: worktreeSession?.worktreeBranch || repository?.worktreeBranch || null,
}
: null
// Fast check: if workDir is not inside a git repo, skip spawning git commands.
// findGitRoot uses fs.stat traversal with LRU cache (<5ms), avoiding 3 slow git
// spawns on non-git directories (each can take seconds as git searches upward).
const gitRoot = findGitRoot(workDir)
if (!gitRoot) {
const dirName = repoNameFromWorkDir(workDir)
return Response.json({
branch: sessionBranch,
repoName: dirName,
workDir,
changedFiles: 0,
worktree,
})
}
try {
// Get branch name
const gitBranch = await runGitInfoCommand(workDir, ['rev-parse', '--abbrev-ref', 'HEAD'])
const materializedWorktree = !!worktree && (
sameResolvedPath(workDir, worktree.path) ||
sameResolvedPath(workDir, worktree.plannedPath)
)
const branch = sessionBranch || (
materializedWorktree
? (worktree.branch || gitBranch)
: gitBranch
)
// Get repo name from remote or directory
const remote = await runGitInfoCommand(workDir, ['remote', 'get-url', 'origin'])
const repoName = repoNameFromRemote(remote) || repoNameFromWorkDir(workDir)
// Get short status
const statusText = await runGitInfoCommand(workDir, ['-c', 'core.fsmonitor=false', 'status', '--porcelain'])
const changedFiles = statusText?.split('\n').filter(Boolean).length ?? 0
return Response.json({
branch,
repoName,
workDir,
changedFiles,
worktree,
})
} catch {
// Not a git repo or git not available
return Response.json({
branch: sessionBranch,
repoName: null,
workDir,
changedFiles: 0,
worktree,
})
}
}
async function rewindSession(req: Request, sessionId: string): Promise<Response> {
let body: RewindTargetSelector & { dryRun?: boolean }
try {
body = (await req.json()) as RewindTargetSelector & { dryRun?: boolean }
} catch {
throw ApiError.badRequest('Invalid JSON body')
}
if (
(typeof body.targetUserMessageId !== 'string' || body.targetUserMessageId.length === 0) &&
!Number.isInteger(body.userMessageIndex)
) {
throw ApiError.badRequest('targetUserMessageId (string) or userMessageIndex (integer) is required')
}
const result = body.dryRun
? await previewSessionRewind(sessionId, body)
: await executeSessionRewind(sessionId, body)
return Response.json(result)
}
async function branchSession(req: Request, sessionId: string): Promise<Response> {
let body: { targetMessageId?: unknown; title?: unknown }
try {
body = (await req.json()) as { targetMessageId?: unknown; title?: unknown }
} catch {
throw ApiError.badRequest('Invalid JSON body')
}
if (typeof body.targetMessageId !== 'string' || body.targetMessageId.trim().length === 0) {
throw ApiError.badRequest('targetMessageId (string) is required in request body')
}
if (body.title !== undefined && typeof body.title !== 'string') {
throw ApiError.badRequest('title must be a string')
}
const launchInfo = await sessionService.getSessionLaunchInfo(sessionId)
if (!launchInfo) {
throw ApiError.notFound(`Session not found: ${sessionId}`)
}
try {
const result = await createSessionBranch({
sourceSessionId: sessionId,
sourceTranscriptPath: launchInfo.filePath,
targetMessageId: body.targetMessageId.trim(),
title: body.title?.trim() || undefined,
sourceWorkDir: launchInfo.workDir,
sourceRepository: launchInfo.repository,
sourceWorktreeSession: launchInfo.worktreeSession,
})
recentProjectsCache = null
return Response.json({
sessionId: result.sessionId,
title: result.title,
workDir: result.workDir ?? launchInfo.workDir,
sourceSessionId: sessionId,
targetMessageId: body.targetMessageId.trim(),
}, { status: 201 })
} catch (error) {
if (error instanceof SessionBranchingError) {
if (error.code === 'SOURCE_NOT_FOUND') {
throw ApiError.notFound(error.message)
}
throw ApiError.badRequest(error.message)
}
throw error
}
}
async function getTurnCheckpoints(sessionId: string): Promise<Response> {
const checkpoints = await listSessionTurnCheckpoints(sessionId)
// Make this turn's real changed files previewable even when they live outside
// the session workdir (e.g. the user told the model to write to an absolute
// path on another drive). Writing them was authorized, so previewing is too.
for (const checkpoint of checkpoints) {
for (const filePath of checkpoint.code.filesChanged) {
registerChangedFileAccessRoot(filePath, checkpoint.workDir)
}
}
return Response.json({ checkpoints })
}
async function getTurnCheckpointDiff(sessionId: string, url: URL): Promise<Response> {
const targetUserMessageId = url.searchParams.get('targetUserMessageId') || undefined
const userMessageIndexParam = url.searchParams.get('userMessageIndex')
const path = url.searchParams.get('path')
const userMessageIndex =
userMessageIndexParam === null ? undefined : Number.parseInt(userMessageIndexParam, 10)
if (
(typeof targetUserMessageId !== 'string' || targetUserMessageId.length === 0) &&
!Number.isInteger(userMessageIndex)
) {
throw ApiError.badRequest('targetUserMessageId (string) or userMessageIndex (integer) is required')
}
if (!path) {
throw ApiError.badRequest('path query parameter is required for turn checkpoint diff')
}
const result = await getSessionTurnCheckpointDiff(
sessionId,
{
targetUserMessageId,
userMessageIndex,
},
path,
)
return Response.json(result)
}
async function patchSession(req: Request, sessionId: string): Promise<Response> {
let body: { title?: string }
try {
body = (await req.json()) as { title?: string }
} catch {
throw ApiError.badRequest('Invalid JSON body')
}
if (!body.title || typeof body.title !== 'string') {
throw ApiError.badRequest('title (string) is required in request body')
}
await sessionService.renameSession(sessionId, body.title)
return Response.json({ ok: true })
}
type RecentProjectEntry = {
projectPath: string
realPath: string
projectName: string
isGit: boolean
repoName: string | null
branch: string | null
modifiedAt: string
sessionCount: number
}
// In-memory cache for recent projects (TTL: 30s)
let recentProjectsCache: { projects: RecentProjectEntry[]; timestamp: number } | null = null
const RECENT_PROJECTS_CACHE_TTL = 30_000
const DESKTOP_WORKTREE_MARKER = '/.claude/worktrees/'
function projectNameForRecentPath(realPath: string, fallback: string): string {
const normalizedRealPath = realPath.replace(/\\/g, '/')
const displayRoot = normalizedRealPath.includes(DESKTOP_WORKTREE_MARKER)
? normalizedRealPath.slice(0, normalizedRealPath.indexOf(DESKTOP_WORKTREE_MARKER))
: normalizedRealPath
return displayRoot.split('/').filter(Boolean).pop() || fallback
}
function isDesktopWorktreeBranchName(branch: string | null): boolean {
return !!branch && branch.startsWith('worktree-desktop-')
}
async function getRecentProjects(url: URL): Promise<Response> {
const limit = Math.min(Math.max(parseInt(url.searchParams.get('limit') || '10', 10) || 10, 1), 500)
const sessionScanLimit = Math.min(Math.max(limit * 8, 50), 200)
// Return cached response if fresh
if (recentProjectsCache && Date.now() - recentProjectsCache.timestamp < RECENT_PROJECTS_CACHE_TTL) {
return Response.json({ projects: recentProjectsCache.projects.slice(0, limit) })
}
const { sessions } = await sessionService.listSessions({ limit: sessionScanLimit })
const validSessions = sessions.filter((session) => session.workDirExists && session.workDir)
// First pass: group by logical project root so worktrees stay under the same project.
// Optimization: prefer s.projectRoot (already resolved by listSessions) and only fall back
// to the expensive getSessionWorkDir (reads the full transcript) when projectRoot is absent.
const realPathMap = new Map<string, { projectPath: string; modifiedAt: string; sessionCount: number; sessionId: string }>()
const fallbackSessionIds: string[] = []
for (const s of validSessions) {
const realPath = s.projectRoot || sessionService.desanitizePath(s.projectPath)
if (!s.projectRoot && s.id) {
fallbackSessionIds.push(s.id)
}
const existing = realPathMap.get(realPath)
if (!existing || s.modifiedAt > existing.modifiedAt) {
realPathMap.set(realPath, {
projectPath: realPath,
modifiedAt: s.modifiedAt,
sessionCount: (existing?.sessionCount ?? 0) + 1,
sessionId: s.id,
})
} else {
existing.sessionCount++
}
}
// Resolve fallback sessions in parallel (only those missing projectRoot)
if (fallbackSessionIds.length > 0) {
const resolvedPaths = await Promise.all(
fallbackSessionIds.map(async (sessionId) => {
try {
const workDir = await sessionService.getSessionWorkDir(sessionId)
return { sessionId, workDir }
} catch {
return { sessionId, workDir: null as string | null }
}
}),
)
for (const { sessionId, workDir } of resolvedPaths) {
if (!workDir) continue
// Find the entry we already inserted with the desanitized projectPath
const session = validSessions.find((s) => s.id === sessionId)
const oldKey = session?.projectRoot || sessionService.desanitizePath(session?.projectPath || '')
const oldEntry = oldKey ? realPathMap.get(oldKey) : undefined
const newRealPath = workDir
if (oldKey && oldEntry && oldKey !== newRealPath) {
// Migrate entry to the resolved real path
realPathMap.delete(oldKey)
const existingNew = realPathMap.get(newRealPath)
if (!existingNew || oldEntry.modifiedAt > (existingNew?.modifiedAt ?? '')) {
realPathMap.set(newRealPath, {
projectPath: newRealPath,
modifiedAt: oldEntry.modifiedAt,
sessionCount: oldEntry.sessionCount + (existingNew?.sessionCount ?? 0),
sessionId: oldEntry.sessionId,
})
} else {
existingNew.sessionCount += oldEntry.sessionCount
}
}
}
}
// Build project list with git info — parallelize git operations
// Optimization: use findGitRoot (fs.stat traversal + LRU cache, <5ms) to skip git spawns
// on non-git directories, avoiding slow git rev-parse on each (seconds per non-git dir).
const entries = Array.from(realPathMap.entries())
const projects = await Promise.all(
entries.map(async ([realPath, info]) => {
const projectName = projectNameForRecentPath(realPath, info.projectPath)
let isGit = false
let repoName: string | null = null
let branch: string | null = null
const gitRoot = findGitRoot(realPath)
if (gitRoot) {
isGit = true
// Run branch + remote in parallel
try {
const [branchResult, remoteResult] = await Promise.all([
runGitInfoCommand(realPath, ['rev-parse', '--abbrev-ref', 'HEAD']),
runGitInfoCommand(realPath, ['remote', 'get-url', 'origin']),
])
branch = isDesktopWorktreeBranchName(branchResult) ? null : branchResult
repoName = ownerRepoNameFromRemote(remoteResult)
} catch { /* git command failed */ }
}
return {
projectPath: info.projectPath, realPath, projectName, isGit, repoName, branch,
modifiedAt: info.modifiedAt, sessionCount: info.sessionCount,
}
})
)
// Sort by most recent
projects.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt))
recentProjectsCache = { projects, timestamp: Date.now() }
return Response.json({ projects: projects.slice(0, limit) })
}