mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
- Enforce auth on API/WS when non-localhost or SERVER_AUTH_REQUIRED=1 - Fix CronScheduler: add --verbose flag and correct stream-json message envelope - Resolve session workdir from JSONL file instead of hardcoding process.cwd() - Give Opus 4.6 1M a unique composite ID (claude-opus-4-6-20250610:1m) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
527 lines
15 KiB
TypeScript
527 lines
15 KiB
TypeScript
/**
|
|
* Session Service — 会话文件的读写操作封装
|
|
*
|
|
* 读写 CLI 持久化在 ~/.claude/projects/{sanitized_path}/{sessionId}.jsonl 的会话数据,
|
|
* 确保 Desktop App 与 CLI 的数据完全互通。
|
|
*/
|
|
|
|
import * as fs from 'node:fs/promises'
|
|
import * as path from 'node:path'
|
|
import * as os from 'node:os'
|
|
import { ApiError } from '../middleware/errorHandler.js'
|
|
|
|
// ============================================================================
|
|
// Types
|
|
// ============================================================================
|
|
|
|
export type SessionListItem = {
|
|
id: string
|
|
title: string
|
|
createdAt: string
|
|
modifiedAt: string
|
|
messageCount: number
|
|
projectPath: string
|
|
}
|
|
|
|
export type SessionDetail = SessionListItem & {
|
|
messages: MessageEntry[]
|
|
}
|
|
|
|
export type MessageEntry = {
|
|
id: string
|
|
type: 'user' | 'assistant' | 'system' | 'tool_use' | 'tool_result'
|
|
content: unknown
|
|
timestamp: string
|
|
model?: string
|
|
parentUuid?: string
|
|
isSidechain?: boolean
|
|
}
|
|
|
|
/** Raw entry parsed from a single JSONL line */
|
|
type RawEntry = {
|
|
type?: string
|
|
uuid?: string
|
|
parentUuid?: string | null
|
|
isSidechain?: boolean
|
|
isMeta?: boolean
|
|
message?: {
|
|
role?: string
|
|
content?: unknown
|
|
model?: string
|
|
id?: string
|
|
type?: string
|
|
}
|
|
timestamp?: string
|
|
customTitle?: string
|
|
title?: string
|
|
[key: string]: unknown
|
|
}
|
|
|
|
// ============================================================================
|
|
// Service
|
|
// ============================================================================
|
|
|
|
export class SessionService {
|
|
// --------------------------------------------------------------------------
|
|
// Config helpers
|
|
// --------------------------------------------------------------------------
|
|
|
|
private getConfigDir(): string {
|
|
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
|
}
|
|
|
|
private getProjectsDir(): string {
|
|
return path.join(this.getConfigDir(), 'projects')
|
|
}
|
|
|
|
/**
|
|
* Sanitize a path the same way the CLI does: replace path separators with '-'.
|
|
* For example, `/Users/nanmi/workspace` becomes `-Users-nanmi-workspace`.
|
|
*/
|
|
private sanitizePath(dirPath: string): string {
|
|
return dirPath.replace(/\//g, '-').replace(/\\/g, '-')
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// JSONL parsing
|
|
// --------------------------------------------------------------------------
|
|
|
|
private async readJsonlFile(filePath: string): Promise<RawEntry[]> {
|
|
let content: string
|
|
try {
|
|
content = await fs.readFile(filePath, 'utf-8')
|
|
} catch (err: unknown) {
|
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
return []
|
|
}
|
|
throw err
|
|
}
|
|
|
|
const entries: RawEntry[] = []
|
|
for (const line of content.split('\n')) {
|
|
const trimmed = line.trim()
|
|
if (!trimmed) continue
|
|
try {
|
|
entries.push(JSON.parse(trimmed) as RawEntry)
|
|
} catch {
|
|
// skip malformed lines
|
|
}
|
|
}
|
|
return entries
|
|
}
|
|
|
|
private async appendJsonlEntry(filePath: string, entry: Record<string, unknown>): Promise<void> {
|
|
const line = JSON.stringify(entry) + '\n'
|
|
await fs.appendFile(filePath, line, 'utf-8')
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Entry → MessageEntry conversion
|
|
// --------------------------------------------------------------------------
|
|
|
|
private entryToMessage(entry: RawEntry): MessageEntry | null {
|
|
const msg = entry.message
|
|
if (!msg || !msg.role) return null
|
|
|
|
// Determine our normalized type
|
|
let type: MessageEntry['type']
|
|
const role = msg.role
|
|
|
|
if (role === 'user') {
|
|
// Check if the content is a tool_result array
|
|
if (Array.isArray(msg.content)) {
|
|
const hasToolResult = msg.content.some(
|
|
(block: Record<string, unknown>) => block.type === 'tool_result'
|
|
)
|
|
if (hasToolResult) {
|
|
type = 'tool_result'
|
|
} else {
|
|
type = 'user'
|
|
}
|
|
} else {
|
|
type = 'user'
|
|
}
|
|
} else if (role === 'assistant') {
|
|
// Check if the content contains tool_use blocks
|
|
if (Array.isArray(msg.content)) {
|
|
const hasToolUse = msg.content.some(
|
|
(block: Record<string, unknown>) => block.type === 'tool_use'
|
|
)
|
|
type = hasToolUse ? 'tool_use' : 'assistant'
|
|
} else {
|
|
type = 'assistant'
|
|
}
|
|
} else {
|
|
type = 'system'
|
|
}
|
|
|
|
return {
|
|
id: entry.uuid || crypto.randomUUID(),
|
|
type,
|
|
content: msg.content,
|
|
timestamp: entry.timestamp || new Date().toISOString(),
|
|
model: msg.model,
|
|
parentUuid: entry.parentUuid ?? undefined,
|
|
isSidechain: entry.isSidechain,
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Title extraction
|
|
// --------------------------------------------------------------------------
|
|
|
|
private extractTitle(entries: RawEntry[]): string {
|
|
// 1. Look for custom title entry (appended by renameSession)
|
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
const e = entries[i]!
|
|
if (e.type === 'custom-title' && e.customTitle) {
|
|
return e.customTitle
|
|
}
|
|
}
|
|
|
|
// 2. Look for first non-meta user message as title
|
|
for (const e of entries) {
|
|
if (e.type === 'user' && !e.isMeta && e.message?.role === 'user') {
|
|
const content = e.message.content
|
|
let text: string | undefined
|
|
if (typeof content === 'string') {
|
|
text = content
|
|
} else if (Array.isArray(content)) {
|
|
// Extract text from content blocks: [{ type: 'text', text: '...' }]
|
|
const textBlock = content.find(
|
|
(block: Record<string, unknown>) => block.type === 'text' && typeof block.text === 'string'
|
|
)
|
|
if (textBlock) text = textBlock.text as string
|
|
}
|
|
if (text) {
|
|
return text.length > 80 ? text.slice(0, 80) + '...' : text
|
|
}
|
|
}
|
|
}
|
|
|
|
return 'Untitled Session'
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Session file discovery
|
|
// --------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Find all .jsonl session files across all project directories.
|
|
* Returns an array of { filePath, projectDir, sessionId }.
|
|
*/
|
|
private async discoverSessionFiles(projectFilter?: string): Promise<
|
|
Array<{ filePath: string; projectDir: string; sessionId: string }>
|
|
> {
|
|
const projectsDir = this.getProjectsDir()
|
|
let projectDirs: string[]
|
|
|
|
try {
|
|
projectDirs = await fs.readdir(projectsDir)
|
|
} catch {
|
|
return []
|
|
}
|
|
|
|
// Optionally filter to a specific project
|
|
if (projectFilter) {
|
|
const sanitized = this.sanitizePath(projectFilter)
|
|
projectDirs = projectDirs.filter((d) => d === sanitized)
|
|
}
|
|
|
|
const results: Array<{ filePath: string; projectDir: string; sessionId: string }> = []
|
|
|
|
for (const dir of projectDirs) {
|
|
const dirPath = path.join(projectsDir, dir)
|
|
|
|
// Ensure it's a directory
|
|
try {
|
|
const stat = await fs.stat(dirPath)
|
|
if (!stat.isDirectory()) continue
|
|
} catch {
|
|
continue
|
|
}
|
|
|
|
let files: string[]
|
|
try {
|
|
files = await fs.readdir(dirPath)
|
|
} catch {
|
|
continue
|
|
}
|
|
|
|
for (const file of files) {
|
|
if (!file.endsWith('.jsonl')) continue
|
|
const sessionId = file.replace('.jsonl', '')
|
|
results.push({
|
|
filePath: path.join(dirPath, file),
|
|
projectDir: dir,
|
|
sessionId,
|
|
})
|
|
}
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
/**
|
|
* Convert a sanitized directory name back to the original absolute path.
|
|
* Reverses sanitizePath(): `-Users-nanmi-workspace` → `/Users/nanmi/workspace`.
|
|
*/
|
|
desanitizePath(sanitized: string): string {
|
|
// The sanitized form replaces all '/' (and '\') with '-'.
|
|
// On POSIX the original path starts with '/', so the sanitized form starts with '-'.
|
|
// We restore by replacing every '-' with '/' (the platform separator).
|
|
// On Windows the leading character would be a drive letter, but we handle POSIX here.
|
|
return sanitized.replace(/-/g, path.sep)
|
|
}
|
|
|
|
/**
|
|
* Find the .jsonl file for a given session ID.
|
|
* Searches across all project directories since sessions may belong to any project.
|
|
*/
|
|
async findSessionFile(
|
|
sessionId: string
|
|
): Promise<{ filePath: string; projectDir: string } | null> {
|
|
// Validate sessionId format to prevent path traversal
|
|
if (!this.isValidSessionId(sessionId)) {
|
|
return null
|
|
}
|
|
|
|
const projectsDir = this.getProjectsDir()
|
|
let projectDirs: string[]
|
|
|
|
try {
|
|
projectDirs = await fs.readdir(projectsDir)
|
|
} catch {
|
|
return null
|
|
}
|
|
|
|
for (const dir of projectDirs) {
|
|
const filePath = path.join(projectsDir, dir, `${sessionId}.jsonl`)
|
|
try {
|
|
await fs.access(filePath)
|
|
return { filePath, projectDir: dir }
|
|
} catch {
|
|
continue
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
private isValidSessionId(id: string): boolean {
|
|
// UUID v4 format
|
|
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Public API
|
|
// --------------------------------------------------------------------------
|
|
|
|
/**
|
|
* List all sessions, optionally filtered by project path.
|
|
*/
|
|
async listSessions(options?: {
|
|
project?: string
|
|
limit?: number
|
|
offset?: number
|
|
}): Promise<{ sessions: SessionListItem[]; total: number }> {
|
|
const sessionFiles = await this.discoverSessionFiles(options?.project)
|
|
|
|
// Build session list items with metadata from file stats & first entries
|
|
const items: SessionListItem[] = []
|
|
|
|
for (const { filePath, projectDir, sessionId } of sessionFiles) {
|
|
try {
|
|
const stat = await fs.stat(filePath)
|
|
const entries = await this.readJsonlFile(filePath)
|
|
|
|
// Count transcript messages only (user + assistant)
|
|
const messageCount = entries.filter(
|
|
(e) => (e.type === 'user' || e.type === 'assistant') && e.message?.role
|
|
).length
|
|
|
|
const title = this.extractTitle(entries)
|
|
|
|
// Find the earliest timestamp from entries, fallback to file birthtime
|
|
let createdAt = stat.birthtime.toISOString()
|
|
for (const e of entries) {
|
|
if (e.timestamp) {
|
|
createdAt = e.timestamp
|
|
break
|
|
}
|
|
}
|
|
|
|
items.push({
|
|
id: sessionId,
|
|
title,
|
|
createdAt,
|
|
modifiedAt: stat.mtime.toISOString(),
|
|
messageCount,
|
|
projectPath: projectDir,
|
|
})
|
|
} catch {
|
|
// Skip unreadable files
|
|
}
|
|
}
|
|
|
|
// Sort by modifiedAt descending (most recent first)
|
|
items.sort((a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime())
|
|
|
|
const total = items.length
|
|
const offset = options?.offset ?? 0
|
|
const limit = options?.limit ?? 50
|
|
const paginated = items.slice(offset, offset + limit)
|
|
|
|
return { sessions: paginated, total }
|
|
}
|
|
|
|
/**
|
|
* Get full session detail including all messages.
|
|
*/
|
|
async getSession(sessionId: string): Promise<SessionDetail | null> {
|
|
const found = await this.findSessionFile(sessionId)
|
|
if (!found) return null
|
|
|
|
const { filePath, projectDir } = found
|
|
const stat = await fs.stat(filePath)
|
|
const entries = await this.readJsonlFile(filePath)
|
|
|
|
const messages = this.entriesToMessages(entries)
|
|
const title = this.extractTitle(entries)
|
|
|
|
let createdAt = stat.birthtime.toISOString()
|
|
for (const e of entries) {
|
|
if (e.timestamp) {
|
|
createdAt = e.timestamp
|
|
break
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: sessionId,
|
|
title,
|
|
createdAt,
|
|
modifiedAt: stat.mtime.toISOString(),
|
|
messageCount: messages.length,
|
|
projectPath: projectDir,
|
|
messages,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get only the messages for a session (lighter than full detail).
|
|
*/
|
|
async getSessionMessages(sessionId: string): Promise<MessageEntry[]> {
|
|
const found = await this.findSessionFile(sessionId)
|
|
if (!found) {
|
|
throw ApiError.notFound(`Session not found: ${sessionId}`)
|
|
}
|
|
|
|
const entries = await this.readJsonlFile(found.filePath)
|
|
return this.entriesToMessages(entries)
|
|
}
|
|
|
|
/**
|
|
* Create a new session file for the given working directory.
|
|
*/
|
|
async createSession(workDir: string): Promise<{ sessionId: string }> {
|
|
if (!workDir) {
|
|
throw ApiError.badRequest('workDir is required')
|
|
}
|
|
|
|
const sessionId = crypto.randomUUID()
|
|
const sanitized = this.sanitizePath(workDir)
|
|
const dirPath = path.join(this.getProjectsDir(), sanitized)
|
|
|
|
// Ensure the project directory exists
|
|
await fs.mkdir(dirPath, { recursive: true })
|
|
|
|
const filePath = path.join(dirPath, `${sessionId}.jsonl`)
|
|
const now = new Date().toISOString()
|
|
|
|
// Write an initial file-history-snapshot entry (matches CLI behavior)
|
|
const initialEntry = {
|
|
type: 'file-history-snapshot',
|
|
messageId: crypto.randomUUID(),
|
|
snapshot: {
|
|
messageId: crypto.randomUUID(),
|
|
trackedFileBackups: {},
|
|
timestamp: now,
|
|
},
|
|
isSnapshotUpdate: false,
|
|
}
|
|
|
|
await fs.writeFile(filePath, JSON.stringify(initialEntry) + '\n', 'utf-8')
|
|
|
|
return { sessionId }
|
|
}
|
|
|
|
/**
|
|
* Delete a session's JSONL file.
|
|
*/
|
|
async deleteSession(sessionId: string): Promise<void> {
|
|
const found = await this.findSessionFile(sessionId)
|
|
if (!found) {
|
|
throw ApiError.notFound(`Session not found: ${sessionId}`)
|
|
}
|
|
|
|
await fs.unlink(found.filePath)
|
|
}
|
|
|
|
/**
|
|
* Rename a session by appending a custom-title entry to its JSONL file.
|
|
*/
|
|
async renameSession(sessionId: string, title: string): Promise<void> {
|
|
if (!title || typeof title !== 'string') {
|
|
throw ApiError.badRequest('title is required')
|
|
}
|
|
|
|
const found = await this.findSessionFile(sessionId)
|
|
if (!found) {
|
|
throw ApiError.notFound(`Session not found: ${sessionId}`)
|
|
}
|
|
|
|
const entry = {
|
|
type: 'custom-title',
|
|
customTitle: title,
|
|
timestamp: new Date().toISOString(),
|
|
}
|
|
|
|
await this.appendJsonlEntry(found.filePath, entry)
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Private helpers
|
|
// --------------------------------------------------------------------------
|
|
|
|
private entriesToMessages(entries: RawEntry[]): MessageEntry[] {
|
|
const messages: MessageEntry[] = []
|
|
for (const entry of entries) {
|
|
// Only process transcript entries (user / assistant / system with messages)
|
|
if (!entry.message?.role) continue
|
|
|
|
// Skip meta entries (CLI internal bookkeeping)
|
|
if (entry.isMeta) continue
|
|
|
|
// Skip non-transcript entry types
|
|
const entryType = entry.type
|
|
if (
|
|
entryType !== 'user' &&
|
|
entryType !== 'assistant' &&
|
|
entryType !== 'system'
|
|
) {
|
|
continue
|
|
}
|
|
|
|
const msg = this.entryToMessage(entry)
|
|
if (msg) {
|
|
messages.push(msg)
|
|
}
|
|
}
|
|
return messages
|
|
}
|
|
}
|
|
|
|
// Singleton instance for shared use across API handlers
|
|
export const sessionService = new SessionService()
|