From df3e3fa25c77aed3e609823eb4ca1cf560958841 Mon Sep 17 00:00:00 2001 From: RemoteCTO <768254+RemoteCTO@users.noreply.github.com> Date: Fri, 13 Feb 2026 19:27:25 +0400 Subject: [PATCH 1/8] Add SEAMLESS_PROMPT_FILE support for custom compaction prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users can override the default compaction prompt by setting SEAMLESS_PROMPT_FILE to a file path. Tilde expansion supported. Validation relaxed for custom prompts — section-name check skipped, minimum length still enforced. --- lib/validate.mjs | 11 +++++++++-- scripts/compactor.mjs | 22 +++++++++++++++++++--- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/lib/validate.mjs b/lib/validate.mjs index 7bbfd16..e09206c 100644 --- a/lib/validate.mjs +++ b/lib/validate.mjs @@ -1,11 +1,18 @@ -import { EXPECTED_SECTIONS, MIN_RESULT_LENGTH } from './config.mjs' +import { + EXPECTED_SECTIONS, + MIN_RESULT_LENGTH, +} from './config.mjs' // Returns true if the compactor result is valid. -export function validateResult(result) { +export function validateResult( + result, + customPrompt = false, +) { if (!result || result.trim().length === 0) { return false } if (result.length < MIN_RESULT_LENGTH) return false + if (customPrompt) return true const lower = result.toLowerCase() const found = EXPECTED_SECTIONS.filter((s) => lower.includes(s.toLowerCase()), diff --git a/scripts/compactor.mjs b/scripts/compactor.mjs index c6d83f8..7dd55a6 100644 --- a/scripts/compactor.mjs +++ b/scripts/compactor.mjs @@ -15,6 +15,7 @@ import { existsSync } from 'node:fs' import { appendFile, writeFile } from 'node:fs/promises' +import { homedir } from 'node:os' import { runClaude } from '../lib/claude.mjs' import { HOOKS_DIR, @@ -96,6 +97,21 @@ const SYSTEM_PROMPT = 'else. Just produce the requested structured ' + 'output.' +// Custom prompt file overrides default +let activePrompt = PROMPT +let usingCustomPrompt = false +const promptFile = process.env.SEAMLESS_PROMPT_FILE +if (promptFile) { + try { + const { readFileSync } = await import('node:fs') + const expanded = promptFile.replace(/^~/, homedir()) + activePrompt = readFileSync(expanded, 'utf8').trim() + usingCustomPrompt = true + } catch { + // Fall back to default prompt + } +} + let PATHS = null async function log(msg) { @@ -163,11 +179,11 @@ async function main() { try { let input if (attempt === 1) { - input = `${PROMPT}\n\n---\n\nTRANSCRIPT (${conversation.length} entries):\n\n${transcriptText}` + input = `${activePrompt}\n\n---\n\nTRANSCRIPT (${conversation.length} entries):\n\n${transcriptText}` } else { const half = Math.floor(transcriptText.length / 2) const shortened = transcriptText.slice(-half) - input = `${PROMPT}\n\n---\n\nTRANSCRIPT (truncated, retry):\n\n${shortened}` + input = `${activePrompt}\n\n---\n\nTRANSCRIPT (truncated, retry):\n\n${shortened}` await log( `Retry with ${input.length} chars (transcript halved)`, ) @@ -176,7 +192,7 @@ async function main() { await log(`Attempt ${attempt}: calling claude -p`) const res = await runClaude(cmd, input, TIMEOUT) - if (!validateResult(res)) { + if (!validateResult(res, usingCustomPrompt)) { throw new Error( `Output failed validation (length=${(res || '').length})`, ) From 83ef612bda82a66c61c6ee652553bfc0592468f9 Mon Sep 17 00:00:00 2001 From: RemoteCTO <768254+RemoteCTO@users.noreply.github.com> Date: Fri, 13 Feb 2026 19:29:34 +0400 Subject: [PATCH 2/8] Add User Intent section to default compaction prompt Instructs the summariser to preserve the user's original requests, clarifications, and stated constraints verbatim. Only direction-shaping messages, not every message. Also updates expected sections in validation config. --- lib/config.mjs | 1 + scripts/compactor.mjs | 14 +++++++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/lib/config.mjs b/lib/config.mjs index 00a1fe8..8ef021c 100644 --- a/lib/config.mjs +++ b/lib/config.mjs @@ -23,6 +23,7 @@ export const MIN_RESULT_LENGTH = 500 export const EXPECTED_SECTIONS = [ 'Session Summary', 'Technical Context', + 'User Intent', 'Knowledge Extractions', 'Next Steps', 'Active Context', diff --git a/scripts/compactor.mjs b/scripts/compactor.mjs index 7dd55a6..38fe07d 100644 --- a/scripts/compactor.mjs +++ b/scripts/compactor.mjs @@ -60,7 +60,15 @@ PRESERVE ALL specific details verbatim: - Architecture decisions with reasoning - Environment details (branches, versions) -### 3. Knowledge Extractions +### 3. User Intent +Include the user's original request(s) VERBATIM — \ +the exact messages that initiated or redirected work. \ +Include any clarifications or corrections \ +("no, I meant X", "actually, do Y instead"). \ +Include stated constraints or preferences. \ +Only direction-shaping messages, not every message. + +### 4. Knowledge Extractions Extract reusable knowledge in these exact formats: \`\`\` DECISION: [choice with full reasoning] @@ -69,11 +77,11 @@ PATTERN: [code pattern with example] BLOCKER: [what doesn't work and why] \`\`\` -### 4. Next Steps +### 5. Next Steps Specific, actionable items. Priority ordered. Include any open questions or blockers. -### 5. Active Context +### 6. Active Context - Working directory - Git branch - Active ticket/epic (if mentioned) From 047a9c4c643968c6dc582684424c13d2e9ddf120 Mon Sep 17 00:00:00 2001 From: RemoteCTO <768254+RemoteCTO@users.noreply.github.com> Date: Fri, 13 Feb 2026 20:07:54 +0400 Subject: [PATCH 3/8] Bump version to 0.2.0 New feature: custom compaction prompts via SEAMLESS_PROMPT_FILE. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 32c2dd9..29205fa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "seamless-claude", - "version": "0.1.1", + "version": "0.2.0", "description": "Zero-downtime compaction for Claude Code. Pre-compacts sessions in the background so you never wait.", "type": "module", "bin": { From d7040d30b2e836bd0dd7100f2ad8d59d372ae10d Mon Sep 17 00:00:00 2001 From: RemoteCTO <768254+RemoteCTO@users.noreply.github.com> Date: Fri, 13 Feb 2026 20:09:40 +0400 Subject: [PATCH 4/8] Document SEAMLESS_PROMPT_FILE and User Intent section in README Add custom compaction prompt section explaining the env var, tilde expansion, silent fallback, and relaxed validation. Update section list from 5 to 6 with User Intent. Add SEAMLESS_PROMPT_FILE to configuration table. --- README.md | 42 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 140ff60..52825ca 100644 --- a/README.md +++ b/README.md @@ -48,16 +48,18 @@ falls back to hook-only operation: ## How it works -The summary is structured into five sections: +The summary is structured into six sections: 1. **Session Summary** — what was requested, accomplished, and what remains 2. **Technical Context** — file paths, commands, config values, error messages (verbatim) -3. **Knowledge Extractions** — reusable decisions, +3. **User Intent** — the user's original requests, + clarifications, and constraints (verbatim) +4. **Knowledge Extractions** — reusable decisions, learnings, patterns, and blockers -4. **Next Steps** — priority-ordered action items -5. **Active Context** — working directory, branch, +5. **Next Steps** — priority-ordered action items +6. **Active Context** — working directory, branch, key files ## Install @@ -369,6 +371,33 @@ variables for tuning: | `SEAMLESS_MAX_CHARS` | `400000` | Max transcript chars | | `SEAMLESS_HOOK_TIMEOUT` | `60` | Per-hook timeout (s) | | `SEAMLESS_DISPLAY_CMD` | — | Custom statusline command | +| `SEAMLESS_PROMPT_FILE` | — | Custom compaction prompt file | + +## Custom compaction prompt + +By default, seamless-claude uses a built-in prompt +that produces the six-section structure above. To +replace it entirely with your own prompt, set +`SEAMLESS_PROMPT_FILE` to a file path: + +```json +{ + "env": { + "SEAMLESS_PROMPT_FILE": "~/.seamless-claude/my-prompt.md" + } +} +``` + +The file content replaces the default prompt +verbatim — the summarisation model receives your +prompt followed by the transcript. Tilde expansion +is supported. If the file is missing or unreadable, +the default prompt is used silently. + +When a custom prompt is active, output validation +is relaxed: the section-name check is skipped +(your prompt produces different sections), but the +minimum length check (500 chars) still applies. ## Cross-session resume @@ -532,8 +561,9 @@ The compactor handles failure gracefully: - **Retry**: On failure (timeout, bad exit, empty result), retries once with a halved transcript - **Validation**: Checks the summary contains at - least 3 of the 5 expected sections (case-insensitive) - and exceeds 500 characters + least 3 of the 6 expected sections (case-insensitive) + and exceeds 500 characters. When using a custom + prompt, only the length check applies - **Lockfiles**: Prevents duplicate compactions; stale locks (>10 min) are automatically cleaned - **Output cap**: Resume output capped at 200K From d378645e595c8a412694543fd4cc8e8ce3ae0e47 Mon Sep 17 00:00:00 2001 From: RemoteCTO <768254+RemoteCTO@users.noreply.github.com> Date: Fri, 13 Feb 2026 20:10:35 +0400 Subject: [PATCH 5/8] Add v0.2.0 changelog entry --- CHANGELOG.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ca3890..3f8cfa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,32 @@ here. Format follows [Keep a Changelog][kac]. [kac]: https://keepachangelog.com/en/1.1.0/ +## [0.2.0] — 2026-02-13 + +### Added + +- **Custom compaction prompt** (`SEAMLESS_PROMPT_FILE`): + override the default prompt entirely with a + user-supplied file. Tilde expansion supported. + Silent fallback to the default if the file is + missing or unreadable. +- **User Intent section** in the default prompt. + Instructs the summariser to preserve the user's + original requests, clarifications, and stated + constraints verbatim — only direction-shaping + messages, not every exchange. + +### Changed + +- Output validation relaxed when a custom prompt is + active: section-name check skipped (custom prompts + produce different sections), minimum length check + (500 chars) still applies. +- Default prompt sections renumbered from 5 to 6 to + accommodate User Intent. +- `EXPECTED_SECTIONS` in config.mjs updated to + include `User Intent`. + ## [0.1.1] — 2026-02-13 ### Fixed From f4347f217bc0ebdf0f26688629cf8b40fbcc7c69 Mon Sep 17 00:00:00 2001 From: RemoteCTO <768254+RemoteCTO@users.noreply.github.com> Date: Fri, 13 Feb 2026 20:23:55 +0400 Subject: [PATCH 6/8] Add claude-summarise and fix nested session guard claude-summarise resolves any historical session ID (or prefix) to its transcript and runs the compactor. Supports --list, --force, and prefix matching. Strip CLAUDECODE env var when spawning claude -p so compaction works from inside a Claude Code session. --- CHANGELOG.md | 14 ++++ README.md | 29 +++++++ bin/claude-summarise | 59 +++++++++++++ lib/claude.mjs | 10 +++ package.json | 3 +- scripts/summarise.mjs | 190 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 304 insertions(+), 1 deletion(-) create mode 100755 bin/claude-summarise create mode 100644 scripts/summarise.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f8cfa7..5dfb23b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,20 @@ here. Format follows [Keep a Changelog][kac]. original requests, clarifications, and stated constraints verbatim — only direction-shaping messages, not every exchange. +- **`claude-summarise` CLI command**: summarise any + historical Claude Code session by ID or prefix. + Searches `~/.claude/projects/*/` for transcripts, + runs the compactor, outputs to the standard + sessions directory. Supports `--list`, `--force`, + and prefix matching. + +### Fixed + +- **Nested session guard**: compactor now strips the + `CLAUDECODE` environment variable when spawning + `claude -p`, so compaction works when triggered + from within a Claude Code session. Previously + failed with a nested-session error. ### Changed diff --git a/README.md b/README.md index 52825ca..3b9ad8c 100644 --- a/README.md +++ b/README.md @@ -399,6 +399,29 @@ is relaxed: the section-name check is skipped (your prompt produces different sections), but the minimum length check (500 chars) still applies. +## Summarise historical sessions + +Got a past session you want a structured summary +of? `claude-summarise` runs the compactor against +any historical transcript: + +```bash +claude-summarise 97aac477 # by prefix +claude-summarise --list # show all +claude-summarise --list ban # filter by project +claude-summarise 97aa --force # re-summarise +``` + +It searches `~/.claude/projects/*/` for matching +transcripts, resolves the full session ID, and +runs the compactor. If a summary already exists, +it tells you and skips (use `--force` to redo). + +The output lands in +`~/.seamless-claude/sessions/{id}.md` — same +format as automatic compaction, so `claude-resume` +can pick it up. + ## Cross-session resume The automatic flow handles compaction within a @@ -460,6 +483,12 @@ exec node \ WRAPPER chmod +x ~/.claude/bin/claude-resume + +# Repeat for claude-summarise +cp ~/.claude/bin/claude-resume \ + ~/.claude/bin/claude-summarise +sed -i '' 's/claude-resume/claude-summarise/g' \ + ~/.claude/bin/claude-summarise ``` Then add `~/.claude/bin` to your PATH (if not diff --git a/bin/claude-summarise b/bin/claude-summarise new file mode 100755 index 0000000..8585b0a --- /dev/null +++ b/bin/claude-summarise @@ -0,0 +1,59 @@ +#!/usr/bin/env node +/** + * Summarise a historical Claude Code session. + * + * Usage: + * claude-summarise + * claude-summarise --force + * claude-summarise --list [prefix] + * claude-summarise --help + */ + +import { execFileSync } from 'node:child_process' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = dirname( + dirname(fileURLToPath(import.meta.url)), +) +const SCRIPT = join( + ROOT, 'scripts', 'summarise.mjs', +) + +const USAGE = `\ +Usage: claude-summarise [options] + +Summarise any historical Claude Code session +by running the seamless-claude compactor against +its transcript. + +Options: + --list List available sessions + --force Re-summarise even if summary exists + --help Show this help + +Arguments: + session UUID or short prefix (min 8 chars) + +Examples: + claude-summarise 97aac477 + claude-summarise --list + claude-summarise --list baldyak + claude-summarise 97aac477 --force` + +const arg = process.argv[2] + +if (arg === '--help' || arg === 'help') { + process.stdout.write(`${USAGE}\n`) + process.exit(0) +} + +try { + execFileSync( + process.execPath, + [SCRIPT, ...process.argv.slice(2)], + { stdio: 'inherit' }, + ) +} catch (err) { + process.exit(err.status ?? 1) +} diff --git a/lib/claude.mjs b/lib/claude.mjs index 7601191..aaf6c5c 100644 --- a/lib/claude.mjs +++ b/lib/claude.mjs @@ -1,11 +1,21 @@ import { spawn } from 'node:child_process' +// Build a clean env for spawning claude -p. +// Strips CLAUDECODE so the child process isn't +// blocked by the nested-session guard. +function cleanEnv() { + const env = { ...process.env } + env.CLAUDECODE = undefined + return env +} + // Runs `claude -p` with input text, returns stdout. // Rejects on timeout or non-zero exit. export function runClaude(cmd, inputText, timeoutMs) { return new Promise((resolve, reject) => { const child = spawn(cmd[0], cmd.slice(1), { stdio: ['pipe', 'pipe', 'pipe'], + env: cleanEnv(), }) let stdout = '' diff --git a/package.json b/package.json index 29205fa..64d5e9a 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "description": "Zero-downtime compaction for Claude Code. Pre-compacts sessions in the background so you never wait.", "type": "module", "bin": { - "claude-resume": "bin/claude-resume" + "claude-resume": "bin/claude-resume", + "claude-summarise": "bin/claude-summarise" }, "scripts": { "test": "node --test test/*.test.mjs", diff --git a/scripts/summarise.mjs b/scripts/summarise.mjs new file mode 100644 index 0000000..a24ee29 --- /dev/null +++ b/scripts/summarise.mjs @@ -0,0 +1,190 @@ +#!/usr/bin/env node +/** + * Summarise any historical Claude Code session. + * + * Resolves a session ID (full or prefix) to its + * transcript path, then runs the compactor. + * + * Usage: + * node summarise.mjs + * node summarise.mjs --force + * node summarise.mjs --list [prefix] + */ + +import { execFileSync } from 'node:child_process' +import { existsSync, readdirSync, statSync } from 'node:fs' +import { homedir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { SESSIONS_DIR } from '../lib/config.mjs' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const COMPACTOR = join(__dirname, 'compactor.mjs') +const PROJECTS_DIR = join(homedir(), '.claude', 'projects') + +function findTranscripts(prefix) { + const results = [] + let dirs + try { + dirs = readdirSync(PROJECTS_DIR) + } catch { + return results + } + for (const dir of dirs) { + const dirPath = join(PROJECTS_DIR, dir) + try { + if (!statSync(dirPath).isDirectory()) { + continue + } + } catch { + continue + } + let files + try { + files = readdirSync(dirPath) + } catch { + continue + } + for (const f of files) { + if (!f.endsWith('.jsonl')) continue + const id = f.slice(0, -6) + if (!id.startsWith(prefix)) continue + const full = join(dirPath, f) + const st = statSync(full) + results.push({ + sessionId: id, + path: full, + project: dir, + size: st.size, + modified: st.mtime, + }) + } + } + results.sort((a, b) => b.modified - a.modified) + return results +} + +function hasSummary(id) { + return existsSync(join(SESSIONS_DIR, `${id}.md`)) +} + +function fmtSize(bytes) { + if (bytes < 1024) return `${bytes}B` + if (bytes < 1_048_576) { + return `${(bytes / 1024).toFixed(0)}KB` + } + return `${(bytes / 1_048_576).toFixed(1)}MB` +} + +function fmtProject(slug) { + // -Users-ed-projects-active-foo → foo + const parts = slug.replace(/^-/, '').split('-') + // Skip Users-name-projects[-active] + const skip = parts.indexOf('projects') + if (skip >= 0 && skip < parts.length - 1) { + const rest = parts.slice(skip + 1) + if (rest[0] === 'active') rest.shift() + return rest.join('-') || slug + } + return parts[parts.length - 1] || slug +} + +function fmtDate(d) { + return d.toISOString().slice(0, 16).replace('T', ' ') +} + +function listSessions(matches) { + const out = process.stdout + out.write(`Found ${matches.length} session(s):\n\n`) + for (const m of matches) { + const dt = fmtDate(m.modified) + const sz = fmtSize(m.size).padStart(6) + const pj = fmtProject(m.project) + const tag = hasSummary(m.sessionId) ? ' [done]' : '' + out.write( + ` ${m.sessionId.slice(0, 8)} ` + `${dt} ${sz} ${pj}${tag}\n`, + ) + } +} + +// --- CLI --- + +const args = process.argv.slice(2) +const force = args.includes('--force') +const list = args.includes('--list') +const positional = args.filter((a) => !a.startsWith('--')) +const prefix = positional[0] || '' + +if (list) { + const matches = findTranscripts(prefix) + if (matches.length === 0) { + const msg = prefix + ? `No sessions matching "${prefix}"` + : 'No sessions found' + process.stderr.write(`${msg}\n`) + process.exit(1) + } + listSessions(matches) + process.exit(0) +} + +if (!prefix) { + process.stderr.write( + 'Usage: claude-summarise ' + + ' [--force]\n' + + ' claude-summarise ' + + '--list [prefix]\n', + ) + process.exit(1) +} + +const matches = findTranscripts(prefix) + +if (matches.length === 0) { + process.stderr.write(`No sessions matching "${prefix}"\n`) + process.exit(1) +} + +if (matches.length > 1) { + process.stderr.write( + `"${prefix}" matches ` + `${matches.length} sessions:\n\n`, + ) + for (const m of matches) { + const dt = fmtDate(m.modified) + const pj = fmtProject(m.project) + process.stderr.write(` ${m.sessionId} ${dt} ${pj}\n`) + } + process.stderr.write('\nUse a longer prefix to narrow.\n') + process.exit(1) +} + +const match = matches[0] + +if (hasSummary(match.sessionId) && !force) { + const p = join(SESSIONS_DIR, `${match.sessionId}.md`) + process.stderr.write( + `Summary exists: ${p}\nUse --force to re-summarise.\n`, + ) + process.exit(0) +} + +const pj = fmtProject(match.project) +const sz = fmtSize(match.size) +process.stderr.write( + `Summarising ${match.sessionId.slice(0, 8)}... (${pj}, ${sz})\n`, +) + +try { + execFileSync( + process.execPath, + [COMPACTOR, match.sessionId, match.path], + { stdio: 'inherit', timeout: 600_000 }, + ) +} catch (err) { + const log = join(SESSIONS_DIR, `${match.sessionId}.log`) + process.stderr.write(`Compaction failed. Log: ${log}\n`) + process.exit(err.status ?? 1) +} + +const mdPath = join(SESSIONS_DIR, `${match.sessionId}.md`) +process.stderr.write(`Done: ${mdPath}\n`) From 894619e2150038b64ea700d88c85f75c02d3dfc5 Mon Sep 17 00:00:00 2001 From: RemoteCTO <768254+RemoteCTO@users.noreply.github.com> Date: Fri, 13 Feb 2026 20:36:00 +0400 Subject: [PATCH 7/8] Fix biome formatting in validate.mjs --- lib/validate.mjs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/lib/validate.mjs b/lib/validate.mjs index e09206c..6266d1d 100644 --- a/lib/validate.mjs +++ b/lib/validate.mjs @@ -1,13 +1,7 @@ -import { - EXPECTED_SECTIONS, - MIN_RESULT_LENGTH, -} from './config.mjs' +import { EXPECTED_SECTIONS, MIN_RESULT_LENGTH } from './config.mjs' // Returns true if the compactor result is valid. -export function validateResult( - result, - customPrompt = false, -) { +export function validateResult(result, customPrompt = false) { if (!result || result.trim().length === 0) { return false } From 5b2bb1d7947fa7db72a99824f87900567b2aeb51 Mon Sep 17 00:00:00 2001 From: RemoteCTO <768254+RemoteCTO@users.noreply.github.com> Date: Fri, 13 Feb 2026 20:41:29 +0400 Subject: [PATCH 8/8] Address review: guard statSync race, fix README example Wrap statSync in try/catch for TOCTOU resilience. Fix misleading --list example (filters by session ID prefix, not project name). --- README.md | 2 +- scripts/summarise.mjs | 20 ++++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 3b9ad8c..faf8b44 100644 --- a/README.md +++ b/README.md @@ -408,7 +408,7 @@ any historical transcript: ```bash claude-summarise 97aac477 # by prefix claude-summarise --list # show all -claude-summarise --list ban # filter by project +claude-summarise 97aa --list # filter by prefix claude-summarise 97aa --force # re-summarise ``` diff --git a/scripts/summarise.mjs b/scripts/summarise.mjs index a24ee29..9a13b09 100644 --- a/scripts/summarise.mjs +++ b/scripts/summarise.mjs @@ -50,14 +50,18 @@ function findTranscripts(prefix) { const id = f.slice(0, -6) if (!id.startsWith(prefix)) continue const full = join(dirPath, f) - const st = statSync(full) - results.push({ - sessionId: id, - path: full, - project: dir, - size: st.size, - modified: st.mtime, - }) + try { + const st = statSync(full) + results.push({ + sessionId: id, + path: full, + project: dir, + size: st.size, + modified: st.mtime, + }) + } catch { + // File vanished between readdir and stat + } } } results.sort((a, b) => b.modified - a.modified)