import { handleSideChatsRoute } from './sideChats.js' /** * 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 { handleSideQuestionRoute } from './sideQuestions.js' import { sessionService } from '../services/sessionService.js' import { conversationService } from '../services/conversationService.js' import { endTeamsForParent } from '../services/teamPlanRuntime.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, type WorkspaceRawFile } 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 { 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 === 'side-chats') { if (segments.length > 5) throw ApiError.notFound('Side chat route not found') return await handleSideChatsRoute(req, sessionId, segments[4]) } if (subResource === 'side-question') { if (segments.length > 5) throw ApiError.notFound('Side question route not found') return await handleSideQuestionRoute(req, url, sessionId, segments[4]) } 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 { const project = url.searchParams.get('project') || undefined const view = url.searchParams.get('view') 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) if (!petAccess && view !== null) { if (view !== 'sidebar') throw ApiError.badRequest('Invalid session list view') const rawPerProjectLimit = url.searchParams.get('perProjectLimit') ?? '6' if (!/^\d+$/.test(rawPerProjectLimit)) throw ApiError.badRequest('Invalid perProjectLimit parameter') const perProjectLimit = Number(rawPerProjectLimit) if (!Number.isSafeInteger(perProjectLimit) || perProjectLimit <= 0) { throw ApiError.badRequest('Invalid perProjectLimit parameter') } return Response.json({ ...await sessionService.listProjectPreviews(perProjectLimit), index: localIndexCoordinator.getPublicStatus(), }) } 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 { 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 { const mode = url.searchParams.get('mode') if (mode !== null && mode !== 'full' && mode !== 'page') throw ApiError.badRequest('Invalid history mode') const cursor = url.searchParams.get('cursor') ?? undefined // A full read always starts from the tail; mixing it with a cursor would // silently turn it back into a paged read with a larger budget. if (mode === 'full' && cursor) throw ApiError.badRequest('mode=full does not take a cursor') return Response.json(await sessionService.getSessionHistoryPage(sessionId, { cursor, // `mode=full` returns the whole transcript up to the reader's byte budget in // one response so the desktop timeline never stitches pages together. full: mode === 'full', signal: req.signal, })) } async function getSessionTrace(req: Request, sessionId: string, url: URL): Promise { 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 { 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 { 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 'raw': return await serveWorkspaceRaw(sessionId, requireWorkspacePath(url, 'raw')) 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 { 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 try { body = (await req.json()) as Record } 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 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(operation: () => Promise): Promise { 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 { let body: { workDir?: string; repository?: CreateSessionRepositoryOptions; permissionMode?: string } try { body = (await req.json()) as { workDir?: string; repository?: CreateSessionRepositoryOptions; permissionMode?: string } } catch { throw ApiError.badRequest('Invalid JSON body') } if (body.workDir && typeof body.workDir !== 'string') { throw ApiError.badRequest('workDir must be a string') } if (body.permissionMode !== undefined && typeof body.permissionMode !== 'string') { throw ApiError.badRequest('permissionMode must be a string') } if (body.permissionMode !== undefined && !isValidPermissionMode(body.permissionMode)) { throw ApiError.badRequest(`Invalid permission mode: "${body.permissionMode}"`) } if (body.repository !== undefined) { if (!body.repository || typeof body.repository !== 'object' || Array.isArray(body.repository)) { throw ApiError.badRequest('repository must be an object') } if (body.repository.branch !== undefined && body.repository.branch !== null && typeof body.repository.branch !== 'string') { throw ApiError.badRequest('repository.branch must be a string') } if (body.repository.worktree !== undefined && typeof body.repository.worktree !== 'boolean') { throw ApiError.badRequest('repository.worktree must be a boolean') } } const result = await sessionService.createSession(body.workDir, body.repository, body.permissionMode) recentProjectsCache = null return Response.json(result, { status: 201 }) } async function getSessionRepositoryContext(url: URL): Promise { const workDir = url.searchParams.get('workDir') if (!workDir) { throw ApiError.badRequest('workDir query parameter is required') } const context = await getRepositoryContext(workDir) registerFilesystemAccessRoot(workDir) registerFilesystemAccessRoot(context.workDir) registerFilesystemAccessRoot(context.repoRoot) return Response.json(context) } async function createSessionRepositoryBranch(req: Request): Promise { 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 { 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' | 'raw'): string { const filePath = url.searchParams.get('path') if (!filePath) { throw ApiError.badRequest(`path query parameter is required for workspace ${route}`) } return filePath } /** * Turns a workspace service failure into the API error a client can act on. * Shared by the JSON routes and the byte-streaming `raw` route so a path outside * the workspace is a 403 and an unknown session a 404 on both. */ function mapWorkspaceError(error: unknown): unknown { if (isOutsideWorkspaceError(error)) { return new ApiError(403, error.message, 'FORBIDDEN') } if (isSessionNotFoundError(error)) { return ApiError.notFound(error.message) } return error } async function runWorkspaceRequest(operation: () => Promise): Promise { try { return Response.json(await operation()) } catch (error) { throw mapWorkspaceError(error) } } /** * Stream a workspace document's bytes to an in-app viewer. * * This is deliberately a `/api/sessions/:id/workspace/*` route rather than a * static file route: the renderer fetches it with the bearer credential, the one * form that works in the desktop shell, in a LAN browser and over remote access * alike (an ``/`