v0.4.0 — the event-log write race and --dry-run's missing rails
The second half of the audit that produced 0.3.6 — the two findings held back because they change behaviour rather than only fixing it.
Concurrent crashes destroyed the event log
appendEvent read the whole file, concatenated, and wrote it back, with no lock. Two phantom-wrapped commands crashing at once — a monorepo, npm-run-all -p, a CI matrix, two terminals — had each writer read a snapshot the other was mid-truncate on and write that shorter version back as the authoritative log.
Not torn lines. The file simply shrank: six writers × ten events against a full log lost 46 of the 60.
Every write is now a bare O_APPEND, which is atomic against other appenders. The cap is enforced separately, under an O_EXCL lock, by writing a sibling and renaming.
Two related fixes came with it:
- Readers could catch the log or the cursor mid-write. Rewriting in place left a window where a concurrent reader saw an empty or truncated file — about 1% of reads under load, and
phantom-statusruns on every status-line render. An empty cursor is the worse of the two: it replays the entire log as unread. Both are now write-and-rename, including the plugin's own copy ofmarkRead. - One crash could inject 200 KB into every prompt.
erroris a line of the crashed program's own output and nothing bounded it, so a minified bundle or a single-line JSON blob went verbatim into the log and from there intoadditionalContexton every Claude Code prompt in that repo — roughly 50k tokens of your context window per event.error,commandandmessageare now clamped, with the truncation visible rather than silent.
--dry-run did not restrict Bash, and never checked what happened
The file tools refused every write under --dry-run from the first release. checkBash had no dry-run branch at all, so echo patched > src/app.js, sed -i and tee went straight through.
Dry run is the worst place for that gap, because it creates no branch. Those writes landed on your own checked-out branch with nothing to roll them back — while the banner said "nothing changed", the report said Files changed | none, and the never-touch row claimed a hard revert that had never happened.
Three changes:
- Bash writes are refused in dry run — redirects,
tee,sed -i,mv/cp/touch,mkdir,chmod. Reading and verifying still work, because that is the whole point:npm test,node --test,cat,grep,git status,git diffand2>&1are all still allowed. - The tree is measured in dry run too, against a baseline of what was already dirty when phantom started — so your own uncommitted work is never attributed to the session.
- Anything that still gets through (the
node -eescape is documented, so prevention alone is not enough) is named, undone, and reported as anerrorrather than a clean dry run.
The undo is deliberately surgical: git checkout HEAD -- <paths> for tracked files, unlink for files the session created. reset --hard would be catastrophic here — dry run takes no stash, so your own uncommitted work is sitting in the same tree. There is a test asserting exactly that: your tracked edit and your untracked file both survive while the session's writes are reverted.
Also
- A failed never-touch revert was still announced as a discard.
resetHardandcleanUntrackedreturn values were dropped, so a staleindex.lockwas enough to make phantom's strongest safety claim false while the edits stayed on disk. - The post-mortem's never-touch row no longer hardcodes "(branch hard-reverted)". It states what actually happened, which in a dry run is that there was no branch at all.
One behaviour change worth knowing
MAX_EVENTS is now a ceiling, not an exact length. The whole-file rewrite that enforces it runs only once the log crosses 1.5× the cap, so the common path stays a lock-free atomic append. Between trims the log may hold up to 300 lines rather than exactly 200.
Notes
Every fix was mutation-checked against the 0.3.6 behaviour — the concurrency test was vacuous on the first attempt, because a log below MAX_EVENTS took an append fast path and never exercised the destructive rewrite.
318 → 325 tests. The suite was also run under FORCE_COLOR=1, the mode that let a terminal-only test failure through in 0.3.6. CI green on all 12 jobs (3 platforms × Node 18/20/22/24).
Full changelog: v0.3.6...v0.4.0