cc-haha/src/server/api/sessions.ts
程序员阿江(Relakkes) b4a5c09b11 fix: expose plugin skills in new session slash commands
New desktop sessions populated slash suggestions from the session endpoint before the CLI had emitted init metadata. That endpoint only scanned user and project skill directories, while the plugin settings view and global skills API already saw enabled plugin skills such as superpowers. The session endpoint now reuses the global skill listing and merges it with any cached CLI slash commands, and both composer surfaces rank command-name matches before broad description matches so /su surfaces superpowers first.

Constraint: New sessions need plugin skills before the first real user turn starts the CLI.

Rejected: Start or restart a hidden CLI process on plugin enable | heavier than needed and still misses the REST slash-command fallback path.

Confidence: high

Scope-risk: moderate

Directive: Keep session slash commands and /api/skills on the same skill discovery path when changing plugin skill loading.

Tested: bun test src/server/__tests__/skills.test.ts src/server/__tests__/plugins.test.ts src/server/__tests__/sessions.test.ts

Tested: cd desktop && bun run test --run src/components/chat/composerUtils.test.ts src/pages/EmptySession.test.tsx src/components/chat/ChatInput.test.tsx

Tested: bun run check:server

Tested: cd desktop && bun run lint

Not-tested: Manual desktop click-through in the packaged app.
2026-05-16 00:03:44 +08:00

879 lines
31 KiB
TypeScript

/**
* Session REST API Routes
*
* 提供会话的 CRUD 操作接口,数据来自 CLI 共享的 JSONL 文件。
*
* Routes:
* GET /api/sessions — 列出会话
* GET /api/sessions/:id — 获取会话详情
* GET /api/sessions/:id/messages — 获取会话消息
* GET /api/sessions/:id/turn-checkpoints — 获取按轮次保留的 checkpoint 预览
* GET /api/sessions/:id/turn-checkpoints/diff — 获取绑定到指定 checkpoint 的 diff
* POST /api/sessions — 创建新会话
* POST /api/sessions/batch-delete — 批量删除会话
* DELETE /api/sessions/:id — 删除会话
* PATCH /api/sessions/:id — 重命名会话
*/
import { sessionService } from '../services/sessionService.js'
import { conversationService } from '../services/conversationService.js'
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
import { closeSessionConnection, getSlashCommands } from '../ws/handler.js'
import { listSkillSlashCommands, type SkillSlashCommand } from './skills.js'
import { WorkspaceService } from '../services/workspaceService.js'
import {
getRepositoryContext,
type CreateSessionRepositoryOptions,
} from '../services/repositoryLaunchService.js'
import {
executeSessionRewind,
getSessionTurnCheckpointDiff,
listSessionTurnCheckpoints,
previewSessionRewind,
type RewindTargetSelector,
} from '../services/sessionRewindService.js'
import { SessionStore } from '../../../adapters/common/session-store.js'
const workspaceService = new WorkspaceService(
async (sessionId) => (
conversationService.getSessionWorkDir(sessionId) ||
await sessionService.getSessionWorkDir(sessionId)
),
async (sessionId) => sessionService.getSessionMessages(sessionId),
async (sessionId) => sessionService.getSessionFileHistorySnapshots(sessionId),
)
export async function handleSessionsApi(
req: Request,
url: URL,
segments: string[]
): Promise<Response> {
try {
// segments: ['api', 'sessions', ...rest]
const sessionId = segments[2] // may be undefined
const subResource = segments[3] // e.g. 'messages'
// -----------------------------------------------------------------------
// Collection routes: /api/sessions
// -----------------------------------------------------------------------
if (!sessionId) {
switch (req.method) {
case 'GET':
return await listSessions(url)
case 'POST':
return await createSession(req)
default:
return Response.json(
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
{ status: 405 }
)
}
}
// Special collection route: /api/sessions/batch-delete
if (sessionId === 'batch-delete') {
if (req.method !== 'POST') {
return Response.json(
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
{ status: 405 }
)
}
return await batchDeleteSessions(req)
}
// Special collection route: /api/sessions/recent-projects
if (sessionId === 'recent-projects' && req.method === 'GET') {
return await getRecentProjects(url)
}
// Special collection route: /api/sessions/repository-context
if (sessionId === 'repository-context' && req.method === 'GET') {
return await getSessionRepositoryContext(url)
}
// -----------------------------------------------------------------------
// Sub-resource routes: /api/sessions/:id/messages
// -----------------------------------------------------------------------
if (subResource === 'messages') {
if (req.method !== 'GET') {
return Response.json(
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
{ status: 405 }
)
}
return await getSessionMessages(sessionId)
}
if (subResource === '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 === 'turn-checkpoints') {
if (req.method !== 'GET') {
return Response.json(
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
{ status: 405 }
)
}
return segments[4] === 'diff'
? await getTurnCheckpointDiff(sessionId, url)
: await getTurnCheckpoints(sessionId)
}
if (subResource === 'slash-commands') {
if (req.method !== 'GET') {
return Response.json(
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
{ status: 405 }
)
}
return await getSessionSlashCommands(sessionId)
}
if (subResource === 'inspection') {
if (req.method !== 'GET') {
return Response.json(
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
{ status: 405 }
)
}
return await getSessionInspection(sessionId, url)
}
if (subResource === 'workspace') {
if (req.method !== 'GET') {
return Response.json(
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
{ status: 405 }
)
}
return await handleSessionWorkspaceRoute(sessionId, url, segments[4])
}
// Route to conversations handler if sub-resource is 'chat'
if (subResource === 'chat') {
// This is handled by the conversations API, but in case the router
// forwards it here, we delegate to the conversations module.
// Normally the router should route /api/sessions/:id/chat/* to conversations.
return Response.json(
{ error: 'NOT_FOUND', message: 'Use /api/sessions/:id/chat via conversations API' },
{ status: 404 }
)
}
// -----------------------------------------------------------------------
// Item routes: /api/sessions/:id
// -----------------------------------------------------------------------
switch (req.method) {
case 'GET':
return await getSession(sessionId)
case 'DELETE':
return await deleteSession(sessionId)
case 'PATCH':
return await patchSession(req, sessionId)
default:
return Response.json(
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
{ status: 405 }
)
}
} catch (error) {
return errorResponse(error)
}
}
// ============================================================================
// Handler implementations
// ============================================================================
async function listSessions(url: URL): Promise<Response> {
const project = url.searchParams.get('project') || undefined
const limit = parseInt(url.searchParams.get('limit') || '20', 10)
const offset = parseInt(url.searchParams.get('offset') || '0', 10)
if (isNaN(limit) || limit < 0) {
throw ApiError.badRequest('Invalid limit parameter')
}
if (isNaN(offset) || offset < 0) {
throw ApiError.badRequest('Invalid offset parameter')
}
const result = await sessionService.listSessions({ project, limit, offset })
return Response.json(result)
}
async function getSession(sessionId: string): Promise<Response> {
const detail = await sessionService.getSession(sessionId)
if (!detail) {
throw ApiError.notFound(`Session not found: ${sessionId}`)
}
return Response.json(detail)
}
async function getSessionMessages(sessionId: string): Promise<Response> {
const [messages, taskNotifications] = await Promise.all([
sessionService.getSessionMessages(sessionId),
sessionService.getSessionTaskNotifications(sessionId),
])
return Response.json({ messages, taskNotifications })
}
async function handleSessionWorkspaceRoute(
sessionId: string,
url: URL,
workspaceResource?: string,
): Promise<Response> {
await requireSessionWorkspace(sessionId)
switch (workspaceResource) {
case 'status':
return Response.json(await workspaceService.getStatus(sessionId))
case 'tree':
return await runWorkspaceRequest(() => workspaceService.readTree(
sessionId,
url.searchParams.get('path') || '',
))
case 'file':
return await runWorkspaceRequest(() => workspaceService.readFile(
sessionId,
requireWorkspacePath(url, 'file'),
))
case 'diff':
return await runWorkspaceDiffRequest(() => workspaceService.getDiff(
sessionId,
requireWorkspacePath(url, 'diff'),
))
default:
throw ApiError.notFound(`Unknown workspace resource: ${workspaceResource || 'workspace'}`)
}
}
async function createSession(req: Request): Promise<Response> {
let body: { workDir?: string; repository?: CreateSessionRepositoryOptions }
try {
body = (await req.json()) as { workDir?: string; repository?: CreateSessionRepositoryOptions }
} catch {
throw ApiError.badRequest('Invalid JSON body')
}
if (body.workDir && typeof body.workDir !== 'string') {
throw ApiError.badRequest('workDir must be a string')
}
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)
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')
}
return Response.json(await getRepositoryContext(workDir))
}
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)
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)
}
return Response.json({
ok: result.failures.length === 0,
successes: result.successes,
failures: result.failures,
})
}
function normalizeSessionIds(value: unknown): string[] {
if (!Array.isArray(value)) {
throw ApiError.badRequest('sessionIds must be an array')
}
const sessionIds: string[] = []
for (const sessionId of value) {
if (typeof sessionId !== 'string' || sessionId.trim().length === 0) {
throw ApiError.badRequest('sessionIds must contain only non-empty strings')
}
sessionIds.push(sessionId.trim())
}
if (sessionIds.length === 0) {
throw ApiError.badRequest('sessionIds must include at least one session id')
}
return [...new Set(sessionIds)]
}
function cleanupAdapterSessionMappings(sessionId: string): void {
const removedChatIds = new SessionStore().deleteBySessionId(sessionId)
if (removedChatIds.length > 0) {
console.log(`[Sessions API] Removed ${removedChatIds.length} adapter session mapping(s) for ${sessionId}`)
}
}
function mergeSessionSlashCommands(
preferred: Array<{ name: string; description?: string; argumentHint?: string }>,
fallback: SkillSlashCommand[],
): Array<{ name: string; description: string; argumentHint?: string }> {
const merged = new Map<string, { name: string; description: string; argumentHint?: string }>()
for (const command of preferred) {
if (!command.name) continue
merged.set(command.name, {
name: command.name,
description: command.description || '',
...(command.argumentHint ? { argumentHint: command.argumentHint } : {}),
})
}
for (const command of fallback) {
if (!command.name || merged.has(command.name)) continue
merged.set(command.name, {
name: command.name,
description: command.description || '',
})
}
return [...merged.values()]
}
async function getSessionSlashCommands(sessionId: string): Promise<Response> {
const cachedCommands = getSlashCommands(sessionId)
const workDir = await sessionService.getSessionWorkDir(sessionId)
if (!workDir) {
throw ApiError.notFound(`Session not found: ${sessionId}`)
}
const skillCommands = await listSkillSlashCommands(workDir)
const slashCommands = cachedCommands.length > 0
? mergeSessionSlashCommands(cachedCommands, skillCommands)
: skillCommands
return Response.json({ commands: slashCommands })
}
async function getSessionInspection(sessionId: string, url: URL): Promise<Response> {
const includeContext = url.searchParams.get('includeContext') !== '0'
const contextOnly = includeContext && url.searchParams.get('contextOnly') === '1'
const workDir =
conversationService.getSessionWorkDir(sessionId) ||
await sessionService.getSessionWorkDir(sessionId)
if (!workDir) {
throw ApiError.notFound(`Session not found: ${sessionId}`)
}
const active = conversationService.hasSession(sessionId)
const initMessage = conversationService.getSessionInitMessage(sessionId) ??
[...conversationService.getRecentSdkMessages(sessionId)]
.reverse()
.find((message) => message?.type === 'system' && message.subtype === 'init')
const transcriptMetadata = await sessionService.getTranscriptMetadata(sessionId)
const cachedSlashCommands = getSlashCommands(sessionId)
const skillSlashCommands = await listSkillSlashCommands(workDir)
const fallbackSlashCommands = cachedSlashCommands.length > 0
? mergeSessionSlashCommands(cachedSlashCommands, skillSlashCommands)
: skillSlashCommands
const slashCommandCount = Array.isArray(initMessage?.slash_commands)
? initMessage.slash_commands.length
: fallbackSlashCommands.length
const response: Record<string, unknown> = {
active,
status: {
sessionId,
workDir,
permissionMode: conversationService.getSessionPermissionMode(sessionId),
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: {},
}
const transcriptUsage = await sessionService.getTranscriptUsage(sessionId)
const transcriptContextEstimate = await sessionService.getTranscriptContextEstimate(sessionId)
if (transcriptContextEstimate) {
response.contextEstimate = transcriptContextEstimate
}
if (!active) {
if (transcriptUsage) {
response.usage = transcriptUsage
}
response.errors = {
...(transcriptUsage ? {} : { usage: 'CLI session is not running' }),
...(includeContext ? { context: 'CLI session is not running' } : {}),
}
return Response.json(response)
}
const errors: Record<string, string> = {}
if (contextOnly) {
try {
response.context = await conversationService.requestControl(
sessionId,
{ subtype: 'get_context_usage', estimateOnly: true },
20_000,
)
} catch (error) {
errors.context = error instanceof Error ? error.message : String(error)
}
} else {
const basicControlTimeoutMs = includeContext ? 10_000 : 4_000
const [usageResult, contextResult, mcpResult] = await Promise.allSettled([
conversationService.requestControl(sessionId, { subtype: 'get_session_usage' }, basicControlTimeoutMs),
includeContext
? conversationService.requestControl(
sessionId,
{ subtype: 'get_context_usage', estimateOnly: true },
20_000,
)
: Promise.resolve(null),
conversationService.requestControl(sessionId, { subtype: 'mcp_status' }, basicControlTimeoutMs),
])
if (usageResult.status === 'fulfilled') {
response.usage = chooseRicherUsage(
{ ...usageResult.value, source: 'current_process' },
transcriptUsage,
)
} else {
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)
}
if (mcpResult.status === 'fulfilled' && response.status && typeof response.status === 'object') {
response.status = {
...response.status,
mcpServers: Array.isArray(mcpResult.value.mcpServers) ? mcpResult.value.mcpServers : (response.status as Record<string, unknown>).mcpServers,
}
}
}
response.errors = errors
return Response.json(response)
}
function usageTokenTotal(usage: unknown): number {
if (!usage || typeof usage !== 'object') return 0
const record = usage as Record<string, unknown>
return [
record.totalInputTokens,
record.totalOutputTokens,
record.totalCacheReadInputTokens,
record.totalCacheCreationInputTokens,
].reduce((sum, value) => sum + (typeof value === 'number' ? value : 0), 0)
}
function chooseRicherUsage(
currentUsage: Record<string, unknown>,
transcriptUsage: Record<string, unknown> | null,
): Record<string, unknown> {
if (!transcriptUsage) return currentUsage
return usageTokenTotal(transcriptUsage) > usageTokenTotal(currentUsage)
? transcriptUsage
: currentUsage
}
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}`)
}
const launchInfo = await sessionService.getSessionLaunchInfo(sessionId).catch(() => null)
const repository = launchInfo?.repository
const worktreeSession = launchInfo?.worktreeSession
// The visible business branch comes from Desktop's launch choice when present.
// CLI originalBranch is the source checkout before creating the worktree, which
// can differ from the selected base ref.
const sessionBranch = repository?.branch || worktreeSession?.originalBranch || null
const worktree = repository?.worktree || worktreeSession
? {
enabled: true,
path: worktreeSession?.worktreePath || workDir,
plannedPath: worktreeSession?.worktreePath || repository?.worktreePath || null,
sourceWorkDir: worktreeSession?.originalCwd || repository?.requestedWorkDir || repository?.repoRoot || null,
slug: worktreeSession?.worktreeName || repository?.worktreeSlug || null,
branch: worktreeSession?.worktreeBranch || repository?.worktreeBranch || null,
}
: null
try {
// Get branch name
const branchProc = Bun.spawn(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], {
cwd: workDir,
stdout: 'pipe',
stderr: 'pipe',
})
const branchText = await new Response(branchProc.stdout).text()
const branch = sessionBranch || branchText.trim()
// Get repo name from remote or directory
let repoName = ''
try {
const remoteProc = Bun.spawn(['git', 'remote', 'get-url', 'origin'], {
cwd: workDir,
stdout: 'pipe',
stderr: 'pipe',
})
const remoteText = await new Response(remoteProc.stdout).text()
const remote = remoteText.trim()
// Extract repo name from URL: git@github.com:user/repo.git or https://...repo.git
const match = remote.match(/\/([^/]+?)(?:\.git)?$/) || remote.match(/:([^/]+\/[^/]+?)(?:\.git)?$/)
repoName = match ? match[1]! : ''
} catch {
// No remote, use directory name
const parts = workDir.split('/')
repoName = parts[parts.length - 1] || ''
}
// Get short status
const statusProc = Bun.spawn(['git', 'status', '--porcelain'], {
cwd: workDir,
stdout: 'pipe',
stderr: 'pipe',
})
const statusText = await new Response(statusProc.stdout).text()
const changedFiles = statusText.trim().split('\n').filter(Boolean).length
return Response.json({
branch,
repoName,
workDir,
changedFiles,
worktree,
})
} catch {
// Not a git repo or git not available
return Response.json({
branch: sessionBranch,
repoName: null,
workDir,
changedFiles: 0,
worktree,
})
}
}
async function rewindSession(req: Request, sessionId: string): Promise<Response> {
let body: RewindTargetSelector & { dryRun?: boolean }
try {
body = (await req.json()) as RewindTargetSelector & { dryRun?: boolean }
} catch {
throw ApiError.badRequest('Invalid JSON body')
}
if (
(typeof body.targetUserMessageId !== 'string' || body.targetUserMessageId.length === 0) &&
!Number.isInteger(body.userMessageIndex)
) {
throw ApiError.badRequest('targetUserMessageId (string) or userMessageIndex (integer) is required')
}
const result = body.dryRun
? await previewSessionRewind(sessionId, body)
: await executeSessionRewind(sessionId, body)
return Response.json(result)
}
async function getTurnCheckpoints(sessionId: string): Promise<Response> {
const checkpoints = await listSessionTurnCheckpoints(sessionId)
return Response.json({ checkpoints })
}
async function getTurnCheckpointDiff(sessionId: string, url: URL): Promise<Response> {
const targetUserMessageId = url.searchParams.get('targetUserMessageId') || undefined
const userMessageIndexParam = url.searchParams.get('userMessageIndex')
const path = url.searchParams.get('path')
const userMessageIndex =
userMessageIndexParam === null ? undefined : Number.parseInt(userMessageIndexParam, 10)
if (
(typeof targetUserMessageId !== 'string' || targetUserMessageId.length === 0) &&
!Number.isInteger(userMessageIndex)
) {
throw ApiError.badRequest('targetUserMessageId (string) or userMessageIndex (integer) is required')
}
if (!path) {
throw ApiError.badRequest('path query parameter is required for turn checkpoint diff')
}
const result = await getSessionTurnCheckpointDiff(
sessionId,
{
targetUserMessageId,
userMessageIndex,
},
path,
)
return Response.json(result)
}
async function patchSession(req: Request, sessionId: string): Promise<Response> {
let body: { title?: string }
try {
body = (await req.json()) as { title?: string }
} catch {
throw ApiError.badRequest('Invalid JSON body')
}
if (!body.title || typeof body.title !== 'string') {
throw ApiError.badRequest('title (string) is required in request body')
}
await sessionService.renameSession(sessionId, body.title)
return Response.json({ ok: true })
}
type RecentProjectEntry = {
projectPath: string
realPath: string
projectName: string
isGit: boolean
repoName: string | null
branch: string | null
modifiedAt: string
sessionCount: number
}
// In-memory cache for recent projects (TTL: 30s)
let recentProjectsCache: { projects: RecentProjectEntry[]; timestamp: number } | null = null
const RECENT_PROJECTS_CACHE_TTL = 30_000
const DESKTOP_WORKTREE_MARKER = '/.claude/worktrees/'
function projectNameForRecentPath(realPath: string, fallback: string): string {
const displayRoot = realPath.includes(DESKTOP_WORKTREE_MARKER)
? realPath.slice(0, realPath.indexOf(DESKTOP_WORKTREE_MARKER))
: realPath
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)
// Return cached response if fresh
if (recentProjectsCache && Date.now() - recentProjectsCache.timestamp < RECENT_PROJECTS_CACHE_TTL) {
return Response.json({ projects: recentProjectsCache.projects.slice(0, limit) })
}
const { sessions } = await sessionService.listSessions({ limit: 200 })
const validSessions = sessions.filter((session) => session.workDirExists && session.workDir)
// First pass: group by logical project root so worktrees stay under the same project.
const realPathMap = new Map<string, { projectPath: string; modifiedAt: string; sessionCount: number; sessionId: string }>()
for (const s of validSessions) {
let realPath: string
try {
const workDir = await sessionService.getSessionWorkDir(s.id)
realPath = s.projectRoot || workDir || sessionService.desanitizePath(s.projectPath)
} catch {
realPath = s.projectRoot || sessionService.desanitizePath(s.projectPath)
}
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++
}
}
// Build project list with git info — parallelize git operations
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
try {
const proc = Bun.spawn(['git', 'rev-parse', '--is-inside-work-tree'], {
cwd: realPath, stdout: 'pipe', stderr: 'pipe',
})
const out = await new Response(proc.stdout).text()
isGit = out.trim() === 'true'
if (isGit) {
// Run branch + remote in parallel
const [branchResult, remoteResult] = await Promise.all([
(async () => {
const branchProc = Bun.spawn(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], {
cwd: realPath, stdout: 'pipe', stderr: 'pipe',
})
return (await new Response(branchProc.stdout).text()).trim() || null
})(),
(async () => {
try {
const remoteProc = Bun.spawn(['git', 'remote', 'get-url', 'origin'], {
cwd: realPath, stdout: 'pipe', stderr: 'pipe',
})
const remote = (await new Response(remoteProc.stdout).text()).trim()
const match = remote.match(/:([^/]+\/[^/]+?)(?:\.git)?$/) || remote.match(/\/([^/]+\/[^/]+?)(?:\.git)?$/)
return match ? match[1]! : null
} catch { return null }
})(),
])
branch = isDesktopWorktreeBranchName(branchResult) ? null : branchResult
repoName = remoteResult
}
} catch { /* not a git repo or dir doesn't exist */ }
return {
projectPath: info.projectPath, realPath, projectName, isGit, repoName, branch,
modifiedAt: info.modifiedAt, sessionCount: info.sessionCount,
}
})
)
// Sort by most recent
projects.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt))
recentProjectsCache = { projects, timestamp: Date.now() }
return Response.json({ projects: projects.slice(0, limit) })
}