← Writing

Unearned Zero

tl;dr

Somebody's Codex had quietly made 118 copies of their working tree β€” 202 gigabytes of them, sitting in /tmp. Codex ships a command that lists those copies. They ran it. It told them there were none.

I have about half a gigabyte of my own agent transcripts and I wanted something that would clear out the old ones without ever losing one I would miss. So I read a cleaner somebody else had written, and then I wrote mine.

Mine did the same thing. Four separate times it told me there was nothing there when what it meant was that it had not looked β€” and one of those four I had recommended in writing, in a report, before I tried it and found out the thing I had specified does not exist.

Now, where it does not know, it says so in a different word than "none".

ELI5 tells it with a smoke alarm. Technical has all four, with the numbers, and the security review that started it.

More of the work: Portfolio

ELI5

The alarm that never went off

Put a smoke alarm in the hallway and go to work. When you come home the house is fine and the alarm is silent.

You have learned nothing.

Silence from a smoke alarm means one of two things and the alarm cannot tell you which. Either there was no fire, or the battery died in March. From the doorway those look identical. The device chirps when the battery is low β€” and that chirp is not a convenience bolted on afterwards. It is the whole reason you are allowed to trust the quiet.

Software mostly does not chirp.

Two hundred gigabytes behind a zero

Someone reported that their AI coding assistant had filled 400 gigabytes of disk with copies of a single image file. Someone else, a day earlier, reported something stranger. Their assistant had a command for listing the temporary working copies it makes. The command said there were none. There were a hundred and eighteen, and together they came to 202 gigabytes.

The command ran and answered in the ordinary way. The answer was zero.

That is a dead battery reporting no smoke.

So I built the alarm

I went looking for whoever had already solved this, found a small tool built for exactly it, read every line, and then built one of my own. Mine refuses to delete a conversation until three separate checks agree: the file is old, no agent is currently working in that project, and the conversation has already been boiled down into something we keep.

Three checks. Every one of them, at some point during the build, was a dead battery.

The first was in the tool I was reviewing, not mine. It refuses to touch a file while the assistant that wrote it is still running, which is sensible, and on my machine it refused to touch all 418 files it found. The assistant running was me β€” I was reading its code from inside a session. The check worked exactly as designed, told me nothing, and told me nothing in the confident voice of a safety guarantee.

The second was mine, and I had written it down as a recommendation before I tried it. Do not ask whether any assistant is running, I said; ask whether this particular conversation is still open. Sharper question, obviously better. It turns out there is no way to ask it. The assistant writes a line to the file and closes it again, so nothing is holding the file open, and the conversation's name appears nowhere visible from outside. I checked ten live sessions and found zero β€” a real zero, that one, and it killed my own design.

What I could ask was cruder: is an assistant working anywhere in this project. So that is what the check does, and that is also what the check is named. Calling a rough guess by the name of a precise one is how it gets trusted six months later by someone who was not there.

The third was the worst, because it looked like it worked. Before setting a conversation aside I check whether it has already been boiled down, and I do that by looking for a summary file named after the conversation. There are 94 summaries on my disk. Eleven are named after a conversation. The other 83 are named after their topic, which is the sensible thing for a person to do and completely invisible to my check. So the gate was answering "this was never kept" when the honest answer was "no filename told me either way" β€” and it was answering that about seven eighths of the evidence.

The fourth was the simplest. Two of the five assistants on this machine store their conversations in a shape I have not taught the tool to read. The scan found nothing for them, because there was nothing it knew how to look at, and it printed that as nothing being there.

The chirp

The repair, every time, was small and was not about deleting anything. Where the tool does not know, it now says so using a different word than the one it uses for none. An assistant it cannot read reports a blank, never a nought. A conversation whose summary it cannot confirm is refused with a reason that says no filename matched, not that nothing was ever kept. A check that can only prove an assistant is somewhere in this project is named for proving exactly that and nothing more.

None of that reclaims a byte of disk. It changes what happens on the day somebody reads the output and decides to act on it.

The tool currently holds 440 conversations across three assistants, about half a gigabyte, and refuses to delete any of them. That is the right answer today, and it can say why.

That’s the kind of problem I like β€” the one where the answer was already on the machine and the job was noticing.

See my portfolio β€” I’d love to work with you β†’

Technical

Four gates, and what each one actually measured

Two reports were filed against the Codex CLI a day apart in July. The first says a session directory reached 400 GiB after an image file was written about 150,000 times.1 The second one's title states that the managed-worktree list command reports zero while 118 run copies totalling 202 GB accumulate in /tmp.2

The zero is the half worth building against. A tool that has not looked and a tool that looked and found nothing produce the same output, and everything downstream β€” the user, the cron job, the agent reading the terminal β€” treats them the same way.

I reviewed an existing cleaner for this problem3 and then wrote one. What follows is four instances of that same failure, three of them mine.

First: the review, and where a cleanup tool's injection surface actually is

The brief was a security review with a prompt-injection focus. The answer is that the tool has almost no prompt-injection surface, for a structural reason: it never reads what it deletes. The only operations performed on a transcript's bytes are sha256 and gzip. Nothing from inside a session log reaches stdout, a manifest, or a model. A search across the package for eval, exec, pickle, shell=True, urllib and socket returns nothing, and the two subprocess calls are fixed argument vectors.

The surface it does have is its own output. _format_candidate interpolates a filename into a line with no escaping, and a filename is attacker-controlled data β€” any process running as you can write one into a session directory. I built three under a disposable root: one whose name contains newlines and a plausible second row, one containing ESC[2J ESC[31m, one containing a carriage return. All three reached stdout intact. The newline file rendered as three lines, two of them fabricated and one reading as an instruction.

The blast radius is narrower than it looks β€” candidate ids are SHA-256 of a resolved path, so a forged id fails the next lookup β€” but a cleanup tool is normally run by an agent that reads the output as fact. The same tool escapes correctly in --json and not at all in the output a person reads, which is the wrong way round.

Second: a process check that blocked everything

The tool refuses to touch a provider's files while a process of that provider is running. Run against this machine it reported 418 candidates and blocked 418 of them,4 because the agent doing the review was itself the claude in ps. Fail-safe and fail-useless at once, and the failure presents as a safety guarantee rather than as an absence of information.

My review recommended replacing it with per-file liveness: not "is any agent running" but "is this transcript's session open".

Third: the signal I recommended does not exist

Claude Code appends a line to a transcript and closes it, so no process holds one open. Across ten live sessions, every one held zero descriptors on a .jsonl.5 The session id appears in neither argv nor the process environment. There is no per-file signal to gate on.

What does exist is the working directory, which Claude Code encodes into its transcript directory name and Gemini uses as a plain name. Codex partitions by date instead and carries no project at all, so there the ceiling is the coarse question. Each provider now declares which granularity it gets, and the reason printed names which was applied β€” agent_working_in_this_project or provider_is_running. The module is called liveness.py and its type is Activity with a method covers(), because it proves an agent is working in this project and not that a session is alive. Naming the weak proxy after the strong signal is the mechanism by which a coarse check gets trusted as a fine one.

Fourth: a gate that measured filenames and reported distillation

Before setting a transcript aside, my tool checks whether the session has already been harvested into a portable package by a separate tool of ours. It does that by listing filenames, never opening one β€” that restraint is deliberate, because the component that deletes must stay blind for the reason in the first section.

The instrument is far weaker than the question. There are 94 packages on this disk. Eleven are named for a session; 83 are named for a topic and cannot be attributed to one without opening them. Of those eleven, two name a transcript that still exists.6 The harvesting tool keeps no ledger β€” its list verb enumerates sessions that could be harvested, not ones that were β€” so there is no authority to ask.

The gate was returning no_cdx_package_for_this_session, which reads as a finding about the user's habits. It is a finding about my instrument. It now answers "yes, provably" or "not provably", never "no", and the two failure reasons are spelled differently: a provider the harvester cannot read at all is refused with harvest_coverage_unknown_for_this_provider. Same refusal, but one of them is true.

Fifth: a provider we cannot read is not a provider with nothing

Five agents are installed here. Three have adapters written against real files; Crush has never written state, and opencode stores a session as a JSON tree rather than one file. The first version simply found nothing for those two and said so.

Coverage is now a type where the count is int | None, and None means no adapter. In JSON an unadapted provider's found is null, never 0. The scan prints the distinction rather than collapsing it:

440 transcripts | 0 eligible | 440 blocked | 531.7 MiB held
coverage: Claude Code 432 transcripts Β· Codex 7 transcripts Β· Gemini CLI 1 transcripts Β· Crush no adapter Β· opencode no adapter

Absence is a value, and it is usually the decision-relevant one. The places it dies are ordinary: unwrap_or(0), ?? 0, COALESCE(x, 0), d.get(k, 0), a scalar whose unset and zero values are the same byte, a missing bar drawn as a bar of height zero. Collapse it at the front end where a person can argue with it, never in the store.

The bug 116 passing tests agreed with

Going multi-agent, I pooled every running agent's working directories into one set. A Claude session open in ~/Work/omarchy-herd then blocked Gemini's transcript for a project of that name β€” although only a Gemini process can append to a Gemini transcript.

116 unit tests passed on it. They passed because the fixture helper I wrote, working_in(directory, provider), built its world the same pooled way the production code read it. Fixture and code shared one author and one wrong model, so they agreed perfectly. The bug surfaced on the first run of the real lifecycle against copies of real transcripts, where an unrelated Claude process happened to be running in a directory that shared a name.7

A hand-built fixture is a restatement of your beliefs about the world. Where those beliefs are wrong the test confirms the error, and the more helpers you factor out, the more tests inherit the single wrong assumption at once.

What the tool does today

Three gates must agree β€” age, per-provider activity, harvest β€” and a waiver exists for the harvest gate alone, recorded in an HMAC-authenticated manifest and printed as no-cdx in the listing. Capture is stream-to-gzip, content-address, decompress-and-rehash, re-stat the source, write the manifest, then os.replace the source to a hidden sibling, verify it again in its new location, and only then unlink. If what landed is not what was captured it is linked back under its own name. Restore refuses to overwrite and validates its target against the scan root recorded at capture.

Real transcripts compress about 1.7Γ—, not the 500Γ— a synthetic demo will show you.8 There are zero duplicate transcripts here, so the deduplication that motivated the original Codex reports finds nothing on my disk β€” their problem is an upstream bug, not a general property of agent residue. This machine writes about 124 MiB of transcript a day.

What would falsify this

The claim is not that unknown-versus-zero is a universal law. It is that four gates in one small tool failed the same way, and that the failure was invisible while every one of them returned successfully.

It is weakened if the distinction changes no decision. If every consumer of these outputs treats blank and zero identically anyway, the types are ceremony and I should say so. It is already partly refuted for the harvest gate: renaming the reason did not make the instrument any better, and 83 of 94 packages remain unattributable. That gate is currently an open issue against my own tool rather than a solved problem, and if topic-naming turns out to be a deliberate convention nobody intends to change, no index will ever attribute them and the gate can only be renamed, not fixed.

The security finding is the sturdier half, and it is checkable in a minute: create a file whose name contains a newline under a session directory, run the scan, and count the lines.

That’s the kind of problem I like β€” the one where the answer was already on the machine and the job was noticing.

See my portfolio β€” I’d love to work with you β†’

Sources

  1. Codex copied the image file 150,000 times, consuming 400 GiB of disk space β€” Upstream issue, opened 2026-07-26, open at the time of writing. The figure and the copy count are the reporter’s, taken from the issue title; I have not reproduced them and this machine shows no comparable duplication.
  2. Worktree/temp-clone lifecycle broken: managed-worktree list reports zero while 118 run copies (202 GB) accumulate β€” Upstream issue, opened 2026-07-25, open at the time of writing. The zero and the 202 GB appear together in the reporter’s own title, which is why this report rather than the larger one is the origin of the argument here.
  3. agent-aftercare, the tool reviewed β€” MIT, reviewed at commit e2e5fe0 on 2026-09-03 by reading all 804 lines of the runtime package, running its 21-test suite and its round-trip demo on Python 3.14, and firing constructed hostile filenames at the installed CLI. Its capture-verify-detach-verify sequence is adapted, and credited, in the tool described here.
  4. 418 of 418 candidates blocked β€” `aftercare scan` run read-only against this machine’s home directory, 2026-09-03. Every candidate carried the reason `provider_process_running`, because the review was being carried out from inside a Claude Code session and the gate matches on process name via `ps -A -o comm=`.
  5. No process holds a transcript open β€” For each pid returned by `pgrep -x claude` (ten at the time), counted symlinks under /proc/<pid>/fd resolving to a path ending .jsonl. Every process returned zero. Separately, `/proc/*/fd/*` across all processes resolved to no path under ~/.claude/projects. Process argv and environ were also checked for a session id and carry none.
  6. 94 packages, 11 named for a session, 2 naming a live transcript β€” Filenames only, never contents. Counted .cdx files under ~/Work, ~/BROWN-FAMILY-SPORTS, ~/Projects, ~/INTELLIMASS and ~/.claude, excluding node_modules; classified by whether the stem fullmatches a UUID; intersected those stems with the set of transcript ids currently on disk. 2026-09-04.
  7. The cross-provider activity leak β€” Found by copying one real transcript from each of Claude Code, Codex and Gemini CLI into a temporary home and running scan β†’ refuse β†’ waive β†’ capture β†’ restore against the copies. The Gemini leg failed because a live Claude process had its working directory in a folder named omarchy-herd, matching the Gemini project key of the same name. 116 unit tests passed before the fix; 119 pass after, including two that pin the cross-provider case.
  8. Compression and growth β€” gzip -9 over the ten largest transcripts on this machine: 198.5 MiB in, 116.1 MiB out β€” 58.5%, a ratio of 1.7Γ—. Growth measured as 503 MiB accumulated between the oldest and newest transcript mtimes, 4.06 days apart, giving ~124 MiB/day; the interval is short because the home directory was rebuilt on 2026-08-30, and ctime equals mtime on the oldest files, so the rate is not an artifact of a restore.