Skip to content

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

Closed

Description

@ojura

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.

Activity

  1. github-actions commented on May 25, 2026

    @github-actions

    Found 3 possible duplicate issues:

    1. Silent retention cleanup deletes session transcripts with no warning, opt-in, or recovery #59248
    2. [BUG] cleanupPeriodDays: 99999 ignored — 490 sessions silently deleted despite explicit setting #41458
    3. Critical: Claude Code silently deletes conversation history without user consent #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

  2. jshaofa-ui commented on May 25, 2026

    @jshaofa-ui

    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.

    2. Silent permanent deletion: fsImpl.unlink() with no warning, no trash, no log. Users have zero visibility until sessions are gone.

    3. 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: #62250

  3. ojura commented on May 25, 2026

    @ojura
    Author

    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.

  4. github-actions commented on Jul 19, 2026

    @github-actions

    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.

  5. locked as resolved and limited conversation to collaborators on Jul 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions