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:
2026-07-24 09:40:58 +00:00
co-authored by Claude Opus 4.8
parent 63819fc25e
commit 319cf39e84
3 changed files with 124 additions and 0 deletions
+12
View File
@@ -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 });
});
+110
View File
@@ -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));
}
+2
View File
@@ -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);