All posts

The day my own cleanup tool deleted 612 MB of live work

On 12 July I shipped a disk-cleanup feature. Later the same day it offered me a session that was still open in another terminal, I pressed the key, and 612 MB of six-day-old work went away. Not to the Trash. Away.

It was my own machine and my own data, which is the only mildly comforting thing about it. Everything else is worth writing down, because three separate guards had to fail for one keypress to do that — and the one that failed first is the one I had never thought of as a guard at all.

Guard one: could it tell the thing mattered?

The cleanup list showed each session by title. Those titles came from the engine's own machine-generated name — the little summary an assistant writes for you, `aiTitle` in the transcript.

But a session can be renamed. You type `/rename`, you give the thing a name, because you have decided it is worth finding again. That name lands in a different field, `customTitle`. My title extractor never read it.

So on that machine, eleven sessions I had deliberately named were listing under the machine's guess instead. The deletion list was showing me the least meaningful label available for exactly the sessions I had gone out of my way to mark.

**The name a human typed is the strongest available signal that a thing matters.** A destructive list that renders a machine's guess instead has already lost, before any of its safety logic runs — because the operator can no longer recognise what they are looking at.

There was a second, dumber version of the same bug waiting behind it. The extractor read only the first 100 lines of a transcript. But `/rename` lands wherever you typed it, and you usually rename a conversation once it has turned out to be important — which is to say, deep in. I later found a 60 MB session renamed at line 13,845, still listing under its original name in one view while a different code path, which happened to read the tail, showed the new one. Two paths deriving the same fact from the same file, quietly disagreeing for months.

Guard two: would it refuse to touch a live one?

There was an in-use guard. It worked. It was simply not wired into the path that killed me.

The feature had grown two candidate classes: one that deduplicates redundant copies, and a main scan that classifies sessions as "untouched for 30+ days" or "big but recent." The live-process check had been written for the first one. The main scan never called it.

So a session with `claude --resume <id>` open in another window sailed into "big but recent" completely unchecked, and sat there one keypress from deletion, looking exactly like a stale one.

This is the failure mode I now watch for hardest: **a guard that exists, that has a name, that you can point to in the source, and that only one of your three code paths actually calls.** It is much worse than a missing guard, because reading the code reassures you. There is now one shared guard, called from every candidate class, and a live session is not offered in any section, full stop.

Guard three: could I get it back?

No. And the reason is specific enough to be useful to you.

Claude Code appends to its transcript per event and holds no open file handle. So there was no process clinging to the inode — nothing for `lsof` to rescue, none of the "it's still open, we can recover it from `/proc`" tricks that save you with a database or a log file. The moment the bytes were unlinked they were gone.

And my code used `rm`, which does not go to the Trash. The Trash is not a nicety; on a desktop OS it is the only undo layer a file operation gets for free, and skipping it converts every bug in the two guards above into an unrecoverable one.

It now moves candidates to `~/.Trash`, collision-safe. If the Trash is unusable for some reason, a row falls back to a direct delete **and says so on that row** — because the one thing worse than deleting a file is telling someone it went somewhere it did not go. Every string that used to promise space had been "freed" got reworded, across all nine locales, since "freed" was a claim about the disk that a Trash move no longer makes.

The shape of it

None of these three were exotic. Each of them, alone, would have been survivable:

  • If the list had shown the name I typed, I would have recognised the session and not pressed the key.
  • If the guard had run on that path, the row would never have been offered.
  • If it had moved to the Trash, I would have dragged it back out in ten seconds.

They are three independent layers, and the incident needed all three to be down at once. Which is the actual lesson, and it generalises past disk cleaners to anything that deletes, archives, revokes, or overwrites:

  1. **Recognisable** — render the identity a human chose, not the one a machine inferred.
  2. **Untouchable** — the in-use check runs on every candidate path, or it protects nothing.
  3. **Recoverable** — reversible by default, and honest on the row when it cannot be.

A useful audit question falls out of that. Not "do we have a guard for this?" but **"which of our three paths has never actually called it?"** — and, sharper, "have I ever watched this guard fire?" A check you have never seen refuse anything is not evidence that it works. It is evidence that your inputs have been clean so far.

The boring footnote

The tool is clikae, a small local layer for people running several AI coding accounts. It is plain bash, MIT, no daemon, no telemetry, every line readable — which is the whole pitch, and also why this postmortem exists in the repo's devlog rather than nowhere.

I would rather ship the version of the story where I come off badly. A cleanup tool asks you to trust it with `rm` on your behalf; the only currency it has is an accurate account of the times that trust was misplaced.

Keep reading

Notes from the workshop — the door is open.