claude-code - 💡(How to fix) Fix Session transcripts silently deleted by cleanupPeriodDays (keyed on file mtime): data loss, no warning, no recovery

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

cleanupPeriodDays (default 30) deletes session transcript JSONLs whose file mtime is older than the retention window. The deletion is silent: no prompt, no "moved to trash", no surfaced log, no undo. Because it keys on filesystem mtime rather than the conversation's own last-activity timestamp, it is easy to trip accidentally, and when tripped it permanently destroys conversation history.

This bit me hard: a routine maintenance pass on my session files (described below) caused 11 session transcripts to be silently deleted, including multi-thousand-message conversations. One is unrecoverable.

Error Message

  1. Safer default: longer retention, or off-by-default, or warn-before-delete.

Root Cause

cleanupPeriodDays (default 30) deletes session transcript JSONLs whose file mtime is older than the retention window. The deletion is silent: no prompt, no "moved to trash", no surfaced log, no undo. Because it keys on filesystem mtime rather than the conversation's own last-activity timestamp, it is easy to trip accidentally, and when tripped it permanently destroys conversation history.

Fix Action

Workaround

Set a large positive cleanupPeriodDays (NOT 0) in ~/.claude/settings.json, e.g. {"cleanupPeriodDays": 3650000} (~10,000 years), which pushes the cutoff far enough back that mtime < cutoff is never true. The schema is z.number().nonnegative().int() (no max), so this validates.

Code Example

function getCutoffDate(): Date {
  const cleanupPeriodDays = settings.cleanupPeriodDays ?? DEFAULT_CLEANUP_PERIOD_DAYS // 30
  return new Date(Date.now() - cleanupPeriodDays * 24*60*60*1000)
}

async function unlinkIfOld(filePath, cutoffDate, fsImpl) {
  const stats = await fsImpl.stat(filePath)
  if (stats.mtime < cutoffDate) {        // <-- keys on FILE MTIME
    await fsImpl.unlink(filePath)         // <-- silent permanent delete
    return true
  }
}

export async function cleanupOldSessionFiles() {
  // walks ~/.claude/projects/<slug>/, and for every *.jsonl / *.cast:
  //   unlinkIfOld(join(projectDir, entry.name), cutoffDate, fsImpl)
}

---

touch -d '40 days ago' ~/.claude/projects/<slug>/<some-session>.jsonl
# start claude (or otherwise trigger the daily cleanup)
# -> <some-session>.jsonl is gone, silently
RAW_BUFFERClick to expand / collapse

Summary

cleanupPeriodDays (default 30) deletes session transcript JSONLs whose file mtime is older than the retention window. The deletion is silent: no prompt, no "moved to trash", no surfaced log, no undo. Because it keys on filesystem mtime rather than the conversation's own last-activity timestamp, it is easy to trip accidentally, and when tripped it permanently destroys conversation history.

This bit me hard: a routine maintenance pass on my session files (described below) caused 11 session transcripts to be silently deleted, including multi-thousand-message conversations. One is unrecoverable.

Where it happens (source)

src/utils/cleanup.ts:

function getCutoffDate(): Date {
  const cleanupPeriodDays = settings.cleanupPeriodDays ?? DEFAULT_CLEANUP_PERIOD_DAYS // 30
  return new Date(Date.now() - cleanupPeriodDays * 24*60*60*1000)
}

async function unlinkIfOld(filePath, cutoffDate, fsImpl) {
  const stats = await fsImpl.stat(filePath)
  if (stats.mtime < cutoffDate) {        // <-- keys on FILE MTIME
    await fsImpl.unlink(filePath)         // <-- silent permanent delete
    return true
  }
}

export async function cleanupOldSessionFiles() {
  // walks ~/.claude/projects/<slug>/, and for every *.jsonl / *.cast:
  //   unlinkIfOld(join(projectDir, entry.name), cutoffDate, fsImpl)
}

Two distinct problems

1. Keying on file mtime, not the in-transcript last-activity timestamp. mtime is fragile, externally-mutable metadata. Anything that touches it desyncs retention from reality:

  • A backup/restore that doesn't preserve mtime (cp, tar -x, rsync without -a, a sync client, moving ~/.claude between machines) resets mtimes. Restored-but-genuinely-recent sessions can be stamped "now" (escape cleanup) or, far worse, stamped old and deleted.

  • Any tool that rewrites/appends to a session file and then restores the true mtime (e.g. to keep the --resume picker, which is recency-sorted, in chronological order) will set the mtime to the real last-activity date. For any session older than the window, that flips it from "present" to "older than cutoff" and it is deleted on the next sweep.

    Concrete repro of what happened to me: I appended custom-title records to ~20 dormant session files, then os.utime(path, last_message_timestamp) to keep the picker chronological. The sessions whose true last activity was >30 days ago were silently deleted on the next cleanup. The "everything looks recent" mtime had been the only thing protecting them.

2. The default is destructive and silent. A 30-day default that permanently unlinks conversation history with no warning, no archival, and nothing in the UI is surprising and unsafe. Users keep long-lived important sessions with no idea they are on a deletion timer.

3. cleanupPeriodDays: 0 is an overloaded footgun. 0 does not mean "disable cleanup". getCutoffDate() returns now, so unlinkIfOld deletes every transcript; and shouldSkipPersistence() (in sessionStorage.ts) treats === 0 as "don't persist", so no new transcripts are written either. The schema doc says as much, but "0" intuitively reads as "off", and choosing it to stop deletion would instead wipe everything.

Minimal repro

touch -d '40 days ago' ~/.claude/projects/<slug>/<some-session>.jsonl
# start claude (or otherwise trigger the daily cleanup)
# -> <some-session>.jsonl is gone, silently

Impact

Permanent, silent loss of conversation transcripts for: anyone who backs up / restores / syncs ~/.claude; anyone whose tooling touches session-file mtimes; and anyone who simply keeps sessions longer than cleanupPeriodDays and isn't aware of the default.

Suggested fixes (any subset)

  1. Retain by the transcript's own last-activity timestamp (the last message's timestamp inside the JSONL), not file mtime. mtime should not be load-bearing for deletion.
  2. Archive/trash instead of unlink (move to a recoverable location), or at minimum log each deletion prominently and/or surface a one-time warning before the first sweep.
  3. Safer default: longer retention, or off-by-default, or warn-before-delete.
  4. Split the 0 overload into separate settings (retention vs persistence), and document that retention deletes by age.
  5. Document clearly that cleanupPeriodDays deletes transcripts and that mtime is the key, so backup/restore and tooling can avoid the trap.

Workaround

Set a large positive cleanupPeriodDays (NOT 0) in ~/.claude/settings.json, e.g. {"cleanupPeriodDays": 3650000} (~10,000 years), which pushes the cutoff far enough back that mtime < cutoff is never true. The schema is z.number().nonnegative().int() (no max), so this validates.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING