| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import * as path from 'node:path' |
| import { sessionService } from '../services/sessionService.js' |
| import { conversationService } from '../services/conversationService.js' |
| import { ApiError, errorResponse } from '../middleware/errorHandler.js' |
| import { closeSessionConnection, getSlashCommands } from '../ws/handler.js' |
| import { listSkillSlashCommands, type SkillSlashCommand } from './skills.js' |
| import { WorkspaceService } from '../services/workspaceService.js' |
| import { |
| getRepositoryContext, |
| type CreateSessionRepositoryOptions, |
| } from '../services/repositoryLaunchService.js' |
| import { |
| executeSessionRewind, |
| getSessionTurnCheckpointDiff, |
| listSessionTurnCheckpoints, |
| previewSessionRewind, |
| type RewindTargetSelector, |
| } from '../services/sessionRewindService.js' |
| import { SessionStore } from '../../../adapters/common/session-store.js' |
| import { |
| createSessionBranch, |
| SessionBranchingError, |
| } from '../../utils/sessionBranching.js' |
| import { registerFilesystemAccessRoot } from '../services/filesystemAccessRoots.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 { |
| |
| const sessionId = segments[2] |
| const subResource = segments[3] |
|
|
| |
| |
| |
| 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 } |
| ) |
| } |
| } |
|
|
| |
| 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) |
| } |
|
|
| |
| if (sessionId === 'recent-projects' && req.method === 'GET') { |
| return await getRecentProjects(url) |
| } |
|
|
| |
| if (sessionId === 'repository-context' && req.method === 'GET') { |
| return await getSessionRepositoryContext(url) |
| } |
|
|
| |
| |
| |
| 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 === 'branch') { |
| if (req.method !== 'POST') { |
| return Response.json( |
| { error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` }, |
| { status: 405 } |
| ) |
| } |
| return await branchSession(req, sessionId) |
| } |
|
|
| if (subResource === 'turn-checkpoints') { |
| if (req.method !== 'GET') { |
| return Response.json( |
| { error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` }, |
| { status: 405 } |
| ) |
| } |
| return segments[4] === 'diff' |
| ? await getTurnCheckpointDiff(sessionId, url) |
| : await getTurnCheckpoints(sessionId) |
| } |
|
|
| if (subResource === 'slash-commands') { |
| if (req.method !== 'GET') { |
| return Response.json( |
| { error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` }, |
| { status: 405 } |
| ) |
| } |
| return await getSessionSlashCommands(sessionId) |
| } |
|
|
| if (subResource === 'inspection') { |
| if (req.method !== 'GET') { |
| return Response.json( |
| { error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` }, |
| { status: 405 } |
| ) |
| } |
| return await getSessionInspection(sessionId, url) |
| } |
|
|
| if (subResource === 'workspace') { |
| if (req.method !== 'GET') { |
| return Response.json( |
| { error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` }, |
| { status: 405 } |
| ) |
| } |
| return await handleSessionWorkspaceRoute(sessionId, url, segments[4]) |
| } |
|
|
| |
| if (subResource === 'chat') { |
| |
| |
| |
| return Response.json( |
| { error: 'NOT_FOUND', message: 'Use /api/sessions/:id/chat via conversations API' }, |
| { status: 404 } |
| ) |
| } |
|
|
| |
| |
| |
| 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) |
| } |
| } |
|
|
| |
| |
| |
|
|
| 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') |
| } |
|
|
| const context = await getRepositoryContext(workDir) |
| registerFilesystemAccessRoot(workDir) |
| registerFilesystemAccessRoot(context.workDir) |
| registerFilesystemAccessRoot(context.repoRoot) |
| return Response.json(context) |
| } |
|
|
| async function requireSessionWorkspace(sessionId: string): Promise<string> { |
| const workDir = |
| conversationService.getSessionWorkDir(sessionId) || |
| await sessionService.getSessionWorkDir(sessionId) |
|
|
| if (!workDir) { |
| throw ApiError.notFound(`Session not found: ${sessionId}`) |
| } |
|
|
| return workDir |
| } |
|
|
| function requireWorkspacePath(url: URL, route: 'file' | 'diff'): string { |
| const filePath = url.searchParams.get('path') |
| if (!filePath) { |
| throw ApiError.badRequest(`path query parameter is required for workspace ${route}`) |
| } |
| return filePath |
| } |
|
|
| async function runWorkspaceRequest<T>(operation: () => Promise<T>): Promise<Response> { |
| try { |
| return Response.json(await operation()) |
| } catch (error) { |
| if (isOutsideWorkspaceError(error)) { |
| throw new ApiError(403, error.message, 'FORBIDDEN') |
| } |
| if (isSessionNotFoundError(error)) { |
| throw ApiError.notFound(error.message) |
| } |
| throw error |
| } |
| } |
|
|
| async function runWorkspaceDiffRequest<T extends { state?: string; error?: string }>( |
| operation: () => Promise<T>, |
| ): Promise<Response> { |
| const result = await runWorkspaceRequest(operation) |
| const body = await result.clone().json() as T |
|
|
| if (body.state === 'error' && typeof body.error === 'string' && body.error.includes('outside workspace')) { |
| throw new ApiError(403, body.error, 'FORBIDDEN') |
| } |
|
|
| return result |
| } |
|
|
| function isOutsideWorkspaceError(error: unknown): error is Error { |
| return error instanceof Error && error.message.includes('outside workspace') |
| } |
|
|
| function isSessionNotFoundError(error: unknown): error is Error { |
| return error instanceof Error && error.message.startsWith('Session not found:') |
| } |
|
|
| async function deleteSession(sessionId: string): Promise<Response> { |
| conversationService.markSessionDeleted(sessionId) |
| try { |
| await sessionService.deleteSession(sessionId) |
| } catch (error) { |
| conversationService.unmarkSessionDeleted(sessionId) |
| throw error |
| } |
| closeSessionConnection(sessionId, 'session deleted') |
| cleanupAdapterSessionMappings(sessionId) |
| 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 || '', |
| ...(command.argumentHint ? { argumentHint: command.argumentHint } : {}), |
| }) |
| } |
|
|
| return [...merged.values()] |
| } |
|
|
| async function getSessionSlashCommands(sessionId: string): Promise<Response> { |
| const cachedCommands = getSlashCommands(sessionId) |
| const workDir = await sessionService.getSessionWorkDir(sessionId) |
| if (!workDir) { |
| throw ApiError.notFound(`Session not found: ${sessionId}`) |
| } |
|
|
| const skillCommands = await listSkillSlashCommands(workDir) |
| const slashCommands = cachedCommands.length > 0 |
| ? mergeSessionSlashCommands(cachedCommands, skillCommands) |
| : skillCommands |
|
|
| return Response.json({ commands: slashCommands }) |
| } |
|
|
| async function getSessionInspection(sessionId: string, url: URL): Promise<Response> { |
| const includeContext = url.searchParams.get('includeContext') !== '0' |
| const contextOnly = includeContext && url.searchParams.get('contextOnly') === '1' |
| 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) { |
| |
| |
| } 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 |
| } |
|
|
| function sameResolvedPath(left: string | null | undefined, right: string | null | undefined): boolean { |
| if (!left || !right) return false |
| return path.resolve(left) === path.resolve(right) |
| } |
|
|
| 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 |
| |
| |
| |
| 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 { |
| |
| 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 gitBranch = branchText.trim() || null |
| const materializedWorktree = !!worktree && ( |
| sameResolvedPath(workDir, worktree.path) || |
| sameResolvedPath(workDir, worktree.plannedPath) |
| ) |
| const branch = materializedWorktree |
| ? (gitBranch || worktree.branch || sessionBranch) |
| : (sessionBranch || gitBranch) |
|
|
| |
| 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() |
| |
| const match = remote.match(/\/([^/]+?)(?:\.git)?$/) || remote.match(/:([^/]+\/[^/]+?)(?:\.git)?$/) |
| repoName = match ? match[1]! : '' |
| } catch { |
| |
| const parts = workDir.split('/') |
| repoName = parts[parts.length - 1] || '' |
| } |
|
|
| |
| 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 { |
| |
| return Response.json({ |
| branch: sessionBranch, |
| repoName: null, |
| workDir, |
| changedFiles: 0, |
| worktree, |
| }) |
| } |
| } |
|
|
| async function rewindSession(req: Request, sessionId: string): Promise<Response> { |
| let body: RewindTargetSelector & { dryRun?: boolean } |
| try { |
| body = (await req.json()) as RewindTargetSelector & { dryRun?: boolean } |
| } catch { |
| throw ApiError.badRequest('Invalid JSON body') |
| } |
|
|
| if ( |
| (typeof body.targetUserMessageId !== 'string' || body.targetUserMessageId.length === 0) && |
| !Number.isInteger(body.userMessageIndex) |
| ) { |
| throw ApiError.badRequest('targetUserMessageId (string) or userMessageIndex (integer) is required') |
| } |
|
|
| const result = body.dryRun |
| ? await previewSessionRewind(sessionId, body) |
| : await executeSessionRewind(sessionId, body) |
|
|
| return Response.json(result) |
| } |
|
|
| async function branchSession(req: Request, sessionId: string): Promise<Response> { |
| let body: { targetMessageId?: unknown; title?: unknown } |
| try { |
| body = (await req.json()) as { targetMessageId?: unknown; title?: unknown } |
| } catch { |
| throw ApiError.badRequest('Invalid JSON body') |
| } |
|
|
| if (typeof body.targetMessageId !== 'string' || body.targetMessageId.trim().length === 0) { |
| throw ApiError.badRequest('targetMessageId (string) is required in request body') |
| } |
|
|
| if (body.title !== undefined && typeof body.title !== 'string') { |
| throw ApiError.badRequest('title must be a string') |
| } |
|
|
| const launchInfo = await sessionService.getSessionLaunchInfo(sessionId) |
| if (!launchInfo) { |
| throw ApiError.notFound(`Session not found: ${sessionId}`) |
| } |
|
|
| try { |
| const result = await createSessionBranch({ |
| sourceSessionId: sessionId, |
| sourceTranscriptPath: launchInfo.filePath, |
| targetMessageId: body.targetMessageId.trim(), |
| title: body.title?.trim() || undefined, |
| sourceWorkDir: launchInfo.workDir, |
| sourceRepository: launchInfo.repository, |
| sourceWorktreeSession: launchInfo.worktreeSession, |
| }) |
|
|
| return Response.json({ |
| sessionId: result.sessionId, |
| title: result.title, |
| workDir: result.workDir ?? launchInfo.workDir, |
| sourceSessionId: sessionId, |
| targetMessageId: body.targetMessageId.trim(), |
| }, { status: 201 }) |
| } catch (error) { |
| if (error instanceof SessionBranchingError) { |
| if (error.code === 'SOURCE_NOT_FOUND') { |
| throw ApiError.notFound(error.message) |
| } |
| throw ApiError.badRequest(error.message) |
| } |
| throw error |
| } |
| } |
|
|
| async function getTurnCheckpoints(sessionId: string): Promise<Response> { |
| const checkpoints = await listSessionTurnCheckpoints(sessionId) |
| 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 |
| } |
|
|
| |
| let recentProjectsCache: { projects: RecentProjectEntry[]; timestamp: number } | null = null |
| const RECENT_PROJECTS_CACHE_TTL = 30_000 |
| const DESKTOP_WORKTREE_MARKER = '/.claude/worktrees/' |
|
|
| function projectNameForRecentPath(realPath: string, fallback: string): string { |
| const normalizedRealPath = realPath.replace(/\\/g, '/') |
| const displayRoot = normalizedRealPath.includes(DESKTOP_WORKTREE_MARKER) |
| ? normalizedRealPath.slice(0, normalizedRealPath.indexOf(DESKTOP_WORKTREE_MARKER)) |
| : normalizedRealPath |
| return displayRoot.split('/').filter(Boolean).pop() || fallback |
| } |
|
|
| function isDesktopWorktreeBranchName(branch: string | null): boolean { |
| return !!branch && branch.startsWith('worktree-desktop-') |
| } |
|
|
| async function getRecentProjects(url: URL): Promise<Response> { |
| const limit = Math.min(Math.max(parseInt(url.searchParams.get('limit') || '10', 10) || 10, 1), 500) |
|
|
| |
| 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) |
|
|
| |
| 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++ |
| } |
| } |
|
|
| |
| 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) { |
| |
| 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 { } |
|
|
| return { |
| projectPath: info.projectPath, realPath, projectName, isGit, repoName, branch, |
| modifiedAt: info.modifiedAt, sessionCount: info.sessionCount, |
| } |
| }) |
| ) |
|
|
| |
| projects.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt)) |
|
|
| recentProjectsCache = { projects, timestamp: Date.now() } |
| return Response.json({ projects: projects.slice(0, limit) }) |
| } |
|
|