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

Status Fixed / completed
Maintainer reply None cached
Activity 4 comments · opened May 25, 2026 · 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 ↗

4 Comments

github-actions[bot] · 3 months ago

Found 3 possible duplicate issues:

  1. https://github.com/anthropics/claude-code/issues/59248
  2. https://github.com/anthropics/claude-code/issues/41458
  3. https://github.com/anthropics/claude-code/issues/46621

This issue will be automatically closed as a duplicate in 3 days.

  • If your issue is a duplicate, please close it and 👍 the existing issue instead
  • To prevent auto-closure, add a comment or 👎 this comment

🤖 Generated with Claude Code

jshaofa-ui · 3 months ago

Solution: claude-code #62250 — Session transcripts silently deleted by cleanupPeriodDays

Issue Summary

cleanupPeriodDays (default 30) deletes session transcript JSONLs keyed on file mtime, not conversation last-activity timestamp. Silent permanent deletion with no warning, no trash, no undo. 11 transcripts lost in the reporter's case.

Root Cause Analysis

Three distinct problems in src/utils/cleanup.ts:

  1. mtime as deletion key: File mtime is externally mutable — backup/restore, sync tools, or any touch/os.utime call changes it independently of actual session activity.
  1. Silent permanent deletion: fsImpl.unlink() with no warning, no trash, no log. Users have zero visibility until sessions are gone.
  1. cleanupPeriodDays: 0 footgun: 0 means "delete everything now" not "disable cleanup". Combined with shouldSkipPersistence() treating === 0 as "don't persist", setting it to 0 wipes all transcripts AND prevents new ones.

Proposed Fix

Phase 1: Key on transcript metadata, not mtime

// Before: uses file mtime
async function unlinkIfOld(filePath, cutoffDate, fsImpl) {
  const stats = await fsImpl.stat(filePath)
  if (stats.mtime < cutoffDate) {
    await fsImpl.unlink(filePath)
    return true
  }
}

// After: read last activity from transcript itself
async function unlinkIfOld(filePath, cutoffDate, fsImpl) {
  const stats = await fsImpl.stat(filePath)
  // Read the JSONL and find the last message timestamp
  const lastActivity = await getLastActivityTimestamp(filePath, fsImpl)
  if (lastActivity && lastActivity < cutoffDate) {
    // Archive instead of delete (Phase 2)
    await archiveFile(filePath, fsImpl)
    return true
  }
  // Fallback to mtime only if we can't read the transcript
  if (!lastActivity && stats.mtime < cutoffDate) {
    await archiveFile(filePath, fsImpl)
    return true
  }
}

async function getLastActivityTimestamp(filePath, fsImpl): Promise<Date | null> {
  try {
    const content = await fsImpl.readFile(filePath, 'utf-8')
    const lines = content.trim().split('\n')
    if (lines.length === 0) return null
    // Last non-empty line should contain a message with timestamp
    for (let i = lines.length - 1; i >= 0; i--) {
      try {
        const entry = JSON.parse(lines[i])
        if (entry.timestamp) return new Date(entry.timestamp)
        if (entry.ts) return new Date(entry.ts)
      } catch {}
    }
  } catch {}
  return null
}

Phase 2: Archive/trash instead of unlink

async function archiveFile(filePath, fsImpl) {
  const archiveDir = path.join(
    path.dirname(filePath),
    '.claude-archive',
    new Date().toISOString().slice(0, 10)
  )
  await fsImpl.mkdir(archiveDir, { recursive: true })
  const archivePath = path.join(
    archiveDir,
    `${Date.now()}-${path.basename(filePath)}`
  )
  await fsImpl.rename(filePath, archivePath)
  // Log prominently
  console.warn(
    `[cleanup] Archived old session transcript: ${filePath} → ${archivePath}`
  )
}

Phase 3: Safer defaults and split the 0 overload

// settings.ts
const CLEANUP_SCHEMA = z.object({
  cleanupPeriodDays: z.number().int().min(1).default(90), // 90 days, min 1
  cleanupAction: z.enum(['archive', 'delete', 'skip']).default('archive'),
  cleanupEnabled: z.boolean().default(false), // opt-in, not opt-out
})

Key changes:

  • Default retention: 30 → 90 days
  • Default action: delete → archive
  • Default enabled: true → false (opt-in)
  • 0 no longer valid; use cleanupEnabled: false to disable

Phase 4: First-run warning

// On first cleanup run, show a warning
if (!settings.cleanupWarningShown) {
  console.warn(
    `[cleanup] Session transcript cleanup is enabled. ` +
    `Transcripts older than ${settings.cleanupPeriodDays} days will be ` +
    `${settings.cleanupAction === 'delete' ? 'permanently deleted' : 'archived'}. ` +
    `Set cleanupEnabled: false to disable. ` +
    `Set cleanupPeriodDays to adjust retention window.`
  )
  settings.cleanupWarningShown = true
}

Test Plan

  1. mtime independence: Create a transcript with mtime 40 days ago but last message timestamp 2 days ago → should NOT be deleted
  2. archive vs delete: Verify archived files appear in .claude-archive/ directory
  3. 0 handling: Setting cleanupPeriodDays: 0 should be rejected by schema validation
  4. opt-in behavior: With default settings, cleanup should not run
  5. warning display: First cleanup run should show a prominent warning

Impact Assessment

  • Severity: P0 data loss bug
  • Affected users: Anyone who backs up/restores/syncs ~/.claude, anyone keeping sessions >30 days
  • Fix complexity: Low (primarily changing the deletion key and adding archive logic)
  • Risk: Minimal — this is a defensive change that only makes cleanup safer

Submission

Post as comment on: https://github.com/anthropics/claude-code/issues/62250

ojura · 3 months ago

Folding into #59248 (the canonical silent-retention-deletion thread). Posted the mtime-keying mechanism, the cleanupPeriodDays: 0 overload, the #41458 / #45735 setting-sources bypass, and a SessionStart backup-hook stopgap there. Closing as a duplicate of #59248.

github-actions[bot] · 1 month ago

This issue has been automatically locked since it was closed and has not had any activity for 7 days. If you're experiencing a similar issue, please file a new issue and reference this one if it's relevant.