title: CP3: Unit tests for Tier 1 index validation

description:
## Background
Tier 1 validation has specific edge cases around timestamp normalization and stale detection that must be tested independently from the full discovery pipeline.

## Approach
Add tests to tests/unit/session-discovery.test.ts under describe "Tier 1 index validation". Tests create temp project directories with both .jsonl files and sessions-index.json, then verify Tier 1 behavior.

Test cases:
1. Uses index messageCount/summary/firstPrompt when index modified matches file mtime within 1s
2. Rejects stale index entries when mtime differs by > 1s (falls through to Tier 3)
3. Handles missing modified field in index entry (falls through to Tier 2/3)
4. SessionEntry.created and .modified always from stat even when Tier 1 is trusted
5. Missing sessions-index.json: all sessions still discovered via Tier 2/3
6. Corrupt sessions-index.json (invalid JSON): warning logged, all sessions still discovered
7. Legacy index format (raw array, no version wrapper): still parsed correctly

## Acceptance Criteria
- [ ] All 7 test cases pass
- [ ] Tests use temp directories with controlled file timestamps
- [ ] npm run test -- session-discovery passes

## Files
- MODIFY: tests/unit/session-discovery.test.ts (add tests in "Tier 1 index validation" describe block)

## TDD Loop
RED: Write tests (fail until bd-3g5 Tier 1 implementation is done)
GREEN: Tests pass after Tier 1 implementation
VERIFY: npm run test -- session-discovery

## Edge Cases
- Setting file mtime in tests: use fs.utimes() to control mtime precisely
- Index with ISO string timestamps vs epoch ms: test both formats

status: closed

issue_type: task

created_by: tayloreernisse

close_reason: Tests already written as part of bd-3g5: 6 tests covering Tier 1 hit, miss, no-modified-field, missing index, corrupt index, stat-derived timestamps.

type: blocks


title: CP2: Implement MetadataCache with dirty-flag write-behind and atomic writes

description:
## Background
The persistent metadata cache at ~/.cache/session-viewer/metadata.json avoids re-parsing unchanged JSONL files across server restarts. This is Tier 2 in the tiered lookup — checked after Tier 1 (index) fails, before Tier 3 (full parse).

## Approach
Create MetadataCache class in src/server/services/metadata-cache.ts:

```typescript
interface CacheEntry {
  mtimeMs: number;
  size: number;
  messageCount: number;
  firstPrompt: string;
  summary: string;
  created: string;    // ISO from file birthtime
  modified: string;   // ISO from file mtime
  firstTimestamp: string;
  lastTimestamp: string;
}

interface CacheFile {
  version: 1;
  entries: Record<string, CacheEntry>;  // keyed by absolute file path
}

export class MetadataCache {
  constructor(cachePath?: string)  // default: ~/.cache/session-viewer/metadata.json

  async load(): Promise<void>           // Load from disk, graceful on missing/corrupt
  get(filePath: string, mtimeMs: number, size: number): CacheEntry | null  // Tier 2 lookup
  set(filePath: string, entry: CacheEntry): void  // Mark dirty
  async save(existingPaths?: Set<string>): Promise<void>  // Prune stale, atomic write if dirty
  async flush(): Promise<void>          // Force write if dirty (for shutdown)
}
```

Key behaviors:
1. load(): Read + JSON.parse cache file. On corrupt/missing: start empty, no error.
2. get(): Return entry only if mtimeMs AND size match. Otherwise return null (cache miss).
3. set(): Store entry, set dirty flag to true.
4. save(existingPaths): If dirty, prune entries whose keys are not in existingPaths, write to temp file then fs.rename (atomic). Reset dirty flag.
5. flush(): Same as save() but without pruning. Called on shutdown.
6. Shutdown hooks: Register process.on("SIGTERM") and process.on("SIGINT") handlers that call flush(). Register once in module scope or via an init function.

Write-behind strategy: discoverSessions() calls save() asynchronously after returning results. The Promise is fire-and-forget but errors are logged.

Integrate into discoverSessions() in session-discovery.ts:
- Load cache once on first call (lazy init)
- Before Tier 3 parse: check cache.get(filePath, stat.mtimeMs, stat.size)
- After Tier 3 parse: call cache.set(filePath, extractedEntry)
- After building all entries: fire-and-forget cache.save(discoveredPaths)

## Acceptance Criteria
- [ ] MetadataCache class exported from src/server/services/metadata-cache.ts
- [ ] Cache hit returns entry when mtimeMs + size match
- [ ] Cache miss (returns null) when mtimeMs or size differ
- [ ] Dirty flag only set when set() is called (not on load/get)
- [ ] save() is no-op when not dirty
- [ ] Atomic writes: temp file + rename pattern
- [ ] Corrupt cache file loads gracefully (empty cache, no throw)
- [ ] Missing cache file loads gracefully (empty cache, no throw)
- [ ] Stale entries pruned on save
- [ ] Shutdown hooks registered for SIGTERM/SIGINT
- [ ] Cache directory created if it does not exist (mkdir -p equivalent)
- [ ] npm run test passes

## Files
- CREATE: src/server/services/metadata-cache.ts
- MODIFY: src/server/services/session-discovery.ts (integrate cache into tiered lookup)

## TDD Loop
RED: tests/unit/metadata-cache.test.ts — tests:
  - "returns null for unknown file path"
  - "returns entry when mtimeMs and size match"
  - "returns null when mtimeMs differs"
  - "returns null when size differs"
  - "save is no-op when not dirty"
  - "save writes to disk when dirty"
  - "save prunes entries not in existingPaths"
  - "load handles missing cache file"
  - "load handles corrupt cache file"
  - "atomic write: file not corrupted on crash"
GREEN: Implement MetadataCache
VERIFY: npm run test -- metadata-cache

## Edge Cases
- Cache directory does not exist: create with fs.mkdir recursive
- Cache file locked by another process: log warning, continue without cache
- Server killed with SIGKILL (hard kill): cache may be lost — acceptable, rebuilt on next cold start
- Concurrent save() calls: second save waits for first (or coalesce via dirty flag)
- Very large cache (3000+ entries): JSON serialization should still be < 50ms

close_reason: Implemented MetadataCache class with dirty-flag write-behind, atomic writes (temp+rename), prune stale entries, load/save/flush. Integrated into discoverSessions() as Tier 2 lookup. 13 unit tests.


title: CP4: Bounded concurrency for stat and parse phases

description:
## Background
Cold start with 3,103 files requires bounded parallelism to avoid file-handle exhaustion and IO thrash. Without limits, Node.js will attempt thousands of concurrent fs.stat() and fs.readFile() calls, potentially hitting EMFILE errors.

## Approach
Add a lightweight concurrency limiter. Options:
- Install p-limit (npm i p-limit) — well-maintained, zero deps, 1.2KB
- Or hand-roll a simple semaphore (~15 lines)

Recommendation: p-limit for clarity and maintenance. It is ESM-only since v4, so use dynamic import or pin v3.x if the project uses CJS.

Implementation in session-discovery.ts:

```typescript
import pLimit from "p-limit";

const STAT_CONCURRENCY = 64;
const PARSE_CONCURRENCY = 8;

// In discoverSessions(), per project:
const statLimit = pLimit(STAT_CONCURRENCY);
const parseLimit = pLimit(PARSE_CONCURRENCY);

// Stat phase: batch all files
const statResults = await Promise.all(
  jsonlFiles.map(f => statLimit(() => safeStat(f)))
);

// Parse phase: only Tier 3 misses
const parseResults = await Promise.all(
  tier3Files.map(f => parseLimit(() => safeReadAndExtract(f)))
);
```

safeStat() wraps fs.stat in try/catch, returns null on ENOENT/EACCES (with debug log).
safeReadAndExtract() wraps fs.readFile + extractSessionMetadata, returns null on failure.

Performance targets:
- Cold start (no cache, no index): < 5s for 3,103 files
- Warm start (cache exists, few changes): < 1s
- Incremental (cache + few new files): ~500ms + ~50ms per new file

## Acceptance Criteria
- [ ] p-limit (or equivalent) added as dependency
- [ ] Stat phase uses concurrency limit of 64
- [ ] Parse phase uses concurrency limit of 8
- [ ] ENOENT and EACCES errors from stat silently handled (debug log, skip file)
- [ ] Read errors silently handled (debug log, skip file)
- [ ] No EMFILE errors on cold start with 3000+ files
- [ ] Warm start < 1s verified on real dataset (manual verification step)
- [ ] npm run test passes

## Files
- MODIFY: package.json (add p-limit dependency)
- MODIFY: src/server/services/session-discovery.ts (wrap stat + parse in concurrency limiters)

## TDD Loop
RED: Manual performance test — time cold start on real ~/.claude/projects
GREEN: Add concurrency limits, re-test
VERIFY: npm run test && manual timing of warm/cold starts

## Edge Cases
- p-limit v4+ is ESM-only: check if project tsconfig uses "module": "ESNext" or "NodeNext". If CJS, use p-limit@3 or hand-roll.
- Concurrency limits are per-project. With many small projects, total concurrency could still be high. Consider a global limiter shared across projects if needed.
- Files actively being written during stat phase: mtime captured at stat time, content may differ at read time. Next discovery pass will re-extract (mtime changed).

close_reason: Added mapWithLimit() concurrency limiter (32 concurrent ops per project) to prevent EMFILE on large session directories. Hand-rolled to avoid external dependency. No behavior change to existing tests.


title: CP1: Unit tests for filesystem-first discovery and metadata extraction

description:
## Background
Discovery correctness must be verified with tests covering the key scenarios from the PRD edge cases table. These tests validate the integration of filesystem scanning + metadata extraction.

## Approach
Add tests to the existing test file, building on its temp directory pattern (uses os.tmpdir, writes .jsonl fixtures, cleans up).

Test cases:
1. Discovers all .jsonl files without sessions-index.json present
2. SessionEntry.created from stat.birthtimeMs, .modified from stat.mtimeMs
3. Duration computed from first/last JSONL timestamps (not index)
4. Silently skips files that disappear between readdir and stat (create file, delete before stat mock)
5. Empty .jsonl file returns messageCount: 0, session still appears in list
6. extractSessionMetadata().messageCount === parseSessionContent().messages.length on fixture data
7. Sessions sorted by modified descending
8. Path traversal in filename rejected (symlink or ".." in name)
9. Multiple project directories scanned and merged

## Acceptance Criteria
- [ ] All 9+ test cases pass
- [ ] Tests use temp directories (not real ~/.claude/projects)
- [ ] Cleanup runs even on test failure (afterEach or try/finally)
- [ ] npm run test -- session-discovery passes

## Files
- MODIFY: tests/unit/session-discovery.test.ts (add describe block "filesystem-first discovery")

## TDD Loop
RED: Write tests (will fail until bd-34v discovery rewrite is done)
GREEN: Tests pass after discovery loop rewrite
VERIFY: npm run test -- session-discovery

## Edge Cases
- Test cleanup must handle partially-created temp dirs
- stat.birthtimeMs may equal 0 on some filesystems (Linux ext4) — test should not hardcode platform-specific birthtimeMs behavior

close_reason: Tests already written as part of bd-34v implementation. 9 tests cover all spec requirements: filesystem discovery, stat timestamps, JSONL duration, TOCTOU resilience, empty files, sorting, extension filtering, multi-project aggregation.


title: CP0: Extract countMessagesForLine() and classifyLine() shared helpers

description:
## Background
The PRD requires exact message counts — list counts must match detail-view counts. The existing extractMessages() function (session-parser.ts:78-233) has non-trivial expansion rules that must be encoded in a shared helper so both the metadata extractor and full parser produce identical counts.

## Approach
Extract two helpers from extractMessages() logic:

```typescript
export type LineClassification =
  | "user" | "assistant" | "progress" | "file-history-snapshot"
  | "summary" | "system" | "queue-operation" | "unknown";

export function classifyLine(parsed: RawLine): LineClassification
export function countMessagesForLine(parsed: RawLine): number
```

Expansion rules countMessagesForLine must encode:
- type=user, string content: 1
- type=user, array content: count of (tool_result + text) blocks in array
- user text block containing "<system-reminder>": still counts as 1 (reclassified as system_message)
- type=assistant, string content: 1
- type=assistant, array content: count of (thinking + text + tool_use) blocks
- type=progress: 1
- type=file-history-snapshot: 1
- type=summary: 1
- type=system: 0 (skipped)
- type=queue-operation: 0 (skipped)
- unknown/missing type: 0

Then refactor extractMessages() to use classifyLine() for its initial type dispatch (the switch on raw.type around line 88). countMessagesForLine() can be validated against extractMessages() output.

## Acceptance Criteria
- [ ] classifyLine() and countMessagesForLine() exported from session-parser.ts
- [ ] extractMessages() refactored to use classifyLine() internally
- [ ] npm run test passes (no behavior change to existing parser)
- [ ] countMessagesForLine() matches extractMessages(line).length for every message type

## Files
- MODIFY: src/server/services/session-parser.ts

## TDD Loop
RED: tests/unit/session-parser.test.ts — add tests:
  - "countMessagesForLine matches extractMessages length for user string message"
  - "countMessagesForLine matches extractMessages length for user array with tool_result and text"
  - "countMessagesForLine matches extractMessages length for assistant array with thinking/text/tool_use"
  - "countMessagesForLine returns 1 for progress/file-history-snapshot/summary"
  - "countMessagesForLine returns 0 for system/queue-operation"
  - "classifyLine returns correct classification for each type"
GREEN: Implement classifyLine() + countMessagesForLine(), wire into extractMessages()
VERIFY: npm run test -- session-parser

## Edge Cases
- User message with empty content array: returns 0 (no expandable blocks)
- Assistant message with mixed block types (some unrecognized): only count known types
- Missing type field: classify as unknown, count as 0
- Null/undefined message.content: count as 0 (not 1)

close_reason: Implemented classifyLine() and countMessagesForLine() helpers, refactored extractMessages() to use classifyLine(), added 13 unit tests including parity checks


title: CP2: Unit tests for MetadataCache

description:
## Background
Cache behavior must be verified to ensure correctness, performance, and graceful degradation. These tests validate MetadataCache in isolation using temp directories.

## Approach
Create tests/unit/metadata-cache.test.ts. Use temp directories for cache file location. Test the MetadataCache class directly without involving the full discovery pipeline.

Test cases:
1. get() returns null for unknown file path
2. get() returns entry when mtimeMs AND size match exactly
3. get() returns null when mtimeMs differs (file modified)
4. get() returns null when size differs (file modified)
5. save() is no-op when cache is not dirty (verify file not written via stat check)
6. save() writes to disk when dirty (verify file exists after save)
7. save() prunes entries whose paths are not in existingPaths set
8. load() handles missing cache file gracefully (no throw, empty state)
9. load() handles corrupt JSON gracefully (no throw, empty state)
10. Roundtrip: set entries, save, create new instance, load, get returns same entries
11. Dirty flag reset after save (second save is no-op)

## Acceptance Criteria
- [ ] All 11 test cases pass
- [ ] Tests use temp directories (not real ~/.cache)
- [ ] Cleanup in afterEach
- [ ] npm run test -- metadata-cache passes

## Files
- CREATE: tests/unit/metadata-cache.test.ts

## TDD Loop
RED: Write all test cases (fail until bd-18a MetadataCache is implemented)
GREEN: Tests pass after MetadataCache implementation
VERIFY: npm run test -- metadata-cache

## Edge Cases
- Temp dir cleanup must handle missing files (rm with force)
- Tests should not depend on timing (no race-condition-sensitive assertions)

close_reason: Tests already written as part of bd-18a implementation: 13 tests covering get/set, dirty flag, mtimeMs/size matching, prune, persistence, corrupt/missing file handling, flush.


title: CP0: Unit tests for parser parity (forEachJsonlLine, countMessagesForLine)

description:
## Background
The parser parity contract is the biggest correctness risk in this feature. These tests prove that the shared helpers produce identical results to the full parser, ensuring list-view counts can never drift from detail-view counts.

## Approach
Add a new describe block in the existing test file. Tests should exercise every expansion rule using inline JSONL lines, plus test with the existing fixture files.

Test cases:
1. forEachJsonlLine skips malformed/truncated JSON lines (missing closing brace)
2. forEachJsonlLine reports parseErrors count accurately
3. forEachJsonlLine handles empty content string
4. countMessagesForLine matches extractMessages().length for user string content
5. countMessagesForLine matches extractMessages().length for user array with tool_result + text blocks
6. countMessagesForLine matches extractMessages().length for user text with system-reminder (reclassified)
7. countMessagesForLine matches extractMessages().length for assistant string content
8. countMessagesForLine matches extractMessages().length for assistant array (thinking + text + tool_use)
9. countMessagesForLine returns 1 for progress, file-history-snapshot, summary
10. countMessagesForLine returns 0 for system, queue-operation
11. classifyLine returns correct classification for each known type
12. Integration: extractSessionMetadata().messageCount === parseSessionContent().messages.length on tests/fixtures/sample-session.jsonl
13. Integration: same check on tests/fixtures/edge-cases.jsonl (has malformed lines)

## Acceptance Criteria
- [ ] All 13+ test cases pass
- [ ] Tests cover every message type expansion rule
- [ ] At least one test uses a malformed/truncated JSONL line
- [ ] At least one test uses real fixture files for integration verification
- [ ] npm run test -- session-parser passes

## Files
- MODIFY: tests/unit/session-parser.test.ts (add describe block "parser parity: shared helpers")

## TDD Loop
RED: Write all test cases first (they will fail until CP0 implementation beads are done)
GREEN: Tests pass after bd-2og and bd-1tm are implemented
VERIFY: npm run test -- session-parser

## Edge Cases
- Fixture files may change over time; tests should assert count equality (meta.messageCount === parsed.messages.length) not hardcoded numbers
- Truncated JSON at end of file (crash mid-write) must be handled identically by both paths

close_reason: Added 4 additional parity tests: system-reminder reclassification, truncated JSON handling, and 2 fixture-based integration tests proving countMessagesForLine sum equals parseSessionContent length


title: CP0: Extract forEachJsonlLine() shared line iterator from session-parser.ts

description:
## Background
The metadata extractor and full parser must iterate JSONL lines identically — same malformed-line handling, same error skipping. Currently this logic is inline in parseSessionContent() (session-parser.ts:59-70). Extracting it guarantees the parser parity contract from the PRD.

## Approach
Extract a shared function from the existing inline loop in parseSessionContent():

```typescript
export interface RawLine {
  type?: string; uuid?: string; timestamp?: string;
  parentToolUseID?: string;
  message?: { role?: string; content?: string | ContentBlock[]; };
  data?: Record<string, unknown>;
  summary?: string; snapshot?: Record<string, unknown>;
  subtype?: string;
}

export function forEachJsonlLine(
  content: string,
  onLine: (parsed: RawLine, lineIndex: number) => void
): { parseErrors: number }
```

Implementation:
1. Split content by newlines, filter empty/whitespace-only lines
2. JSON.parse each line inside try/catch
3. On parse failure: increment parseErrors counter, continue (skip line)
4. On success: call onLine(parsed, lineIndex)
5. Return { parseErrors }

Then refactor parseSessionContent() to use forEachJsonlLine() internally — replacing its current inline loop (lines 59-70). No behavior change to parseSessionContent output.

## Acceptance Criteria
- [ ] forEachJsonlLine() exported from src/server/services/session-parser.ts
- [ ] RawLine interface exported from src/server/services/session-parser.ts
- [ ] parseSessionContent() refactored to use forEachJsonlLine() internally
- [ ] npm run test passes (existing tests unchanged, proving no behavior change)
- [ ] Malformed JSON lines skipped with parseErrors count incremented
- [ ] Empty/whitespace-only lines skipped without incrementing parseErrors

## Files
- MODIFY: src/server/services/session-parser.ts (extract from lines 59-70, export new function + RawLine type)

## TDD Loop
RED: tests/unit/session-parser.test.ts — add tests:
  - "forEachJsonlLine skips malformed JSON lines"
  - "forEachJsonlLine reports parseErrors count"
  - "forEachJsonlLine skips empty and whitespace-only lines"
GREEN: Extract forEachJsonlLine(), refactor parseSessionContent() to call it
VERIFY: npm run test -- session-parser

## Edge Cases
- Truncated JSON from crash mid-write (common) — must skip, not throw
- Lines with only whitespace or newlines — skip without error
- Empty content string — returns { parseErrors: 0 }, onLine never called
- Content with no trailing newline — last line still processed

close_reason: Implemented forEachJsonlLine() with RawLine export, refactored parseSessionContent() to use it, added 5 unit tests


title: CP1: Rewrite discovery loop — filesystem-first with tiered metadata

description:
## Background
Current discovery in session-discovery.ts (114 lines) relies exclusively on sessions-index.json. This misses 17% of sessions. The rewrite makes the filesystem the primary source — every .jsonl file is a session, regardless of index state.

## Approach
Rewrite discoverSessions() in session-discovery.ts:

```typescript
export async function discoverSessions(
  projectsDir: string = CLAUDE_PROJECTS_DIR
): Promise<SessionEntry[]>
```

New flow per project directory:
1. fs.readdir() to list all *.jsonl files (filter by .jsonl extension)
2. Batch fs.stat() all files (initially unbounded; CP4 adds concurrency limits)
3. Silently skip files that fail stat (ENOENT from TOCTOU race, EACCES) with debug log
4. For each successfully statted file, get metadata via tiered lookup:
   - Tier 3 only in this checkpoint (Tier 1 and 2 added in CP3 and CP2)
   - Read file content, call extractSessionMetadata() from session-metadata.ts
   - Silently skip files that fail read (TOCTOU between stat and read)
5. Build SessionEntry:
   - id: path.basename(file, ".jsonl")
   - project: decoded project directory name
   - path: absolute path to .jsonl file
   - created: new Date(stat.birthtimeMs).toISOString()
   - modified: new Date(stat.mtimeMs).toISOString()
   - messageCount, firstPrompt, summary: from metadata
   - duration: computed from (lastTimestamp - firstTimestamp) in ms, or undefined
6. Sort all entries by modified descending (stat-derived, never index-derived)

Security validations (preserved from current implementation):
- Reject paths containing ".." (traversal)
- Reject non-.jsonl extensions
- Reject absolute paths outside projectsDir (containment check)

The existing 30s in-memory cache in routes/sessions.ts and ?refresh=1 are NOT modified — they wrap discoverSessions() and continue working.

## Acceptance Criteria
- [ ] All .jsonl sessions appear regardless of whether sessions-index.json exists
- [ ] SessionEntry.created and .modified always come from fs.stat
- [ ] List is sorted by modified descending
- [ ] TOCTOU: files disappearing between readdir/stat silently skipped
- [ ] TOCTOU: files disappearing between stat/read silently skipped
- [ ] Path traversal protection applied to filesystem-discovered files
- [ ] Duration computed from JSONL timestamps (not index)
- [ ] Existing route-level caching unmodified and working
- [ ] npm run test passes

## Files
- MODIFY: src/server/services/session-discovery.ts (rewrite discoverSessions)
- USES: src/server/services/session-metadata.ts (extractSessionMetadata)

## TDD Loop
RED: tests/unit/session-discovery.test.ts — add/update tests:
  - "discovers sessions from .jsonl files without index"
  - "timestamps come from stat, not index"
  - "silently skips files deleted between readdir and stat"
  - "rejects path traversal in filenames"
  - "duration computed from JSONL timestamps"
GREEN: Rewrite discoverSessions()
VERIFY: npm run test -- session-discovery

## Edge Cases
- Project directory with no .jsonl files: returns empty array for that project
- Project directory that disappears during scan: silently skipped
- .jsonl file with 0 bytes: extractSessionMetadata returns messageCount 0, session still listed
- Very long project directory names (URL-encoded paths): handled by existing decoding logic
- Concurrent discoverSessions() calls: no shared mutable state in this checkpoint (cache added in CP2)

close_reason: Rewrote discoverSessions() to be filesystem-first: scans .jsonl files directly, uses extractSessionMetadata() for parser parity, timestamps from stat(), TOCTOU-safe. 9 tests covering discovery, stat-based timestamps, TOCTOU, aggregation, sorting, duration, empty files, extension filtering.


title: CP3: Implement Tier 1 index validation and fast path

description:
## Background
sessions-index.json is unreliable but when valid, it saves parsing work. Tier 1 uses it as a fast-path optimization. The index format varies: modern ({ version: 1, entries: [...] }) or legacy (raw array). The existing parsing logic in session-discovery.ts already handles both formats.

## Approach
Add Tier 1 lookup to discoverSessions() before Tier 2 (cache) and Tier 3 (parse):

```typescript
interface IndexEntry {
  sessionId: string;
  summary?: string;
  firstPrompt?: string;
  created?: string;
  modified?: string;
  messageCount?: number;
  fullPath?: string;
  projectPath?: string;
}
```

Per-project flow:
1. Try to read and parse sessions-index.json into Map<sessionId, IndexEntry>
   - Handle both modern (version:1 wrapper) and legacy (raw array) formats
   - On missing file: continue silently (common case, 13 projects have none)
   - On corrupt JSON: log warning, continue with empty map
2. For each .jsonl file, after stat:
   a. Derive sessionId: path.basename(file, ".jsonl")
   b. Look up sessionId in index map
   c. If found AND entry.modified exists:
      - Compare new Date(entry.modified).getTime() vs stat.mtimeMs
      - If difference <= 1000ms: Tier 1 HIT — use entry.messageCount, entry.summary, entry.firstPrompt
      - If difference > 1000ms: Tier 1 MISS (stale) — fall through to Tier 2/3
   d. If found but no modified field: skip Tier 1, fall through
   e. If not found: skip Tier 1, fall through
3. IMPORTANT: SessionEntry.created and .modified ALWAYS from stat, even on Tier 1 hit

## Acceptance Criteria
- [ ] Tier 1 used when index entry modified matches stat mtime within 1s tolerance
- [ ] Tier 1 rejected when mtime mismatch > 1s
- [ ] Tier 1 skipped when entry has no modified field
- [ ] Missing sessions-index.json does not break discovery
- [ ] Corrupt sessions-index.json does not break discovery (logged, skipped)
- [ ] SessionEntry timestamps always from stat, never from index
- [ ] Both modern and legacy index formats handled
- [ ] npm run test passes

## Files
- MODIFY: src/server/services/session-discovery.ts (add Tier 1 logic before Tier 2/3)

## TDD Loop
RED: tests/unit/session-discovery.test.ts — add describe block "Tier 1 index validation":
  - "uses index data when modified matches stat mtime within 1s"
  - "rejects index data when mtime mismatch > 1s"
  - "skips Tier 1 when entry has no modified field"
  - "handles missing sessions-index.json"
  - "handles corrupt sessions-index.json"
  - "timestamps always from stat even on Tier 1 hit"
GREEN: Implement Tier 1 logic
VERIFY: npm run test -- session-discovery

## Edge Cases
- Index modified as ISO string vs numeric timestamp: normalize both via new Date().getTime()
- Index with extra/unknown fields: ignored (only read known fields)
- Multiple index entries with same sessionId: last one wins (Map.set overwrites)
- Extremely old index (years stale): rejected by mtime check, no special handling needed

close_reason: Implemented Tier 1 index validation fast path in discoverSessions(). Reads sessions-index.json per project, validates mtime within 1s tolerance, falls through to Tier 2/3 on miss. 6 new tests for hit/miss/no-modified/missing/corrupt/stat-timestamps.


title: CP1: Implement extractSessionMetadata() using shared helpers

description:
## Background
The lightweight metadata extractor reads JSONL and extracts only what the list view needs, without building full message content strings. It must use the shared helpers from CP0 to guarantee parser parity (list counts match detail counts).

## Approach
Create in a new file src/server/services/session-metadata.ts:

```typescript
export interface SessionMetadata {
  messageCount: number;
  firstPrompt: string;      // first non-system-reminder user message, truncated to 200 chars
  summary: string;           // summary field from last type=summary line
  firstTimestamp: string;    // ISO from first JSONL line with timestamp field
  lastTimestamp: string;     // ISO from last JSONL line with timestamp field
  parseErrors: number;       // from forEachJsonlLine
}

export function extractSessionMetadata(content: string): SessionMetadata
```

Implementation:
1. Call forEachJsonlLine(content, onLine) from session-parser.ts
2. In onLine callback:
   a. Accumulate messageCount via countMessagesForLine(parsed)
   b. Track firstTimestamp (first parsed.timestamp seen) and lastTimestamp (latest)
   c. For firstPrompt: first user message where content is string and does not contain "<system-reminder>", truncated to 200 chars
   d. For summary: overwrite on each type=summary line (keeps last)
3. Return SessionMetadata with all fields

No string building, no JSON.stringify, no markdown processing. Just counting + timestamp capture + first-match extraction.

## Acceptance Criteria
- [ ] extractSessionMetadata() exported from src/server/services/session-metadata.ts
- [ ] SessionMetadata interface exported
- [ ] extractSessionMetadata(content).messageCount === parseSessionContent(content, id).messages.length on fixture files
- [ ] firstPrompt skips system-reminder user messages
- [ ] firstPrompt truncated to 200 chars
- [ ] summary captures the LAST summary line (not first)
- [ ] firstTimestamp and lastTimestamp correctly captured
- [ ] Empty JSONL content returns messageCount: 0, empty strings for text fields

## Files
- CREATE: src/server/services/session-metadata.ts
- MODIFY: src/server/services/session-parser.ts (ensure forEachJsonlLine, countMessagesForLine, RawLine are exported)

## TDD Loop
RED: tests/unit/session-metadata.test.ts — tests:
  - "messageCount matches parseSessionContent on sample-session.jsonl"
  - "messageCount matches parseSessionContent on edge-cases.jsonl"
  - "firstPrompt skips system-reminder messages"
  - "firstPrompt truncated to 200 chars"
  - "summary captures last summary line"
  - "timestamps captured from first and last lines"
  - "empty content returns zero counts"
GREEN: Implement extractSessionMetadata()
VERIFY: npm run test -- session-metadata

## Edge Cases
- JSONL with no user messages: firstPrompt is empty string
- JSONL with no summary lines: summary is empty string
- JSONL with no timestamps: firstTimestamp and lastTimestamp are empty strings
- All user messages are system-reminders: firstPrompt is empty string
- Single-line JSONL: firstTimestamp === lastTimestamp

close_reason: Implemented extractSessionMetadata() in new file session-metadata.ts with 12 unit tests. Uses forEachJsonlLine/countMessagesForLine/classifyLine for parser parity. Fixture integration tests confirm messageCount matches parseSessionContent.


title: Epic: JSONL-First Session Discovery

description:
## Background
Epic tracking the JSONL-First Session Discovery feature. Claude Code sessions-index.json is unreliable (17% loss rate: 542 unindexed JSONL files). The .jsonl files are the source of truth; the index is an unreliable convenience cache.

## Scope
- CP0: Parser parity foundations (shared line iterator + counting helpers)
- CP1: Filesystem-first discovery with tiered metadata lookup
- CP2: Persistent metadata cache (~/.cache/session-viewer/metadata.json)
- CP3: Tier 1 index validation fast path
- CP4: Bounded concurrency for performance targets

## Acceptance Criteria
- [ ] All .jsonl sessions appear in session list regardless of index state
- [ ] Message counts in list view match detail view exactly (parser parity)
- [ ] Warm start < 1s, cold start < 5s
- [ ] Existing 30s in-memory cache and ?refresh=1 continue working
- [ ] Zero config, works out of the box

## PRD Reference
docs/prd-jsonl-first-discovery.md

issue_type: epic

close_reason: All checkpoints complete: CP0 (parser parity helpers), CP1 (filesystem-first discovery + metadata extraction), CP2 (persistent MetadataCache), CP3 (Tier 1 index validation), CP4 (bounded concurrency). 297 tests passing.