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:
Yaojia Wang
2026-06-18 08:04:04 +02:00
parent 25269cad3f
commit e36ca272ed
8 changed files with 366 additions and 1 deletions

121
src/http/history.ts Normal file
View 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 };
});
}

View File

@@ -37,6 +37,7 @@ import { loadConfig } from './config.js'
import { parseClientMessage, serialize } from './protocol.js'
import { isOriginAllowed } from './http/origin.js'
import { parseHookEvent } from './http/hook.js'
import { listSessions } from './http/history.js'
import { createSessionManager } from './session/manager.js'
import { detachWs, writeInput, resize } from './session/session.js'
import type { Config } from './types.js'
@@ -105,6 +106,11 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
const publicDir = path.join(__dirname, '..', 'public')
app.use(express.static(publicDir))
// ── Claude Code history (O2) — list past sessions for the resume browser ──
app.get('/sessions', (_req, res) => {
res.json(listSessions(50))
})
// ── Claude Code hook side-channel (H2) ────────────────────────────────────
// Hooks running inside a spawned shell POST status here (loopback only — the
// shell runs on the host). sessionId arrives in the X-Webterm-Session header.