mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
UI rebuild (desktop): - TraceSession: replace 3-column layout with two panes — turn-grouped timeline tree (draggable splitter, search/filter, keyboard nav) and a section-flow detail panel (Response / Messages / System Prompt / Tools / Parameters / Raw), collapse state persists across spans - Render LLM requests/responses semantically: messages as role-colored conversation with tool_use/tool_result pairing instead of raw JSON dumps; Raw fallback via CodeViewer for legacy truncated records - TraceList: row-style list with model chips, mono metrics, hover actions; content-visibility rows (no virtualization, WebKit-safe) - i18n synced across zh/en/jp/kr/zh-TW (+25/-55 keys) Data & capture (server): - Capture full bodies: preview cap 2048 -> 240k chars, stream cap 256KB -> 1MB; list API trims previews to keep polling light; new GET /api/sessions/:id/trace/calls/:callId returns the full record - Extract per-call token usage at read time (SSE + JSON + proxy wrapped); mtime-keyed read cache for the polling path - Fix sensitive-key regex redacting *_tokens count fields, which made token stats always report 0 Frontend data layer: - SSE stream reassembly (Anthropic + OpenAI chat) adapted from claude-tap (MIT, attribution in THIRD_PARTY_LICENSES.md), request/ response body parsers, shared formatters, on-demand call detail cache; traceViewModel gains tokenUsage/isLifecycleNoise, drops fullRaw Tested: - bun run check:server (1201 pass) - bun run check:desktop (lint + 1358 tests + build) - Chromium walkthrough against real local traces: list, session tree, LLM semantic detail (new format), legacy fallback, tool detail
1021 lines
36 KiB
TypeScript
1021 lines
36 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/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
|
||
* POST /api/sessions — 创建新会话
|
||
* POST /api/sessions/batch-delete — 批量删除会话
|
||
* DELETE /api/sessions/:id — 删除会话
|
||
* PATCH /api/sessions/:id — 重命名会话
|
||
*/
|
||
|
||
import * as path from 'node:path'
|
||
import { sessionService } from '../services/sessionService.js'
|
||
import { conversationService } from '../services/conversationService.js'
|
||
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
||
import { closeSessionConnection, getSlashCommands } from '../ws/handler.js'
|
||
import { listSkillSlashCommands, type SkillSlashCommand } from './skills.js'
|
||
import { WorkspaceService } from '../services/workspaceService.js'
|
||
import {
|
||
getRepositoryContext,
|
||
type CreateSessionRepositoryOptions,
|
||
} from '../services/repositoryLaunchService.js'
|
||
import {
|
||
executeSessionRewind,
|
||
getSessionTurnCheckpointDiff,
|
||
listSessionTurnCheckpoints,
|
||
previewSessionRewind,
|
||
type RewindTargetSelector,
|
||
} from '../services/sessionRewindService.js'
|
||
import { SessionStore } from '../../../adapters/common/session-store.js'
|
||
import {
|
||
createSessionBranch,
|
||
SessionBranchingError,
|
||
} from '../../utils/sessionBranching.js'
|
||
import { registerFilesystemAccessRoot } from '../services/filesystemAccessRoots.js'
|
||
import { traceCaptureService, trimTraceCallPreviews } from '../services/traceCaptureService.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 === 'trace') {
|
||
if (req.method !== 'GET') {
|
||
return Response.json(
|
||
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
|
||
{ status: 405 }
|
||
)
|
||
}
|
||
return segments[4] === 'calls'
|
||
? await getSessionTraceCall(sessionId, segments[5])
|
||
: await getSessionTrace(sessionId)
|
||
}
|
||
|
||
if (subResource === 'git-info') {
|
||
if (req.method !== 'GET') {
|
||
return Response.json(
|
||
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
|
||
{ status: 405 }
|
||
)
|
||
}
|
||
return await getGitInfo(sessionId)
|
||
}
|
||
|
||
if (subResource === 'rewind') {
|
||
if (req.method !== 'POST') {
|
||
return Response.json(
|
||
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
|
||
{ status: 405 }
|
||
)
|
||
}
|
||
return await rewindSession(req, sessionId)
|
||
}
|
||
|
||
if (subResource === 'branch') {
|
||
if (req.method !== 'POST') {
|
||
return Response.json(
|
||
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
|
||
{ status: 405 }
|
||
)
|
||
}
|
||
return await branchSession(req, sessionId)
|
||
}
|
||
|
||
if (subResource === 'turn-checkpoints') {
|
||
if (req.method !== 'GET') {
|
||
return Response.json(
|
||
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
|
||
{ status: 405 }
|
||
)
|
||
}
|
||
return segments[4] === 'diff'
|
||
? await getTurnCheckpointDiff(sessionId, url)
|
||
: await getTurnCheckpoints(sessionId)
|
||
}
|
||
|
||
if (subResource === 'slash-commands') {
|
||
if (req.method !== 'GET') {
|
||
return Response.json(
|
||
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
|
||
{ status: 405 }
|
||
)
|
||
}
|
||
return await getSessionSlashCommands(sessionId)
|
||
}
|
||
|
||
if (subResource === 'inspection') {
|
||
if (req.method !== 'GET') {
|
||
return Response.json(
|
||
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
|
||
{ status: 405 }
|
||
)
|
||
}
|
||
return await getSessionInspection(sessionId, url)
|
||
}
|
||
|
||
if (subResource === 'workspace') {
|
||
if (req.method !== 'GET') {
|
||
return Response.json(
|
||
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
|
||
{ status: 405 }
|
||
)
|
||
}
|
||
return await handleSessionWorkspaceRoute(sessionId, url, segments[4])
|
||
}
|
||
|
||
// 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 getSessionTrace(sessionId: string): Promise<Response> {
|
||
const [trace, session] = await Promise.all([
|
||
traceCaptureService.getSessionTrace(sessionId),
|
||
sessionService.getSession(sessionId).catch(() => null),
|
||
])
|
||
return Response.json({
|
||
...trace,
|
||
calls: trace.calls.map((call) => trimTraceCallPreviews(call)),
|
||
session: session
|
||
? {
|
||
id: session.id,
|
||
title: session.title,
|
||
projectPath: session.projectPath,
|
||
workDir: session.workDir,
|
||
}
|
||
: null,
|
||
})
|
||
}
|
||
|
||
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(
|
||
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; 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.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 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 launchInfo = await sessionService.getSessionLaunchInfo(sessionId).catch(() => 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 = 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,
|
||
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
|
||
}
|
||
|
||
function sameResolvedPath(left: string | null | undefined, right: string | null | undefined): boolean {
|
||
if (!left || !right) return false
|
||
return path.resolve(left) === path.resolve(right)
|
||
}
|
||
|
||
async function getGitInfo(sessionId: string): Promise<Response> {
|
||
const workDir = conversationService.getSessionWorkDir(sessionId) || await sessionService.getSessionWorkDir(sessionId)
|
||
if (!workDir) {
|
||
throw ApiError.notFound(`Session not found: ${sessionId}`)
|
||
}
|
||
registerFilesystemAccessRoot(workDir)
|
||
const launchInfo = await sessionService.getSessionLaunchInfo(sessionId).catch(() => null)
|
||
const repository = launchInfo?.repository
|
||
const worktreeSession = launchInfo?.worktreeSession
|
||
// 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 gitBranch = branchText.trim() || null
|
||
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
|
||
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 branchSession(req: Request, sessionId: string): Promise<Response> {
|
||
let body: { targetMessageId?: unknown; title?: unknown }
|
||
try {
|
||
body = (await req.json()) as { targetMessageId?: unknown; title?: unknown }
|
||
} catch {
|
||
throw ApiError.badRequest('Invalid JSON body')
|
||
}
|
||
|
||
if (typeof body.targetMessageId !== 'string' || body.targetMessageId.trim().length === 0) {
|
||
throw ApiError.badRequest('targetMessageId (string) is required in request body')
|
||
}
|
||
|
||
if (body.title !== undefined && typeof body.title !== 'string') {
|
||
throw ApiError.badRequest('title must be a string')
|
||
}
|
||
|
||
const launchInfo = await sessionService.getSessionLaunchInfo(sessionId)
|
||
if (!launchInfo) {
|
||
throw ApiError.notFound(`Session not found: ${sessionId}`)
|
||
}
|
||
|
||
try {
|
||
const result = await createSessionBranch({
|
||
sourceSessionId: sessionId,
|
||
sourceTranscriptPath: launchInfo.filePath,
|
||
targetMessageId: body.targetMessageId.trim(),
|
||
title: body.title?.trim() || undefined,
|
||
sourceWorkDir: launchInfo.workDir,
|
||
sourceRepository: launchInfo.repository,
|
||
sourceWorktreeSession: launchInfo.worktreeSession,
|
||
})
|
||
|
||
return Response.json({
|
||
sessionId: result.sessionId,
|
||
title: result.title,
|
||
workDir: result.workDir ?? launchInfo.workDir,
|
||
sourceSessionId: sessionId,
|
||
targetMessageId: body.targetMessageId.trim(),
|
||
}, { status: 201 })
|
||
} catch (error) {
|
||
if (error instanceof SessionBranchingError) {
|
||
if (error.code === 'SOURCE_NOT_FOUND') {
|
||
throw ApiError.notFound(error.message)
|
||
}
|
||
throw ApiError.badRequest(error.message)
|
||
}
|
||
throw error
|
||
}
|
||
}
|
||
|
||
async function getTurnCheckpoints(sessionId: string): Promise<Response> {
|
||
const checkpoints = await listSessionTurnCheckpoints(sessionId)
|
||
return Response.json({ checkpoints })
|
||
}
|
||
|
||
async function getTurnCheckpointDiff(sessionId: string, url: URL): Promise<Response> {
|
||
const targetUserMessageId = url.searchParams.get('targetUserMessageId') || undefined
|
||
const userMessageIndexParam = url.searchParams.get('userMessageIndex')
|
||
const path = url.searchParams.get('path')
|
||
const userMessageIndex =
|
||
userMessageIndexParam === null ? undefined : Number.parseInt(userMessageIndexParam, 10)
|
||
|
||
if (
|
||
(typeof targetUserMessageId !== 'string' || targetUserMessageId.length === 0) &&
|
||
!Number.isInteger(userMessageIndex)
|
||
) {
|
||
throw ApiError.badRequest('targetUserMessageId (string) or userMessageIndex (integer) is required')
|
||
}
|
||
|
||
if (!path) {
|
||
throw ApiError.badRequest('path query parameter is required for turn checkpoint diff')
|
||
}
|
||
|
||
const result = await getSessionTurnCheckpointDiff(
|
||
sessionId,
|
||
{
|
||
targetUserMessageId,
|
||
userMessageIndex,
|
||
},
|
||
path,
|
||
)
|
||
|
||
return Response.json(result)
|
||
}
|
||
|
||
async function patchSession(req: Request, sessionId: string): Promise<Response> {
|
||
let body: { title?: string }
|
||
try {
|
||
body = (await req.json()) as { title?: string }
|
||
} catch {
|
||
throw ApiError.badRequest('Invalid JSON body')
|
||
}
|
||
|
||
if (!body.title || typeof body.title !== 'string') {
|
||
throw ApiError.badRequest('title (string) is required in request body')
|
||
}
|
||
|
||
await sessionService.renameSession(sessionId, body.title)
|
||
return Response.json({ ok: true })
|
||
}
|
||
|
||
type RecentProjectEntry = {
|
||
projectPath: string
|
||
realPath: string
|
||
projectName: string
|
||
isGit: boolean
|
||
repoName: string | null
|
||
branch: string | null
|
||
modifiedAt: string
|
||
sessionCount: number
|
||
}
|
||
|
||
// 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)
|
||
const sessionScanLimit = Math.min(Math.max(limit * 8, 50), 200)
|
||
|
||
// 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: sessionScanLimit })
|
||
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) })
|
||
}
|