Session transcripts silently deleted by cleanupPeriodDays (keyed on file mtime): data loss, no warning, no recovery

Resolved 💬 3 comments Opened May 25, 2026 by ojura Closed May 25, 2026

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.

  1. 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.

  1. Safer default: longer retention, or off-by-default, or warn-before-delete.
  2. Split the 0 overload into separate settings (retention vs persistence), and document

that retention deletes by age.

  1. 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.

View original on GitHub ↗

This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗