Files
claude-code-haha/src/server/api/sessions.ts
T
程序员阿江-Relakkes fd42b0ca05 fix(agent-teams): keep long-running teams recoverable (#1426)
Long Agent Teams runs lost members for good: a truncated provider stream
ended a member's turn with nobody to wake it, the desktop Stop button and
every lead restart killed all members and marked the plan interrupted,
mail sent to a stopped member landed in an inbox nothing read, and a lead
kept inside one long turn never saw member reports. Aligned with the
official CLI 2.1.284 and verified with DeepSeek Flash through a
fault-injecting proxy.

Stream recovery
- Re-send a stream that breaks before any tool ran (proxy truncation,
  transport errors), with the existing retry budget and backoff; the
  desktop drops the discarded attempt's tool cards and todo update.

Desktop team runtime (teamPlanRuntime)
- The server supervises members: a stopped member restarts from its own
  transcript when messaged; transient failures continue automatically
  (15s/45s/2m/5m/10m) and only exhausted retries reach the lead; ready
  dependent tasks wake their owner; a crash-loop guard ignores user stops.
- Stop pauses the team instead of ending it; the lead's next user message
  is followed by a notice listing the stopped members and their open
  tasks. Lead restarts (model/permission switch, crash) keep members;
  server restarts re-own the team. Teams end on /clear or session delete.
- Approving a plan no longer races a concurrent plan read into
  "Launch ownership was lost".

Mailbox and messaging
- Atomic inbox writes, identity-based read marking, read history files,
  idle notifications with result/failureReason, and write failures
  reported instead of "Message sent". External builds keep the official
  between-turn delivery to the lead.
- SendMessage resumes non-running in-process teammates from their
  transcript, notes restarting desktop members, queues mail for members
  of a plan awaiting approval, and rejects unknown names.

CLI in-process teammates
- Compaction uses the teammate's own controller and real history and no
  longer kills it on error; failed turns are classified and continued;
  the turn-end mailbox drains as one batch; one durable transcript per
  teammate.

Lead behaviour
- An unmet /goal ends the lead turn while members work, so member reports
  arrive; WaitSessions on own team members returns immediately.

Desktop UI
- Member states for stopped, auto-retrying and failed, with reason,
  countdown and recovery hint in all five locales.

Tests and tooling
- Regression tests for every behaviour above; module mocks in four test
  files are restored after use so the single-process coverage run is not
  polluted; the desktop smoke asserts the new Stop semantics.
2026-10-03 17:07:30 +08:00

1775 lines
65 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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 === '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<Response> {
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<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> {
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<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 '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<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' | '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<T>(operation: () => Promise<T>): Promise<Response> {
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 `<img>`/`<iframe>` subresource cannot carry the header, and remote
* access only exposes `/api/sessions`). Everything else about it — the workspace
* boundary, the extension allowlist, the size cap — lives in
* `WorkspaceService.resolveRawFile`.
*/
async function serveWorkspaceRaw(sessionId: string, requestedPath: string): Promise<Response> {
let file: WorkspaceRawFile
try {
file = await workspaceService.resolveRawFile(sessionId, requestedPath)
} catch (error) {
throw mapWorkspaceError(error)
}
return new Response(Bun.file(file.canonicalPath), {
status: 200,
headers: {
'Content-Type': file.format.mimeType,
// No hand-written Content-Length: the size validated above was read a
// moment ago, and this route's usual caller fetches a file an agent may be
// rewriting. Bun derives the length from the file it actually sends, so a
// figure copied from the earlier stat could only be redundant or wrong.
// A viewer refetches on a new `version`; a stale copy after an agent
// rewrites the file is worse than one revalidation.
'Cache-Control': 'private, no-cache',
'X-Content-Type-Options': 'nosniff',
},
})
}
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
// A deleted lead ends its reviewed team for good; Stop and lead restarts only pause it.
void endTeamsForParent(sessionId).catch(error => console.error(`[Sessions] Failed to end the deleted session's team: ${error}`))
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)
void endTeamsForParent(sessionId).catch(error => console.error(`[Sessions] Failed to end the deleted session's team: ${error}`))
}
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) })
}