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>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { getClaudeSessionsCwd, listClaudeSessions } from './claude-sessions';
|
||||
|
||||
export const chatRouter = createRouter();
|
||||
|
||||
// GET /chat/sessions — the /chat route's conversations, read straight from Claude's own transcript
|
||||
// store for the dedicated claude_sessions working directory (Claude is the source of truth).
|
||||
chatRouter.get('/sessions', (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
const sessions = listClaudeSessions(email, getClaudeSessionsCwd(email));
|
||||
return ctx.json({ sessions });
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
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));
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import { desktopRouter } from './api/desktop/rest';
|
||||
import { appsRouter, appServeRouter } from './api/apps';
|
||||
import { bugReportRouter } from './api/bug-report/bug-report';
|
||||
import { savedSessionsRouter } from './api/saved-sessions/saved-sessions';
|
||||
import { chatRouter } from './api/chat/chat';
|
||||
import { pipelineJobsRouter } from './api/tasks/pipeline-jobs-routes';
|
||||
import { broadcastPanelRefresh } from './api/terminal/websocket';
|
||||
import { CustomError } from './custom-errors';
|
||||
@@ -103,6 +104,7 @@ protectedRouter.route('/browser', browserRouter);
|
||||
protectedRouter.route('/apps', appsRouter);
|
||||
protectedRouter.route('/bug-report', bugReportRouter);
|
||||
protectedRouter.route('/saved-sessions', savedSessionsRouter);
|
||||
protectedRouter.route('/chat', chatRouter);
|
||||
protectedRouter.route('/pipeline-jobs', pipelineJobsRouter);
|
||||
protectedRouter.route('/jobs', pipelineJobsRouter); // unified jobs API (script + pipeline); /pipeline-jobs kept for the existing UI
|
||||
desktopRouter.use(superAdminMiddleware);
|
||||
|
||||
Reference in New Issue
Block a user