Rewrite session discovery to be filesystem-first, addressing the widespread bug where Claude Code's sessions-index.json files are unreliable (87 MB of unindexed sessions, 17% loss rate across all projects). Architecture: Three-tier metadata lookup Tier 1 - Index validation (instant): - Parse sessions-index.json into Map<sessionId, IndexEntry> - Validate entry.modified against actual file stat.mtimeMs - Use 1s tolerance to account for ISO string → filesystem mtime rounding - Trust content fields only (messageCount, summary, firstPrompt) - Timestamps always come from fs.stat, never from index Tier 2 - Persistent cache hit (instant): - Check MetadataCache by (filePath, mtimeMs, size) - If match, use cached metadata - Survives server restarts Tier 3 - Full JSONL parse (~5-50ms/file): - Call extractSessionMetadata() with shared parser helpers - Cache result for future lookups Key correctness guarantees: - All .jsonl files appear regardless of index state - SessionEntry timestamps always from fs.stat (list ordering never stale) - Message counts exact (shared helpers ensure parser parity) - Duration computed from JSONL timestamps, not index Performance: - Bounded concurrency: 32 concurrent operations per project - mapWithLimit() prevents file handle exhaustion - Warm start <1s (stat all files, in-memory lookups) - Cold start ~3-5s for 3,103 files (stat + parse phases) TOCTOU handling: - Files that disappear between readdir and stat: silently skipped - Files that disappear between stat and read: silently skipped - File actively being written: partial parse handled gracefully Include PRD document that drove this implementation with detailed requirements, edge cases, and verification plan. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
314 lines
19 KiB
Markdown
314 lines
19 KiB
Markdown
# PRD: JSONL-First Session Discovery
|
|
|
|
## Status: Ready for Implementation
|
|
|
|
## Context
|
|
|
|
Session viewer relies exclusively on `sessions-index.json` files that Claude Code maintains. These indexes are unreliable — a known, widespread bug with multiple open GitHub issues ([#22030](https://github.com/anthropics/claude-code/issues/22030), [#21610](https://github.com/anthropics/claude-code/issues/21610), [#18619](https://github.com/anthropics/claude-code/issues/18619), [#22114](https://github.com/anthropics/claude-code/issues/22114)).
|
|
|
|
### Root cause
|
|
|
|
Claude Code updates `sessions-index.json` only at session end. If a session crashes, is killed, or is abandoned, the JSONL file is written but the index is never updated. Multiple concurrent Claude instances can also corrupt the index (last-write-wins on a single JSON file). There is no reindex command and no background repair process.
|
|
|
|
### Impact on this system
|
|
|
|
- **542 unindexed JSONL files** across all projects (87 MB total)
|
|
- **48 unindexed in last 7 days** (30.8 MB)
|
|
- **13 projects** have JSONL session files but no index at all
|
|
- **Zero sessions from today** (Feb 4, 2026) appear in any index
|
|
- **3,103 total JSONL files** vs **2,563 indexed entries** = 17% loss rate
|
|
|
|
### Key insight
|
|
|
|
The `.jsonl` files are the source of truth. The index is an unreliable convenience cache. The session viewer must treat it that way.
|
|
|
|
## Requirements
|
|
|
|
### Must have
|
|
|
|
1. **All sessions with a `.jsonl` file must appear in the session list**, regardless of whether they're in `sessions-index.json`
|
|
2. **Exact message counts** — no estimates, no approximations. Contract: Tier 3 extraction MUST reuse the same line-classification logic as `parseSessionContent` (shared helper), so list counts cannot drift from detail parsing.
|
|
3. **Performance**: Warm start (cache exists, few changes) must complete under 1 second. Cold start (no cache) is acceptable up to 5 seconds for first request
|
|
4. **Correctness over speed** — never show stale metadata if the file has been modified
|
|
5. **Zero config** — works out of the box with no setup or external dependencies
|
|
|
|
### Should have
|
|
|
|
6. Session `summary` extracted from the last `type="summary"` line in the JSONL
|
|
7. Session `firstPrompt` extracted from the first non-system-reminder user message
|
|
8. Session `duration` MUST be derivable without relying on `sessions-index.json` — extract first and last timestamps from JSONL when index is missing or stale
|
|
9. Persistent metadata cache survives server restarts
|
|
|
|
### Won't have (this iteration)
|
|
|
|
- Real-time push updates (sessions appearing in UI without refresh)
|
|
- Background file watcher daemon
|
|
- Integration with `cass` as a search/indexing backend
|
|
- Rebuilding Claude Code's `sessions-index.json`
|
|
|
|
## Technical Design
|
|
|
|
### Architecture: Filesystem-primary with tiered metadata lookup
|
|
|
|
```
|
|
discoverSessions()
|
|
|
|
|
+-- For each project directory under ~/.claude/projects/:
|
|
| |
|
|
| +-- fs.readdir() --> list all *.jsonl files
|
|
| +-- Read sessions-index.json (optional, used as pre-populated cache)
|
|
| |
|
|
| +-- Batch stat all .jsonl files (bounded concurrency)
|
|
| | Files that disappeared between readdir and stat are silently skipped (TOCTOU race)
|
|
| |
|
|
| +-- For each .jsonl file:
|
|
| | |
|
|
| | +-- Tier 1: Check index
|
|
| | | Entry exists AND normalize(index.modified) matches stat mtime?
|
|
| | | --> Use index content data (messageCount, summary, firstPrompt)
|
|
| | | --> Use stat-derived timestamps for created/modified (always)
|
|
| | |
|
|
| | +-- Tier 2: Check persistent metadata cache
|
|
| | | path + mtimeMs + size match?
|
|
| | | --> Use cached metadata (fast path)
|
|
| | |
|
|
| | +-- Tier 3: Extract metadata from JSONL content
|
|
| | Read file, lightweight parse using shared line iterator + counting helper
|
|
| | --> Cache result for future lookups
|
|
| |
|
|
| +-- Collect SessionEntry[] for this project
|
|
|
|
|
+-- Merge all projects
|
|
+-- Sort by modified (descending) — always stat-derived, never index-derived
|
|
+-- Async: persist metadata cache to disk (if dirty)
|
|
```
|
|
|
|
### Tier explanation
|
|
|
|
| Tier | Source | Speed | When used | Trusts from source |
|
|
|------|--------|-------|-----------|--------------------|
|
|
| 1 | `sessions-index.json` | Instant (in-memory lookup) | Index exists, entry present, `normalize(modified)` matches actual file mtime | `messageCount`, `summary`, `firstPrompt` only. Timestamps always from stat. |
|
|
| 2 | Persistent metadata cache | Instant (in-memory lookup) | Index missing/stale, but file hasn't changed since last extraction (mtimeMs + size match) | All cached fields |
|
|
| 3 | JSONL file parse | ~5-50ms/file | New or modified file, not in any cache | Extracted fresh |
|
|
|
|
Tier 1 reuses Claude's index when it's valid — no wasted work. The index `modified` field (ISO string) is normalized to milliseconds and compared against the real file `stat.mtimeMs`. If the index is missing or corrupt, discovery continues with Tier 2 and 3 without error. Even when Tier 1 is valid, `created` and `modified` timestamps on the `SessionEntry` always come from `fs.stat` — the index is a content cache only.
|
|
|
|
### Tier 1: Index validation details
|
|
|
|
The actual `sessions-index.json` format has `created` and `modified` as ISO strings, not a `fileMtime` field. Tier 1 validation must:
|
|
|
|
1. Map JSONL filename to sessionId: `sessionId := path.basename(jsonlFile, '.jsonl')`
|
|
2. Look up `sessionId` in the index `Map<string, IndexEntry>`
|
|
3. Compare `new Date(entry.modified).getTime()` against `stat.mtimeMs` — reject if they differ by more than 1000ms (accounts for ISO string → filesystem mtime rounding)
|
|
4. If the index entry has no `modified` field, skip Tier 1 (fall through to Tier 2)
|
|
5. When Tier 1 is valid, trust only content fields (`messageCount`, `summary`, `firstPrompt`). The `created`/`modified` on the resulting `SessionEntry` must come from `stat.birthtimeMs`/`stat.mtimeMs` respectively — this ensures list ordering is never stale even within the 1s mtime tolerance window.
|
|
|
|
### Shared line-iteration and counting (parser parity contract)
|
|
|
|
The biggest correctness risk in this design is duplicating any JSONL processing logic. The real parser in `session-parser.ts` has non-trivial expansion rules:
|
|
|
|
- User array content: expands `tool_result` and `text` blocks into separate messages
|
|
- `system-reminder` detection reclassifies user `text` blocks as `system_message`
|
|
- Assistant array content: `thinking`, `text`, and `tool_use` each become separate messages
|
|
- `progress`, `file-history-snapshot`, `summary` → 1 message each
|
|
- `system`, `queue-operation` → 0 (skipped)
|
|
|
|
It also has error-handling behavior: malformed/truncated JSON lines are skipped (common when sessions crash mid-write). If the metadata extractor and the full parser handle malformed lines differently, counts will drift.
|
|
|
|
Rather than reimplementing any of these rules, extract shared helpers at two levels:
|
|
|
|
```typescript
|
|
// In session-parser.ts (or a shared module):
|
|
|
|
// Level 1: Line iteration with consistent error handling
|
|
// Splits content by newlines, JSON.parse each, skips malformed lines identically
|
|
// to how parseSessionContent handles them. Returns parse error count for diagnostics.
|
|
export function forEachJsonlLine(
|
|
content: string,
|
|
onLine: (parsed: RawLine, lineIndex: number) => void
|
|
): { parseErrors: number }
|
|
|
|
// Level 2: Classification and counting (called per parsed line)
|
|
export function countMessagesForLine(parsed: RawLine): number
|
|
export function classifyLine(parsed: RawLine): LineClassification
|
|
```
|
|
|
|
Both `extractSessionMetadata()` and `parseSessionContent()` use `forEachJsonlLine()` for iteration, ensuring identical malformed-line handling. Both use `countMessagesForLine()` for counting. This two-level sharing guarantees that list counts can never drift from detail-view counts, regardless of future parser changes or edge cases in error handling.
|
|
|
|
### Metadata extraction (Tier 3)
|
|
|
|
A lightweight `extractSessionMetadata()` function reads the JSONL file and extracts only what the list view needs, without building full message content strings:
|
|
|
|
```typescript
|
|
export function extractSessionMetadata(content: string): SessionMetadata
|
|
```
|
|
|
|
Implementation:
|
|
|
|
1. Iterate lines via `forEachJsonlLine(content, ...)` — the shared iterator with identical malformed-line handling as the main parser
|
|
2. Call `countMessagesForLine(parsed)` per line — the shared helper that uses the **same classification rules** as `parseSessionContent` in `session-parser.ts`
|
|
3. Extract `firstPrompt`: content of the first user message that isn't a `<system-reminder>`, truncated to 200 characters
|
|
4. Extract `summary`: the `summary` field from the last `type="summary"` line
|
|
5. Capture first and last `timestamp` fields for duration computation
|
|
|
|
No string building, no `JSON.stringify`, no markdown processing — just counting, timestamp capture, and first-match extraction. This is exact (matches `parseSessionContent().length` via shared helpers) but 2-3x faster than full parsing.
|
|
|
|
### Persistent metadata cache
|
|
|
|
**Location:** `~/.cache/session-viewer/metadata.json`
|
|
|
|
```typescript
|
|
interface CacheFile {
|
|
version: 1;
|
|
entries: Record<string, { // keyed by absolute file path
|
|
mtimeMs: number;
|
|
size: number;
|
|
messageCount: number;
|
|
firstPrompt: string;
|
|
summary: string;
|
|
created: string; // ISO string from file birthtime
|
|
modified: string; // ISO string from file mtime
|
|
firstTimestamp: string; // ISO from first JSONL line with timestamp
|
|
lastTimestamp: string; // ISO from last JSONL line with timestamp
|
|
}>;
|
|
}
|
|
```
|
|
|
|
Behavior:
|
|
- Loaded once on first `discoverSessions()` call
|
|
- Entries validated by `(mtimeMs, size)` — if either changes, entry is re-extracted via Tier 3
|
|
- Written to disk asynchronously using a dirty-flag write-behind strategy: only when cache has new/updated entries, coalescing multiple discovery passes, non-blocking
|
|
- Flush any pending write on process exit (`SIGTERM`, `SIGINT`) and graceful server shutdown — prevents losing cache updates when the server stops before the async write fires
|
|
- Corrupt or missing cache file triggers graceful fallback (all files go through Tier 3, cache rebuilt)
|
|
- Atomic writes: write to temp file, then rename (prevents corruption from crashes during write)
|
|
- Stale entries (file no longer exists on disk) are pruned on save
|
|
|
|
### Concurrency model
|
|
|
|
Cold start with 3,103 files requires bounded parallelism to avoid file-handle exhaustion and IO thrash, while still meeting the <5s target:
|
|
|
|
- **Stat phase**: Batch all `fs.stat()` calls with concurrency limit (e.g., 64). This classifies each file into Tier 1/2 (cache hit) or Tier 3 (needs parse). Files that fail stat (ENOENT from deletion race, EACCES) are silently skipped with a debug log.
|
|
- **Parse phase**: Process Tier-3 misses with bounded concurrency (e.g., 8). Each parse reads + iterates via shared `forEachJsonlLine()` + shared counter. With max file size 4.5MB, each parse is ~5-50ms.
|
|
- Use a simple async work queue (e.g., `p-limit` or hand-rolled semaphore). No worker threads needed for this IO-bound workload.
|
|
|
|
### Performance expectations
|
|
|
|
| Scenario | Estimated time |
|
|
|----------|---------------|
|
|
| Cold start (no cache, no index) | ~3-5s for 3,103 files (~500MB), bounded concurrency: stat@64, parse@8 |
|
|
| Warm start (cache exists, few changes) | ~300-500ms (stat all files at bounded concurrency, in-memory lookups) |
|
|
| Incremental (cache + few new sessions) | ~500ms + ~50ms per new file |
|
|
| Subsequent API calls within 30s TTL | <1ms (in-memory session list cache) |
|
|
|
|
### Existing infrastructure leveraged
|
|
|
|
- **30-second in-memory cache** in `sessions.ts` (`getCachedSessions()`) — unchanged, provides the fast path for repeated API calls
|
|
- **`?refresh=1` query parameter** — forces cache invalidation, unchanged
|
|
- **Concurrent request deduplication** via `cachePromise` pattern — unchanged
|
|
- **Security validations** — path traversal rejection, containment checks, `.jsonl` extension enforcement — applied to filesystem-discovered files identically
|
|
|
|
## Implementation scope
|
|
|
|
### Checkpoints
|
|
|
|
#### CP0 — Parser parity foundations
|
|
- Extract `forEachJsonlLine()` shared line iterator from existing parser
|
|
- Extract `countMessagesForLine()` and `classifyLine()` shared helpers
|
|
- Refactor `extractMessages()` to use these internally (no behavior change to parseSessionContent)
|
|
- Tests verify identical behavior on malformed/truncated lines
|
|
|
|
#### CP1 — Filesystem-first correctness
|
|
- All `.jsonl` sessions appear even with missing/corrupt index
|
|
- `extractSessionMetadata()` uses shared line iterator + counting helpers; exact counts verified by tests
|
|
- Stat-derived `created`/`modified` are the single source for SessionEntry timestamps and list ordering
|
|
- Duration computed from JSONL timestamps, not index
|
|
- TOCTOU races (readdir/stat, stat/read) handled gracefully — disappeared files silently skipped
|
|
|
|
#### CP2 — Persistent cache
|
|
- Atomic writes with dirty-flag write-behind; prune stale entries
|
|
- Invalidation keyed on `(mtimeMs, size)`
|
|
- Flush pending writes on process exit / server shutdown
|
|
|
|
#### CP3 — Index fast path (Tier 1)
|
|
- Parse index into Map; normalize `modified` ISO → ms; validate against stat mtime with 1s tolerance
|
|
- sessionId mapping: `basename(file, '.jsonl')`
|
|
- Tier 1 trusts content fields only; timestamps always from stat
|
|
|
|
#### CP4 — Performance hardening
|
|
- Bounded concurrency for stat + parse phases
|
|
- Warm start <1s verified on real dataset
|
|
|
|
### Modified files
|
|
|
|
**`src/server/services/session-parser.ts`**
|
|
|
|
1. Extract `forEachJsonlLine(content, onLine): { parseErrors: number }` — shared line iterator with consistent malformed-line handling
|
|
2. Extract `countMessagesForLine(parsed: RawLine): number` — shared counting helper
|
|
3. Extract `classifyLine(parsed: RawLine): LineClassification` — shared classification
|
|
4. Refactor `extractMessages()` to use these shared helpers internally (no behavior change to parseSessionContent)
|
|
|
|
**`src/server/services/session-discovery.ts`**
|
|
|
|
1. Add `extractSessionMetadata(content: string): SessionMetadata` — lightweight JSONL metadata extractor using shared line iterator + counting helper
|
|
2. Add `MetadataCache` class — persistent cache with load/get/set/save, dirty-flag write-behind, shutdown flush
|
|
3. Rewrite per-project discovery loop — filesystem-first, tiered metadata lookup with bounded concurrency
|
|
4. Read `sessions-index.json` as optimization only — parse into `Map<sessionId, IndexEntry>`, normalize `modified` to ms, validate against stat mtime before trusting
|
|
5. Register shutdown hooks for cache flush on `SIGTERM`/`SIGINT`
|
|
|
|
### Unchanged files
|
|
|
|
- `src/server/routes/sessions.ts` — existing caching layer works as-is
|
|
- `src/shared/types.ts` — `SessionEntry` type already has `duration?: number`
|
|
- All client components — no changes needed
|
|
|
|
### New tests
|
|
|
|
- Unit test: `forEachJsonlLine()` skips malformed lines identically to how `parseSessionContent` handles them
|
|
- Unit test: `forEachJsonlLine()` reports parse error count for truncated/corrupted lines
|
|
- Unit test: `countMessagesForLine()` matches actual `extractMessages()` output length on sample lines
|
|
- Unit test: `extractSessionMetadata()` output matches `parseSessionContent().length` on sample fixtures (including malformed/truncated lines)
|
|
- Unit test: Duration extracted from JSONL timestamps matches expected values
|
|
- Unit test: SessionEntry `created`/`modified` always come from stat, even when Tier 1 index data is trusted
|
|
- Unit test: Tier 1 validation rejects stale index entries (mtime mismatch beyond 1s tolerance)
|
|
- Unit test: Tier 1 handles missing `modified` field gracefully (falls through to Tier 2)
|
|
- Unit test: Discovery works with no `sessions-index.json` present
|
|
- Unit test: Discovery silently skips files that disappear between readdir and stat (TOCTOU)
|
|
- Unit test: Cache hit/miss/invalidation behavior (mtimeMs + size)
|
|
- Unit test: Cache dirty-flag only triggers write when entries changed
|
|
|
|
## Edge cases
|
|
|
|
| Scenario | Behavior |
|
|
|----------|----------|
|
|
| File actively being written | mtime changes between stat and read. Next discovery pass re-extracts. Partial JSONL handled gracefully (malformed lines skipped via shared `forEachJsonlLine`, same behavior as real parser). |
|
|
| Deleted session files | File in cache but gone from disk. Entry silently dropped, pruned from cache on next save. |
|
|
| File disappears between readdir and stat | TOCTOU race. Stat failure (ENOENT) silently skipped with debug log. |
|
|
| File disappears between stat and read | Read failure silently skipped; file excluded from results. Next pass re-discovers if it reappears. |
|
|
| Index entry with wrong mtime | Tier 1 validation rejects it (>1s tolerance). Falls through to Tier 2/3. |
|
|
| Index entry with no `modified` field | Tier 1 skips it. Falls through to Tier 2/3. |
|
|
| Index `modified` in seconds vs milliseconds | Normalization handles both ISO strings and numeric timestamps. |
|
|
| Cache file locked or unwritable | Extraction still works, just doesn't persist. Warning logged to stderr. |
|
|
| Very large files | 4.5MB max observed. Tier 3 parse ~50ms. Acceptable. |
|
|
| Concurrent server restarts | Cache writes are atomic (temp file + rename). |
|
|
| Server killed before async cache write | Shutdown hooks flush pending writes on SIGTERM/SIGINT. Hard kills (SIGKILL) may lose updates — acceptable, cache rebuilt on next cold start. |
|
|
| Empty JSONL files | Returns `messageCount: 0`, empty `firstPrompt`, `summary`, and timestamps. Duration: 0. |
|
|
| Projects with no index file | Discovery proceeds normally via Tier 2/3. Common case (13 projects). |
|
|
| Non-JSONL files in project dirs | Filtered out by `.jsonl` extension check in `readdir` results. |
|
|
| File handle exhaustion | Bounded concurrency (stat@64, parse@8) prevents opening thousands of handles. |
|
|
| Future parser changes (new message types) | Shared line iterator + counting helper in session-parser.ts means Tier 3 automatically stays in sync. |
|
|
| Malformed JSONL lines (crash mid-write) | Shared `forEachJsonlLine()` skips identically in both metadata extraction and full parsing — no count drift. |
|
|
|
|
## Verification plan
|
|
|
|
1. Start dev server, confirm today's sessions appear immediately in the session list
|
|
2. Compare message counts for indexed sessions: Tier 1 data vs Tier 3 extraction (should match)
|
|
3. Verify duration is shown for sessions that have no index entry (JSONL-only sessions)
|
|
4. Delete a `sessions-index.json`, refresh — verify all sessions for that project still appear with correct counts and durations
|
|
5. Run existing test suite: `npm test`
|
|
6. Run new unit tests for shared line iterator, counting helper, `extractSessionMetadata()`, and `MetadataCache`
|
|
7. Verify `created`/`modified` in session list come from stat, not index (compare with `ls -l` output)
|
|
8. Verify cold start performance: delete `~/.cache/session-viewer/metadata.json`, time the first API request
|
|
9. Verify warm start performance: time a subsequent server start with cache in place
|
|
10. Verify cache dirty-flag: repeated refreshes with no file changes should not write cache to disk
|
|
11. Kill server with SIGTERM, restart — verify cache was flushed (no full re-parse on restart)
|