import { readdirSync, existsSync, createReadStream, statSync } from 'fs'; import { join, basename } from 'path'; import { homedir } from 'os'; import { createInterface } from 'readline'; import { BaseEngineAdapter } from './base.js'; const CLAUDE_HOME = process.env.CLAUDE_HOME || join(homedir(), '.claude'); const PROJECTS_DIR = join(CLAUDE_HOME, 'projects'); // Directories known to contain subagent spawn files, excluded from top-level sessions const SUBAGENT_SUFFIXES = [ '--claude', '-cliproxyapi', ]; export class ClaudeAdapter extends BaseEngineAdapter { constructor() { super('claude', 'Claude'); this._sessionMessages = new Map(); } async checkHealth() { const hasDir = existsSync(PROJECTS_DIR); this.available = hasDir; return this.getHealthInfo(); } async refresh() { this.error = null; try { await this._scanSessions(); this.available = true; this.lastRefresh = Date.now(); } catch (err) { this.error = err.message; console.error(`[ClaudeAdapter] Refresh error:`, err.message); } return this.getHealthInfo(); } _isSubagentDir(dirName) { return SUBAGENT_SUFFIXES.some(suffix => dirName.endsWith(suffix)); } async _scanSessions() { this._sessions = []; this._sessionsMap.clear(); this._sessionMessages.clear(); if (!existsSync(PROJECTS_DIR)) return; const projectDirs = readdirSync(PROJECTS_DIR); for (const dir of projectDirs) { const dirPath = join(PROJECTS_DIR, dir); if (!statSync(dirPath).isDirectory()) continue; // Skip subagent directories if (this._isSubagentDir(dir)) continue; const files = readdirSync(dirPath).filter(f => f.endsWith('.jsonl')); for (const file of files) { const filePath = join(dirPath, file); const session = await this._parseSessionFile(filePath, dir); if (session) { this._sessions.push(session.session); this._sessionsMap.set(session.session.id, session.session); if (session.messages?.length) { this._sessionMessages.set(session.session.id, session.messages); } } } } } async _parseSessionFile(filePath, projectDir) { try { const sessionId = basename(filePath, '.jsonl'); let firstUserMessage = ''; let hasAssistant = false; let hasError = false; let hasInterrupt = false; const toolCalls = {}; let toolCallCount = 0; let messageCount = 0; let userMessageCount = 0; let assistantMessageCount = 0; let earliestTime = null; let latestTime = null; let cwd = ''; let model = ''; let lastType = ''; const assistantTypes = new Set(); const messages = []; const rl = createInterface({ input: createReadStream(filePath, { highWaterMark: 65536 }), crlfDelay: Infinity, }); for await (const line of rl) { if (!line.trim()) continue; try { const entry = JSON.parse(line); messageCount++; const type = entry.type || ''; // Get timestamp from any available field const entryTime = entry.timestamp || entry.ts || ''; if (type === 'user') { const msg = entry.message || {}; const rawContent = msg.content || entry.content || ''; const text = typeof rawContent === 'string' ? rawContent : Array.isArray(rawContent) ? rawContent.map(c => c.text || c.content || '').join(' ') : typeof rawContent === 'object' ? JSON.stringify(rawContent) : ''; // Skip system-generated command wrapper messages const isSystemMsg = text.startsWith(' c.text || '').join(' ') : ''; messages.push({ time: entryTime, type: 'function_call_output', output_preview: (output || '').slice(0, 300), }); } } } const text = textParts.join(' ').trim(); if (text) { messages.push({ time: entryTime, type: 'assistant_message', message: text.slice(0, 2000) }); } } else if (type === 'ai-title') { // title info } else if (type === 'error' || type === 'fatal_error') { hasError = true; } else if (type === 'mode' || type === 'permission-mode') { // config entries } // Track time from message content const rawTs = entry.timestamp || entry.ts || ''; if (rawTs) { const t = new Date(rawTs).getTime(); if (!isNaN(t)) { if (earliestTime === null || t < earliestTime) earliestTime = t; if (latestTime === null || t > latestTime) latestTime = t; } } // Track last entry type for interrupt detection if (type === 'user' || type === 'assistant') { lastType = type; } } catch (e) { // skip malformed lines } } if (!hasAssistant && !firstUserMessage) return null; // Convert project dir name to a filesystem path // e.g. "-home-liumangmang-IdeaProjects-PR7050" → "/home/liumangmang/IdeaProjects/PR7050" if (!cwd) { let raw = projectDir; // Strip leading dash(es) raw = raw.replace(/^-+/, ''); // Replace inter-segment dashes with / raw = raw.replace(/-/g, '/'); // Ensure leading / cwd = '/' + raw; // Collapse any double slashes cwd = cwd.replace(/\/+/g, '/'); } // Claude JSONL doesn't include interrupt/error signals reliably. // Only set status=error when an explicit error type is found. // Otherwise use "unknown" rather than guessing "success". return { session: { key: `claude:${sessionId}`, engine: 'claude', engine_label: 'Claude', id: sessionId, summary: firstUserMessage || '(无消息)', cwd: cwd, model: model || 'claude', cli_version: '', source: 'cli', created_at: earliestTime || 0, updated_at: latestTime || 0, duration_ms: earliestTime && latestTime ? latestTime - earliestTime : null, status: hasError ? 'error' : 'unknown', tool_call_count: toolCallCount, tool_calls: toolCalls, message_count: userMessageCount + assistantMessageCount, resume_command: this.buildResumeCommand(sessionId), can_resume: true, }, messages: messages.slice(0, 300), }; } catch (err) { console.warn(`[ClaudeAdapter] Parse error ${filePath}:`, err.message); return null; } } async getMessages(id) { if (this._sessionMessages.has(id)) { return this._sessionMessages.get(id); } return []; } buildResumeCommand(id) { return `claude --resume ${id}`; } }