Files
agent-session-manager/backend/src/engine/claude-adapter.js
T
2026-07-16 15:42:50 +08:00

248 lines
8.4 KiB
JavaScript

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('<local-command') || text.startsWith('<command-');
if (!firstUserMessage && text.trim() && !isSystemMsg) {
firstUserMessage = text.slice(0, 500);
}
userMessageCount++;
if (!isSystemMsg) {
messages.push({ time: entryTime, type: 'user_message', message: text.slice(0, 2000) });
}
} else if (type === 'assistant') {
hasAssistant = true;
assistantMessageCount++;
const msgContent = entry.message || {};
const content = msgContent.content || [];
const textParts = [];
if (Array.isArray(content)) {
for (const block of content) {
if (block.type === 'text') {
textParts.push(block.text || '');
} else if (block.type === 'tool_use') {
const name = block.name || 'unknown';
toolCalls[name] = (toolCalls[name] || 0) + 1;
toolCallCount++;
messages.push({
time: entryTime,
type: 'function_call',
name,
arguments: block.input ? JSON.stringify(block.input).slice(0, 500) : '',
});
} else if (block.type === 'tool_result') {
const output = typeof block.content === 'string' ? block.content :
Array.isArray(block.content) ? block.content.map(c => 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}`;
}
}