mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +08:00
27f660fe5c
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.
1690 lines
61 KiB
TypeScript
1690 lines
61 KiB
TypeScript
/**
|
||
* 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) })
|
||
}
|