Files
platform/src/servers/api/chat/claude-sessions.ts
T
pastilhasandClaude Opus 4.8 319cf39e84 chat: read the /chat session list from Claude's own transcript store
First slice of making Claude's session store the source of truth. Adds a reader
(api/chat/claude-sessions.ts) that lists sessions straight from Claude's JSONL
transcripts under $HOME/.claude/projects/<cwd-slug>/, plus GET /chat/sessions
over a dedicated per-user claude_sessions working directory. No bookkeeping
layer — Claude's files are authoritative. Verified against real transcripts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 09:40:58 +00:00

111 lines
3.7 KiB
TypeScript

import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { DATA_PATH } from '../../data-path';
// ── Claude session store (source of truth) ──
// The `claude` CLI persists every session as a JSONL transcript at
// $HOME/.claude/projects/<slug>/<session-uuid>.jsonl
// where <slug> is the working directory with every non-alphanumeric char replaced by '-'.
// A user's claude process runs with HOME = DATA_PATH/<email>/home (see sidecar/claude/user-instance.ts),
// so we read transcripts from there. We never maintain our own copy — Claude's files are authoritative.
/** Dedicated working directory for /chat sessions, so they form their own Claude "project" group. */
export const getClaudeSessionsCwd = (email: string): string => join(DATA_PATH, email, 'claude_sessions');
const claudeProjectsDir = (email: string): string => join(DATA_PATH, email, 'home', '.claude', 'projects');
/** Claude's folder name for a working directory. */
export const projectSlug = (cwd: string): string => cwd.replace(/[^a-zA-Z0-9]/g, '-');
export type ClaudeSessionSummary = {
id: string; // session uuid (= transcript filename)
title: string; // first human message, trimmed
cwd: string;
createdAt: string; // ISO, first entry
updatedAt: string; // ISO, last entry
messageCount: number; // human + assistant turns
};
/** Pull the plain text out of a transcript entry's `message.content` (string or content-block array). */
function entryText(message: unknown): string {
if (!message || typeof message !== 'object') return '';
const content = (message as { content?: unknown }).content;
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content
.map((block) => (block && typeof block === 'object' && (block as { type?: string }).type === 'text' ? (block as { text?: string }).text ?? '' : ''))
.join('')
.trim();
}
return '';
}
type Entry = {
type?: string;
timestamp?: string;
sessionId?: string;
cwd?: string;
message?: { role?: string; content?: unknown };
isMeta?: boolean;
};
function summarizeTranscript(filePath: string, id: string): ClaudeSessionSummary | null {
let raw: string;
try {
raw = readFileSync(filePath, 'utf-8');
} catch {
return null;
}
let title = '';
let cwd = '';
let firstTs = '';
let lastTs = '';
let messageCount = 0;
for (const line of raw.split('\n')) {
if (!line.trim()) continue;
let entry: Entry;
try {
entry = JSON.parse(line) as Entry;
} catch {
continue;
}
if (entry.cwd && !cwd) cwd = entry.cwd;
if (entry.timestamp) {
if (!firstTs) firstTs = entry.timestamp;
lastTs = entry.timestamp;
}
if (entry.type === 'user' || entry.type === 'assistant') {
messageCount += 1;
if (!title && entry.type === 'user' && !entry.isMeta) {
title = entryText(entry.message).split('\n')[0]!.trim();
}
}
}
const mtime = statSync(filePath).mtime.toISOString();
return {
id,
title: title || '(untitled)',
cwd,
createdAt: firstTs || mtime,
updatedAt: lastTs || mtime,
messageCount,
};
}
/** List sessions Claude has stored for a given working directory, newest first. */
export function listClaudeSessions(email: string, cwd: string): ClaudeSessionSummary[] {
const dir = join(claudeProjectsDir(email), projectSlug(cwd));
if (!existsSync(dir)) return [];
const sessions: ClaudeSessionSummary[] = [];
for (const file of readdirSync(dir)) {
if (!file.endsWith('.jsonl')) continue;
const summary = summarizeTranscript(join(dir, file), file.replace(/\.jsonl$/, ''));
if (summary) sessions.push(summary);
}
return sessions.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}