/** * 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 — 获取会话级模型调用 trace(body 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 { 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 { 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 { 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 { const [messages, taskNotifications] = await Promise.all([ sessionService.getSessionMessages(sessionId), sessionService.getSessionTaskNotifications(sessionId), ]) return Response.json({ messages, taskNotifications }) } async function getSessionTrace(sessionId: string): Promise { 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 { 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 { 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 { 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 { 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 { 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(operation: () => Promise): Promise { 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( operation: () => Promise, ): Promise { 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 { 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 { 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() 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 { 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 { const includeContext = url.searchParams.get('includeContext') !== '0' const contextOnly = includeContext && url.searchParams.get('contextOnly') === '1' let transcriptSnapshot: Awaited> | 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 = { 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 = {} 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).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 return [ record.totalInputTokens, record.totalOutputTokens, record.totalCacheReadInputTokens, record.totalCacheCreationInputTokens, ].reduce((sum, value) => sum + (typeof value === 'number' ? value : 0), 0) } function chooseRicherUsage( currentUsage: Record, transcriptUsage: Record | null, ): Record { 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 { let proc: Bun.Subprocess<'ignore', 'pipe', 'ignore'> | null = null let timeout: ReturnType | 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((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 { 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 { 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 { 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 { 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 { 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 { 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 { 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() 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) }) }