diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts new file mode 100644 index 00000000..841b19e6 --- /dev/null +++ b/src/servers/api/chat/chat.ts @@ -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 }); +}); diff --git a/src/servers/api/chat/claude-sessions.ts b/src/servers/api/chat/claude-sessions.ts new file mode 100644 index 00000000..d8f7c672 --- /dev/null +++ b/src/servers/api/chat/claude-sessions.ts @@ -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//.jsonl +// where is the working directory with every non-alphanumeric char replaced by '-'. +// A user's claude process runs with HOME = DATA_PATH//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)); +} diff --git a/src/servers/hono.ts b/src/servers/hono.ts index ac8b43f2..aa044052 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -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);