muonry 0.2.4 is out.
Five releases since 0.2.0, all on the same major. Replace runs 4.92× faster than sed and stays correct on read-only files. Parallel meta_search no longer hangs. The memo file stops growing past 4 MiB instead of silently dropping reads at 16. The frecency cache stops at 5000 paths instead of climbing forever. And muonry --changelog now prints the release timeline inline, so agents can tell you what changed without leaving the terminal.
This post walks the whole 0.2.0 → 0.2.4 arc. The first half is the unified-replace story, unchanged from 0.2.0. The second half is what happened next.
TL;DR
- 0.2.0. One replace surface across CLI and MCP. In-place by default, 4.92× faster than sed, mode and xattr preserved.
- 0.2.1. Memo file auto-compacts past 4 MiB. Frecency tracker caps at 5000 entries with LRU eviction.
- 0.2.2. Per-iteration ArenaAllocator protects the line read and JSON parse from any future leak.
- 0.2.3.
cancel_flagandtruncated_anymove to a per-callSearchCtx, ending the parallel-meta_search hang. Arena now spans the full dispatch path. - 0.2.4.
muonry --changelogprints this list inline. MCPinitializeadvertises the version and update hint to agents.
One replace surface (0.2.0)
Thirteen feature dimensions, identical between CLI and MCP. Multi-path iszigrep -R NEW OLD dir1 dir2on the CLI side and apaths:[]array on the MCP side. Regex is--regexorregex:true. Backref expansion works even for trivially-literal patterns; the engine auto-falls-back when the replacement contains\1through\9, instead of splicing the digits verbatim.
Same atomic_write flag. Same backup behavior. Same type filter. Same hidden-files toggle. The two front-ends are now the same tool accessed by two client shapes.
The numbers (0.2.0)
On 500 small source files in 10 subdirectories, replacing everyfoowithX:
zigrep -R(in-place, parallel walker): 16 ms- python3 os.walk + read/replace/write: 39 ms (2.37× slower)
find -exec sed -i '': 83 ms (4.92× slower)
500 small source files in 10 subdirectories. Replace every foo with X. Median of 12 hyperfine runs each, --prepare resets the tree per run.
The same workload run against earlier zigrep releases tells the story. v0.1.15 and v0.1.19 both used a tmp+rename atomic write per file and walked serially. 0.2.0 made in-place the default and added a parallel walker, and only those two changes. The result on the real repo:
Same binary on the same workload: recursive replace across the zigrepper source tree (1917 files, ~28 MiB of code). Median of 9 runs each, 2-run warmup, per-iter watchdog at 5 s. Pre-0.2.0 releases used a tmp+rename atomic write per file; 0.2.0 made in-place the default and added a parallel walker. 0.2.1 → 0.2.4 kept the replace path unchanged, so those two changes are still the entire gap.
Search hasn't regressed either. Six of eight common patterns (literalfn,TODO, single-class\d+,[a-z]+, and anchored^pub fn) run faster than ripgrep on the same tree. Two patterns still trail rg by about 25%:foo|bar|bazalternation (no Teddy yet) and\bfn \w+word-boundary regex (engine-bound, not wrapper-bound).
fn (literal)1.50× fasterTODO (literal)1.22× faster\d+1.41× faster[a-z]+1.33× faster^pub fn1.01× fasterstd\.mem\.indexOf1.12× fasterfoo|bar|baz1.27× slower\bfn \w+1.24× slowerRecursive search across the zigrepper repo (1763 files). Median of 10 hyperfine runs each. The two slower patterns are nanoregex engine-bound (alternation lacks a Teddy SIMD path; Pike VM word-boundary is a known regression vs rg's regex-automata).
What in-place keeps (0.2.0)
The default mode is O_WRONLY|O_TRUNC on the original path. Two syscalls (open and write) instead of the four a tmp+rename pattern costs. That speed is the obvious win. The quieter win is what survives.
- Permissions stay. A 0754 file ends 0754. A 0444 file is refused with
files_changed:0, because O_WRONLY on a read-only file fails with EACCES. sed -i'' silently overwrites it via rename. - Extended attributes stay. com.apple.quarantine, Finder tags, ACLs are all bound to the inode, so an in-place write keeps them. sed strips them on every rewrite.
- Hard links and symlinks stay. Modify one path of a hard-link pair and both see the change. Modify a symlink path and the target gets written, the link stays a link.
| Property | zigrep -R (default) | --atomic-write | sed -i '' | python3 |
|---|---|---|---|---|
| Mode (chmod bits) | ✓ | ✓ | ✓ | ✓ |
| Extended attributes | ✓ | ✓ | ✕ | ✓ |
| Inode preserved | ✓ | ✕ | ✕ | ✓ |
| Hard links share update | ✓ | ✕ | ✕ | ✓ |
| Symlink stays a link | ✓ | ✕ | ✕ | ✓ |
| 0444 refused (not silently overwritten) | ✓ | ✓ | ✕ | ✕ |
| Crash-safe mid-write | ✕ | ✓ | ✓ | ✕ |
Audited 2026-05-20 with explicit before/after snapshots of stat -f "%Lp %i" and xattr -l on a file with mode 0754 and a user xattr. Hard-link and symlink behavior verified on macOS APFS.
When --atomic-write (0.2.0)
The cost of in-place is that a mid-write SIGKILL leaves a truncated file. For workloads that need crash safety (config files, lockfiles, anything where half-written is worse than not-written),--atomic-write(CLI) oratomic_write:true(MCP) switches to tmp+rename.
On macOS the new file inherits mode, xattr, and ACL from the original viafcopyfile(COPYFILE_METADATA)before the rename lands. Linux falls back to fchmod-only for now. Atomic changes the inode (an inherent property of rename), so hard-link siblings don't see the change and symlinks get replaced with regular files, exactly the trade you make when you opt in. A leftover .zigrep.tmp from a SIGINT'd run is swept up by the next walk so nothing accumulates.
Cross-platform CI (0.2.0)
CI had been red on main since May 11. Tracking the failure down turned up eleven tools whosecio.zighad hardcoded macOS open(2) flag values,O_CREAT=0x200on macOS aliasesO_TRUNCon Linux, so "create file" calls truncated nothing and returned ENOENT.
Same pattern in muonry's sigbus guard, which extern'dsigsetjmp, a glibc macro that aliases the actual exported symbol__sigsetjmp. Fixed with a comptime-conditional shadow extern. Both Linux and macOS now build, test, and ship green.
Memo + frecency caps (0.2.1)
After 0.2.0 shipped, two latent unboundedness bugs surfaced in long-running daemons.
The memo file grew without limit. Every memo store / error / plan appended a JSONL line. Past 16 MiB the read cap kicked in and memo list silently returned empty.
Memo file lifecycle
Before 0.2.1: past the 16 MiB read cap, every memo listsilently returned empty. Now: append → check size → if >4 MiB, rewrite keeping the newest entries that fit in 2 MiB. Tunable via ZIGMEMO_COMPACT_TRIGGER / ZIGMEMO_COMPACT_TARGET.
The frecency tracker had no LRU cap. Tracker.files was a hashmap keyed by every path the daemon had ever seen. Per-path timestamps were capped at 100 (good), but the path count was unbounded. Long sessions across multiple monorepos accumulated per-path state forever.
Tracker.files at 5001 entries
Pre-0.2.1: every distinct path lived in Tracker.files forever. A daemon traversing millions of unique paths grew per-path state unbounded. Now: cap at 5000. When the next path arrives at 5001, evict the entry whose most recent access timestamp is oldest. The newly-added entry has nowas its most-recent ts, so it's never the victim.
Per-request arena (0.2.2 → 0.2.3)
The 0.2.0 leak fixes ended on a defensive question: even with every individual leak plugged, the loop in runPipeMode passed init.gpa straight to handlers. A future missed-free would still become a permanent per-request leak.
The fix is a per-iteration arena allocator, reset at the bottom of every loop:
One iteration of the pipe loop
Before (gpa)
After (per-request arena)
Each loop iteration owns an arena. Line, JSON tree, handler scratch, batch-worker scratch all allocate from it. arena.reset() at iteration end with retain_with_limit = 1 MiBreclaims everything that didn't escape the iteration. A future leak inside any handler is now a single-request bump, not a permanent climb.
0.2.2 shipped the wrapper around std.heap.ArenaAllocator and wired it into line + JSON parse only. The first attempt at widening it to handler dispatch hung. See the next section. 0.2.3 ran it all the way through dispatch + worker-thread scratch once the hang was rooted out.
The meta_search hang (0.2.3)
Wiring the arena through dispatch in 0.2.2 hung meta_search for 30 seconds against a non-existent path. A second-pass audit by a Sonnet 4.6 subagent found the real cause, and it had nothing to do with the arena.
handler_search.zig had two module-level atomic flags, cancel_flag and truncated_any. Every handle() reset to false at the top and set to true via a defer block at the end. When handler_meta_search spawned N concurrent variant threads, they raced:
Pre-0.2.3 race on a module-level cancel_flag
Variant A (done)
Variant B (concurrent)
Fix: per-call SearchCtx
Each handle() allocates a fresh SearchCtx{ cancel, truncated_any } on its stack. Threadlocal ctx_ptr routes search-loop helpers to it without changing every signature. The watchdog gets the pointer through its spawn args and signals its ctx, not whichever sibling happened to share the singleton.
The race was latent in the gpa path too. meta_search has always been concurrent. The arena's reset cycle made the timing windows wider and exposed it as a hang instead of an occasional aborted search.
Same audit surfaced three smaller fixes shipped in 0.2.3: lazy frecency init dropped its mutex during disk I/O (double-checked-lock); handler_symbol's path-too-long bail-out deinits its snapshot; file_cache.getWithIndex detects a peer racing through the miss path and frees its duplicate.
Retrieval-quality check
Six representative search / faster_search / meta_search / batch queries return byte-identical responses between the 0.2.2 and 0.2.3 binaries. The concurrency refactor has zero impact on output semantics.
--changelog + update hints (0.2.4)
The story above lived in commit messages and todo.md. 0.2.4 makes it discoverable from the terminal.
Three surfaces, one source of truth (src/version.zig)
--version
--changelog
MCP initialize
The CLI surfaces (--version, --changelog) and the MCP initialize response all read the same VERSION and CHANGELOG constants from src/version.zig. Agents reading the MCP instructions field on connect can surface "you're on 0.2.4. Run `muonry --changelog` to see what's new" to the user.
Same source of truth as the page you're reading. When the next release ships, updating src/version.zig updates the binary, the CLI hints, the MCP initialize response, and (manually) this post.
Upgrade
Same installer, fresh version:
curl -fsSL https://codegraff.com/install.sh | shAlready on muonry? Just:
muonry updateAll 13 tools (zigrep, zigread, zigpatch, zigcreate, zigdiff, zigmemo, zigpar, muonry, zigedit, ziggram, zigtools, zigauth, mcp-zip) are notarized on macOS and cross-compiled to linux-x86_64.--versionnow reports 0.2.4 across the toolchain.