mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
Remote main carries portable-mode, legacy Windows workdir recovery, and Feishu path-safety fixes while local main carries terminal shell, update proxy, slash-command, prompt-draft, AskUserQuestion, background-work, and shell-env changes. This merge keeps both lines by layering portable Bash-path defaults underneath the desktop terminal shell preference and preserving both update-proxy and app-mode settings state. Constraint: Local main and origin/main diverged after v0.2.7 and both lines contain release-relevant desktop/runtime fixes Rejected: Prefer either side's terminal settings wholesale | would drop either Windows portable Bash support or explicit desktop startup-shell support Confidence: medium Scope-risk: moderate Directive: Keep portable Bash path as the system-default terminal fallback; explicit desktop startup-shell settings should continue to override it Tested: cd desktop && bun run test -- --run src/pages/TerminalSettings.test.tsx src/stores/settingsStore.test.ts Tested: cd desktop/src-tauri && cargo test terminal -- --nocapture Tested: bun test src/server/__tests__/sessions.test.ts -t stale worktree Tested: bun run check:desktop Tested: bun run check:server Tested: bun run check:native Not-tested: Manual Windows packaged-app terminal/portable smoke
881 lines
31 KiB
TypeScript
881 lines
31 KiB
TypeScript
/**
|
|
* Session REST API Routes
|
|
*
|
|
* 提供会话的 CRUD 操作接口,数据来自 CLI 共享的 JSONL 文件。
|
|
*
|
|
* Routes:
|
|
* GET /api/sessions — 列出会话
|
|
* GET /api/sessions/:id — 获取会话详情
|
|
* GET /api/sessions/:id/messages — 获取会话消息
|
|
* GET /api/sessions/:id/turn-checkpoints — 获取按轮次保留的 checkpoint 预览
|
|
* GET /api/sessions/:id/turn-checkpoints/diff — 获取绑定到指定 checkpoint 的 diff
|
|
* POST /api/sessions — 创建新会话
|
|
* POST /api/sessions/batch-delete — 批量删除会话
|
|
* DELETE /api/sessions/:id — 删除会话
|
|
* PATCH /api/sessions/:id — 重命名会话
|
|
*/
|
|
|
|
import { sessionService } from '../services/sessionService.js'
|
|
import { conversationService } from '../services/conversationService.js'
|
|
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
|
import { closeSessionConnection, getSlashCommands } from '../ws/handler.js'
|
|
import { listSkillSlashCommands, type SkillSlashCommand } from './skills.js'
|
|
import { WorkspaceService } from '../services/workspaceService.js'
|
|
import {
|
|
getRepositoryContext,
|
|
type CreateSessionRepositoryOptions,
|
|
} from '../services/repositoryLaunchService.js'
|
|
import {
|
|
executeSessionRewind,
|
|
getSessionTurnCheckpointDiff,
|
|
listSessionTurnCheckpoints,
|
|
previewSessionRewind,
|
|
type RewindTargetSelector,
|
|
} from '../services/sessionRewindService.js'
|
|
import { SessionStore } from '../../../adapters/common/session-store.js'
|
|
|
|
const workspaceService = new WorkspaceService(
|
|
async (sessionId) => (
|
|
conversationService.getSessionWorkDir(sessionId) ||
|
|
await sessionService.getSessionWorkDir(sessionId)
|
|
),
|
|
async (sessionId) => sessionService.getSessionMessages(sessionId),
|
|
async (sessionId) => sessionService.getSessionFileHistorySnapshots(sessionId),
|
|
)
|
|
|
|
export async function handleSessionsApi(
|
|
req: Request,
|
|
url: URL,
|
|
segments: string[]
|
|
): Promise<Response> {
|
|
try {
|
|
// segments: ['api', 'sessions', ...rest]
|
|
const sessionId = segments[2] // may be undefined
|
|
const subResource = segments[3] // e.g. 'messages'
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Collection routes: /api/sessions
|
|
// -----------------------------------------------------------------------
|
|
if (!sessionId) {
|
|
switch (req.method) {
|
|
case 'GET':
|
|
return await listSessions(url)
|
|
case 'POST':
|
|
return await createSession(req)
|
|
default:
|
|
return Response.json(
|
|
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
|
|
{ status: 405 }
|
|
)
|
|
}
|
|
}
|
|
|
|
// Special collection route: /api/sessions/batch-delete
|
|
if (sessionId === 'batch-delete') {
|
|
if (req.method !== 'POST') {
|
|
return Response.json(
|
|
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
|
|
{ status: 405 }
|
|
)
|
|
}
|
|
return await batchDeleteSessions(req)
|
|
}
|
|
|
|
// Special collection route: /api/sessions/recent-projects
|
|
if (sessionId === 'recent-projects' && req.method === 'GET') {
|
|
return await getRecentProjects(url)
|
|
}
|
|
|
|
// Special collection route: /api/sessions/repository-context
|
|
if (sessionId === 'repository-context' && req.method === 'GET') {
|
|
return await getSessionRepositoryContext(url)
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Sub-resource routes: /api/sessions/:id/messages
|
|
// -----------------------------------------------------------------------
|
|
if (subResource === 'messages') {
|
|
if (req.method !== 'GET') {
|
|
return Response.json(
|
|
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
|
|
{ status: 405 }
|
|
)
|
|
}
|
|
return await getSessionMessages(sessionId)
|
|
}
|
|
|
|
if (subResource === 'git-info') {
|
|
if (req.method !== 'GET') {
|
|
return Response.json(
|
|
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
|
|
{ status: 405 }
|
|
)
|
|
}
|
|
return await getGitInfo(sessionId)
|
|
}
|
|
|
|
if (subResource === 'rewind') {
|
|
if (req.method !== 'POST') {
|
|
return Response.json(
|
|
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
|
|
{ status: 405 }
|
|
)
|
|
}
|
|
return await rewindSession(req, sessionId)
|
|
}
|
|
|
|
if (subResource === 'turn-checkpoints') {
|
|
if (req.method !== 'GET') {
|
|
return Response.json(
|
|
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
|
|
{ status: 405 }
|
|
)
|
|
}
|
|
return segments[4] === 'diff'
|
|
? await getTurnCheckpointDiff(sessionId, url)
|
|
: await getTurnCheckpoints(sessionId)
|
|
}
|
|
|
|
if (subResource === 'slash-commands') {
|
|
if (req.method !== 'GET') {
|
|
return Response.json(
|
|
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
|
|
{ status: 405 }
|
|
)
|
|
}
|
|
return await getSessionSlashCommands(sessionId)
|
|
}
|
|
|
|
if (subResource === 'inspection') {
|
|
if (req.method !== 'GET') {
|
|
return Response.json(
|
|
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
|
|
{ status: 405 }
|
|
)
|
|
}
|
|
return await getSessionInspection(sessionId, url)
|
|
}
|
|
|
|
if (subResource === 'workspace') {
|
|
if (req.method !== 'GET') {
|
|
return Response.json(
|
|
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
|
|
{ status: 405 }
|
|
)
|
|
}
|
|
return await handleSessionWorkspaceRoute(sessionId, url, segments[4])
|
|
}
|
|
|
|
// Route to conversations handler if sub-resource is 'chat'
|
|
if (subResource === 'chat') {
|
|
// This is handled by the conversations API, but in case the router
|
|
// forwards it here, we delegate to the conversations module.
|
|
// Normally the router should route /api/sessions/:id/chat/* to conversations.
|
|
return Response.json(
|
|
{ error: 'NOT_FOUND', message: 'Use /api/sessions/:id/chat via conversations API' },
|
|
{ status: 404 }
|
|
)
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Item routes: /api/sessions/:id
|
|
// -----------------------------------------------------------------------
|
|
switch (req.method) {
|
|
case 'GET':
|
|
return await getSession(sessionId)
|
|
case 'DELETE':
|
|
return await deleteSession(sessionId)
|
|
case 'PATCH':
|
|
return await patchSession(req, sessionId)
|
|
default:
|
|
return Response.json(
|
|
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
|
|
{ status: 405 }
|
|
)
|
|
}
|
|
} catch (error) {
|
|
return errorResponse(error)
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Handler implementations
|
|
// ============================================================================
|
|
|
|
async function listSessions(url: URL): Promise<Response> {
|
|
const project = url.searchParams.get('project') || undefined
|
|
const limit = parseInt(url.searchParams.get('limit') || '20', 10)
|
|
const offset = parseInt(url.searchParams.get('offset') || '0', 10)
|
|
|
|
if (isNaN(limit) || limit < 0) {
|
|
throw ApiError.badRequest('Invalid limit parameter')
|
|
}
|
|
if (isNaN(offset) || offset < 0) {
|
|
throw ApiError.badRequest('Invalid offset parameter')
|
|
}
|
|
|
|
const result = await sessionService.listSessions({ project, limit, offset })
|
|
return Response.json(result)
|
|
}
|
|
|
|
async function getSession(sessionId: string): Promise<Response> {
|
|
const detail = await sessionService.getSession(sessionId)
|
|
if (!detail) {
|
|
throw ApiError.notFound(`Session not found: ${sessionId}`)
|
|
}
|
|
return Response.json(detail)
|
|
}
|
|
|
|
async function getSessionMessages(sessionId: string): Promise<Response> {
|
|
const [messages, taskNotifications] = await Promise.all([
|
|
sessionService.getSessionMessages(sessionId),
|
|
sessionService.getSessionTaskNotifications(sessionId),
|
|
])
|
|
return Response.json({ messages, taskNotifications })
|
|
}
|
|
|
|
async function handleSessionWorkspaceRoute(
|
|
sessionId: string,
|
|
url: URL,
|
|
workspaceResource?: string,
|
|
): Promise<Response> {
|
|
await requireSessionWorkspace(sessionId)
|
|
|
|
switch (workspaceResource) {
|
|
case 'status':
|
|
return Response.json(await workspaceService.getStatus(sessionId))
|
|
case 'tree':
|
|
return await runWorkspaceRequest(() => workspaceService.readTree(
|
|
sessionId,
|
|
url.searchParams.get('path') || '',
|
|
))
|
|
case 'file':
|
|
return await runWorkspaceRequest(() => workspaceService.readFile(
|
|
sessionId,
|
|
requireWorkspacePath(url, 'file'),
|
|
))
|
|
case 'diff':
|
|
return await runWorkspaceDiffRequest(() => workspaceService.getDiff(
|
|
sessionId,
|
|
requireWorkspacePath(url, 'diff'),
|
|
))
|
|
default:
|
|
throw ApiError.notFound(`Unknown workspace resource: ${workspaceResource || 'workspace'}`)
|
|
}
|
|
}
|
|
|
|
async function createSession(req: Request): Promise<Response> {
|
|
let body: { workDir?: string; repository?: CreateSessionRepositoryOptions }
|
|
try {
|
|
body = (await req.json()) as { workDir?: string; repository?: CreateSessionRepositoryOptions }
|
|
} catch {
|
|
throw ApiError.badRequest('Invalid JSON body')
|
|
}
|
|
|
|
if (body.workDir && typeof body.workDir !== 'string') {
|
|
throw ApiError.badRequest('workDir must be a string')
|
|
}
|
|
|
|
if (body.repository !== undefined) {
|
|
if (!body.repository || typeof body.repository !== 'object' || Array.isArray(body.repository)) {
|
|
throw ApiError.badRequest('repository must be an object')
|
|
}
|
|
if (body.repository.branch !== undefined && body.repository.branch !== null && typeof body.repository.branch !== 'string') {
|
|
throw ApiError.badRequest('repository.branch must be a string')
|
|
}
|
|
if (body.repository.worktree !== undefined && typeof body.repository.worktree !== 'boolean') {
|
|
throw ApiError.badRequest('repository.worktree must be a boolean')
|
|
}
|
|
}
|
|
|
|
const result = await sessionService.createSession(body.workDir, body.repository)
|
|
recentProjectsCache = null
|
|
return Response.json(result, { status: 201 })
|
|
}
|
|
|
|
async function getSessionRepositoryContext(url: URL): Promise<Response> {
|
|
const workDir = url.searchParams.get('workDir')
|
|
if (!workDir) {
|
|
throw ApiError.badRequest('workDir query parameter is required')
|
|
}
|
|
|
|
return Response.json(await getRepositoryContext(workDir))
|
|
}
|
|
|
|
async function requireSessionWorkspace(sessionId: string): Promise<string> {
|
|
const workDir =
|
|
conversationService.getSessionWorkDir(sessionId) ||
|
|
await sessionService.getSessionWorkDir(sessionId)
|
|
|
|
if (!workDir) {
|
|
throw ApiError.notFound(`Session not found: ${sessionId}`)
|
|
}
|
|
|
|
return workDir
|
|
}
|
|
|
|
function requireWorkspacePath(url: URL, route: 'file' | 'diff'): string {
|
|
const filePath = url.searchParams.get('path')
|
|
if (!filePath) {
|
|
throw ApiError.badRequest(`path query parameter is required for workspace ${route}`)
|
|
}
|
|
return filePath
|
|
}
|
|
|
|
async function runWorkspaceRequest<T>(operation: () => Promise<T>): Promise<Response> {
|
|
try {
|
|
return Response.json(await operation())
|
|
} catch (error) {
|
|
if (isOutsideWorkspaceError(error)) {
|
|
throw new ApiError(403, error.message, 'FORBIDDEN')
|
|
}
|
|
if (isSessionNotFoundError(error)) {
|
|
throw ApiError.notFound(error.message)
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async function runWorkspaceDiffRequest<T extends { state?: string; error?: string }>(
|
|
operation: () => Promise<T>,
|
|
): Promise<Response> {
|
|
const result = await runWorkspaceRequest(operation)
|
|
const body = await result.clone().json() as T
|
|
|
|
if (body.state === 'error' && typeof body.error === 'string' && body.error.includes('outside workspace')) {
|
|
throw new ApiError(403, body.error, 'FORBIDDEN')
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
function isOutsideWorkspaceError(error: unknown): error is Error {
|
|
return error instanceof Error && error.message.includes('outside workspace')
|
|
}
|
|
|
|
function isSessionNotFoundError(error: unknown): error is Error {
|
|
return error instanceof Error && error.message.startsWith('Session not found:')
|
|
}
|
|
|
|
async function deleteSession(sessionId: string): Promise<Response> {
|
|
conversationService.markSessionDeleted(sessionId)
|
|
try {
|
|
await sessionService.deleteSession(sessionId)
|
|
} catch (error) {
|
|
conversationService.unmarkSessionDeleted(sessionId)
|
|
throw error
|
|
}
|
|
closeSessionConnection(sessionId, 'session deleted')
|
|
cleanupAdapterSessionMappings(sessionId)
|
|
return Response.json({ ok: true })
|
|
}
|
|
|
|
async function batchDeleteSessions(req: Request): Promise<Response> {
|
|
let body: { sessionIds?: unknown }
|
|
try {
|
|
body = (await req.json()) as { sessionIds?: unknown }
|
|
} catch {
|
|
throw ApiError.badRequest('Invalid JSON body')
|
|
}
|
|
|
|
const sessionIds = normalizeSessionIds(body.sessionIds)
|
|
conversationService.markSessionsDeleted(sessionIds)
|
|
const result = await sessionService.deleteSessions(sessionIds)
|
|
|
|
if (result.failures.length > 0) {
|
|
conversationService.unmarkSessionsDeleted(result.failures.map((failure) => failure.sessionId))
|
|
}
|
|
|
|
for (const sessionId of result.successes) {
|
|
closeSessionConnection(sessionId, 'session deleted')
|
|
cleanupAdapterSessionMappings(sessionId)
|
|
}
|
|
|
|
return Response.json({
|
|
ok: result.failures.length === 0,
|
|
successes: result.successes,
|
|
failures: result.failures,
|
|
})
|
|
}
|
|
|
|
function normalizeSessionIds(value: unknown): string[] {
|
|
if (!Array.isArray(value)) {
|
|
throw ApiError.badRequest('sessionIds must be an array')
|
|
}
|
|
|
|
const sessionIds: string[] = []
|
|
for (const sessionId of value) {
|
|
if (typeof sessionId !== 'string' || sessionId.trim().length === 0) {
|
|
throw ApiError.badRequest('sessionIds must contain only non-empty strings')
|
|
}
|
|
sessionIds.push(sessionId.trim())
|
|
}
|
|
|
|
if (sessionIds.length === 0) {
|
|
throw ApiError.badRequest('sessionIds must include at least one session id')
|
|
}
|
|
|
|
return [...new Set(sessionIds)]
|
|
}
|
|
|
|
function cleanupAdapterSessionMappings(sessionId: string): void {
|
|
const removedChatIds = new SessionStore().deleteBySessionId(sessionId)
|
|
if (removedChatIds.length > 0) {
|
|
console.log(`[Sessions API] Removed ${removedChatIds.length} adapter session mapping(s) for ${sessionId}`)
|
|
}
|
|
}
|
|
|
|
function mergeSessionSlashCommands(
|
|
preferred: Array<{ name: string; description?: string; argumentHint?: string }>,
|
|
fallback: SkillSlashCommand[],
|
|
): Array<{ name: string; description: string; argumentHint?: string }> {
|
|
const merged = new Map<string, { name: string; description: string; argumentHint?: string }>()
|
|
|
|
for (const command of preferred) {
|
|
if (!command.name) continue
|
|
merged.set(command.name, {
|
|
name: command.name,
|
|
description: command.description || '',
|
|
...(command.argumentHint ? { argumentHint: command.argumentHint } : {}),
|
|
})
|
|
}
|
|
|
|
for (const command of fallback) {
|
|
if (!command.name || merged.has(command.name)) continue
|
|
merged.set(command.name, {
|
|
name: command.name,
|
|
description: command.description || '',
|
|
...(command.argumentHint ? { argumentHint: command.argumentHint } : {}),
|
|
})
|
|
}
|
|
|
|
return [...merged.values()]
|
|
}
|
|
|
|
async function getSessionSlashCommands(sessionId: string): Promise<Response> {
|
|
const cachedCommands = getSlashCommands(sessionId)
|
|
const workDir = await sessionService.getSessionWorkDir(sessionId)
|
|
if (!workDir) {
|
|
throw ApiError.notFound(`Session not found: ${sessionId}`)
|
|
}
|
|
|
|
const skillCommands = await listSkillSlashCommands(workDir)
|
|
const slashCommands = cachedCommands.length > 0
|
|
? mergeSessionSlashCommands(cachedCommands, skillCommands)
|
|
: skillCommands
|
|
|
|
return Response.json({ commands: slashCommands })
|
|
}
|
|
|
|
async function getSessionInspection(sessionId: string, url: URL): Promise<Response> {
|
|
const includeContext = url.searchParams.get('includeContext') !== '0'
|
|
const contextOnly = includeContext && url.searchParams.get('contextOnly') === '1'
|
|
const workDir =
|
|
conversationService.getSessionWorkDir(sessionId) ||
|
|
await sessionService.getSessionWorkDir(sessionId)
|
|
|
|
if (!workDir) {
|
|
throw ApiError.notFound(`Session not found: ${sessionId}`)
|
|
}
|
|
|
|
const active = conversationService.hasSession(sessionId)
|
|
const initMessage = conversationService.getSessionInitMessage(sessionId) ??
|
|
[...conversationService.getRecentSdkMessages(sessionId)]
|
|
.reverse()
|
|
.find((message) => message?.type === 'system' && message.subtype === 'init')
|
|
const transcriptMetadata = await sessionService.getTranscriptMetadata(sessionId)
|
|
const cachedSlashCommands = getSlashCommands(sessionId)
|
|
const skillSlashCommands = await listSkillSlashCommands(workDir)
|
|
const fallbackSlashCommands = cachedSlashCommands.length > 0
|
|
? mergeSessionSlashCommands(cachedSlashCommands, skillSlashCommands)
|
|
: skillSlashCommands
|
|
const slashCommandCount = Array.isArray(initMessage?.slash_commands)
|
|
? initMessage.slash_commands.length
|
|
: fallbackSlashCommands.length
|
|
|
|
const response: Record<string, unknown> = {
|
|
active,
|
|
status: {
|
|
sessionId,
|
|
workDir,
|
|
permissionMode: conversationService.getSessionPermissionMode(sessionId),
|
|
version: typeof initMessage?.claude_code_version === 'string' ? initMessage.claude_code_version : transcriptMetadata?.version,
|
|
cwd: typeof initMessage?.cwd === 'string' ? initMessage.cwd : transcriptMetadata?.cwd ?? workDir,
|
|
model: typeof initMessage?.model === 'string' ? initMessage.model : transcriptMetadata?.model,
|
|
apiKeySource: typeof initMessage?.apiKeySource === 'string' ? initMessage.apiKeySource : undefined,
|
|
outputStyle: typeof initMessage?.output_style === 'string' ? initMessage.output_style : undefined,
|
|
tools: Array.isArray(initMessage?.tools) ? initMessage.tools : [],
|
|
mcpServers: Array.isArray(initMessage?.mcp_servers) ? initMessage.mcp_servers : [],
|
|
slashCommandCount,
|
|
skillCount: Array.isArray(initMessage?.skills) ? initMessage.skills.length : 0,
|
|
},
|
|
errors: {},
|
|
}
|
|
const transcriptUsage = await sessionService.getTranscriptUsage(sessionId)
|
|
const transcriptContextEstimate = await sessionService.getTranscriptContextEstimate(sessionId)
|
|
if (transcriptContextEstimate) {
|
|
response.contextEstimate = transcriptContextEstimate
|
|
}
|
|
|
|
if (!active) {
|
|
if (transcriptUsage) {
|
|
response.usage = transcriptUsage
|
|
}
|
|
response.errors = {
|
|
...(transcriptUsage ? {} : { usage: 'CLI session is not running' }),
|
|
...(includeContext ? { context: 'CLI session is not running' } : {}),
|
|
}
|
|
return Response.json(response)
|
|
}
|
|
|
|
const errors: Record<string, string> = {}
|
|
if (contextOnly) {
|
|
try {
|
|
response.context = await conversationService.requestControl(
|
|
sessionId,
|
|
{ subtype: 'get_context_usage', estimateOnly: true },
|
|
20_000,
|
|
)
|
|
} catch (error) {
|
|
errors.context = error instanceof Error ? error.message : String(error)
|
|
}
|
|
} else {
|
|
const basicControlTimeoutMs = includeContext ? 10_000 : 4_000
|
|
const [usageResult, contextResult, mcpResult] = await Promise.allSettled([
|
|
conversationService.requestControl(sessionId, { subtype: 'get_session_usage' }, basicControlTimeoutMs),
|
|
includeContext
|
|
? conversationService.requestControl(
|
|
sessionId,
|
|
{ subtype: 'get_context_usage', estimateOnly: true },
|
|
20_000,
|
|
)
|
|
: Promise.resolve(null),
|
|
conversationService.requestControl(sessionId, { subtype: 'mcp_status' }, basicControlTimeoutMs),
|
|
])
|
|
|
|
if (usageResult.status === 'fulfilled') {
|
|
response.usage = chooseRicherUsage(
|
|
{ ...usageResult.value, source: 'current_process' },
|
|
transcriptUsage,
|
|
)
|
|
} else {
|
|
if (transcriptUsage) {
|
|
response.usage = transcriptUsage
|
|
} else {
|
|
errors.usage = usageResult.reason instanceof Error ? usageResult.reason.message : String(usageResult.reason)
|
|
}
|
|
}
|
|
|
|
if (!includeContext) {
|
|
// Context can be expensive on large live sessions. The desktop UI loads it
|
|
// separately when the context tab is actually selected.
|
|
} else if (contextResult.status === 'fulfilled' && contextResult.value) {
|
|
response.context = contextResult.value
|
|
} else {
|
|
errors.context = contextResult.reason instanceof Error ? contextResult.reason.message : String(contextResult.reason)
|
|
}
|
|
|
|
if (mcpResult.status === 'fulfilled' && response.status && typeof response.status === 'object') {
|
|
response.status = {
|
|
...response.status,
|
|
mcpServers: Array.isArray(mcpResult.value.mcpServers) ? mcpResult.value.mcpServers : (response.status as Record<string, unknown>).mcpServers,
|
|
}
|
|
}
|
|
}
|
|
|
|
response.errors = errors
|
|
return Response.json(response)
|
|
}
|
|
|
|
function usageTokenTotal(usage: unknown): number {
|
|
if (!usage || typeof usage !== 'object') return 0
|
|
const record = usage as Record<string, unknown>
|
|
return [
|
|
record.totalInputTokens,
|
|
record.totalOutputTokens,
|
|
record.totalCacheReadInputTokens,
|
|
record.totalCacheCreationInputTokens,
|
|
].reduce((sum, value) => sum + (typeof value === 'number' ? value : 0), 0)
|
|
}
|
|
|
|
function chooseRicherUsage(
|
|
currentUsage: Record<string, unknown>,
|
|
transcriptUsage: Record<string, unknown> | null,
|
|
): Record<string, unknown> {
|
|
if (!transcriptUsage) return currentUsage
|
|
return usageTokenTotal(transcriptUsage) > usageTokenTotal(currentUsage)
|
|
? transcriptUsage
|
|
: currentUsage
|
|
}
|
|
|
|
async function getGitInfo(sessionId: string): Promise<Response> {
|
|
const workDir = conversationService.getSessionWorkDir(sessionId) || await sessionService.getSessionWorkDir(sessionId)
|
|
if (!workDir) {
|
|
throw ApiError.notFound(`Session not found: ${sessionId}`)
|
|
}
|
|
const launchInfo = await sessionService.getSessionLaunchInfo(sessionId).catch(() => null)
|
|
const repository = launchInfo?.repository
|
|
const worktreeSession = launchInfo?.worktreeSession
|
|
// The visible business branch comes from Desktop's launch choice when present.
|
|
// CLI originalBranch is the source checkout before creating the worktree, which
|
|
// can differ from the selected base ref.
|
|
const sessionBranch = repository?.branch || worktreeSession?.originalBranch || null
|
|
const worktree = repository?.worktree || worktreeSession
|
|
? {
|
|
enabled: true,
|
|
path: worktreeSession?.worktreePath || workDir,
|
|
plannedPath: worktreeSession?.worktreePath || repository?.worktreePath || null,
|
|
sourceWorkDir: worktreeSession?.originalCwd || repository?.requestedWorkDir || repository?.repoRoot || null,
|
|
slug: worktreeSession?.worktreeName || repository?.worktreeSlug || null,
|
|
branch: worktreeSession?.worktreeBranch || repository?.worktreeBranch || null,
|
|
}
|
|
: null
|
|
|
|
try {
|
|
// Get branch name
|
|
const branchProc = Bun.spawn(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
cwd: workDir,
|
|
stdout: 'pipe',
|
|
stderr: 'pipe',
|
|
})
|
|
const branchText = await new Response(branchProc.stdout).text()
|
|
const branch = sessionBranch || branchText.trim()
|
|
|
|
// Get repo name from remote or directory
|
|
let repoName = ''
|
|
try {
|
|
const remoteProc = Bun.spawn(['git', 'remote', 'get-url', 'origin'], {
|
|
cwd: workDir,
|
|
stdout: 'pipe',
|
|
stderr: 'pipe',
|
|
})
|
|
const remoteText = await new Response(remoteProc.stdout).text()
|
|
const remote = remoteText.trim()
|
|
// Extract repo name from URL: git@github.com:user/repo.git or https://...repo.git
|
|
const match = remote.match(/\/([^/]+?)(?:\.git)?$/) || remote.match(/:([^/]+\/[^/]+?)(?:\.git)?$/)
|
|
repoName = match ? match[1]! : ''
|
|
} catch {
|
|
// No remote, use directory name
|
|
const parts = workDir.split('/')
|
|
repoName = parts[parts.length - 1] || ''
|
|
}
|
|
|
|
// Get short status
|
|
const statusProc = Bun.spawn(['git', 'status', '--porcelain'], {
|
|
cwd: workDir,
|
|
stdout: 'pipe',
|
|
stderr: 'pipe',
|
|
})
|
|
const statusText = await new Response(statusProc.stdout).text()
|
|
const changedFiles = statusText.trim().split('\n').filter(Boolean).length
|
|
|
|
return Response.json({
|
|
branch,
|
|
repoName,
|
|
workDir,
|
|
changedFiles,
|
|
worktree,
|
|
})
|
|
} catch {
|
|
// Not a git repo or git not available
|
|
return Response.json({
|
|
branch: sessionBranch,
|
|
repoName: null,
|
|
workDir,
|
|
changedFiles: 0,
|
|
worktree,
|
|
})
|
|
}
|
|
}
|
|
|
|
async function rewindSession(req: Request, sessionId: string): Promise<Response> {
|
|
let body: RewindTargetSelector & { dryRun?: boolean }
|
|
try {
|
|
body = (await req.json()) as RewindTargetSelector & { dryRun?: boolean }
|
|
} catch {
|
|
throw ApiError.badRequest('Invalid JSON body')
|
|
}
|
|
|
|
if (
|
|
(typeof body.targetUserMessageId !== 'string' || body.targetUserMessageId.length === 0) &&
|
|
!Number.isInteger(body.userMessageIndex)
|
|
) {
|
|
throw ApiError.badRequest('targetUserMessageId (string) or userMessageIndex (integer) is required')
|
|
}
|
|
|
|
const result = body.dryRun
|
|
? await previewSessionRewind(sessionId, body)
|
|
: await executeSessionRewind(sessionId, body)
|
|
|
|
return Response.json(result)
|
|
}
|
|
|
|
async function getTurnCheckpoints(sessionId: string): Promise<Response> {
|
|
const checkpoints = await listSessionTurnCheckpoints(sessionId)
|
|
return Response.json({ checkpoints })
|
|
}
|
|
|
|
async function getTurnCheckpointDiff(sessionId: string, url: URL): Promise<Response> {
|
|
const targetUserMessageId = url.searchParams.get('targetUserMessageId') || undefined
|
|
const userMessageIndexParam = url.searchParams.get('userMessageIndex')
|
|
const path = url.searchParams.get('path')
|
|
const userMessageIndex =
|
|
userMessageIndexParam === null ? undefined : Number.parseInt(userMessageIndexParam, 10)
|
|
|
|
if (
|
|
(typeof targetUserMessageId !== 'string' || targetUserMessageId.length === 0) &&
|
|
!Number.isInteger(userMessageIndex)
|
|
) {
|
|
throw ApiError.badRequest('targetUserMessageId (string) or userMessageIndex (integer) is required')
|
|
}
|
|
|
|
if (!path) {
|
|
throw ApiError.badRequest('path query parameter is required for turn checkpoint diff')
|
|
}
|
|
|
|
const result = await getSessionTurnCheckpointDiff(
|
|
sessionId,
|
|
{
|
|
targetUserMessageId,
|
|
userMessageIndex,
|
|
},
|
|
path,
|
|
)
|
|
|
|
return Response.json(result)
|
|
}
|
|
|
|
async function patchSession(req: Request, sessionId: string): Promise<Response> {
|
|
let body: { title?: string }
|
|
try {
|
|
body = (await req.json()) as { title?: string }
|
|
} catch {
|
|
throw ApiError.badRequest('Invalid JSON body')
|
|
}
|
|
|
|
if (!body.title || typeof body.title !== 'string') {
|
|
throw ApiError.badRequest('title (string) is required in request body')
|
|
}
|
|
|
|
await sessionService.renameSession(sessionId, body.title)
|
|
return Response.json({ ok: true })
|
|
}
|
|
|
|
type RecentProjectEntry = {
|
|
projectPath: string
|
|
realPath: string
|
|
projectName: string
|
|
isGit: boolean
|
|
repoName: string | null
|
|
branch: string | null
|
|
modifiedAt: string
|
|
sessionCount: number
|
|
}
|
|
|
|
// In-memory cache for recent projects (TTL: 30s)
|
|
let recentProjectsCache: { projects: RecentProjectEntry[]; timestamp: number } | null = null
|
|
const RECENT_PROJECTS_CACHE_TTL = 30_000
|
|
const DESKTOP_WORKTREE_MARKER = '/.claude/worktrees/'
|
|
|
|
function projectNameForRecentPath(realPath: string, fallback: string): string {
|
|
const 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)
|
|
|
|
// Return cached response if fresh
|
|
if (recentProjectsCache && Date.now() - recentProjectsCache.timestamp < RECENT_PROJECTS_CACHE_TTL) {
|
|
return Response.json({ projects: recentProjectsCache.projects.slice(0, limit) })
|
|
}
|
|
|
|
const { sessions } = await sessionService.listSessions({ limit: 200 })
|
|
const validSessions = sessions.filter((session) => session.workDirExists && session.workDir)
|
|
|
|
// First pass: group by logical project root so worktrees stay under the same project.
|
|
const realPathMap = new Map<string, { projectPath: string; modifiedAt: string; sessionCount: number; sessionId: string }>()
|
|
for (const s of validSessions) {
|
|
let realPath: string
|
|
try {
|
|
const workDir = await sessionService.getSessionWorkDir(s.id)
|
|
realPath = s.projectRoot || workDir || sessionService.desanitizePath(s.projectPath)
|
|
} catch {
|
|
realPath = s.projectRoot || sessionService.desanitizePath(s.projectPath)
|
|
}
|
|
|
|
const existing = realPathMap.get(realPath)
|
|
if (!existing || s.modifiedAt > existing.modifiedAt) {
|
|
realPathMap.set(realPath, {
|
|
projectPath: realPath,
|
|
modifiedAt: s.modifiedAt,
|
|
sessionCount: (existing?.sessionCount ?? 0) + 1,
|
|
sessionId: s.id,
|
|
})
|
|
} else {
|
|
existing.sessionCount++
|
|
}
|
|
}
|
|
|
|
// Build project list with git info — parallelize git operations
|
|
const entries = Array.from(realPathMap.entries())
|
|
const projects = await Promise.all(
|
|
entries.map(async ([realPath, info]) => {
|
|
const projectName = projectNameForRecentPath(realPath, info.projectPath)
|
|
|
|
let isGit = false
|
|
let repoName: string | null = null
|
|
let branch: string | null = null
|
|
try {
|
|
const proc = Bun.spawn(['git', 'rev-parse', '--is-inside-work-tree'], {
|
|
cwd: realPath, stdout: 'pipe', stderr: 'pipe',
|
|
})
|
|
const out = await new Response(proc.stdout).text()
|
|
isGit = out.trim() === 'true'
|
|
|
|
if (isGit) {
|
|
// Run branch + remote in parallel
|
|
const [branchResult, remoteResult] = await Promise.all([
|
|
(async () => {
|
|
const branchProc = Bun.spawn(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
cwd: realPath, stdout: 'pipe', stderr: 'pipe',
|
|
})
|
|
return (await new Response(branchProc.stdout).text()).trim() || null
|
|
})(),
|
|
(async () => {
|
|
try {
|
|
const remoteProc = Bun.spawn(['git', 'remote', 'get-url', 'origin'], {
|
|
cwd: realPath, stdout: 'pipe', stderr: 'pipe',
|
|
})
|
|
const remote = (await new Response(remoteProc.stdout).text()).trim()
|
|
const match = remote.match(/:([^/]+\/[^/]+?)(?:\.git)?$/) || remote.match(/\/([^/]+\/[^/]+?)(?:\.git)?$/)
|
|
return match ? match[1]! : null
|
|
} catch { return null }
|
|
})(),
|
|
])
|
|
branch = isDesktopWorktreeBranchName(branchResult) ? null : branchResult
|
|
repoName = remoteResult
|
|
}
|
|
} catch { /* not a git repo or dir doesn't exist */ }
|
|
|
|
return {
|
|
projectPath: info.projectPath, realPath, projectName, isGit, repoName, branch,
|
|
modifiedAt: info.modifiedAt, sessionCount: info.sessionCount,
|
|
}
|
|
})
|
|
)
|
|
|
|
// Sort by most recent
|
|
projects.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt))
|
|
|
|
recentProjectsCache = { projects, timestamp: Date.now() }
|
|
return Response.json({ projects: projects.slice(0, limit) })
|
|
}
|