Files
claudecodeui/server/modules/providers/list/claude/claude-session-synchronizer.provider.ts
Karel Bourgois a12ca8eed3 fix(claude-sync): skip subagent transcripts to prevent main session corruption (#854)
The session indexer scans ~/.claude/projects recursively via
findFilesRecursivelyCreatedAfter, which descends into per-session
subagents/ directories. Claude writes subagent transcripts at:

  ~/.claude/projects/<encoded-cwd>/<session-id>/subagents/agent-<id>.jsonl

These files repeat the parent session's sessionId. When indexed as
standalone sessions they upsert over the parent row and overwrite its
jsonl_path with the subagent path, corrupting the main session record
(the sidebar then points at, and renders, the subagent transcript).

Add a single isSubagentTranscript() guard (path segment named
"subagents") and apply it in both the recursive scan and the
single-file watcher path.

Co-authored-by: Haile <118998054+blackmammoth@users.noreply.github.com>
2026-06-18 15:37:37 +03:00

202 lines
6.2 KiB
TypeScript

import os from 'node:os';
import path from 'node:path';
import { readFile } from 'node:fs/promises';
import { sessionsDb } from '@/modules/database/index.js';
import {
buildLookupMap,
extractFirstValidJsonlData,
findFilesRecursivelyCreatedAfter,
normalizeSessionName,
readFileTimestamps,
} from '@/shared/utils.js';
import type { IProviderSessionSynchronizer } from '@/shared/interfaces.js';
type ParsedSession = {
sessionId: string;
projectPath: string;
sessionName?: string;
};
/**
* Session indexer for Claude transcript artifacts.
*/
export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer {
private readonly provider = 'claude' as const;
private readonly claudeHome = path.join(os.homedir(), '.claude');
/**
* Returns true when a JSONL file is a subagent transcript rather than a
* top-level session.
*
* Claude stores subagent transcripts under a `subagents/` directory, e.g.
* `~/.claude/projects/<encoded-cwd>/<session-id>/subagents/agent-<id>.jsonl`.
* Those files repeat the parent session's `sessionId`, so indexing them as
* standalone sessions overwrites the parent row's `jsonl_path` and corrupts
* the main session record. The recursive scan in `synchronize()` reaches
* them, so both entry points must skip them.
*/
private isSubagentTranscript(filePath: string): boolean {
return path.normalize(filePath).split(path.sep).includes('subagents');
}
/**
* Scans ~/.claude/projects and upserts discovered sessions into DB.
*/
async synchronize(since?: Date): Promise<number> {
const nameMap = await buildLookupMap(path.join(this.claudeHome, 'history.jsonl'), 'sessionId', 'display');
const files = await findFilesRecursivelyCreatedAfter(
path.join(this.claudeHome, 'projects'),
'.jsonl',
since ?? null
);
let processed = 0;
for (const filePath of files) {
if (this.isSubagentTranscript(filePath)) {
continue;
}
const parsed = await this.processSessionFile(filePath, nameMap);
if (!parsed) {
continue;
}
const timestamps = await readFileTimestamps(filePath);
sessionsDb.createSession(
parsed.sessionId,
this.provider,
parsed.projectPath,
parsed.sessionName,
timestamps.createdAt,
timestamps.updatedAt,
filePath
);
processed += 1;
}
return processed;
}
/**
* Parses and upserts one Claude session JSONL file.
*/
async synchronizeFile(filePath: string): Promise<string | null> {
if (!filePath.endsWith('.jsonl')) {
return null;
}
if (this.isSubagentTranscript(filePath)) {
return null;
}
const nameMap = await buildLookupMap(path.join(this.claudeHome, 'history.jsonl'), 'sessionId', 'display');
const parsed = await this.processSessionFile(filePath, nameMap);
if (!parsed) {
return null;
}
const timestamps = await readFileTimestamps(filePath);
return sessionsDb.createSession(
parsed.sessionId,
this.provider,
parsed.projectPath,
parsed.sessionName,
timestamps.createdAt,
timestamps.updatedAt,
filePath
);
}
/**
* Extracts session metadata from one Claude JSONL session file.
*/
private async processSessionFile(
filePath: string,
nameMap: Map<string, string>
): Promise<ParsedSession | null> {
const parsed = await extractFirstValidJsonlData(filePath, (rawData) => {
const data = rawData as Record<string, unknown>;
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : undefined;
const projectPath = typeof data.cwd === 'string' ? data.cwd : undefined;
if (!sessionId || !projectPath) {
return null;
}
return {
sessionId,
projectPath,
};
});
if (!parsed) {
return null;
}
// App-created sessions are keyed by an app id, so disk-discovered provider
// ids must be resolved through the provider-id mapping first.
const existingSession = sessionsDb.getSessionByProviderSessionId(parsed.sessionId)
?? sessionsDb.getSessionById(parsed.sessionId);
const existingSessionName = existingSession?.custom_name;
if (existingSessionName && existingSessionName !== 'Untitled Claude Session') {
return {
...parsed,
sessionName: normalizeSessionName(existingSessionName, 'Untitled Claude Session'),
};
}
let sessionName = nameMap.get(parsed.sessionId);
if (!sessionName) {
sessionName = await this.extractSessionAiTitleFromEnd(filePath, parsed.sessionId);
}
return {
...parsed,
sessionName: normalizeSessionName(sessionName, 'Untitled Claude Session'),
};
}
private async extractSessionAiTitleFromEnd(
filePath: string,
sessionId: string
): Promise<string | undefined> {
try {
const content = await readFile(filePath, 'utf8');
const lines = content.split(/\r?\n/);
for (let index = lines.length - 1; index >= 0; index -= 1) {
const line = lines[index]?.trim();
if (!line) {
continue;
}
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch {
continue;
}
const data = parsed as Record<string, unknown>;
const eventType = typeof data.type === 'string' ? data.type : undefined;
const eventSessionId = typeof data.sessionId === 'string' ? data.sessionId : undefined;
const aiTitle = typeof data.aiTitle === 'string' ? data.aiTitle : undefined;
const lastPrompt = typeof data.lastPrompt === 'string' ? data.lastPrompt : undefined;
const claudeRenamedTitle = typeof data.customTitle === 'string' ? data.customTitle : undefined;
if (
(eventType === 'ai-title' && eventSessionId === sessionId && aiTitle?.trim()) ||
(eventType === 'last-prompt' && eventSessionId === sessionId && lastPrompt?.trim()) ||
(eventType === "custom-title" && eventSessionId === sessionId && claudeRenamedTitle?.trim())
) {
return aiTitle || lastPrompt || claudeRenamedTitle;
}
}
} catch {
// Ignore missing/unreadable files so sync can continue.
}
return undefined;
}
}