Files
claude-code-haha/src/server/api/sessions.ts
T
程序员阿江(Relakkes) 27f660fe5c fix(perf): bound large session history and tracing resource usage
Page transcript and trace reads, bound UI caches and retained task records,
and replace full-file background polling with incremental projections.
Preserve recovery and ownership semantics across pages and cancel stale work.
2026-09-19 20:29:20 +08:00

1690 lines
61 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/project-history — 按逻辑项目分批浏览历史会话
* GET /api/sessions/:id — 获取会话详情
* GET /api/sessions/:id/summary — 获取不含消息的会话元数据
* GET /api/sessions/:id/messages — 获取会话消息
* GET /api/sessions/:id/subagents/by-tool/:toolUseId — 获取 SubAgent 运行详情
* POST /api/sessions/:id/subagents/by-tool/:toolUseId/messages — 继续与 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
* GET /api/sessions/:id/review — 按显式来源获取 Git 审查状态
* GET /api/sessions/:id/review/diff — 获取单个文件在该来源下的 diff
* POST /api/sessions/:id/review/stage|unstage|stage-hunk|unstage-hunk|revert — 真实 Git 写操作
* 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,
ensureCliSessionStartedForControl,
getSlashCommands,
} from '../ws/handler.js'
import { listSkillSlashCommands, type SkillSlashCommand } from './skills.js'
import { WorkspaceService } from '../services/workspaceService.js'
import { ReviewService, type ReviewSource } from '../services/reviewService.js'
import {
createRepositoryBranch,
getRepositoryContext,
type CreateSessionRepositoryOptions,
} from '../services/repositoryLaunchService.js'
import {
executeSessionRewind,
getSessionTurnCheckpointDiff,
listSessionTurnCheckpoints,
parseSessionRewindMode,
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 { getSubagentRunByAgentId, getSubagentRunByTool } from '../services/subagentRunService.js'
import { isValidPermissionMode } from '../services/settingsService.js'
import { handleWorkspaceSearchRoute } from './workspaceSearch.js'
import { handleWorkspaceWatchRoute } from './workspaceWatch.js'
import { localIndexCoordinator } from '../services/localIndex/coordinator.js'
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
import { isPetAccessAuthorized } from '../localAccessAuth.js'
import { PET_SESSION_LIMIT } from '../petAccessPolicy.js'
const DEFAULT_GIT_INFO_COMMAND_TIMEOUT_MS = 3_000
/**
* Budget for the polling `get_session_usage` control. Shorter than the inspection's basic
* control timeout because the caller retries on its own cadence: a slow answer is worth less
* than a stale one that blocks the next poll.
*/
const USAGE_ONLY_CONTROL_TIMEOUT_MS = 2_500
const workspaceService = new WorkspaceService(
async (sessionId) => (
conversationService.getSessionWorkDir(sessionId) ||
await sessionService.getSessionWorkDir(sessionId)
),
async (sessionId) => {
const recovery = await sessionService.getSessionHistoryRecovery(sessionId)
if (!(recovery.completeness?.workspace ?? recovery.status === 'ready')) throw new ApiError(413, 'Workspace transcript exceeds the viewing budget', 'HISTORY_WORKSPACE_LIMIT')
return recovery.messages
},
async (sessionId) => sessionService.getSessionFileHistorySnapshots(sessionId, { bounded: true }),
)
const reviewService = new ReviewService(async (sessionId) => (
conversationService.getSessionWorkDir(sessionId) ||
await sessionService.getSessionWorkDir(sessionId)
))
const REVIEW_WRITE_RESOURCES = new Set([
'stage',
'unstage',
'stage-hunk',
'unstage-hunk',
'revert',
])
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(req, 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)
}
if (sessionId === 'project-history') {
if (req.method !== 'GET') return Response.json(
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` }, { status: 405 },
)
const limit = url.searchParams.get('limit')
if (limit !== null && !/^\d+$/.test(limit)) throw ApiError.badRequest('Invalid limit parameter')
return Response.json(await sessionService.listProjectHistory({
projectRoot: url.searchParams.get('projectRoot') ?? '',
...(limit !== null ? { limit: Number(limit) } : {}),
...(url.searchParams.has('cursor') ? { cursor: url.searchParams.get('cursor')! } : {}),
...(url.searchParams.has('beforeModifiedAt') ? { beforeModifiedAt: url.searchParams.get('beforeModifiedAt')! } : {}),
...(url.searchParams.has('beforeId') ? { beforeId: url.searchParams.get('beforeId')! } : {}),
}))
}
// Special collection route: /api/sessions/repository-context
if (sessionId === 'repository-context' && req.method === 'GET') {
return await getSessionRepositoryContext(url)
}
// Special collection route: /api/sessions/repository-branch
if (sessionId === 'repository-branch') {
if (req.method !== 'POST') {
return Response.json(
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
{ status: 405 }
)
}
return await createSessionRepositoryBranch(req)
}
// -----------------------------------------------------------------------
// Sub-resource routes: /api/sessions/:id/messages
// -----------------------------------------------------------------------
if (subResource === 'summary') {
if (req.method !== 'GET') {
return Response.json(
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
{ status: 405 }
)
}
const summary = await sessionService.getSessionSummary(sessionId)
if (!summary) throw ApiError.notFound(`Session not found: ${sessionId}`)
return Response.json(summary)
}
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(req, sessionId, url)
}
if (subResource === 'history-recovery' && req.method === 'GET') {
return Response.json(await sessionService.getSessionHistoryRecovery(sessionId, { signal: req.signal }))
}
if (subResource === 'trace') {
if (req.method !== 'GET') {
return Response.json(
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
{ status: 405 }
)
}
if (segments[4] === 'raw') {
const source = await traceCaptureService.getSessionTraceFile(sessionId)
if (!source) throw ApiError.notFound(`Trace not found: ${sessionId}`)
return new Response(Bun.file(source.path), { headers: {
'content-type': 'application/x-ndjson',
'content-disposition': 'attachment; filename="trace.jsonl"',
'cache-control': 'no-store',
} })
}
return segments[4] === 'calls'
? await getSessionTraceCall(sessionId, segments[5])
: await getSessionTrace(req, sessionId, url)
}
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(req, 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(req, 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(req, sessionId, url, segments[4])
}
if (subResource === 'review') {
return await handleSessionReviewRoute(req, sessionId, url, segments[4])
}
if (subResource === 'subagents') {
// Workflow agents have no parent `Agent` tool call to key off, so they
// are addressed by agent id instead. Same response shape, same page.
if (segments[4] === 'by-agent' && segments[5] && segments.length === 6) {
if (req.method !== 'GET') {
return Response.json(
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
{ status: 405 },
)
}
let agentId: string
try {
agentId = decodeURIComponent(segments[5])
} catch {
return Response.json(
{ error: 'NOT_FOUND', message: 'SubAgent route not found' },
{ status: 404 },
)
}
const byAgent = await getSubagentRunByAgentId(sessionId, agentId)
if (!byAgent) {
throw ApiError.notFound(`SubAgent run not found: ${agentId}`)
}
return Response.json(byAgent)
}
const isRunRoute = segments[4] === 'by-tool' && Boolean(segments[5])
const isRunRead = isRunRoute && segments.length === 6 && req.method === 'GET'
const isRunMessage = isRunRoute && segments.length === 7 &&
segments[6] === 'messages' && req.method === 'POST'
if (!isRunRead && !isRunMessage) {
const isKnownRunResource = isRunRoute && (
segments.length === 6 ||
(segments.length === 7 && segments[6] === 'messages')
)
if (isKnownRunResource) {
return Response.json(
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
{ status: 405 },
)
}
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,
url.searchParams.get('taskId') ?? undefined,
)
if (!result) {
throw ApiError.notFound(`SubAgent run not found: ${toolUseId}`)
}
if (isRunMessage) {
let body: { content?: unknown }
try {
body = await req.json() as { content?: unknown }
} catch {
throw ApiError.badRequest('Invalid JSON body')
}
const content = typeof body.content === 'string' ? body.content.trim() : ''
if (!content) throw ApiError.badRequest('content (string) is required in request body')
if (!result.agentId) {
throw ApiError.conflict(`SubAgent run has no resumable agent id: ${toolUseId}`)
}
await ensureCliSessionStartedForControl(sessionId, url)
const response = await conversationService.requestControl(sessionId, {
subtype: 'send_agent_message',
agent_id: result.agentId,
content,
})
return Response.json({ ok: true, ...response })
}
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) {
const code = (error as { code?: string } | null)?.code
const status = code === 'TRACE_PAGE_STALE' || code === 'HISTORY_PAGE_STALE' ? 409
: code === 'TRACE_RECORD_TOO_LARGE' ? 413
: code === 'TRACE_INDEX_BUSY' ? 503 : code === 'HISTORY_QUEUE_FULL' ? 429 : undefined
if (status) return errorResponse(new ApiError(status, error instanceof Error ? error.message : code!, code))
return errorResponse(error)
}
}
// ============================================================================
// Handler implementations
// ============================================================================
async function listSessions(req: Request, url: URL): Promise<Response> {
const project = url.searchParams.get('project') || undefined
const requestedLimit = parseInt(url.searchParams.get('limit') || '20', 10)
const offset = parseInt(url.searchParams.get('offset') || '0', 10)
if (isNaN(requestedLimit) || requestedLimit < 0) {
throw ApiError.badRequest('Invalid limit parameter')
}
if (isNaN(offset) || offset < 0) {
throw ApiError.badRequest('Invalid offset parameter')
}
const petAccess = isPetAccessAuthorized(req)
const limit = petAccess ? Math.min(requestedLimit, PET_SESSION_LIMIT) : requestedLimit
const result = await sessionService.listSessions({
...(petAccess ? {} : { project }),
limit,
offset: petAccess ? 0 : offset,
})
if (petAccess) {
return Response.json({
sessions: result.sessions.map((session) => ({
id: session.id,
title: session.title,
createdAt: session.createdAt,
modifiedAt: session.modifiedAt,
messageCount: session.messageCount,
projectPath: '',
workDir: null,
workDirExists: false,
})),
total: result.sessions.length,
})
}
return Response.json({
...result,
index: localIndexCoordinator.getPublicStatus(),
})
}
async function getSession(sessionId: string): Promise<Response> {
const [summary, history] = await Promise.all([
sessionService.getSessionSummary(sessionId),
sessionService.getSessionHistoryPage(sessionId),
])
if (!summary) throw ApiError.notFound(`Session not found: ${sessionId}`)
return Response.json({ ...summary, ...history })
}
async function getSessionMessages(req: Request, sessionId: string, url: URL): Promise<Response> {
return Response.json(await sessionService.getSessionHistoryPage(sessionId, {
cursor: url.searchParams.get('cursor') ?? undefined,
signal: req.signal,
}))
}
async function getSessionTrace(req: Request, sessionId: string, url: URL): Promise<Response> {
const [trace, sessionMeta, messageSignature] = await Promise.all([
traceCaptureService.getSessionTraceOverview(sessionId, {
offset: parseTracePageOffset(url),
revisionToken: url.searchParams.get('revisionToken') ?? undefined,
scanCursor: url.searchParams.get('scanCursor') ?? undefined,
signal: req.signal,
}),
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,
})
}
function parseTracePageOffset(url: URL): number {
const value = url.searchParams.get('offset') ?? '0'
if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value))) {
throw ApiError.badRequest('Invalid trace offset')
}
return Number(value)
}
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(
req: Request,
sessionId: string,
url: URL,
workspaceResource?: string,
): Promise<Response> {
const workDir = await requireSessionWorkspace(sessionId)
switch (workspaceResource) {
case 'watch':
return handleWorkspaceWatchRoute(req, sessionId, url, workspaceService)
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'}`)
}
}
/**
* Review sub-resource: `/api/sessions/:id/review[...]`.
*
* Separate from `workspace` on purpose. The workspace routes keep serving the
* chat "changed files" card, whose diff is always `HEAD`-based and blended with
* session history; review states its comparison explicitly and is the only
* surface that writes to the index or the working tree.
*/
async function handleSessionReviewRoute(
req: Request,
sessionId: string,
url: URL,
reviewResource?: string,
): Promise<Response> {
await requireSessionWorkspace(sessionId)
if (!reviewResource) {
if (req.method !== 'GET') return reviewMethodNotAllowed(req)
return await runReviewRequest(() =>
reviewService.getStatus(sessionId, parseReviewSourceFromQuery(url)),
)
}
if (reviewResource === 'revision') {
if (req.method !== 'GET') return reviewMethodNotAllowed(req)
return await runReviewRequest(() => reviewService.getRevision(sessionId, parseReviewSourceFromQuery(url)))
}
if (reviewResource === 'diff') {
if (req.method !== 'GET') return reviewMethodNotAllowed(req)
const filePath = url.searchParams.get('path')
if (!filePath) {
throw ApiError.badRequest('path query parameter is required for review diff')
}
return await runReviewRequest(() =>
reviewService.getFileDiff(sessionId, {
source: parseReviewSourceFromQuery(url),
path: filePath,
oldPath: url.searchParams.get('oldPath') ?? undefined,
}),
)
}
if (!REVIEW_WRITE_RESOURCES.has(reviewResource)) {
throw ApiError.notFound(`Unknown review resource: ${reviewResource}`)
}
if (req.method !== 'POST') return reviewMethodNotAllowed(req)
let body: Record<string, unknown>
try {
body = (await req.json()) as Record<string, unknown>
} catch {
throw ApiError.badRequest('Invalid JSON body')
}
if (!body || typeof body !== 'object' || Array.isArray(body)) {
throw ApiError.badRequest('Request body must be an object')
}
const snapshot = body.snapshot
if (typeof snapshot !== 'string' || snapshot.length === 0) {
throw ApiError.badRequest('snapshot is required')
}
const source = body.source === undefined ? undefined : parseReviewWriteSource(body.source)
if (reviewResource === 'stage-hunk' || reviewResource === 'unstage-hunk') {
const patch = body.patch
if (typeof patch !== 'string' || patch.trim().length === 0) {
throw ApiError.badRequest('patch is required')
}
const request = { patch, snapshot, source }
return await runReviewRequest(() =>
reviewResource === 'stage-hunk'
? reviewService.stageHunk(sessionId, request)
: reviewService.unstageHunk(sessionId, request),
)
}
const request = { paths: parseReviewPaths(body.paths), snapshot, source }
return await runReviewRequest(() => {
switch (reviewResource) {
case 'stage':
return reviewService.stage(sessionId, request)
case 'unstage':
return reviewService.unstage(sessionId, request)
default:
return reviewService.revert(sessionId, request)
}
})
}
function reviewMethodNotAllowed(req: Request): Response {
return Response.json(
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
{ status: 405 },
)
}
function parseReviewPaths(value: unknown): string[] {
if (!Array.isArray(value) || value.length === 0) {
throw ApiError.badRequest('paths must be a non-empty array')
}
return value.map((entry) => {
if (typeof entry !== 'string' || entry.trim().length === 0) {
throw ApiError.badRequest('paths must contain non-empty strings')
}
return entry
})
}
function parseReviewSourceFromQuery(url: URL): ReviewSource {
return parseReviewSourceValue({
kind: url.searchParams.get('source'),
baseRef: url.searchParams.get('baseRef') ?? undefined,
commit: url.searchParams.get('commit') ?? undefined,
turnKey: url.searchParams.get('turnKey') ?? undefined,
})
}
/**
* Source for a write route.
*
* `branch` and `commit` compare against history: their left-hand side is a
* commit and their right-hand side is the working tree, so "stage this" or
* "revert this" has no meaning there. Read-only was previously enforced only
* by the renderer not drawing the buttons, which left `POST /review/revert`
* with `{"kind":"commit"}` performing a real working-tree write.
*/
function parseReviewWriteSource(value: unknown): ReviewSource {
const source = parseReviewSourceValue(value)
if (source.kind === 'branch' || source.kind === 'commit') {
throw ApiError.badRequest(
`Review source "${source.kind}" is a read-only comparison and cannot be written to`,
)
}
return source
}
function parseReviewSourceValue(value: unknown): ReviewSource {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw ApiError.badRequest('source is required')
}
const { kind, baseRef, commit } = value as Record<string, unknown>
switch (kind) {
case 'unstaged':
case 'staged':
case 'uncommitted':
return { kind }
case 'branch':
if (typeof baseRef !== 'string' || baseRef.length === 0) {
throw ApiError.badRequest('baseRef is required for the branch source')
}
return { kind: 'branch', baseRef }
case 'commit':
if (typeof commit !== 'string' || commit.length === 0) {
throw ApiError.badRequest('commit is required for the commit source')
}
return { kind: 'commit', commit }
case 'turn':
// Turn history is a session-transcript question. Answering it from the
// current Git state would silently show the wrong changes, so it is
// refused here rather than approximated.
throw ApiError.badRequest(
'Review source "turn" is served by the session turn history, not the Git review service',
)
default:
throw ApiError.badRequest(`Unknown review source: ${String(kind ?? '')}`)
}
}
async function runReviewRequest<T>(operation: () => Promise<T>): Promise<Response> {
try {
return Response.json(await operation())
} catch (error) {
if (isOutsideWorkspaceError(error) || isReviewPathRejection(error)) {
throw new ApiError(403, error.message, 'FORBIDDEN')
}
if (isSessionNotFoundError(error)) {
throw ApiError.notFound(error.message)
}
if (error instanceof Error && error.message === 'path is required') {
throw ApiError.badRequest(error.message)
}
throw error
}
}
function isReviewPathRejection(error: unknown): error is Error {
return error instanceof Error && error.message.includes('version-control metadata')
}
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 createSessionRepositoryBranch(req: Request): Promise<Response> {
let body: { workDir?: unknown; name?: unknown; from?: unknown }
try {
body = (await req.json()) as { workDir?: unknown; name?: unknown; from?: unknown }
} catch {
throw ApiError.badRequest('Invalid JSON body')
}
if (typeof body.workDir !== 'string' || !body.workDir) {
throw ApiError.badRequest('workDir is required')
}
if (typeof body.name !== 'string') {
throw ApiError.badRequest('name must be a string')
}
if (body.from !== undefined && body.from !== null && typeof body.from !== 'string') {
throw ApiError.badRequest('from must be a string')
}
// `createRepositoryBranch` goes through `getRepositoryContext`, which registers
// the requested path, its resolved form and the repo root itself — repeating
// them here would imply a guarantee this handler does not add.
const result = await createRepositoryBranch(body.workDir, {
name: body.name,
from: body.from ?? null,
})
return Response.json(result, { status: 201 })
}
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[],
): SkillSlashCommand[] {
const fallbackByName = new Map(
fallback
.filter((command) => command.name)
.map((command) => [command.name, command] as const),
)
const merged = new Map<string, SkillSlashCommand>()
for (const command of preferred) {
if (!command.name) continue
const fallbackCommand = fallbackByName.get(command.name)
const argumentHint = command.argumentHint || fallbackCommand?.argumentHint
merged.set(command.name, {
name: command.name,
description: command.description || fallbackCommand?.description || '',
...(argumentHint ? { argumentHint } : {}),
kind: fallbackCommand?.kind ?? 'command',
...(fallbackCommand?.source ? { source: fallbackCommand.source } : {}),
})
}
for (const command of fallback) {
if (!command.name || merged.has(command.name)) continue
merged.set(command.name, command)
}
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 hasCliList = cachedCommands.length > 0
const skillCommands = await listSkillSlashCommands(workDir, {
includeCompiledIn: !hasCliList,
})
const slashCommands = hasCliList
? mergeSessionSlashCommands(cachedCommands, skillCommands)
: skillCommands
return Response.json({ commands: slashCommands })
}
async function getSessionInspection(req: Request, sessionId: string, url: URL): Promise<Response> {
const includeContext = url.searchParams.get('includeContext') !== '0'
const contextOnly = includeContext && url.searchParams.get('contextOnly') === '1'
// Lightweight polling mode for the context panel: one `get_session_usage` control and
// nothing else. The full inspection also scans the skills directory and re-reads the whole
// transcript to cross-check usage, which is far too much work to repeat every few seconds.
const usageOnly = !includeContext && url.searchParams.get('usageOnly') === '1'
let transcriptSnapshot: Awaited<ReturnType<typeof sessionService.getInspectionTranscriptSnapshot>> | undefined
const getTranscriptSnapshot = async () => {
if (transcriptSnapshot !== undefined) return transcriptSnapshot
transcriptSnapshot = await sessionService.getInspectionTranscriptSnapshot(sessionId)
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 = !usageOnly && (!active || !initMessage)
? (await getTranscriptSnapshot())?.metadata ?? null
: null
const cachedSlashCommands = getSlashCommands(sessionId)
const hasCliSlashCommands = cachedSlashCommands.length > 0
// `listSkillSlashCommands` walks every skill directory on disk with no cache. The usage
// poll does not need a command count, so it must not pay for one.
const fallbackSlashCommands = usageOnly
? []
: hasCliSlashCommands
? mergeSessionSlashCommands(
cachedSlashCommands,
await listSkillSlashCommands(workDir, { includeCompiledIn: false }),
)
: await listSkillSlashCommands(workDir, { includeCompiledIn: true })
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 (usageOnly) {
// No `mcp_status`, no skills scan, and deliberately no transcript cross-check: the
// transcript re-read is what made this endpoint too expensive to poll. The CLI's own
// running totals are the authoritative numbers for a live session anyway.
try {
response.usage = {
...(await conversationService.requestControl(
sessionId,
{ subtype: 'get_session_usage' },
USAGE_ONLY_CONTROL_TIMEOUT_MS,
req.signal,
)),
source: 'current_process',
}
} catch (error) {
throwIfRequestAborted(req)
errors.usage = error instanceof Error ? error.message : String(error)
}
response.errors = errors
return Response.json(response)
}
if (contextOnly) {
try {
response.context = await conversationService.requestControl(
sessionId,
{ subtype: 'get_context_usage', estimateOnly: true },
20_000,
req.signal,
)
} catch (error) {
throwIfRequestAborted(req)
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, req.signal),
includeContext
? conversationService.requestControl(
sessionId,
{ subtype: 'get_context_usage', estimateOnly: true },
20_000,
req.signal,
)
: Promise.resolve(null),
conversationService.requestControl(sessionId, { subtype: 'mcp_status' }, basicControlTimeoutMs, req.signal),
])
throwIfRequestAborted(req)
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 throwIfRequestAborted(req: Request): void {
if (!req.signal.aborted) return
if (req.signal.reason instanceof Error) throw req.signal.reason
throw new DOMException('The operation was aborted', 'AbortError')
}
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 plannedWorktreePath = worktreeSession?.worktreePath || repository?.worktreePath || null
const activeWorktreePath = worktreeSession?.worktreePath || (
sameResolvedPath(workDir, plannedWorktreePath) ? workDir : null
)
const worktree = repository?.worktree || worktreeSession
? {
enabled: true,
path: activeWorktreePath,
plannedPath: plannedWorktreePath,
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; mode?: unknown }
try {
body = (await req.json()) as RewindTargetSelector & { dryRun?: boolean; mode?: unknown }
} 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 mode = parseSessionRewindMode(body.mode)
const result = body.dryRun
? await previewSessionRewind(sessionId, body)
: await executeSessionRewind(sessionId, body, mode)
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 assertCheckpointPreviewBudget(sessionId: string): Promise<void> {
const found = await sessionService.findSessionFile(sessionId)
if (found && Bun.file(found.filePath).size > 16 * 1024 * 1024) {
throw new ApiError(413, 'This transcript exceeds the full checkpoint preview budget. Chat history remains available in pages.', 'HISTORY_CHECKPOINT_PREVIEW_LIMIT')
}
}
async function getTurnCheckpoints(req: Request, sessionId: string): Promise<Response> {
await assertCheckpointPreviewBudget(sessionId)
const checkpoints = await listSessionTurnCheckpoints(sessionId, req.signal, new URL(req.url).searchParams.get('frozen') === 'true')
// 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> {
await assertCheckpointPreviewBudget(sessionId)
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,
url.searchParams.get('frozen') === 'true',
)
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: {
scope: string
/** How many sessions were scanned to build `projects` — see getRecentProjects. */
scanLimit: number
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 scanParam = parseInt(url.searchParams.get('scan') || '', 10)
const sessionScanLimit = Number.isFinite(scanParam)
? Math.min(Math.max(scanParam, 100), 5000)
: Math.min(Math.max(limit * 16, 100), 500)
const scope = path.resolve(getClaudeConfigHomeDir())
// Return cached response if fresh. The cache is only valid for requests whose
// scan depth it already covers — a shallow (small-limit) scan must not serve
// a deeper one, or older projects would be silently truncated away.
if (
recentProjectsCache?.scope === scope &&
recentProjectsCache.scanLimit >= sessionScanLimit &&
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.workspaceState !== 'missing' &&
(session.projectRoot || 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 = { scope, scanLimit: sessionScanLimit, projects, timestamp: Date.now() }
return Response.json({ projects: projects.slice(0, limit) })
}