<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Blog | lillecarl</title><link>https://lillecarl.com/blog/</link><description>DevOps/SRE Portfolio</description><language>en-us</language><lastBuildDate>Sat, 10 Oct 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://lillecarl.com/blog/index.xml" rel="self" type="application/rss+xml"/><item><title>self-verifying agents: I built a VT multiplexer I don't understand</title><link>https://lillecarl.com/blog/pymux/</link><guid>https://lillecarl.com/blog/pymux/</guid><pubDate>Sat, 10 Oct 2026 00:00:00 +0000</pubDate><description>&lt;p&gt;cmatrix in a background window pegged a whole core and drew five frames in five seconds. My multiplexer was working harder to show nothing than to show something. That multiplexer is &lt;a href="https://github.com/Lillecarl/pymux"&gt;pymux&lt;/a&gt; — Jonathan Slenders&amp;rsquo; Python tmux, dead since 2017, which I forked and let agents rebuild: 820 commits since January, all agent-written except the prompts. I still can&amp;rsquo;t read VT by sight. &lt;code&gt;CSI 4:1 m&lt;/code&gt; means nothing to me, and I stayed ignorant on purpose.&lt;/p&gt;
&lt;p&gt;This post isn&amp;rsquo;t about pymux. It&amp;rsquo;s about the only agentic workflow I&amp;rsquo;ve seen actually work: the human builds the machine that checks the work, the agents do the work. My contribution is test facilities with mechanical verdicts. Pymux just fit that workflow like a glove, because terminal correctness is observable: six other emulators to vote, pixels to subtract, bytecode to count. Where correctness needs taste, agents guess — so the harness never asks them to.&lt;/p&gt;
&lt;p&gt;The trick: I never let the agent decide what &amp;ldquo;correct&amp;rdquo; means. I made it count shit instead.&lt;/p&gt;
&lt;h2 id="there-is-no-spec-only-whatever-xterm-does"&gt;There is no spec, only whatever xterm does&lt;/h2&gt;
&lt;p&gt;VT &amp;ldquo;spec&amp;rdquo; is a polite fiction. The real spec is xterm&amp;rsquo;s mood that day, plus 40 years of programs that depend on its bugs.&lt;/p&gt;
&lt;p&gt;Fuck documents. I test against six other terminals that hate each other: &lt;strong&gt;kitty, WezTerm, Alacritty, libvterm, Ghostty, xterm.js&lt;/strong&gt;. C, Rust, Zig, TypeScript — different bloodlines, different bugs. Same bytes go into my widget (ptterm draws one terminal, pymux arranges several) and into all six, and the harness compares screens cell by cell.&lt;/p&gt;
&lt;p&gt;xterm itself sits in the corner as a seventh oracle. It answers characters but no attributes over DECRQCRA, so it gets to comment but never vote. Fair. It invented the game.&lt;/p&gt;
&lt;p&gt;A judge that can&amp;rsquo;t hold something abstains instead of lying. Only libvterm and WezTerm know what SGR 73 even is, so the rest sit that round out. The verdict stays dumb on purpose. Agree means ship it. ptterm-wrong means every voting judge agrees with each other and not with me, so I eat the bug. Split means the judges fight each other, so it&amp;rsquo;s taste, not a bug: write it down, touch nothing. No LLM judgment call anywhere. I just count shit.&lt;/p&gt;
&lt;p&gt;The same harness flogs borrowed suites. esctest2 runs from the inside: 5 fails bare, 9 fails in a pane, and that delta is what pymux adds. vttest goes screen by screen. libvterm&amp;rsquo;s own 43 test files get turned around and fired at us (270 assertions). Alacritty&amp;rsquo;s 45 recordings of vim/tmux/fish replay through a real pane into a real &lt;code&gt;Term&lt;/code&gt; (Alacritty&amp;rsquo;s grid struct). What breaks in a pane but not on a pty is my fault. What breaks in both belongs to the emulator. That split ends hours of pointing fingers.&lt;/p&gt;
&lt;p&gt;Price: the panel group needs kitty, Rust, C, node and X. 19 files carry that weight, 17 stay pure Python. A new test lands in the right group by the import it writes, and a unit test that tries to skip fails loud. No silent green.&lt;/p&gt;
&lt;h2 id="screenshots-caught-the-shit-cells-couldnt"&gt;Screenshots caught the shit cells couldn&amp;rsquo;t&lt;/h2&gt;
&lt;p&gt;The model holds cells. Your eyeballs eat pixels. Those two divorce exactly where it hurts: double-size lines, blinking cursors, underlines.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;checks.pymux-pictures&lt;/code&gt; runs the same program twice in the same real terminal — once bare, once inside a fullscreen pymux pane — and subtracts the screenshots. Never cross-compare terminals; every font stack draws different glyphs, so that diff is toilet paper. Bare-vs-pane in the &lt;em&gt;same&lt;/em&gt; emulator or GTFO.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s how we caught two ghosts: a cursor that stopped blinking, and an underline that showed up uninvited. Every cell check waved both through.&lt;/p&gt;
&lt;p&gt;And sometimes the diff says the &lt;em&gt;other guy&lt;/em&gt; is the idiot. A pane re-spells &lt;code&gt;CSI 4:1 m&lt;/code&gt; into &lt;code&gt;CSI 4 m&lt;/code&gt;. xterm ignores the first and draws the second, so 206 pixels differ. foot reads both forms, pictures identical. Verdict: the difference belongs to xterm, written down in &lt;code&gt;picture-differences.txt&lt;/code&gt; with the reason. That list gets judged in both directions, so a regression screams and a fix screams too. Same deal for the esctest/vttest/vterm failure lists.&lt;/p&gt;
&lt;p&gt;Want a real fixture instead of a hand-made toy? Record your own broken session:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;ptterm-record --into pymux/tests/recordings --lines 24 --columns 80 \
 claude -- claude
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Go use the thing, reproduce the fuckup, quit. Now &lt;code&gt;recorded-claude&lt;/code&gt; replays your actual bytes, bare vs pane. A hand-written fixture only holds what somebody bothered to imagine. This one holds what actually bit you. Read it before you commit it, it holds your screen.&lt;/p&gt;
&lt;p&gt;Flaky bit: vttest pictures item 2 still flaps on a fixed-delay change after one screen draw, pure timing flake. It stays non-gating. A flaky gate is worse than no gate.&lt;/p&gt;
&lt;h2 id="wall-clock-lies-so-i-count-bytecode"&gt;Wall-clock lies, so I count bytecode&lt;/h2&gt;
&lt;p&gt;Agents love making code slower while all tests stay green. Wall-clock can&amp;rsquo;t fail a build — a second belongs to whatever else was running on the box. So I count &lt;strong&gt;bytecode instructions&lt;/strong&gt; with &lt;code&gt;sys.monitoring&lt;/code&gt;. Same code + same bytes + same interpreter = same number, regardless of box load. Carl&amp;rsquo;s law: &amp;ldquo;number go up = bad.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;Budgets live in &lt;code&gt;*-budgets.txt&lt;/code&gt; next to the tests. Drift &amp;gt;5% either way and you&amp;rsquo;re red. Up means you got slow. Down means you forgot to update the budget, also red. &lt;code&gt;PYTHONHASHSEED&lt;/code&gt; pinned, Python upgrade means re-record everything. That&amp;rsquo;s the bill, pay it.&lt;/p&gt;
&lt;p&gt;Counting found the fat one: every title bar re-measured the whole window, 950k instructions against a 55k frame. We now measure the plan once. The census also found a one-pane layout is 106 nested boxes and 36 of them are chrome (borders, title bars, status fluff) for that single pane (&lt;a href="https://github.com/Lillecarl/pymux/issues/366"&gt;#366&lt;/a&gt;). A key press still walks the whole layout to build a cache key that then hits (&lt;a href="https://github.com/Lillecarl/pymux/issues/317"&gt;#317&lt;/a&gt;), 33% of the press, growing with every overlay. The fix needs a layout version counter prompt-toolkit doesn&amp;rsquo;t have. So it sits open, with numbers, instead of rotting in someone&amp;rsquo;s head.&lt;/p&gt;
&lt;p&gt;Current damage, counted and timed:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;bare pty: 0.05 ms median
pymux: 3.85 ms median, 7.6 ms p99 (1.1 ms in, 2.7 ms out)
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Socket vs in-process transport: identical. The socket costs nothing here; the renderer costs everything.&lt;/p&gt;
&lt;p&gt;The cmatrix story from the top? &lt;code&gt;ptyhost&lt;/code&gt; polled for an idle loop that never comes while prompt-toolkit reposted redraws the same way: 40,552 reposts to defer one read a quarter second (&lt;a href="https://github.com/Lillecarl/pymux/issues/253"&gt;#253&lt;/a&gt;). After: 19% of a core, 26 frames in 5s instead of 5. Same shape fixed nyancat, pipes, tty-clock. And the &amp;ldquo;impossible rate&amp;rdquo; tell: one cold count showed 500M instr/s next to 132M in the same run. Can&amp;rsquo;t happen. We had compared two different piles of work, first-frame paint vs steady-state, merged keybindings vs hot path. Split them and the numbers made sense again.&lt;/p&gt;
&lt;p&gt;Big-screen tax, measured not guessed: 80x24 idle 6.9 ms, 342x74 idle 22.2 ms. A frame is ~5.7 ms fixed + 0.65 µs per cell. So &lt;code&gt;frame-rate 30&lt;/code&gt; (default) stays because uncapped melts cores: uncapped cmatrix eats 93% of a core at 45 fps, capped at 10 it eats 32% at 9.8 fps, typing latency unchanged. That&amp;rsquo;s a window option because a window is what you look at.&lt;/p&gt;
&lt;h2 id="557-issues-and-i-wrote-zero-of-them"&gt;557 issues and I wrote zero of them&lt;/h2&gt;
&lt;p&gt;Over 500 issues on the tracker. I filed none. Not one. The rule agents actually follow: file an issue for what you find and do not fix. The issue is how you stay on the task instead of wandering off to stroke shiny crap.&lt;/p&gt;
&lt;p&gt;So the tracker reads like a drunk lab journal, and I love it:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/Lillecarl/pymux/issues/227"&gt;#227&lt;/a&gt; — every scrollback row allocated a closure just to return one shared cell. 10.7 MB of identical lambdas at 50k rows. Of ~800 bytes a row, 215 was the &lt;code&gt;Row&lt;/code&gt; and its little hat.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/Lillecarl/pymux/issues/426"&gt;#426&lt;/a&gt; — resize on the alternate screen eats a wrapped line. pyte-roaming finds it one run in ten with a two-chunk seed. Fucker hid since before the fork.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/Lillecarl/pymux/issues/232"&gt;#232&lt;/a&gt; — 45% of a keystroke was neither work nor socket, just waiting. Instruction counts can&amp;rsquo;t see waiting by construction. So now we count loop turns too.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/Lillecarl/pymux/issues/254"&gt;#254&lt;/a&gt; — a frame builds 11,033 cells to emit 437 characters. The diff emits only what changed, but everything before it rebuilds the universe to find out.&lt;/p&gt;
&lt;p&gt;437 closed, 120 open as I type this. All in &lt;code&gt;Lillecarl/pymux&lt;/code&gt;, even the cross-repo ones. If it&amp;rsquo;s not in an issue it doesn&amp;rsquo;t exist, and if your exclusion pattern matches nothing the gate fails — so dead excuses die fast.&lt;/p&gt;
&lt;h2 id="count-votes-diff-pixels-budget-bytecode"&gt;Count votes, diff pixels, budget bytecode&lt;/h2&gt;
&lt;p&gt;Full disclosure like last time: robots wrote ~all the code, and one wrote the first draft of this post. Recursion is the point.&lt;/p&gt;
&lt;p&gt;What actually transferred, in order:&lt;/p&gt;
&lt;p&gt;Give the robot a number and it behaves: agree/wrong/split, pixel diffs, instruction deltas. Ask it &amp;ldquo;is this correct?&amp;rdquo; and you get vibes back.&lt;/p&gt;
&lt;p&gt;Record reality. &lt;code&gt;ptterm-record&lt;/code&gt; your broken Claude session instead of inventing bytes. Alacritty&amp;rsquo;s vim recordings beat any synthetic corpus. Real programs or it didn&amp;rsquo;t happen.&lt;/p&gt;
&lt;p&gt;Keep failure lists bidirectional. Every &lt;code&gt;*-failures.txt&lt;/code&gt; / &lt;code&gt;*-budgets.txt&lt;/code&gt; / &lt;code&gt;picture-differences.txt&lt;/code&gt; fails on regression &lt;em&gt;and&lt;/em&gt; on silent fix. A fix you didn&amp;rsquo;t notice means your test is blind.&lt;/p&gt;
&lt;p&gt;This setup survives because the verdicts are mechanical and the evidence is checked in. I built the checker, not the knowledge: I still can&amp;rsquo;t read VT. I can read a vote, a diff, and a budget. That&amp;rsquo;s the whole fucking job, and I get to stay dumb.&lt;/p&gt;
&lt;p&gt;Umbrella with all eight repos and every check: &lt;a href="https://github.com/Lillecarl/pyterm"&gt;github.com/Lillecarl/pyterm&lt;/a&gt;. The fork: &lt;a href="https://github.com/Lillecarl/pymux"&gt;github.com/Lillecarl/pymux&lt;/a&gt;. Run &lt;code&gt;nix build --file . checks.all&lt;/code&gt;, then &lt;code&gt;checks.pymux-pictures.run&lt;/code&gt; and go look at the pictures — that&amp;rsquo;s the fun one.&lt;/p&gt;</description></item><item><title>ghanix (GitHub Actions workflows as NixOS modules)</title><link>https://lillecarl.com/blog/ghanix/</link><guid>https://lillecarl.com/blog/ghanix/</guid><pubDate>Wed, 16 Sep 2026 00:00:00 +0000</pubDate><description>&lt;p&gt;This site&amp;rsquo;s deploy workflow used to be the usual thing: a step to wget a Hugo .deb, a step to build, a step to upload. Earlier this year I got tired of it and moved the whole build into Nix — the flake holds the site package and the spell check, and the YAML job shrank to installing Nix and running two &lt;code&gt;nix build&lt;/code&gt; calls. All the logic lives where it can be tested; the YAML is a shell.&lt;/p&gt;
&lt;p&gt;That left exactly one piece of YAML worth complaining about: the workflow file itself. So I wrote &lt;a href="https://github.com/lillecarl/ghanix"&gt;ghanix&lt;/a&gt; — write GitHub Actions workflows as NixOS modules, and get back plain values that a caller turns into YAML.&lt;/p&gt;
&lt;h2 id="why-bother"&gt;Why Bother?&lt;/h2&gt;
&lt;p&gt;Because a workflow is configuration of exactly the kind the module system already solves, and GitHub Actions&amp;rsquo; answer to composition is copy-paste. The workflow I actually want to write looks like this:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"&gt;&lt;code class="language-nix" data-lang="nix"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;ghalib&lt;span style="color:#f92672"&gt;.&lt;/span&gt;evalWorkflow {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; name &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;On commit&amp;#34;&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; on&lt;span style="color:#f92672"&gt;.&lt;/span&gt;push &lt;span style="color:#f92672"&gt;=&lt;/span&gt; { };
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; jobs&lt;span style="color:#f92672"&gt;.&lt;/span&gt;build&lt;span style="color:#f92672"&gt;.&lt;/span&gt;steps &lt;span style="color:#f92672"&gt;=&lt;/span&gt; [
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; (ghalib&lt;span style="color:#f92672"&gt;.&lt;/span&gt;steps&lt;span style="color:#f92672"&gt;.&lt;/span&gt;checkout { })
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; (ghalib&lt;span style="color:#f92672"&gt;.&lt;/span&gt;steps&lt;span style="color:#f92672"&gt;.&lt;/span&gt;installNix { })
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; ];
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;evalWorkflow&lt;/code&gt; runs the attrset through &lt;code&gt;lib.evalModules&lt;/code&gt; against a schema of the workflow, job and step shapes, then strips nulls. A declared key gets a type, a default and a description. And the result is &lt;em&gt;values&lt;/em&gt;, not text — which means a job is code: one definition shared by two variants, a step list built by a function, a timeout computed once instead of pasted into four places where it will drift.&lt;/p&gt;
&lt;h2 id="what-it-does-not-give-you"&gt;What it does not give you&lt;/h2&gt;
&lt;p&gt;Being a spell-checker. The workflow level is closed — a key it does not know is an evaluation error — but jobs and steps are deliberately freeform, so GitHub can grow a key and nothing here has to be taught it. The cost is that a typo passes:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"&gt;&lt;code class="language-nix" data-lang="nix"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;jobs&lt;span style="color:#f92672"&gt;.&lt;/span&gt;a&lt;span style="color:#f92672"&gt;.&lt;/span&gt;steps &lt;span style="color:#f92672"&gt;=&lt;/span&gt; [ { run &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;true&amp;#34;&lt;/span&gt;; tiemout-minutes &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;5&lt;/span&gt;; } ];
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# renders &amp;#34;tiemout-minutes&amp;#34;: 5, and GitHub ignores it&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Note the irony on a site whose CI now runs &lt;a href="https://github.com/crate-ci/typos"&gt;typos&lt;/a&gt; at build time. The point is: the schema is there to give the common keys a type and a sane default, not to police the rest. What ghanix buys is that the workflow is code. What it does not buy is that you spelled it right.&lt;/p&gt;
&lt;h2 id="the-job-preamble"&gt;The job preamble&lt;/h2&gt;
&lt;p&gt;The steps at the front of every job are the same in every repository, and writing them out is how they drift. So a job can ask for them by name:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"&gt;&lt;code class="language-nix" data-lang="nix"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;jobs&lt;span style="color:#f92672"&gt;.&lt;/span&gt;test &lt;span style="color:#f92672"&gt;=&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; ghanix &lt;span style="color:#f92672"&gt;=&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; checkout&lt;span style="color:#f92672"&gt;.&lt;/span&gt;enable &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;true&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; nix&lt;span style="color:#f92672"&gt;.&lt;/span&gt;install &lt;span style="color:#f92672"&gt;=&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; enable &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;true&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; settings&lt;span style="color:#f92672"&gt;.&lt;/span&gt;trusted-users &lt;span style="color:#f92672"&gt;=&lt;/span&gt; [ &lt;span style="color:#e6db74"&gt;&amp;#34;root&amp;#34;&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;runner&amp;#34;&lt;/span&gt; ];
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; };
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; nix&lt;span style="color:#f92672"&gt;.&lt;/span&gt;cachix&lt;span style="color:#f92672"&gt;.&lt;/span&gt;enable &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;true&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; userNamespaces&lt;span style="color:#f92672"&gt;.&lt;/span&gt;enable &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;true&lt;/span&gt;; &lt;span style="color:#75715e"&gt;# the Nix sandbox, and passt&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; freeDiskSpace&lt;span style="color:#f92672"&gt;.&lt;/span&gt;enable &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;true&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; };
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; steps &lt;span style="color:#f92672"&gt;=&lt;/span&gt; [ { run &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;nix build --file . thing&amp;#34;&lt;/span&gt;; } ];
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;};
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Each contributes to &lt;code&gt;steps&lt;/code&gt; through &lt;code&gt;mkOrder&lt;/code&gt;, below the 1000 a plain list definition gets, so they come first and in a fixed order: checkout, room on the disk, Nix, cachix, then the runner&amp;rsquo;s own permissions. Everything the job wrote for itself follows.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;nix.install.settings&lt;/code&gt; trick is my favorite: it is an attrset of nix.conf keys, where a list joins on spaces. That lets a project with a cache of its own drop the composite action it would otherwise carry around — and a composite action cannot hold &lt;code&gt;timeout-minutes&lt;/code&gt; at all, which is the kind of thing you learn by losing twenty minutes to it.&lt;/p&gt;
&lt;p&gt;Every step constructor also carries a default &lt;code&gt;timeout-minutes&lt;/code&gt;, generous against the measured time — the cap is there to catch a step that &lt;em&gt;stopped&lt;/em&gt;, not a step that is slow.&lt;/p&gt;
&lt;h2 id="rendering-is-the-callers-job"&gt;Rendering is the caller&amp;rsquo;s job&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;evalWorkflow&lt;/code&gt; returns an attrset and the caller writes the file. That is not laziness; it is that my two consumers disagree about how. nanopynix renders through its own &lt;code&gt;to_yaml&lt;/code&gt; because it wants the key order, and a pytest gate compares the result against the checked-in YAML. nixkube writes with &lt;code&gt;pkgs.formats.yaml&lt;/code&gt;, runs yamlfmt, and ends CI in &lt;code&gt;git diff --exit-code&lt;/code&gt;. Both gates compare &lt;em&gt;values&lt;/em&gt;, so style never fails a build.&lt;/p&gt;
&lt;p&gt;(YAML is a superset of JSON, so &lt;code&gt;builtins.toJSON&lt;/code&gt; into a &lt;code&gt;.yml&lt;/code&gt; file is also a valid workflow and needs no builder at all. It is unreadable in a diff, which is usually the reason not to.)&lt;/p&gt;
&lt;h2 id="takes-lib-and-nothing-else"&gt;Takes lib and nothing else&lt;/h2&gt;
&lt;p&gt;The constraint that makes it a project instead of a directory: &lt;code&gt;lib&lt;/code&gt; from nixpkgs — for &lt;code&gt;evalModules&lt;/code&gt;, &lt;code&gt;types&lt;/code&gt; and &lt;code&gt;asserts&lt;/code&gt; — and nothing else. No flake, no store path, no nixpkgs source. A repository that pins its own nixpkgs, or none, can still import it.&lt;/p&gt;
&lt;p&gt;ghanix grew inside nanopynix, which is still its first consumer. The header there used to say the split &amp;ldquo;would let this directory be lifted into its own project unchanged&amp;rdquo;. It was, and it was: three files moved without an edit to any of them. That is the kind of sentence I want more of in my repos.&lt;/p&gt;
&lt;h2 id="try-it"&gt;Try it&lt;/h2&gt;
&lt;p&gt;Source at &lt;a href="https://github.com/lillecarl/ghanix"&gt;github.com/lillecarl/ghanix&lt;/a&gt;, with the honest caveat from its own CV entry: good, but not even half-finished. The step library covers what my repositories needed; yours will want more. If writing a workflow as a module system configuration sounds like your kind of Tuesday, it is usable today — and if it sounds like heresy, the YAML was always this shape, ghanix just makes you look at it.&lt;/p&gt;</description></item><item><title>pyedit (super editing powers for agents)</title><link>https://lillecarl.com/blog/pyedit/</link><guid>https://lillecarl.com/blog/pyedit/</guid><pubDate>Tue, 15 Sep 2026 00:00:00 +0000</pubDate><description>&lt;p&gt;As promised in the first post: an occasional opinion. This one comes with software.&lt;/p&gt;
&lt;p&gt;If you run AI coding agents you have watched the edit-tool saga unfold. First came line-number patching, and line numbers go stale the moment two edits touch the same file. Then search-and-replace, and the model replaces a string in a different file than it meant to. Underneath both sits the same mistake: every edit is a discrete gamble with no receipt. A &lt;em&gt;complicated&lt;/em&gt; edit — ten files, one refactor — is twenty little gambles, and if gamble twelve is wrong you find out by reading.&lt;/p&gt;
&lt;p&gt;Then Anthropic decided the dedicated tools were the problem. Recent Claude Code builds inject a standing instruction while auto mode is active, verbatim from the binary:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Do your work through the Bash tool wherever it can accomplish the job: read files with cat, head, or sed -n, search with grep and find, and make file changes with sed, heredocs, or short scripts, rather than using the dedicated Read, Edit, or Write tools. Fall back to a dedicated tool only when Bash genuinely cannot do the job.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;So Claude started editing files with bare Python, everywhere, all the time. In practice that is a &lt;code&gt;python3 - &amp;lt;&amp;lt;'PY'&lt;/code&gt; block doing &lt;code&gt;src.replace(...)&lt;/code&gt; on real code: no diff to review, no failure when the anchor text is wrong, nothing anyone can run again — and per the bug reports, occasionally half a document deleted because a one-liner misfired. The instruction is hardcoded behind an undocumented experiment gate with no setting to turn it off, two changelog entries that had shipped the &lt;em&gt;opposite&lt;/em&gt; guidance as fixes a few versions earlier never got a reversal entry, and the &lt;a href="https://github.com/anthropics/claude-code/issues/87971"&gt;issue tracker&lt;/a&gt; &lt;a href="https://github.com/anthropics/claude-code/issues/89731"&gt;filled&lt;/a&gt; &lt;a href="https://github.com/anthropics/claude-code/issues/88041"&gt;up&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Now, my own rule file opens with the standard countermeasure: &lt;em&gt;never rewrite a source file by piping a Python or sed program into the shell&lt;/em&gt;. But somewhere around the fifth Python heredoc of the week it clicked: &lt;strong&gt;if Anthropic thinks bare Python is good enough for editing files, why not make bare Python actually good at editing files?&lt;/strong&gt; Not another rule telling the model to stop — a proper library and runner for scripted edits.&lt;/p&gt;
&lt;p&gt;So I wrote &lt;a href="https://github.com/Lillecarl/pyedit"&gt;pyedit&lt;/a&gt;. It gives an agent super editing powers, and the edits stop being gambles.&lt;/p&gt;
&lt;p&gt;Full disclosure, since we are being honest: pyedit was built with AI, and the first draft of this post was written by one. I am not even trying to hide it — I am firmly in the &amp;ldquo;honesty is best&amp;rdquo; camp. A tool for AI agents, built with AI, announced in an AI-written post: the recursion is the point.&lt;/p&gt;
&lt;h2 id="why-bother"&gt;Why Bother?&lt;/h2&gt;
&lt;p&gt;pyedit runs a Python edit script. That is the whole interface — everything around the script is the point:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Every file the script touches lands in an &lt;strong&gt;in-memory overlay&lt;/strong&gt;. The first read proxies to disk and caches the whole file; writes stay in memory. Reads see staged content, so read-your-writes holds.&lt;/li&gt;
&lt;li&gt;A run &lt;strong&gt;prints a unified diff and touches nothing&lt;/strong&gt;. Disk is written only with &lt;code&gt;--apply&lt;/code&gt;. The diff is not a prediction of what would happen, it is a rendering of what &lt;code&gt;--apply&lt;/code&gt; &lt;em&gt;will&lt;/em&gt; write.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Syntax is checked before the diff prints&lt;/strong&gt; — tree-sitter where a grammar is installed, &lt;code&gt;compile()&lt;/code&gt; for Python. Applying known-broken syntax refuses without &lt;code&gt;--force&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Every applied change prints an &lt;strong&gt;undo id&lt;/strong&gt; — a reverse patch, rendered before the write. &lt;code&gt;pyedit apply &amp;lt;id&amp;gt;&lt;/code&gt; un-does the whole run.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The invariant: staged state lives in memory, and one function is the only thing that writes to disk. A dry-run is not a second code path — it is a run that reaches the end without calling apply.&lt;/p&gt;
&lt;h2 id="many-edits-zero-line-numbers"&gt;Many edits, zero line numbers&lt;/h2&gt;
&lt;p&gt;The part agents actually care about. pyedit allows edits in multiple VFSes, so the model never needs to be concerned with multi-edit line number differences:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;pyedit&lt;span style="color:#f92672"&gt;.&lt;/span&gt;edit(&lt;span style="color:#e6db74"&gt;&amp;#34;src/app.py&amp;#34;&lt;/span&gt;, &lt;span style="color:#e6db74"&gt;&amp;#34;def parse(cfg):&amp;#34;&lt;/span&gt;, &lt;span style="color:#e6db74"&gt;&amp;#34;def parse(cfg, strict):&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;with&lt;/span&gt; pyedit&lt;span style="color:#f92672"&gt;.&lt;/span&gt;VFS():
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; pyedit&lt;span style="color:#f92672"&gt;.&lt;/span&gt;edit(&lt;span style="color:#e6db74"&gt;&amp;#34;src/app.py&amp;#34;&lt;/span&gt;, &lt;span style="color:#e6db74"&gt;&amp;#34;import json&amp;#34;&lt;/span&gt;, &lt;span style="color:#e6db74"&gt;&amp;#34;import json&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;\n&lt;/span&gt;&lt;span style="color:#e6db74"&gt;import os&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A &lt;code&gt;pyedit.VFS()&lt;/code&gt; scope is an independent overlay on top of disk truth. It cannot see the parent&amp;rsquo;s staged state, and sibling scopes cannot see each other. When the scope exits, pyedit merges it with &lt;strong&gt;git&amp;rsquo;s own three-way merge&lt;/strong&gt;: disk is the ancestor, the parent is ours, the scope is theirs. Edits in different regions merge clean no matter what line numbers each one was written against; two edits to neighboring lines belong in one scope, and git says so.&lt;/p&gt;
&lt;p&gt;That is the whole trick — &lt;strong&gt;line numbers never enter into it&lt;/strong&gt;. A conflict is a &lt;code&gt;Collision&lt;/code&gt;, never a guess, and nothing is staged before every conflict is known. (Rename detection rides along, like git&amp;rsquo;s: a delete is paired with a similar add so an edit follows the file to its new name. &lt;code&gt;--no-rename-detection&lt;/code&gt; turns it off.)&lt;/p&gt;
&lt;h2 id="git-does-the-paperwork"&gt;git does the paperwork&lt;/h2&gt;
&lt;p&gt;Storing and replaying patches is a solved problem, and the solver is libgit2. pyedit runs it entirely in memory — a repository with a mempack object database, no workdir, no index file, no path on disk. It owns every format git invented: hunk rendering, base85 binary payloads, symlinks as mode-120000 blobs.&lt;/p&gt;
&lt;p&gt;So pyedit&amp;rsquo;s own patches are git&amp;rsquo;s own, written by libgit2 and applied by libgit2:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A dry-run wraps its diff in &lt;code&gt;# pyedit dry-run &amp;lt;id&amp;gt;&lt;/code&gt; comments, and &lt;code&gt;pyedit apply &amp;lt;id&amp;gt;&lt;/code&gt; applies that stored patch later. Handy when a human wants to review agent output before it lands.&lt;/li&gt;
&lt;li&gt;An applied run&amp;rsquo;s undo id is the reverse patch, written before the write.&lt;/li&gt;
&lt;li&gt;No Python parses a diff anywhere on this path. Line numbers are exact by construction, so nothing needs fuzzy matching.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="trusting-patch-confetti"&gt;Trusting patch confetti&lt;/h2&gt;
&lt;p&gt;Agents also handle &lt;em&gt;other people&amp;rsquo;s&lt;/em&gt; patch formats — OpenAI &lt;code&gt;apply_patch&lt;/code&gt; envelopes, unified diffs from tool output — and what arrives is often patch confetti. libgit2&amp;rsquo;s apply refuses most of it: no &lt;code&gt;diff --git&lt;/code&gt; line (agents write these constantly), creates without mode headers, zero-hunk sections. So pyedit meets the confetti where it is:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;pyedit.apply_v4a(text)&lt;/code&gt; — the vendored OpenAI primitive&lt;/li&gt;
&lt;li&gt;&lt;code&gt;pyedit.apply_diff_git(text)&lt;/code&gt; — libgit2, exact line numbers&lt;/li&gt;
&lt;li&gt;&lt;code&gt;pyedit.apply_diff_unidiff(text)&lt;/code&gt; — the unidiff library, with hunks anchored by search and a whitespace-insensitive fallback, because an &lt;code&gt;@@&lt;/code&gt; number from a model is a suggestion, not a promise&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Fail closed, always: one hunk that will not anchor fails the whole input and nothing is staged. &lt;code&gt;--force&lt;/code&gt; downgrades that to per-file skips.&lt;/p&gt;
&lt;p&gt;The same spirit runs the semantic layer: rope does Python renames and reference finding in-process, a real language server covers other languages, and tree-sitter serves position queries and outlines — all reading staged content, so the tools agree with the diff.&lt;/p&gt;
&lt;h2 id="try-it"&gt;Try it&lt;/h2&gt;
&lt;p&gt;Source and the full manual live at &lt;a href="https://github.com/Lillecarl/pyedit"&gt;github.com/Lillecarl/pyedit&lt;/a&gt; — &lt;code&gt;pyedit skill&lt;/code&gt; prints the whole agent-facing manual as markdown, which is the authoritative usage guide and doubles as a decent architecture lesson.&lt;/p&gt;
&lt;p&gt;The pitch: if your agent makes three search-and-replace calls to rename one function, give it pyedit. It writes one script, reads one diff, applies one patch — and if the result is wrong, one id un-does the whole thing.&lt;/p&gt;</description></item><item><title>Hello world... Hello Carl!</title><link>https://lillecarl.com/blog/hello-world/</link><guid>https://lillecarl.com/blog/hello-world/</guid><pubDate>Sun, 25 Jan 2026 00:00:00 +0000</pubDate><description>&lt;p&gt;Finally got around to setting up a proper website. Built with Hugo, deployed via GitHub Actions, styled to be as minimal as possible.&lt;/p&gt;
&lt;h2 id="why-bother"&gt;Why Bother?&lt;/h2&gt;
&lt;p&gt;A little portfolio thingy can be useful when selling yourself online, if I ever decide to write something
that isn&amp;rsquo;t code I don&amp;rsquo;t want to lock it behind some evil megacorporations ad network (The hypocracy is strong with this one, using
GitHub pages).&lt;/p&gt;
&lt;h2 id="what-to-expect"&gt;What to Expect&lt;/h2&gt;
&lt;p&gt;Mostly technical content - infrastructure, DevOps, NixOS, that sort of thing. Occasional opinions. Infrequent updates, probably.&lt;/p&gt;
&lt;h2 id="the-stack"&gt;The Stack&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Hugo&lt;/strong&gt; for static site generation&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;GitHub Pages&lt;/strong&gt; for hosting&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;GitHub Actions&lt;/strong&gt; for deployment&lt;/li&gt;
&lt;li&gt;Custom minimal theme because I don&amp;rsquo;t need much&lt;/li&gt;
&lt;li&gt;Fully vibecoded with Claude Opus 4.5&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;No JavaScript frameworks were harmed in the making of this site.&lt;/p&gt;</description></item></channel></rss>