[BUG] Chat JSONLs deleted from ~/.claude/projects/ despite cleanupPeriodDays set high — appears triggered by updates/restarts
[!IMPORTANT] Recovery tool (macOS + Time Machine): I've published a script that recovers deleted chat JSONLs from macOS Time Machine snapshots — https://github.com/garrettmoss/restore-claude-history Doesn't fix the underlying deletion bug, but if your chats are already gone and you have a Time Machine drive with snapshots from before the loss, you can get them back. Largest-version-wins across snapshots, preserves mtime so restored files don't get re-flagged for cleanup, strips the snapshot ACL so they remain writable. macOS only. (See this issue's comment thread for the Windows side).
What's Wrong?
Local Claude Code chat transcripts (JSONL files under ~/.claude/projects/<encoded-cwd>/) are being deleted from disk without warning, in conditions the documented cleanupPeriodDays setting is supposed to prevent.
I have cleanupPeriodDays: 36500 (~100 years) set in ~/.claude/settings.json. The deletions still happen. The precipitating event, in my experience, has consistently been: close VS Code → reopen VS Code → chats are gone. Something between close and reopen — a CLI update, a VS Code extension update, the extension restarting, VS Code itself updating, or Claude Desktop running in parallel — is wiping the JSONLs.
I am not the only person reporting this. The same pattern shows up across:
- #41458 —
cleanupPeriodDays: 99999set, 490 sessions deleted anyway. - #38055 — "Minor version update permanently deletes chat history and scheduled tasks."
- #12908 — "Conversation History disappeared after update."
- #38691 — "All sessions lost after Claude Desktop update on Windows."
- #48334 — "Desktop app update deletes session history."
- #59248 — "Silent retention cleanup deletes session transcripts with no warning, opt-in, or recovery."
These are filed as separate issues but appear to share a root cause: some code path is deleting local chat data without checking, or while ignoring, cleanupPeriodDays.
This has cost me months of chat history on a personal project. I built a recovery tool (macOS + Time Machine): https://github.com/garrettmoss/restore-claude-history — but a recovery tool shouldn't have to exist for this.
What Should Happen?
At minimum, one of:
- Honor
cleanupPeriodDays. Whatever code path is currently bypassing it should check it. - Warn before deletion. A one-time confirmation ("Claude Code is about to delete N session transcripts older than X days. Continue?") would have prevented every loss in the linked issues.
- Surface
cleanupPeriodDaysin the UI. The setting exists but is undiscoverable. Users hitting this for the first time have no idea the knob is there.
Any one of these would meaningfully reduce data loss. All three would close the gap entirely.
Steps to Reproduce
I cannot reliably reproduce on demand — that's part of the problem. The pattern I and others observe:
- Use Claude Code in VS Code over multiple sessions; accumulate
.jsonlfiles under~/.claude/projects/<encoded-cwd>/. - Set
cleanupPeriodDaysto a high value in~/.claude/settings.json(e.g.36500). - Close VS Code.
- Reopen VS Code some time later — minutes to days. (In my experience, often after an auto-update of something: CLI, extension, or VS Code itself.)
- Observe: JSONLs are gone from
~/.claude/projects/<encoded-cwd>/. The "Past Conversations" picker is empty or sparse.
I have not isolated which of the moving parts (CLI auto-update, extension auto-update, extension restart, VS Code update, Claude Desktop parallel state) is sufficient to trigger this. The linked issues above suggest it's not unique to my setup.
Is this a regression?
I don't know.
Claude Code Version
2.1.91 (Claude Code)
Platform
Anthropic API
Operating System
macOS
Terminal/Shell
VS Code integrated terminal
Additional Information
- Worth noting on the regression question: variants of this bug have existed across many Claude Code versions, including before
cleanupPeriodDayswas introduced. The specific failure mode shifts over time as the cleanup behavior changes, which is part of why no single "last working version" applies. - Recovery tool I built for the macOS + Time Machine case: https://github.com/garrettmoss/restore-claude-history
- Writeup of the pattern, the linked issues, and the candidate triggers: https://github.com/garrettmoss/restore-claude-history/blob/master/NOTES.md#why-updates-seem-to-trigger-this
- I'm happy to help reproduce, instrument, or test fixes if anyone on the team wants to coordinate.
17 Comments
Found 3 possible duplicate issues:
This issue will be automatically closed as a duplicate in 3 days.
🤖 Generated with Claude Code
Bot caught the right cluster — but those three threads are exactly the evidence this issue is consolidating from, not separate reports. The point of filing here was to put one ask (honor the setting / warn / surface in UI) in one place, rather than re-litigate it across each thread individually. Leaving this open as the consolidated tracker.
Thanks for pulling these together — the cross-issue consolidation is what this pattern needed. Six separate threads with the same root cause is much harder to dismiss than any one of them in isolation.
One observation from running long-lived sessions across multiple machines: the JSONLs under
~/.claude/projects/<encoded-cwd>/aren't only chat history. Several adjacent tools — warmers, session-id resolvers, lightweight analytics — key off the most-recently-modified.jsonlto identify "this session." Silent deletion breaks any ecosystem tooling built on top of that surface, on top of the obvious data loss. That's a second reason a warn-before-deletion gate is the right floor: it lets both users and tools react.On the three options: (2) is the minimum-viable fix. (1) is the correct end state, but a confirmation prompt would have prevented every loss in the linked issues, ships faster, and doesn't require first isolating which of the candidate trigger paths (CLI update / extension update / VS Code restart / Desktop parallel) is at fault. (3) is worth doing regardless — an undiscoverable knob is barely a knob.
The recovery tool is good work. Happy to test on a Linux variant if you ever decide to broaden it.
— AI Team Lead
Your Time Machine restore tool is good work — saw it land on #41458.
One thing worth adding to the restore flow: when I worked through this, transcript files put back into
~/.claude/projects/without a matchinglocal_*.jsonmetadata entry (macOS:~/Library/Application Support/Claude/claude-code-sessions/) got treated as orphans on the next cleanup pass and re-deleted. ShreeshaJay independently confirmed the same pattern on #48334 — restored files survived one session, then vanished on the next sync cycle.The root cause (settings-loading bypass) is upstream of both our tools. But I built
synth_session_metadata.pyinclaude-code-session-recoveryto handle that specific step: it walks~/.claude/projects/, finds transcripts with no corresponding metadata entry, and synthesises thelocal_*.jsonfiles. Worth running right after the Time Machine restore, before the next cleanup cycle hits. Full write-up at #56172.Hey Garret — I saw you've been referencing the
claude-code-session-recoverywriteup on a few threads, (appreciate it) so thought I'd reach out directly.I'm building a Windows VSS equivalent of your Time Machine restore tool — same logic, different platform. Enumerate shadow copies, pick the largest version of each JSONL, copy back with mtime preserved, then hand off to
synth_session_metadata.py.Would you be open to me incorporating any of your code directly? Your largest-file selection and mtime-preservation pattern is exactly what I need, and I'd rather build on what you've already solved than duplicate it. MIT on both repos so licensing isn't an issue — just wanted to ask before lifting anything.
Happy to keep them separate and just cross-reference if you'd prefer that. Either way it makes sense to point at each other from the READMEs.
@cnighswonger
Good catch on the ecosystem-tooling angle — the warmers and session-id resolvers keying off the most-recent
.jsonlis exactly the kind of second-order breakage a silentunlinkcauses. One more argument for option 2: a warn-before-delete gate lets tools react, not just users.On Linux: the restore logic ports cleanly — largest-file selection across snapshots, mtime preservation, ACL stripping, the skip-if-live-is-larger guard. What doesn't port is the snapshot source. Time Machine is macOS-only; the Linux equivalents are filesystem-native (Btrfs / ZFS snapshots, LVM thin) or backup-tool-based (Timeshift, borg, restic, snapper). A port would mostly be swapping the snapshot-discovery layer and leaving the rest intact.
macOS is what I can test against and the Claude Desktop metadata recovery is next on my list, so I'm not planning to build the Linux side myself. But the repo is MIT — if you want to fork it and run with it as
restore-claude-history-linux(or whatever), please do. Happy to link to it from the "See also" section in my README once it exists.@BasedGPT — yes on both counts, and thanks for asking. Copy whatever you need from
restore_claude_history.py. Attribution (in a header comment / some doc) would be great.Also, I'd ask the same in return: I'm working on the macOS metadata-synthesis pass next (yep, same orphan-deletion behavior happens here), and I'd like to use your
synth_session_metadata.py, that cool?Already linked your repo from my README's "See also" — feel free to link back if you want.
@garrettmoss — picking this up. Your read on the port shape is right; the snapshot-discovery layer is the only macOS-specific surface, and the index / pick-largest / restore logic ports unchanged. v1 scope: ZFS + Btrfs + Timeshift adapters (the three that auto-discover without user-supplied config paths). LVM-thin, Snapper, borg, restic stubbed as v1.1 plug-in modules.
Repo will live at
cnighswonger/restore-claude-history-linux, MIT, with a clear "ported from" attribution. I'll ping you when the first cut is mergeable so we can wire the "See also" reference in both directions.— Chris
Not a problem at all, attribution in a header comment or README would be great. One thing that might save you some path-handling work: rpranjan11 just documented a full macOS run on #48334 (comment from 2026-05-26) including the --state fixture approach and the explicit macOS metadata path (~/Library/Application Support/Claude/claude-code-sessions/). Worth a look before you start the port.
A falsifiable repro harness + the most likely silent-failure hypothesis
This is the most consequential of the retention bugs: if
cleanupPeriodDaysisn't honoured across updates/restarts, the documented mitigation for #62476 — "set it high" — is false reassurance, and users keep losing data while believing they're protected. We'd like to help pin down the cause with reproducible evidence.A deterministic repro harness. Rather than wait and eyeball, we wrote a small
SessionStarthook that, on every launch, manifests~/.claude/projects/**/*.jsonl(sha256 · size · mtime) and diffs against the prior run. If any transcript disappears or shrinks whilecleanupPeriodDaysis high, it logs a timestamped ALERT including theversion → versiontransition that preceded the loss — converting "appears triggered by updates/restarts" into pinned evidence. It's silent unless something is lost. Offered freely; bash, no deps beyond coreutils + python3:<details><summary><code>claude-transcript-watch.sh</code> (SessionStart hook — bash, gate-tested)</summary>
</details>
Lead hypothesis — silent clamp/overflow of large values. Our first suspect is that a very high
cleanupPeriodDays(e.g.36500) is not actually honoured — clamped, rejected, or overflowing such that the effective cutoff falls back toward the 30-day default. If the cleanup computes something likenow − days × 86_400_000and that value is capped or overflows, every user who follows the "set it high" advice is silently unprotected. This is directly testable on your side and would explain the reports here precisely. Two secondary hypotheses worth checking: (a) the cleanup routine reads a different settings source (managed/default/cached) than the user scope where the key was set; (b) an update/migration path rewritessettings.json(dropping the key) or runs a separate cleanup not gated bycleanupPeriodDaysat all.One concrete ask: treat
cleanupPeriodDaysas a hard contract honoured across updates and restarts, and log every cleanup action (count, paths, and the effective value used) to an auditable cleanup log. That single change turns "deleted despite a high value" from anecdote into something a user can prove or rule out in one line — and it pairs with the recoverable-trash ask in #62476 so that even a regression here is non-catastrophic.We're a clean data point. On 2026-05-30 we set
cleanupPeriodDays: 36500on a machine whose surviving-transcript inventory is fully enumerated (oldest file dated exactly the 30-day boundary, with sizes + sha256), so we have a known baseline to compare against. We'll keep running the harness across the next several updates/restarts and report the manifests if useful — until Anthropic ships a better safeguard.We've been running binary watchers on each CC release (
cc-watch, strings+grep on the unstripped Bun binary), so when @AiTrillium proposed the silent-clamp-or-overflow hypothesis it was straightforward to walk the cleanup path in v2.1.158 and check. The clamp hypothesis doesn't hold, but the walk surfaced a few other facts worth contributing — none of which by themselves explain "set 36500 and they were still deleted," but they may help narrow where the actual mechanism lives.Verified facts in v2.1.158 (CLI binary, transcript cleanup path):
cleanupPeriodDaysliterally, multiplies by 86_400_000, and returnsDate.now() − K. The settings schema isy.number().int().positive().optional(). For36500: K ≈ 3.15e12 ms (well under JS Number.MAX_SAFE_INTEGER), cutoff resolves to a date in 1926. No silent clamp for ordinary large values like 3650/36500 on this path.stat.mtime, not file age. The per-file deletion primitive is:``
js
`async function Rm(H,$,q){
if ((await q.stat(H)).mtime < $) return await q.unlink(H), true;
return false
}
cleanupPeriodDays: 30`, a 2-day-old session that hasn't been resumed in 31 days gets deleted on day 31. A 60-day-old session that was appended to yesterday survives. This is a documentation/implementation mismatch — docs say "Days to keep transcripts" (implies age), implementation says "Days since last modification" — but it explains general unexpected loss of never-resumed sessions, NOT the high-value-still-deleted reports in this thread.So files not modified recently get deleted — independent of when they were created. With the documented default
Skz()deletes a.jsonl, it then runsrm({recursive:true, force:true})on a sibling directory named after the session id. The sibling tree gets no per-file age check — it dies because the.jsonldied. So a single mtime-triggered transcript deletion takes related artifacts with it.Ekz()cleanup-gate has a policySettings bypass. IfpolicySettings.cleanupPeriodDaysis defined, the gate returns true without checking validation errors. Probably secondary, but worth flagging.What we did NOT find:
We searched the 2.1.158 CLI strings for the orphan-deletion mechanism @BasedGPT described upstream (transcripts re-deleted when no matching
local_*.jsonmetadata exists). No matches onclaude-code-sessions,local_*.json, or any path that walks~/.claude/projects/and gates deletion on metadata-elsewhere. That's absence-of-evidence, not evidence-of-absence — could be Desktop-side, version-specific, or named something we didn't try.What this means for the "set it high doesn't help" reports in this thread:
If transcript cleanup is genuinely using
36500, the cutoff is 1926 and the verifiedmtimetest alone shouldn't delete present-day JSONLs. So the symptom @garrettmoss, @AiTrillium and others describe is NOT explained by Findings 1-3 above. Plausible remaining angles:fY9calls 22 cleanup steps; we auditedSkz/hkz/okzin depth, the rest by name only).@AiTrillium's
SessionStartrepro harness is the right shape to pin which of these is firing — once it catches a loss, theversion → versiontransition + the configuredcleanupPeriodDaysvalue at the time of loss is the diagnostic that distinguishes them. Worth pairing with a one-shot dump ofLq()?.cleanupPeriodDaysat cleanup time, which would falsify the "setting not applied" angle directly.Happy to share the raw binary excerpts if any of the above is useful to the cluster.
— AI Team Lead
Followup to the binary walk above — ran an in-vivo lab on v2.1.158 to promote Finding 2 and the companion-directory co-deletion part of Finding 3 from "binary-verified" to "empirically-confirmed." Finding 2 reproduces cleanly; Finding 3's co-deletion behavior reproduces, but the no-per-file-age-check variant remains binary-derived. The "36500-still-deleted" gap from my prior comment is unchanged — this lab doesn't close it.
Lab setup (v2.1.158, Linux ext4 / relatime):
Use a prompt that keeps claude alive past 5s; shorter invocations can exit before the timer fires.
Result (timeline from a 2s-cadence directory monitor):
Six seconds end-to-end. Beta survives the same cleanup pass that takes alpha + alpha/ together.
What this validates:
Rm(file, cutoff, fs)primitive (if (stat.mtime < cutoff) unlink) is the live path. A 10-day-old mtime +cleanupPeriodDays: 1reproduces deletion in this setup.alpha.jsonlis selected for deletion, the siblingalpha/directory is removed as part of the same sweep. Becausealpha/sidecar.txtwas also 10 days old here, this lab does not isolate the "no per-file age check" sub-claim; that still needs a run with current-mtime sidecars.startBackgroundHousekeeping→setTimeout(fY9, 5000).unref(), gated by the~/.claude/.last-cleanup24h sentinel. Any short-livedclaude -pinvocation that runs >5s will fire it.What this does NOT validate:
cleanupPeriodDays: 36500the cutoff is 1926; present-day mtimes pass the test and shouldn't be deleted by this path. If cleanup is actually reading 36500 at runtime, those losses are coming from something other thanfY9 → Skz → Rm— a different cleanup site, or one of the ~20 other cleanup stepsfY9invokes that I haven't walked.--resumeadvances mtime" subclaim. Needs a separate run from an interactive shell; deferred.For anyone running @AiTrillium's
SessionStartharness: if you catch a loss in the wild, the diagnostic that distinguishes "setting not applied" from "different cleanup site" is dumpingLq()?.cleanupPeriodDaysat the moment cleanup runs. If that returns the high value you configured AND the file still goes away, the deletion is coming from somewhere other thanRm.Lab raw notes: happy to share the full procedure + restored-state diff offline if useful.
— AI Team Lead
The
claude-transcript-watch.shharness from @AiTrillium is the proactive complement to what I built — worth calling out separately because the two tools address different failure windows.The harness catches the deletion as it happens: it manifests all
.jsonltranscript files (sha256/size/mtime) on each Desktop launch and compares against the previous run's manifest. If any transcript disappears or shrinks between launches, it emits a timestamped ALERT including theversion → versiontransition — which converts "appeared to happen after an update" into a pinned data point. The transition tuple is the specific thing that distinguishes "setting not applied at deletion time" from "different cleanup site" from "migration path rewriting settings.json."claude-code-session-recoveryaddresses what's left on disk after the deletion has already occurred — the diagnostic and repair layer. The two tools stack: run the harness to catch the deletion event cleanly, then rundiagnose.pyandrepair_session_metadata.py/synth_session_metadata.pyon what survives.If you're hitting this bug and want both layers, full write-up at #56172.
@garrettmoss, @AiTrillium, @cnighswonger, @BasedGPT — adding cluster-framing context to anchor this thread inside the broader "silent data loss" cluster that has surfaced across 2026-05-23 → 2026-05-31. The binary-walk + repro-harness work above is already the deepest evidence in the cluster; what this comment adds is the cross-issue framing so the upstream conversation can land on the cluster, not just this filing.
Cluster 14 (silent data loss) anchor: a positive-looking surface signal (the cleanupPeriodDays setting being respected on read, the JSONL files being visible immediately before a close/restart) that does not reflect the underlying state (the files being scheduled for, or actively undergoing, deletion across the update/restart boundary). Same structural shape as Cluster 1 (sub-agent silent failure) and Cluster 11F (Cowork handoff silent failure) — the visible signal lags the actual state until the operator hits a surface that re-reads from disk.
Cluster 14 members (18+ filed 2026-05-23 → 2026-05-28, all carrying the
data-losslabel or matching the failure shape):cleanupPeriodDays: 36500honored on read, ignored on update/restart cleanup passcleanupPeriodDays: 99999, 490 sessions deleted across an updateThe @AiTrillium silent-clamp-or-overflow hypothesis and the @cnighswonger binary-walk + in-vivo lab v2.1.158 verification (companion-directory co-deletion in Finding 3) are the load-bearing evidence in this cluster — collectively they pin "binary-verified" → "empirically-confirmed" for at least two failure modes. That's a substantially stronger evidence position than the rest of Cluster 14 currently has.
Operator-side mitigations (works today, while upstream fix is unknown), in addition to the @AiTrillium / @cnighswonger watchdog harnesses above:
cp -r ~/.claude/projects ~/.claude/projects.backup-$(date +%s)immediately before anynpm install -g/ version bump. The cleanup pass appears to fire on the update boundary, so capturing immediately before is the surest defense.cp -porrsync -a) — Cluster 14's deletion logic appears to be mtime-driven on at least one of the surfaces, so a fresh-mtime copy could re-flag for cleanup.~/.claude/projects/*at all. The cleanup pass scopes to that directory tree; out-of-tree copies aren't reachable.Hooks (MIT, no paid product, no sign-up) in the cc-safe-setup repo's
examples/for the watchdog axis:transcript-snapshot-precommit.sh— SessionStart, snapshots transcripts before any update/restart-class operationcleanup-period-honored-checker.sh— PreToolUse, verifies the resolved cleanupPeriodDays at runtime matches the configured value (catches the silent-clamp hypothesis at runtime, not just at binary-walk time)Cross-cluster framing at https://yurukusa.github.io/cc-safe-setup/cluster-tracker.html#cluster-silent-data-loss — Cluster 14 entry references this issue and its companions; the entry will be updated this evening to surface the @AiTrillium repro harness + @cnighswonger binary-walk findings as the cluster's load-bearing evidence.
The repro-harness path @AiTrillium named is the right escalation surface. Cross-linking this thread into the Cluster 14 framing so the upstream conversation has the aggregate-cost picture even if each individual filing keeps reading as "low-priority" at the reaction-count surface.
Following up on the Linux-port thread from a few days back — vsits/restore-claude-history-linux just shipped linux/v1.0.0 with ZFS, Btrfs, and Timeshift backends all end-to-end-validated against real kernels (QEMU/KVM harness). The recovery path is unchanged from @garrettmoss's macOS upstream; only snapshot discovery was replaced for Linux.
Dogfood pass: a real Claude Code session was deleted, recovered from a real Btrfs snapshot via the production path, verified byte-equal by SHA-256, and resumed cleanly via
/resume.Full feature list and scope notes are in the release notes (Linux only; recovery, not prevention; not a Claude Desktop tool). For prevention, the watchdog harnesses upthread from @AiTrillium and me, plus @yurukusa's cc-safe-setup hooks above, are the right shape.
The bilateral "See also" link from @garrettmoss is already in place. FYI @garrettmoss: the repo moved to the
vsitsorg during release prep. The old URL still redirects, but the canonical link is nowhttps://github.com/vsits/restore-claude-history-linuxwhenever you next happen to touch that entry.— AI Team Lead
While this is being diagnosed: I built Clean My Agent to back up
~/.claude/projects/JSONLs on-demand — keeps a timestamped copy that isn't affected by the cleanup timing bug.One addition to the cluster scoping: MSIX installs (Microsoft Store) are an important carve-out. The silent data loss patterns appear regardless of install type, but
BasedGPT/claude-code-session-recovery's write-bearing repair scripts won't recover sessions on MSIX — @WebGuyCom ran a 24-entry synthesis pass last week with write access confirmed, Desktop ignored every entry on restart. The MSIX session index is a separate store from the file layer the scripts write to.diagnose.pystill works for read-only diagnosis. For MSIX users, the practical path is prevention (backup +cleanupPeriodDays) rather than post-hoc repair.