I was reading through the v2.1.248 change list top to bottom when one line stopped me: Claude Desktop and Cowork sessions had been disappearing after 30 days.
As an indie developer maintaining a few apps, I hand a handful of jobs to scheduled tasks every day — image exports, backups, that sort of thing — and I go back to them later to see what happened. The thing I was opening to do that was the session history in the app.
Now that I think about it, I cannot remember opening a session older than a month. It wasn't that I noticed them missing. It's that the possibility never crossed my mind.
What was happening, and what changed
The published changelog says the transcript cleanup was sweeping up sessions written by the desktop apps. After the fix, sessions are kept while they are in the app — unless your organization manages retention through policy, in which case policy wins.
The same release added a new setting, desktopSessionCleanupPeriodDays, to cap that exemption.
| Item | Before (through v2.1.247) | After (v2.1.248 onward) |
|---|---|---|
| Desktop / Cowork sessions | Swept by transcript cleanup after 30 days | Kept while they live in the app |
| Org-managed retention | — | Policy takes precedence |
| Cap on the exemption | None | Set with desktopSessionCleanupPeriodDays |
The same release also fixed an agent view that would resurrect a weeks-old background session after the machine had been off, and a VSCode chat tab stuck on "No conversation found" when its session was never saved. Read together, this looks like a release that went after session lifetime and session state as one problem.
What matters is that none of this reaches backwards. Sessions that were already swept are not coming back. Everything from here on is what gets protected.
Check whether your setup is affected
The only thing you need to do first is read your version.
claude --versionAnything at 2.1.248 or above has the fix. The 2.1.250 build that landed the next day is a stability-only release, so being there is plenty. How you upgrade depends on how you installed it — npm, native binary, or a package manager — so use whichever path you already have.
If you want the broader tour of what the desktop app can do, the walkthrough of the desktop app is a better fit. This piece stays on the records that were quietly going away.
desktopSessionCleanupPeriodDays is not "how long to keep things"
The name reads like a retention period, but the changelog wording is that it caps the exemption. It puts a ceiling on how long a session may stay excluded from cleanup, rather than promising to keep it forever.
{
"desktopSessionCleanupPeriodDays": 180
}After you write it, confirm it is actually being read. Claude Code settings do not warn you about a misspelled key — a one-character typo is silently ignored, which I wrote about in the piece on settings keys that vanish without a word. A setting that looks written but isn't in effect is the worst of both worlds.
Personally, I never felt much pull toward raising this number. The next section explains why.
Count how many days of sessions you actually have
Start by measuring. Claude Code stores session transcripts as JSONL, so a short script that counts files and the age of the oldest one is enough.
#!/usr/bin/env bash
# Count local session transcripts and the age of the oldest one
set -euo pipefail
ROOT="${1:-$HOME/.claude/projects}"
if [ ! -d "$ROOT" ]; then
echo "not found: $ROOT"
exit 1
fi
NOW=$(date +%s)
TOTAL=0; OVER30=0; OLDEST=0
while IFS= read -r f; do
M=$(date -r "$f" +%s 2>/dev/null || stat -c %Y "$f")
AGE=$(( (NOW - M) / 86400 ))
TOTAL=$((TOTAL + 1))
[ "$AGE" -gt 30 ] && OVER30=$((OVER30 + 1))
[ "$AGE" -gt "$OLDEST" ] && OLDEST=$AGE
done < <(find "$ROOT" -type f -name '*.jsonl')
echo "root : $ROOT"
echo "sessions : $TOTAL"
echo "over 30d : $OVER30"
echo "oldest : ${OLDEST}d"Here is the output from a run against five sample files I created with spread-out timestamps, just to confirm the script behaves:
root : /home/me/lab/fake/projects
sessions : 5
over 30d : 2
oldest : 45d
The storage path varies by environment. If the default above finds nothing, this usually locates it:
find "$HOME" -name '*.jsonl' -path '*claude*' 2>/dev/null | headIf over 30d sits at zero and never climbs, cleanup is probably still taking them. If that number grows normally, your machine is on the keeping side. One number turns "maybe things are disappearing" into something you can check.
Stop treating the session as the place records live
This is why I didn't reach for a bigger retention number.
A session transcript holds the entire conversation, which also makes it too heavy to search later. What I want to know about a scheduled job is whether last night's run went through or was skipped — not the full path that led there. Having everything stored and being able to read what you need are two different things.
So I started writing exactly one line per run, somewhere other than the session.
#!/usr/bin/env bash
# Always leave one line per run, outside the session
JOURNAL_DIR="${JOURNAL_DIR:-$HOME/run-journal}"
JOB="${1:?job name required}"; shift
mkdir -p "$JOURNAL_DIR"
JOURNAL="$JOURNAL_DIR/$(TZ=Asia/Tokyo date +%Y-%m).log"
STATUS="FAILED" # if nothing happens at all, it lands as a failure
NOTE=""
write_line() {
printf '%s\t%s\t%s\t%s\n' \
"$(TZ=Asia/Tokyo date '+%Y-%m-%d %H:%M:%S')" "$JOB" "$STATUS" "$NOTE" >> "$JOURNAL"
}
trap write_line EXIT # a killed run still leaves its line
if OUT=$("$@" 2>&1); then
STATUS="SUCCESS"; NOTE=$(printf '%s' "$OUT" | tail -1 | cut -c1-120)
else
CODE=$?
if [ "$CODE" -eq 2 ]; then STATUS="SKIPPED"; else STATUS="FAILED"; fi
NOTE="exit=$CODE $(printf '%s' "$OUT" | tail -1 | cut -c1-100)"
fiThree things carry the design.
STATUS starts at FAILED. Success only overwrites it once the work is done, so a run that died halfway never disappears as "no record". Nothing is silently absent, which means the totals add up when you count later.
trap write_line EXIT is installed before anything runs. Without it, the runs that were force-killed are exactly the ones that leave no trace — the failure you most want to see becomes the one least likely to be recorded.
Exit code 2 is reserved for "skipped." A run that did nothing for a legitimate reason — the previous run is still going, there was nothing to process — should not be filed next to a real failure.
I ran all four endings to check:
./run_journal.sh backup-photos bash -c 'echo "copied 128 files"'
./run_journal.sh thumbnail-batch bash -c 'echo "already running"; exit 2'
./run_journal.sh sync-remote bash -c 'echo "ssh: connection refused" >&2; exit 255'
./run_journal.sh crash-case bash -c 'kill -9 $$'And the journal it produced:
2026-08-29 15:08:32 backup-photos SUCCESS copied 128 files
2026-08-29 15:08:32 thumbnail-batch SKIPPED exit=2 already running
2026-08-29 15:08:32 sync-remote FAILED exit=255 ssh: connection refused
2026-08-29 15:08:32 crash-case FAILED exit=137
The last line is the one worth looking at. The run I killed with kill -9 still recorded itself as exit=137. Without the trap, that line simply would not exist.
The monthly roll-up is a one-liner:
awk -F'\t' '{c[$3]++} END {for (s in c) printf "%-8s %d\n", s, c[s]}' "$HOME"/run-journal/*.logFAILED 2
SUCCESS 1
SKIPPED 1
Because skips were never folded into successes, those numbers read straight. The more work you delegate, the more it pays to count success, failure, and skip separately. The same thinking applies when you hand a long job to another session and wait for it, which I covered in the note on completion markers.
One caveat: recording a skip when a lock is held doesn't work everywhere. On a cloud-synced folder where deletes are refused, the lock stays behind and shuts out every later run. I measured that failure mode in the piece on locks in a shared folder.
One thing to do today
Run claude --version and see whether you are at 2.1.248 or above. If you are, your sessions are on the protected side now. If you aren't, treat the history you can currently see as something that will go away.
Then create this month's journal file. Next month you will have something to compare against. This is the nudge that finally got me to do it.
References: Claude Code v2.1.248 release notes / Claude Code changelog