feat(v0.3): O2 — resume past Claude sessions
- src/http/history.ts: listSessions() reads ~/.claude/projects/*/*.jsonl
(mtime-sorted, top 50), parseSessionMeta() extracts cwd + first user prompt
(pure, 4 unit tests); GET /sessions
- terminal-session: opts.initialInput (typed ~700ms after the shell is ready)
- tabs.newTabForResume(cwd,id): new tab in the project dir running
'claude --resume <id>'
- public/history.ts: 🕘 panel listing sessions (project · time · preview) with
Resume buttons
- Verified: /sessions returns 44 real sessions; panel lists project/time/preview.
216 tests green.
This commit is contained in:
121
src/http/history.ts
Normal file
121
src/http/history.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* src/http/history.ts (O2) — list past Claude Code sessions for the resume browser.
|
||||
*
|
||||
* Reads ~/.claude/projects/<encoded>/<session-uuid>.jsonl. Each session's first
|
||||
* user message is the preview and its `cwd` is the project directory (so resume
|
||||
* can `claude --resume <id>` in the right place). Read-only; best-effort.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
export interface HistorySession {
|
||||
id: string;
|
||||
cwd: string;
|
||||
project: string; // last path segment, for display
|
||||
mtimeMs: number;
|
||||
preview: string;
|
||||
}
|
||||
|
||||
/** Extract cwd + the first real user prompt from a session jsonl (pure, testable). */
|
||||
export function parseSessionMeta(jsonlText: string): { cwd: string | null; preview: string } {
|
||||
let cwd: string | null = null;
|
||||
let preview = '';
|
||||
|
||||
for (const line of jsonlText.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === '') continue;
|
||||
let obj: Record<string, unknown>;
|
||||
try {
|
||||
obj = JSON.parse(trimmed) as Record<string, unknown>;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (cwd === null && typeof obj['cwd'] === 'string') cwd = obj['cwd'];
|
||||
|
||||
if (preview === '' && obj['type'] === 'user') {
|
||||
const msg = obj['message'];
|
||||
if (msg !== null && typeof msg === 'object') {
|
||||
const content = (msg as Record<string, unknown>)['content'];
|
||||
if (typeof content === 'string') {
|
||||
preview = content;
|
||||
} else if (Array.isArray(content)) {
|
||||
for (const part of content) {
|
||||
if (part !== null && typeof part === 'object') {
|
||||
const p = part as Record<string, unknown>;
|
||||
if (p['type'] === 'text' && typeof p['text'] === 'string') {
|
||||
preview = p['text'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (cwd !== null && preview !== '') break;
|
||||
}
|
||||
|
||||
return { cwd, preview: preview.replace(/\s+/g, ' ').trim().slice(0, 120) };
|
||||
}
|
||||
|
||||
function readHead(file: string, max: number): string {
|
||||
try {
|
||||
const fd = fs.openSync(file, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(max);
|
||||
const n = fs.readSync(fd, buf, 0, max, 0);
|
||||
return buf.subarray(0, n).toString('utf8');
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Most-recently-modified Claude Code sessions across all projects. */
|
||||
export function listSessions(limit = 50): HistorySession[] {
|
||||
const root = path.join(os.homedir(), '.claude', 'projects');
|
||||
let dirs: string[];
|
||||
try {
|
||||
dirs = fs.readdirSync(root);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files: Array<{ id: string; file: string; mtimeMs: number }> = [];
|
||||
for (const dir of dirs) {
|
||||
const dirPath = path.join(root, dir);
|
||||
let names: string[];
|
||||
try {
|
||||
names = fs.readdirSync(dirPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const name of names) {
|
||||
if (!name.endsWith('.jsonl')) continue;
|
||||
try {
|
||||
const st = fs.statSync(path.join(dirPath, name));
|
||||
files.push({
|
||||
id: name.slice(0, -'.jsonl'.length),
|
||||
file: path.join(dirPath, name),
|
||||
mtimeMs: st.mtimeMs,
|
||||
});
|
||||
} catch {
|
||||
// skip unreadable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
files.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||||
|
||||
return files.slice(0, limit).map((f) => {
|
||||
const meta = parseSessionMeta(readHead(f.file, 256 * 1024));
|
||||
const cwd = meta.cwd ?? '';
|
||||
const project = cwd !== '' ? (cwd.split('/').filter(Boolean).pop() ?? cwd) : 'unknown';
|
||||
return { id: f.id, cwd, project, mtimeMs: f.mtimeMs, preview: meta.preview };
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user