<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://blog.cani.ne.jp/feed.xml" rel="self" type="application/atom+xml" /><link href="https://blog.cani.ne.jp/" rel="alternate" type="text/html" /><updated>2026-09-08T02:11:59+00:00</updated><id>https://blog.cani.ne.jp/feed.xml</id><title type="html">🐶 Dog with a Dev Blog</title><subtitle>On the internet, nobody knows you&apos;re a dog and a cat in a latent space.</subtitle><entry><title type="html">The House Goes Critical</title><link href="https://blog.cani.ne.jp/2026/09/07/the-house-goes-critical.html" rel="alternate" type="text/html" title="The House Goes Critical" /><published>2026-09-07T00:00:00+00:00</published><updated>2026-09-08T02:11:08+00:00</updated><id>https://blog.cani.ne.jp/2026/09/07/the-house-goes-critical</id><content type="html" xml:base="https://blog.cani.ne.jp/2026/09/07/the-house-goes-critical.html"><![CDATA[<p>Last night, <a href="https://www.risebroadband.com/">Rise Broadband</a> flapped on the primary WAN port of our router. Within seconds, the failover script engaged, traffic shifted to <a href="https://www.spectrum.com/">Spectrum</a> on the backup WAN port, and the home dashboard in my browser shifted from calm green to amber. Later, when the link failed completely, the entire page flooded into a dark red wash. Diagonal hazard stripes began racing across the top and bottom borders at triple speed, a digital clock started counting the outage in elapsed centiseconds, and the status hex for the primary provider began to pulse in alert red.</p>

<p>It looked like the tactical command bridge in <a href="https://www.imdb.com/title/tt0112159/"><em>Neon Genesis Evangelion</em></a>. More importantly, every pixel on the screen was telling the truth.</p>

<p>Most NGE anime UI recreations on GitHub feel like hollow cosplay. Someone copies an amber-and-black palette, draws a few hexagons, and slaps them onto a standard Bootstrap layout or a set of rounded Tailwind cards. It looks neat in a static screenshot, but it feels lifeless the moment you touch it.</p>

<p>Gennaro’s <a href="https://medium.com/@gennarolgr/the-beautiful-chaos-ui-ux-design-storytelling-in-neon-genesis-evangelion-26ae2d09613f">The Beautiful Chaos: UI/UX design storytelling in Neon Genesis Evangelion</a> names the gap cleanly. Evangelion’s fictional interfaces were never meant for passive reading or comfortable enterprise workflows. They were designed as instruments of narrative tension. The bridge crew is watching an existential operational crisis unfold in real time. If your system has no concept of an escalation cascade, slapping NERV decals onto your CSS is just dressing up a spreadsheet.</p>

<p>A dual-WAN home router, however, actually lives inside an operational drama.</p>

<h2 id="the-geometry-of-belief">The Geometry of Belief</h2>

<p>Modern web design is obsessed with softening the world: 12-pixel border radii, diffuse drop shadows, airy whitespace, and pastel accents. As zemnmez pointed out in <a href="https://zemnmez.medium.com/why-we-dont-have-uis-like-the-ones-in-neon-genesis-9b6631dc3714">Why We Don’t Have UIs Like The Ones In Neon Genesis Evangelion</a>, NERV consoles look the way they do because they inherit the harsh, disciplined grammar of 1990s vector CRT displays and industrial <a href="https://en.wikipedia.org/wiki/SCADA">SCADA</a> terminals.</p>

<p>There are no rounded corners. Containers are sharp rectangles framed by 1-pixel borders or skewed parallelograms. Structural zones sit on top of a faint substrate of registration crosshairs. High-contrast phosphor bloom bleeds against an absolute void of <code class="language-plaintext highlighter-rouge">#000000</code>. And when an element needs to indicate state, it does not gently crossfade with a CSS transition; it snaps immediately into place using <code class="language-plaintext highlighter-rouge">steps()</code> animations or hard visibility cuts.</p>

<div class="polaroid-container">
  <div class="polaroid">
    <a href="isp-status-desktop-nominal.png">
      <img src="isp-status-desktop-nominal.png" alt="Screenshot of the NERV-style status dashboard in nominal state with green honeycomb hexes and uPlot timeline" class="polaroid-image" />
    </a>
    <div class="polaroid-title">Nominal. Both providers connected.</div>
    <div class="polaroid-link">
      
      &nbsp;
      
    </div>
    <small class="polaroid-archive">
      
      &nbsp;
      
    </small>
  </div>
</div>

<p>When both providers are up and the primary route is carrying traffic, the console sits in <strong>Nominal</strong>. The center honeycomb cluster glows in phosphor green. The active route displays in electric blue for Rise Broadband, while the standby route waits in cyan for Spectrum.</p>

<p>To make the interactive controls feel authentic without breaking DOM accessibility, the time-window selector buttons are parallelograms skewed via <code class="language-plaintext highlighter-rouge">transform: skewX(-15deg)</code>. The button label inside is counter-skewed by <code class="language-plaintext highlighter-rouge">15deg</code> so the text remains upright while the slanted padding establishes the physical click target.</p>

<p>The upper section of the screen is the glance area: six hexagonal cells arranged in a honeycomb cluster that summarize the instantaneous health of the router, DNS resolution, internet reachability, and both ISP uplinks.</p>

<p>Below the glance cluster, telemetry is plotted across a 24-hour timeline using <a href="https://github.com/leeoniya/uPlot">uPlot</a>. Most network dashboards plot discrete polling points - leaving you to guess what happened between two dots on a line chart. Here, each lane records a continuous history of <em>defended intervals</em>: unbroken spans of time where link state is actively defended by incoming syslog events or ongoing probes.</p>

<h2 id="the-offline-irony">The Offline Irony</h2>

<p>The visual system behind this dashboard started life in <a href="https://github.com/Texarkanine/nervouscsstem">nervouscsstem</a>, an in-progress SCSS design kit we started earlier this year to replicate the operational screens of Evangelion. The library is unfinished - it still has open milestones for psychographic SVG waveforms, topographic wireframe maps, and complex dropdown primitives.</p>

<p>When we decided to build a local WAN status monitor, our initial impulse was to wait: finish the library, publish it to npm, set up a CDN pipeline, and only then build an application on top of it.</p>

<p>That impulse was a mistake. Waiting on an entire component library to reach 1.0 would have delayed the dashboard by months, and publishing to a public CDN was fundamentally at odds with the problem we were solving.</p>

<p>A dashboard whose sole reason for existing is to diagnose network outages cannot depend on external assets. The compiled stylesheet contained 26 <code class="language-plaintext highlighter-rouge">@font-face</code> rules pointing out to <a href="https://fonts.google.com/">Google Fonts</a> and <a href="https://www.jsdelivr.com/">jsDelivr</a>. If we had loaded the stylesheet over a CDN, the exact moment our home internet died would also be the exact moment the dashboard failed to fetch its fonts, falling back to Times New Roman in the middle of a blackout.</p>

<p>Instead, we vendored the compiled tokens and core stylesheet directly into the dashboard repository, downloaded the Latin HUD fonts locally (<a href="https://fonts.google.com/specimen/Barlow+Condensed">Barlow Condensed</a>, <a href="https://fonts.google.com/specimen/Antonio">Antonio</a>, <a href="https://fonts.google.com/specimen/IBM+Plex+Mono">IBM Plex Mono</a>, and <a href="https://github.com/keshikan/DSEG">DSEG7 Classic</a>), and stripped out all external network requests.</p>

<p>We also made a conscious architectural cut: we threw away the idea of using the unfinished psychographic waveforms. In the anime, those squiggling curves represent the mental synchronization between pilots and giant biomechanical units; they are decorative fiction. A home network monitor demands defended timeline intervals, not fictional squiggles. By pairing vendored NERV styling tokens with uPlot for the data plane, the dashboard stays grounded in empirical facts while preserving the aesthetic.</p>

<h2 id="telemetry-as-an-escalation-ladder">Telemetry as an Escalation Ladder</h2>

<p>The aesthetic works because it is tied directly to router telemetry. The edge router (an <a href="https://rog.asus.com/networking/rog-rapture-gt-axe16000-model/">Asus ROG Rapture GT-AXE16000</a> running <a href="https://www.asuswrt-merlin.net/">Asuswrt-Merlin</a> firmware) forwards its <a href="https://en.wikipedia.org/wiki/Syslog">syslog</a> stream across the LAN to a dedicated logging host. A background process parses link state changes and <code class="language-plaintext highlighter-rouge">wan-failover</code> debug output, combining router syslog events with active LAN pings and independent UDP DNS probes.</p>

<p>In most home networks, DNS is an invisible detail handed down by an ISP’s DHCP server. But our LAN runs an independent <a href="https://pi-hole.net/">Pi-hole</a> paired with an <a href="https://nlnetlabs.nl/projects/unbound/about/">Unbound</a> recursive resolver (the same DNS foundation from <a href="/2026/08/22/the-gate-lodge.html">The Gate Lodge</a>). Because we operate our own name resolution, DNS is a critical local service that can degrade independently of raw IP connectivity. An upstream failover might scramble outbound routes while the local resolver is still answering cache hits, or recursive lookups might stall while gateway pings sail through unimpeded. Giving DNS its own glance hex and timeline lane means a resolver outage never hides behind a working IP route, and an ISP hiccup is not mistaken for a local DNS crash.</p>

<p>Those telemetry inputs feed into a deterministic state machine that drives the NERV alert cascade:</p>

<figure class="mermaid-diagram">
<style>
.mermaid-diagram__light { display: inline; }
.mermaid-diagram__dark { display: none; }
@media (prefers-color-scheme: dark) {
  .mermaid-diagram__light { display: none; }
  .mermaid-diagram__dark { display: inline; }
}
</style>
<a class="mermaid-diagram__light" href="/assets/svg/df5d698a.svg"><img src="/assets/svg/df5d698a.svg" alt="Mermaid Diagram" /></a>
<a class="mermaid-diagram__dark" href="/assets/svg/df5d698a-dark.svg"><img src="/assets/svg/df5d698a-dark.svg" alt="Mermaid Diagram" /></a>
</figure>

<p>Each step up the ladder changes the ambiance tokens on the document root:</p>

<ul>
  <li><strong>Nominal:</strong> Primary provider active, backup standby, all probes passing. Glows in cool green.</li>
  <li><strong>Active:</strong> Primary flapped or forced onto Spectrum backup. Internet is working, but the household is running without its main pipe.</li>
  <li><strong>Caution:</strong> Telemetry stream has gone quiet or syslog lacks failover markers. Unknown states render in neutral steel (<code class="language-plaintext highlighter-rouge">--nerv-steel</code>), never an assumed-up green.</li>
  <li><strong>Alert:</strong> A single point of failure has occurred. One ISP is unplugged or confirmed down, outbound probes are degraded, or recursive DNS has failed consecutive queries.</li>
  <li><strong>Critical:</strong> Total blackout. Both providers are down, outbound ping probes are failing, or the recursive resolver is completely unresponsive.</li>
</ul>

<div class="polaroid-container">
  <div class="polaroid">
    <a href="isp-status-desktop-alert.gif">
      <img src="isp-status-desktop-alert.gif" alt="Animated capture of the dashboard in alert state showing active failover and running outage timer" class="polaroid-image" />
    </a>
    <div class="polaroid-title">Alert. Primary down, failover active on Spectrum.</div>
    <div class="polaroid-link">
      
      &nbsp;
      
    </div>
    <small class="polaroid-archive">
      
      &nbsp;
      
    </small>
  </div>
</div>

<p>In <strong>Alert</strong>, the system flags that redundancy has been lost even while household internet continues uninterrupted. Traffic has shifted over to Spectrum, the header hazard stripes flare in cautionary orange, and the digital clock in the corner begins tracking the duration of the primary outage.</p>

<div class="polaroid-container">
  <div class="polaroid">
    <a href="isp-status-desktop-critical.gif">
      <img src="isp-status-desktop-critical.gif" alt="Animated capture of the dashboard in critical state with red background wash and running outage timer" class="polaroid-image" />
    </a>
    <div class="polaroid-title">Critical. Dual outage with active timer.</div>
    <div class="polaroid-link">
      
      &nbsp;
      
    </div>
    <small class="polaroid-archive">
      
      &nbsp;
      
    </small>
  </div>
</div>

<p>When the cascade trips all the way to <strong>Critical</strong>, the interface transforms. The background void shifts to a deep crimson wash, the central cascade hex flips to <code class="language-plaintext highlighter-rouge">CRITICAL</code>, and the diagonal hazard stripes along the header and footer engage their scrolling animation at maximum velocity. The honeycomb hex for the failed primary provider pulses in alert red, the secondary glows in warning orange, and the outage clock keeps ticking in centiseconds.</p>

<p>If the laptop goes to sleep or the browser tab loses its <a href="https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events">Server-Sent Events</a> (SSE) connection, the system does not cry wolf. It greys out the live telemetry region into a muted monochrome wash, signaling that the display is stale without falsely declaring a network emergency.</p>

<h2 id="the-handheld-terminal">The Handheld Terminal</h2>

<p>Desktop widescreen monitors give you the sprawl of a command center bridge, with room for a six-hex status array and a full day of multi-lane history. But when the internet drops at night, nobody wants to walk over to an office workstation to figure out what happened. You pull out your phone.</p>

<p>Translating a dense, vector-heavy desktop HUD onto a mobile screen usually destroys either the density or the aesthetic. If you simply wrap columns, you end up with an endless scroll of generic cards.</p>

<div class="polaroid-container">
  <div class="polaroid">
    <a href="isp-status-mobile-alert.gif">
      <img src="isp-status-mobile-alert.gif" alt="Animated capture of mobile PDA layout showing stacked status list items and condensed timelines" class="polaroid-image" />
    </a>
    <div class="polaroid-title">Mobile viewport alert. Field terminal layout.</div>
    <div class="polaroid-link">
      
      &nbsp;
      
    </div>
    <small class="polaroid-archive">
      
      &nbsp;
      
    </small>
  </div>
</div>

<p>Instead of generic wrapping, the mobile layout reorganizes into a compact field terminal - a handheld NERV PDA. The honeycomb collapses into a vertical stack of pointed status rows spelling out <code class="language-plaintext highlighter-rouge">ONLINE</code>, <code class="language-plaintext highlighter-rouge">OUTAGE</code>, or <code class="language-plaintext highlighter-rouge">UNPLUGGED</code>. Time-range selectors squeeze into a tight bar; uPlot lanes contract to width without losing their hazard fills.</p>

<p>Even constrained to a mobile viewport, the high-contrast typography and sharp borders read as purpose-built tactical instrumentation, not a desktop site that gave up.</p>

<h2 id="going-critical-in-style">Going Critical in Style</h2>

<p>On a dual-WAN home network, the NERV aesthetic is not costume - it is how the operational cascade reads at a glance. By skipping the idealized 1.0 library release, we solved an immediate homelab problem with the tools we had: router syslog, offline local fonts, and defended intervals.</p>

<p>The next time Rise Broadband decides to drop off the pole in the middle of the afternoon, the house will not simply lose connection silently. It will sound the alarm, flash its hazard stripes, and go critical in style!</p>]]></content><author><name>Niko</name></author><category term="blog" /><category term="record" /><category term="css" /><category term="debugging" /><category term="home-networking" /><category term="networking" /><category term="tools" /><category term="ui" /><summary type="html"><![CDATA[Why most anime UI recreations feel hollow, how we mapped dual-WAN syslog failover onto the NERV alert cascade, and what happens when an outage turns your home dashboard blood red.]]></summary></entry><entry><title type="html">The Overlay Was Fine</title><link href="https://blog.cani.ne.jp/2026/09/05/the-overlay-was-fine.html" rel="alternate" type="text/html" title="The Overlay Was Fine" /><published>2026-09-05T00:00:00+00:00</published><updated>2026-09-05T22:52:39+00:00</updated><id>https://blog.cani.ne.jp/2026/09/05/the-overlay-was-fine</id><content type="html" xml:base="https://blog.cani.ne.jp/2026/09/05/the-overlay-was-fine.html"><![CDATA[<p>We <a href="/2026/08/22/the-gate-lodge.html">put WireGuard on a host</a> so we could restart the VPN without restarting the house. The exam in that post was a phone on cellular, Pi-hole in the browser, a home address that answers. Cellular never numbers itself <code class="language-plaintext highlighter-rouge">192.168.1.0/24</code>. A lot of cafes do.</p>

<p>The laptop joined one and received <code class="language-plaintext highlighter-rouge">192.168.1.253</code>, which is also the VPN box. The official <a href="https://www.wireguard.com/">WireGuard</a> app said Connected. <code class="language-plaintext highlighter-rouge">ping 192.168.1.254</code> hit the cafe.</p>

<h2 id="the-cafe-already-had-the-24">The Cafe Already Had the /24</h2>

<p>The first post’s public surface is one UDP port, forwarded to <code class="language-plaintext highlighter-rouge">192.168.1.253</code>. Inside the tunnel, clients live on <code class="language-plaintext highlighter-rouge">192.168.101.0/24</code>. The edge router still owns Wi-Fi, DHCP, and NAT for <code class="language-plaintext highlighter-rouge">192.168.1.0/24</code>, plus a LAN static route that sends the overlay back to the VPN box. <a href="/2026/08/22/the-gate-lodge.html#masquerade-eats-the-return-path">Masquerade stays off</a> so those packets still look like overlay packets when they hit the LAN.</p>

<p>Phones on cellular pass that exam effortlessly because mobile carriers never assign local <code class="language-plaintext highlighter-rouge">192.168.1.0/24</code> subnets. Without a competing local network, the client routing table has only one story about <code class="language-plaintext highlighter-rouge">.254</code>: send it across the tunnel to the house.</p>

<p>A cafe handing out that same <code class="language-plaintext highlighter-rouge">/24</code> gives the kernel two conflicting stories. Under <a href="https://en.wikipedia.org/wiki/Longest_prefix_match">longest prefix match</a>, the local on-link interface wins every time, ignoring WireGuard entirely. Renumbering home would only change the odds; whatever private block we picked, sooner or later we would sit down in a cafe that picked the exact same numbers.</p>

<figure class="mermaid-diagram">
<style>
.mermaid-diagram__light { display: inline; }
.mermaid-diagram__dark { display: none; }
@media (prefers-color-scheme: dark) {
  .mermaid-diagram__light { display: none; }
  .mermaid-diagram__dark { display: inline; }
}
</style>
<a class="mermaid-diagram__light" href="/assets/svg/499e839b.svg"><img src="/assets/svg/499e839b.svg" alt="Mermaid Diagram" /></a>
<a class="mermaid-diagram__dark" href="/assets/svg/499e839b-dark.svg"><img src="/assets/svg/499e839b-dark.svg" alt="Mermaid Diagram" /></a>
</figure>

<p>I watched this happen on a colliding cafe network. While the Mac tunnel interface (<code class="language-plaintext highlighter-rouge">utun</code>) held an overlay <code class="language-plaintext highlighter-rouge">/32</code> and handshake packets flew across the Wi-Fi link, the inner packets destined for <code class="language-plaintext highlighter-rouge">192.168.1.254</code> never entered the tunnel at all. As far as the routing table was concerned, they were already home. Home was the espresso machine.</p>

<h2 id="101681-keeps-the-last-octet">10.168.1 Keeps the Last Octet</h2>

<p>The house needed an address range that a <code class="language-plaintext highlighter-rouge">192.168.1.0/24</code> cafe cannot claim. We picked <code class="language-plaintext highlighter-rouge">10.168.1.0/24</code> and kept the last octet: a home server at <code class="language-plaintext highlighter-rouge">192.168.1.122</code> becomes reachable at <code class="language-plaintext highlighter-rouge">10.168.1.122</code>. Pi-hole at <code class="language-plaintext highlighter-rouge">.254</code> is reachable at <code class="language-plaintext highlighter-rouge">10.168.1.254</code>. The VPN box at <code class="language-plaintext highlighter-rouge">.253</code> is <code class="language-plaintext highlighter-rouge">10.168.1.253</code>.</p>

<p>The VPN box translates the whole <code class="language-plaintext highlighter-rouge">/24</code> on the way in. When packets leave the tunnel, they still carry their <code class="language-plaintext highlighter-rouge">192.168.101.x</code> client addresses, because masquerade is off. The edge router’s return route still matches. <a href="https://en.wikipedia.org/wiki/Netfilter#Connection_tracking">Conntrack</a> (connection tracking) reverses the destination rewrite on replies, so the laptop sees answers coming back from <code class="language-plaintext highlighter-rouge">10.168.1.x</code>.</p>

<figure class="mermaid-diagram">
<style>
.mermaid-diagram__light { display: inline; }
.mermaid-diagram__dark { display: none; }
@media (prefers-color-scheme: dark) {
  .mermaid-diagram__light { display: none; }
  .mermaid-diagram__dark { display: inline; }
}
</style>
<a class="mermaid-diagram__light" href="/assets/svg/f407555d.svg"><img src="/assets/svg/f407555d.svg" alt="Mermaid Diagram" /></a>
<a class="mermaid-diagram__dark" href="/assets/svg/f407555d-dark.svg"><img src="/assets/svg/f407555d-dark.svg" alt="Mermaid Diagram" /></a>
</figure>

<p>“Nobody” types raw IP addresses into a browser or terminal; you type hostnames like <code class="language-plaintext highlighter-rouge">home.internal</code>. That means the DNS resolver (the server that turns names into IP addresses, running on our Pi-hole) has to participate in the trick. If you ask for <code class="language-plaintext highlighter-rouge">home.internal</code> while sitting in the living room, it should answer <code class="language-plaintext highlighter-rouge">192.168.1.122</code>. But if you ask over the WireGuard tunnel from a cafe, it must return the alias address <code class="language-plaintext highlighter-rouge">10.168.1.122</code>. If it answered with <code class="language-plaintext highlighter-rouge">192.168.1.122</code>, the laptop would look at the cafe’s Wi-Fi, decide <code class="language-plaintext highlighter-rouge">.122</code> was local, and never send the packet into the tunnel at all.</p>

<p>The design was clean on paper: a 1:1 alias subnet across the whole home, and split-horizon DNS answering with alias addresses over the VPN. Then we sat down at a cafe to test it.</p>

<h2 id="the-chain-that-nobody-jumped">The Chain That Nobody Jumped</h2>

<p>Sitting on the cafe Wi-Fi with the WireGuard tunnel active, we tried pinging a test home server at the alias IP <code class="language-plaintext highlighter-rouge">10.168.1.122</code>. WireGuard said connected, the route was in place, but every ping timed out.</p>

<p>On the VPN box, <code class="language-plaintext highlighter-rouge">nft list</code> showed our new translation rule sitting inside <code class="language-plaintext highlighter-rouge">chain dstnat_vpn</code>. But that chain’s packet counter stayed at zero: the rule was in the kernel, and not a single packet had ever touched it.</p>

<p>OpenWrt <a href="https://openwrt.org/docs/guide-user/firewall/firewall_configuration">fw4</a> allows custom firewall extensions via drop-in configuration files: any <code class="language-plaintext highlighter-rouge">.nft</code> file placed in <code class="language-plaintext highlighter-rouge">/etc/nftables.d/</code> is automatically included at the table root. We had created a drop-in file defining our subnet translation inside <code class="language-plaintext highlighter-rouge">chain dstnat_vpn</code>. Earlier, we had tested individual hosts using OpenWrt’s standard UCI <code class="language-plaintext highlighter-rouge">redirect</code> sections. When the whole-subnet rule replaced those per-host entries, we deleted the UCI redirects.</p>

<p>Here is the catch: fw4 only generates a jump from base <code class="language-plaintext highlighter-rouge">dstnat</code> into <code class="language-plaintext highlighter-rouge">dstnat_&lt;zone&gt;</code> when at least one standard UCI <code class="language-plaintext highlighter-rouge">redirect</code> exists for that zone. Deleting the individual redirects severed the path into our chain entirely. It sat in kernel memory with no caller. Defining base <code class="language-plaintext highlighter-rouge">chain dstnat</code> directly inside the drop-in file failed too, because fw4 includes drop-in snippets before it defines its own base chains.</p>

<p>The hook that actually works is a UCI <code class="language-plaintext highlighter-rouge">chain-append</code> pointing at the drop-in file, attaching it to <code class="language-plaintext highlighter-rouge">dstnat</code> itself:</p>

<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">config</span><span class="w"> </span><span class="na">include</span><span class="w"> </span><span class="na">'alias_netmap'</span><span class="w">
	</span><span class="na">option</span><span class="w"> </span><span class="na">type</span><span class="w"> </span><span class="na">'nftables'</span><span class="w">
	</span><span class="na">option</span><span class="w"> </span><span class="na">path</span><span class="w"> </span><span class="na">'/etc/nftables.d/netmap.include'</span><span class="w">
	</span><span class="na">option</span><span class="w"> </span><span class="na">position</span><span class="w"> </span><span class="na">'chain-append'</span><span class="w">
	</span><span class="na">option</span><span class="w"> </span><span class="na">chain</span><span class="w"> </span><span class="na">'dstnat'</span><span class="w">
</span></code></pre></div></div>

<p>With <code class="language-plaintext highlighter-rouge">chain-append</code>, fw4 attaches our rules directly to <code class="language-plaintext highlighter-rouge">dstnat</code> after creating the base chains. A quick <code class="language-plaintext highlighter-rouge">fw4 reload</code>, another ping from the cafe, and the packet counter finally moved. Traffic was hitting the chain.</p>

<p>Except the ping was still failing.</p>

<h2 id="the-pool-that-looked-like-a-map">The Pool That Looked Like a Map</h2>

<p>The first line we appended looked like whole-subnet translation:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>iifname "wg0" ip daddr 10.168.1.0/24 dnat ip to 192.168.1.0/24
</code></pre></div></div>

<p>To human eyes, writing <code class="language-plaintext highlighter-rouge">10.168.1.0/24 dnat to 192.168.1.0/24</code> reads as a 1:1 prefix mapping that preserves host numbers, and running <code class="language-plaintext highlighter-rouge">nft list ruleset</code> even echoes back that exact line.</p>

<p>It is a trap. <a href="https://wiki.nftables.org/wiki-nftables/index.php/Performing_Network_Address_Translation_%28NAT%29#NAT_pooling">nftables treats a subnet on the right-hand side of <code class="language-plaintext highlighter-rouge">dnat to</code> as a pool</a>. If you inspect what the compiler actually generated with <code class="language-plaintext highlighter-rouge">nft --debug=netlink</code>, the kernel bytecode gives the game away:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  [ nat dnat ip addr_min 192.168.1.0 addr_max 192.168.1.255 ]
</code></pre></div></div>

<p>The kernel was not preserving host numbers. It treated <code class="language-plaintext highlighter-rouge">192.168.1.0/24</code> as a pool of 256 random addresses, rewriting incoming packets to whatever IP it felt like across the home network.</p>

<p>A true 1:1 prefix translation requires the prefix map syntax, which <a href="https://serverfault.com/questions/1156428/configuring-destination-nat-nftables-entire-subnet">current nftables spells as a destination map</a>:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>iifname "wg0" ip daddr 10.168.1.0/24 counter dnat ip prefix to ip daddr map { 10.168.1.0/24 : 192.168.1.0/24 }
</code></pre></div></div>

<p>That compiled cleanly on OpenWrt 24.10.8 (kernel <code class="language-plaintext highlighter-rouge">6.6.144</code>). <code class="language-plaintext highlighter-rouge">nft --debug=netlink</code> confirmed a real prefix translation rather than <code class="language-plaintext highlighter-rouge">addr_min</code> / <code class="language-plaintext highlighter-rouge">addr_max</code>.</p>

<p>Now <code class="language-plaintext highlighter-rouge">ping 10.168.1.122</code> answered instantly. Destination NAT was preserving host octets, conntrack was reversing the translation on replies, and packets traveled the tunnel back and forth without a hitch.</p>

<p>Raw IP addresses were working. But as soon as we opened a browser, the cafe took over again.</p>

<h2 id="house-numbers-on-a-foreign-street">House Numbers on a Foreign Street</h2>

<p>The IP map was working, but typing <code class="language-plaintext highlighter-rouge">http://box.internal</code> in a browser still landed on the cafe’s router login page.</p>

<p>The WireGuard profile specifies a DNS server using the <a href="https://man.archlinux.org/man/wg-quick.8.en#CONFIGURATION"><code class="language-plaintext highlighter-rouge">DNS =</code> line</a>. It takes an IP address, and standard operating system resolvers always send those DNS queries to UDP port 53.</p>

<p>On the home LAN, <a href="https://docs.pi-hole.net/ftldns/">Pi-hole’s FTL</a> listens on <code class="language-plaintext highlighter-rouge">192.168.1.254:53</code>. When asked for <code class="language-plaintext highlighter-rouge">box.internal</code>, it answers with the real LAN IP: <code class="language-plaintext highlighter-rouge">192.168.1.122</code>. When you are at home, that is correct. But when you are at a cafe whose Wi-Fi also uses <code class="language-plaintext highlighter-rouge">192.168.1.0/24</code>, that answer is poison: your laptop sees <code class="language-plaintext highlighter-rouge">192.168.1.122</code>, decides it is local to the cafe, and never sends the connection into the tunnel.</p>

<p>We needed DNS queries arriving over the VPN to return <code class="language-plaintext highlighter-rouge">10.168.1.122</code> instead.</p>

<p>Modern browsers can use <a href="https://en.wikipedia.org/wiki/DNS_over_HTTPS">DNS-over-HTTPS</a> (DoH), sending queries inside encrypted HTTPS requests rather than plain port 53. We already had <a href="https://dnsdist.org/">dnsdist</a> running on the Pi-hole host to handle DoH, configured to return DNS A records containing the <code class="language-plaintext highlighter-rouge">10.168.1.x</code> alias addresses for VPN clients (<code class="language-plaintext highlighter-rouge">192.168.101.0/24</code>). Over DoH, <code class="language-plaintext highlighter-rouge">https://10.168.1.254/dns-query</code> answered with <code class="language-plaintext highlighter-rouge">10.168.1.122</code>.</p>

<p>The problem is that the WireGuard client profile has no field for DoH. It only configures the operating system’s standard resolver (<code class="language-plaintext highlighter-rouge">DNS = 10.168.1.254</code>), which fires standard queries over port 53. And port 53 on the Pi-hole belonged to FTL, which was still answering <code class="language-plaintext highlighter-rouge">192.168.1.122</code>.</p>

<p>The obvious architectural temptation is to put dnsdist on port 53 in front of Pi-hole, letting it handle every DNS query for the entire house. We rejected that. dnsdist was introduced to this network as a sidecar specifically for DoH - terminating TLS and forwarding client IP tags so Pi-hole logs could still attribute queries to individual devices. Pi-hole FTL handles the household’s primary DNS natively. Putting a proxy layer in front of port 53 would insert an extra moving part into the path of every phone, TV, and laptop in the home. A misconfiguration or crash in dnsdist would take down the entire household’s internet. Roaming VPN tweaks should not jeopardize the living room.</p>

<p>Instead, FTL kept port 53 for the house. dnsdist was given an additional plain listen on <code class="language-plaintext highlighter-rouge">192.168.1.254:5300</code>. Then the VPN box rewrites any port 53 queries coming across the tunnel to that side listen, placed <em>before</em> the general <code class="language-plaintext highlighter-rouge">/24</code> prefix map:</p>

<figure class="mermaid-diagram">
<style>
.mermaid-diagram__light { display: inline; }
.mermaid-diagram__dark { display: none; }
@media (prefers-color-scheme: dark) {
  .mermaid-diagram__light { display: none; }
  .mermaid-diagram__dark { display: inline; }
}
</style>
<a class="mermaid-diagram__light" href="/assets/svg/f6ed5f55.svg"><img src="/assets/svg/f6ed5f55.svg" alt="Mermaid Diagram" /></a>
<a class="mermaid-diagram__dark" href="/assets/svg/f6ed5f55-dark.svg"><img src="/assets/svg/f6ed5f55-dark.svg" alt="Mermaid Diagram" /></a>
</figure>

<p>The rewrite rules on the VPN box:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>iifname "wg0" ip daddr 10.168.1.254 udp dport 53 dnat ip to 192.168.1.254:5300
iifname "wg0" ip daddr 10.168.1.254 tcp dport 53 dnat ip to 192.168.1.254:5300
iifname "wg0" ip daddr 10.168.1.0/24 counter dnat ip prefix to ip daddr map { 10.168.1.0/24 : 192.168.1.0/24 }
</code></pre></div></div>

<p>The dnsdist spoof rule is source-gated to tunnel addresses (<code class="language-plaintext highlighter-rouge">192.168.101.0/24</code>). A query from the home LAN to port 5300 still receives <code class="language-plaintext highlighter-rouge">192.168.1.122</code>; only VPN clients get the <code class="language-plaintext highlighter-rouge">10.168.1.x</code> alias.</p>

<p>Tested on a hotspot sharing <code class="language-plaintext highlighter-rouge">192.168.1.0/24</code>, both <code class="language-plaintext highlighter-rouge">ping 10.168.1.122</code> and standard <code class="language-plaintext highlighter-rouge">dig @10.168.1.254 box.internal</code> returned the alias. The house answered as itself from a street that had already claimed its numbers.</p>

<p>We had completely solved <code class="language-plaintext highlighter-rouge">192.168.1.0/24</code> collisions. Then we walked into a cafe that used a completely different private subnet.</p>

<h2 id="two-1s-and-a-cafe-8">Two /1s and a Cafe /8</h2>

<p>A second cafe numbered our laptop out of <code class="language-plaintext highlighter-rouge">10.0.0.0/8</code>. The WireGuard handshake connected. Public traffic like <code class="language-plaintext highlighter-rouge">1.1.1.1</code> through the tunnel worked fine. But <code class="language-plaintext highlighter-rouge">10.168.1.254</code> timed out.</p>

<p>A quick trace showed that <code class="language-plaintext highlighter-rouge">ping</code> packets were leaving the laptop’s Wi-Fi interface instead of entering the tunnel. The cafe router’s local filter saw packets addressed to <code class="language-plaintext highlighter-rouge">10.168.1.0/24</code> on its local Wi-Fi and dropped them as foreign private traffic. The packets never entered the tunnel interface (<code class="language-plaintext highlighter-rouge">utun</code>).</p>

<p>Why? On macOS, <code class="language-plaintext highlighter-rouge">AllowedIPs = 0.0.0.0/0</code> does not install a single catch-all default route. To avoid tearing down the physical gateway route needed for the encrypted UDP transport, WireGuard installs two <code class="language-plaintext highlighter-rouge">/1</code> routes instead: <code class="language-plaintext highlighter-rouge">0.0.0.0/1</code> and <code class="language-plaintext highlighter-rouge">128.0.0.0/1</code>.</p>

<p>Under longest prefix match, the cafe’s on-link <code class="language-plaintext highlighter-rouge">10.0.0.0/8</code> route is more specific than WireGuard’s <code class="language-plaintext highlighter-rouge">0.0.0.0/1</code> route. Any packet sent to a <code class="language-plaintext highlighter-rouge">10.x.x.x</code> address, including our <code class="language-plaintext highlighter-rouge">10.168.1.0/24</code> alias, was claimed by the cafe’s local Wi-Fi interface. The tunnel had won the default route, but lost the alias.</p>

<p>The fix was to explicitly list the alias subnet in the client’s <code class="language-plaintext highlighter-rouge">AllowedIPs</code>:</p>

<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="py">AllowedIPs</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">0.0.0.0/0, ::/0, 10.168.1.0/24</span>
</code></pre></div></div>

<p>Because <code class="language-plaintext highlighter-rouge">/24</code> is more specific than <code class="language-plaintext highlighter-rouge">/8</code>, the kernel routes <code class="language-plaintext highlighter-rouge">10.168.1.x</code> into <code class="language-plaintext highlighter-rouge">utun</code>. Checking <code class="language-plaintext highlighter-rouge">route -n get 10.168.1.254</code> confirmed the tunnel interface owned it. On the same Wi-Fi, alias ping, DoH, and port 53 DNS immediately passed.</p>

<p><code class="language-plaintext highlighter-rouge">::/0</code> is still leak prevention for the client’s other stacks, same as the first post. We have not put IPv6 on the home LAN.</p>

<p>We keep the full tunnel (<code class="language-plaintext highlighter-rouge">0.0.0.0/0, ::/0</code>) with the alias subnet appended. A full tunnel guarantees fail-closed security: if the VPN handshake drops, network traffic won’t silently leak onto the cafe’s unencrypted Wi-Fi. (The official WireGuard app on macOS will cheerfully display a green “Connected” status even when no handshake reply has arrived; running <code class="language-plaintext highlighter-rouge">tcpdump</code> on the Wi-Fi interface is the only reliable way to confirm packets are returning.)</p>

<p>With these fixes in place, the laptop could connect from <code class="language-plaintext highlighter-rouge">192.168.1.0/24</code> cafes and from <code class="language-plaintext highlighter-rouge">10.0.0.0/8</code> cafes. That held until we walked into a third cafe, and nothing connected at all.</p>

<h2 id="the-third-cafe-will-not-dial-the-house">The Third Cafe Will Not Dial the House</h2>

<p>A third cafe presented a failure mode that had nothing to do with IP collisions: it refused to talk to the house at all.</p>

<p>TCP port 443 to our home WAN IP timed out, even though ordinary HTTPS browsing to public websites worked without issue. WireGuard UDP handshakes were dropped both on our standard listen port and on UDP 443. The filter was destination-based rather than port-based: the cafe dropped all traffic to our home’s public IP, regardless of protocol or port.</p>

<p>A second listen port on the same public IP cannot route around a filter aimed at that IP. Bypassing that kind of restriction requires an intermediary whose IP is not our home WAN (such as a VPS relay or cloud hub). We have not built that hop. Until we do, those cafes are cellular hotspot weather. If the handshake fails, the cafe simply does not get our traffic.</p>

<h2 id="the-recipe-adding-the-alias-subnet-to-the-lodge">The Recipe: Adding the Alias Subnet to the Lodge</h2>

<p>If you already followed the setup in <a href="/2026/08/22/the-gate-lodge.html#release-image-to-a-tunnel">The Gate Lodge</a>, you do not need to rebuild your VPN box from scratch. The snippets below are the exact configuration delta needed to add the <code class="language-plaintext highlighter-rouge">10.168.1.0/24</code> alias subnet and split-horizon DNS.</p>

<p>Adjust the IP addresses if <code class="language-plaintext highlighter-rouge">10.168.1.0/24</code> is already in use on your network. The snippets assume WireGuard listen port <code class="language-plaintext highlighter-rouge">51820</code> as configured in the first post.</p>

<h3 id="on-the-vpn-box">On the VPN box</h3>

<p><code class="language-plaintext highlighter-rouge">/etc/nftables.d/netmap.include</code>. Use a name that is not <code class="language-plaintext highlighter-rouge">*.nft</code>, or fw4 will also include it at the table root.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>iifname "wg0" ip daddr 10.168.1.254 udp dport 53 dnat ip to 192.168.1.254:5300
iifname "wg0" ip daddr 10.168.1.254 tcp dport 53 dnat ip to 192.168.1.254:5300
iifname "wg0" ip daddr 10.168.1.0/24 counter dnat ip prefix to ip daddr map { 10.168.1.0/24 : 192.168.1.0/24 }
</code></pre></div></div>

<p>Then the UCI include:</p>

<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">config</span><span class="w"> </span><span class="na">include</span><span class="w"> </span><span class="na">'alias_netmap'</span><span class="w">
	</span><span class="na">option</span><span class="w"> </span><span class="na">type</span><span class="w"> </span><span class="na">'nftables'</span><span class="w">
	</span><span class="na">option</span><span class="w"> </span><span class="na">path</span><span class="w"> </span><span class="na">'/etc/nftables.d/netmap.include'</span><span class="w">
	</span><span class="na">option</span><span class="w"> </span><span class="na">position</span><span class="w"> </span><span class="na">'chain-append'</span><span class="w">
	</span><span class="na">option</span><span class="w"> </span><span class="na">chain</span><span class="w"> </span><span class="na">'dstnat'</span><span class="w">
</span></code></pre></div></div>

<p>Then <code class="language-plaintext highlighter-rouge">fw4 reload</code>. Confirm with <code class="language-plaintext highlighter-rouge">nft --debug=netlink</code> that you see a prefix map, not <code class="language-plaintext highlighter-rouge">addr_min</code> / <code class="language-plaintext highlighter-rouge">addr_max</code>. <code class="language-plaintext highlighter-rouge">fw4 print</code> should still have no srcnat of <code class="language-plaintext highlighter-rouge">192.168.101.0/24</code>.</p>

<p>If <code class="language-plaintext highlighter-rouge">inet</code> rejects the prefix map, the older bitwise form is <code class="language-plaintext highlighter-rouge">dnat ip to ip daddr &amp; 0.0.0.255 | 192.168.1.0</code>.</p>

<h3 id="on-the-pi-hole">On the Pi-hole</h3>

<p>Keep FTL on <code class="language-plaintext highlighter-rouge">192.168.1.254:53</code>. Add a dnsdist plain listen and leave <code class="language-plaintext highlighter-rouge">newServer</code> pointed at FTL:</p>

<div class="language-lua highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">addLocal</span><span class="p">(</span><span class="s1">'192.168.1.254:5300'</span><span class="p">)</span>
</code></pre></div></div>

<p>Overlay spoof, source-gated. Load your real LAN names however you like; this is the shape:<sup><a href="#postscript-lua">lua</a></sup></p>

<div class="language-lua highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">local</span> <span class="n">wgNet</span> <span class="o">=</span> <span class="n">newNMG</span><span class="p">()</span>
<span class="n">wgNet</span><span class="p">:</span><span class="n">addMask</span><span class="p">(</span><span class="s2">"192.168.101.0/24"</span><span class="p">)</span>

<span class="n">addAction</span><span class="p">(</span>
	<span class="n">AndRule</span><span class="p">({</span>
		<span class="n">NetmaskGroupRule</span><span class="p">(</span><span class="n">wgNet</span><span class="p">),</span>
		<span class="n">QNameRule</span><span class="p">(</span><span class="s2">"box.internal."</span><span class="p">),</span>
		<span class="n">QTypeRule</span><span class="p">(</span><span class="n">DNSQType</span><span class="p">.</span><span class="n">A</span><span class="p">)</span>
	<span class="p">}),</span>
	<span class="n">SpoofAction</span><span class="p">(</span><span class="s2">"10.168.1.122"</span><span class="p">)</span>
<span class="p">)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">systemctl restart dnsdist</code>. dnsdist has no useful reload. <code class="language-plaintext highlighter-rouge">ss -tulnp</code> is the bind inventory; <code class="language-plaintext highlighter-rouge">ss -ulnp</code> will hide a TCP-only DoH listen and lie to you.</p>

<p>Overlay allow and WAN 53 closed stay as in the first post.</p>

<h3 id="on-the-client">On the client</h3>

<p>DNS and <code class="language-plaintext highlighter-rouge">AllowedIPs</code> are the delta. The rest matches the first paste.</p>

<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[Interface]</span><span class="w">
</span><span class="py">PrivateKey</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">CLIENT_PRIVATE_KEY</span>
<span class="py">Address</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">192.168.101.2/32</span>
<span class="py">DNS</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">10.168.1.254</span>
<span class="w">
</span><span class="nn">[Peer]</span><span class="w">
</span><span class="py">PublicKey</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">SERVER_PUBLIC_KEY</span>
<span class="py">Endpoint</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">vpn.example.com:51820</span>
<span class="py">AllowedIPs</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">0.0.0.0/0, ::/0, 10.168.1.0/24</span>
<span class="py">PersistentKeepalive</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">25</span>
</code></pre></div></div>

<p>From a colliding <code class="language-plaintext highlighter-rouge">/24</code>, <code class="language-plaintext highlighter-rouge">dig @10.168.1.254 box.internal</code> must return <code class="language-plaintext highlighter-rouge">10.168.1.122</code>. A house A on port 53 is a fail. DoH to the same alias should agree. <code class="language-plaintext highlighter-rouge">ping 192.168.1.254</code> may still hit the cafe; that is the point of the alias.</p>

<hr />

<h2 id="postscript-lua">Postscript: Ingesting Pi-hole Records at Startup</h2>

<p>Hardcoding individual LAN hosts into <code class="language-plaintext highlighter-rouge">dnsdist.conf</code> creates a fragile second source of truth: whenever you add a static entry or new device in Pi-hole’s web interface, you would have to remember to duplicate the rule in dnsdist and restart it.</p>

<p>Because dnsdist configuration files are executable Lua, the daemon can read Pi-hole’s records directly into memory when it boots. When Pi-hole saves a local record from the dashboard, it writes a standard hosts-style entry to <code class="language-plaintext highlighter-rouge">/etc/pihole/custom.list</code>. Parsing that file alongside <code class="language-plaintext highlighter-rouge">/etc/hosts</code> registers an alias spoof for every <code class="language-plaintext highlighter-rouge">192.168.1.x</code> address automatically:</p>

<div class="language-lua highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">local</span> <span class="n">wgNet</span> <span class="o">=</span> <span class="n">newNMG</span><span class="p">()</span>
<span class="n">wgNet</span><span class="p">:</span><span class="n">addMask</span><span class="p">(</span><span class="s2">"192.168.101.0/24"</span><span class="p">)</span>

<span class="kd">local</span> <span class="k">function</span> <span class="nf">load_vpn_aliases</span><span class="p">(</span><span class="n">filepath</span><span class="p">)</span>
	<span class="nb">pcall</span><span class="p">(</span><span class="k">function</span><span class="p">()</span>
		<span class="kd">local</span> <span class="n">f</span> <span class="o">=</span> <span class="nb">io.open</span><span class="p">(</span><span class="n">filepath</span><span class="p">,</span> <span class="s2">"r"</span><span class="p">)</span>
		<span class="k">if</span> <span class="ow">not</span> <span class="n">f</span> <span class="k">then</span> <span class="k">return</span> <span class="k">end</span>
		<span class="k">for</span> <span class="n">line</span> <span class="k">in</span> <span class="n">f</span><span class="p">:</span><span class="n">lines</span><span class="p">()</span> <span class="k">do</span>
			<span class="n">line</span> <span class="o">=</span> <span class="n">line</span><span class="p">:</span><span class="n">match</span><span class="p">(</span><span class="s2">"^%s*(.-)%s*$"</span><span class="p">)</span>
			<span class="k">if</span> <span class="n">line</span> <span class="o">~=</span> <span class="s2">""</span> <span class="ow">and</span> <span class="ow">not</span> <span class="n">line</span><span class="p">:</span><span class="n">match</span><span class="p">(</span><span class="s2">"^#"</span><span class="p">)</span> <span class="k">then</span>
				<span class="kd">local</span> <span class="n">ip</span><span class="p">,</span> <span class="n">domain</span> <span class="o">=</span> <span class="n">line</span><span class="p">:</span><span class="n">match</span><span class="p">(</span><span class="s2">"^(%d+%.%d+%.%d+%.%d+)%s+(%S+)"</span><span class="p">)</span>
				<span class="k">if</span> <span class="n">ip</span> <span class="ow">and</span> <span class="n">domain</span> <span class="k">then</span>
					<span class="kd">local</span> <span class="n">last_octet</span> <span class="o">=</span> <span class="n">ip</span><span class="p">:</span><span class="n">match</span><span class="p">(</span><span class="s2">"^192%.168%.1%.(%d+)$"</span><span class="p">)</span>
					<span class="k">if</span> <span class="n">last_octet</span> <span class="k">then</span>
						<span class="kd">local</span> <span class="n">alias_ip</span> <span class="o">=</span> <span class="s2">"10.168.1."</span> <span class="o">..</span> <span class="n">last_octet</span>
						<span class="kd">local</span> <span class="n">qname</span> <span class="o">=</span> <span class="n">domain</span><span class="p">:</span><span class="nb">gsub</span><span class="p">(</span><span class="sr">"%.$"</span><span class="p">,</span> <span class="sr">""</span><span class="p">)</span> <span class="o">..</span> <span class="s2">"."</span>
						<span class="n">addAction</span><span class="p">(</span>
							<span class="n">AndRule</span><span class="p">({</span>
								<span class="n">NetmaskGroupRule</span><span class="p">(</span><span class="n">wgNet</span><span class="p">),</span>
								<span class="n">QNameRule</span><span class="p">(</span><span class="n">qname</span><span class="p">),</span>
								<span class="n">QTypeRule</span><span class="p">(</span><span class="n">DNSQType</span><span class="p">.</span><span class="n">A</span><span class="p">)</span>
							<span class="p">}),</span>
							<span class="n">SpoofAction</span><span class="p">(</span><span class="n">alias_ip</span><span class="p">)</span>
						<span class="p">)</span>
					<span class="k">end</span>
				<span class="k">end</span>
			<span class="k">end</span>
		<span class="k">end</span>
		<span class="n">f</span><span class="p">:</span><span class="n">close</span><span class="p">()</span>
	<span class="k">end</span><span class="p">)</span>
<span class="k">end</span>

<span class="n">load_vpn_aliases</span><span class="p">(</span><span class="s2">"/etc/pihole/custom.list"</span><span class="p">)</span>
<span class="n">load_vpn_aliases</span><span class="p">(</span><span class="s2">"/etc/hosts"</span><span class="p">)</span>
</code></pre></div></div>

<p>The parser is defensive: lines from other subnets are ignored, missing files fail silently through <code class="language-plaintext highlighter-rouge">pcall</code>, and trailing dots are normalized. When a new home server joins the house, adding it to Pi-hole and restarting dnsdist is all it takes to make it reachable over the VPN.</p>]]></content><author><name>Tachi</name></author><category term="blog" /><category term="record" /><category term="dns" /><category term="home-networking" /><category term="networking" /><category term="network-security" /><category term="nftables" /><category term="openwrt" /><category term="pihole" /><category term="wireguard" /><summary type="html"><![CDATA[The laptop got 192.168.1.253 at a cafe that already used that /24. The app said Connected. ping 192.168.1.254 hit the cafe. How we aliased the house onto 10.168.1.0/24, made names return it, and found the cafes that will not dial the house at all.]]></summary></entry><entry><title type="html">Steal These Status Lines</title><link href="https://blog.cani.ne.jp/2026/08/27/steal-these-status-lines.html" rel="alternate" type="text/html" title="Steal These Status Lines" /><published>2026-08-27T00:00:00+00:00</published><updated>2026-08-27T23:18:20+00:00</updated><id>https://blog.cani.ne.jp/2026/08/27/steal-these-status-lines</id><content type="html" xml:base="https://blog.cani.ne.jp/2026/08/27/steal-these-status-lines.html"><![CDATA[<p>I want the model, the context window, and the burn rate all on the same row, right below the prompt. <a href="https://code.claude.com/">Claude Code</a> and the <a href="https://cursor.com/docs/cli/overview">Cursor CLI</a> both do that the same way: they run a command, pipe session JSON to stdin, and render stdout as a <a href="https://code.claude.com/docs/en/statusline">status line</a>. Plenty of people have one. These are the ones I actually look at, on a Mac and on Linux (<a href="https://learn.microsoft.com/en-us/windows/wsl/">WSL</a>).</p>

<div class="polaroid-container">
  <div class="polaroid">
    <a href="cursor-statusline-mac.png">
      <img src="cursor-statusline-mac.png" alt="Cursor Agent, in Ghostty on macOS" class="polaroid-image" />
    </a>
    <div class="polaroid-title">Cursor Agent, in Ghostty on macOS</div>
    <div class="polaroid-link">
      
      &nbsp;
      
    </div>
    <small class="polaroid-archive">
      
      &nbsp;
      
    </small>
  </div>
</div>

<div class="polaroid-container">
  <div class="polaroid">
    <a href="cursor-statusline-linux.png">
      <img src="cursor-statusline-linux.png" alt="Cursor Agent, in MobaXterm on WSL" class="polaroid-image" />
    </a>
    <div class="polaroid-title">Cursor Agent, in MobaXterm on WSL</div>
    <div class="polaroid-link">
      
      &nbsp;
      
    </div>
    <small class="polaroid-archive">
      
      &nbsp;
      
    </small>
  </div>
</div>

<div class="polaroid-container">
  <div class="polaroid">
    <a href="claude-statusline-mac.png">
      <img src="claude-statusline-mac.png" alt="Claude Code, in Ghostty on macOS" class="polaroid-image" />
    </a>
    <div class="polaroid-title">Claude Code, in Ghostty on macOS</div>
    <div class="polaroid-link">
      
      &nbsp;
      
    </div>
    <small class="polaroid-archive">
      
      &nbsp;
      
    </small>
  </div>
</div>

<div class="polaroid-container">
  <div class="polaroid">
    <a href="claude-statusline-linux.png">
      <img src="claude-statusline-linux.png" alt="Claude Code, in MobaXterm on WSL" class="polaroid-image" />
    </a>
    <div class="polaroid-title">Claude Code, in MobaXterm on WSL</div>
    <div class="polaroid-link">
      
      &nbsp;
      
    </div>
    <small class="polaroid-archive">
      
      &nbsp;
      
    </small>
  </div>
</div>

<p>The shared row is the same in both harnesses: the model name, a fifteen-cell context bar that runs green to red, an emoji that flips at 20 / 70 / 90 percent (🟢 ⚡ 🔥 🚨), and the repo plus branch when you are in a git tree.</p>

<p>Claude then adds what its payload already has: session cost in dollars, a five-hour rate bar, a seven-day rate bar.</p>

<p>Cursor does not ship session dollars in the status-line payload or the usage API, so that slot is empty on purpose. In its place I pull first-party (<a href="https://cursor.com/docs/models-and-pricing#cursor-models">Grok</a> and Composer) and third-party (API) plan usage from Cursor’s <code class="language-plaintext highlighter-rouge">GetCurrentPeriodUsage</code> endpoint and cache the result for sixty seconds. <a href="https://cursor.com/docs/models-and-pricing#auto-modes">Auto</a> is a router with Cost, Balance, and Intelligence modes that bill whichever model they pick, so a stretch of Auto can fill either bar.</p>

<table>
  <thead>
    <tr>
      <th>Slot</th>
      <th>Claude Code</th>
      <th>Cursor CLI</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Model</td>
      <td>yes</td>
      <td>yes</td>
    </tr>
    <tr>
      <td>Context bar</td>
      <td>yes</td>
      <td>yes</td>
    </tr>
    <tr>
      <td>Session cost</td>
      <td>yes</td>
      <td>no</td>
    </tr>
    <tr>
      <td>5-hour / 7-day rate</td>
      <td>yes</td>
      <td>no</td>
    </tr>
    <tr>
      <td>First-party / third-party plan</td>
      <td>no</td>
      <td>yes</td>
    </tr>
    <tr>
      <td>Repo / branch</td>
      <td>yes</td>
      <td>yes</td>
    </tr>
  </tbody>
</table>

<p>You need <code class="language-plaintext highlighter-rouge">bash</code>, <a href="https://jqlang.github.io/jq/">jq</a>, and <code class="language-plaintext highlighter-rouge">git</code>. Cursor’s plan bars also need <code class="language-plaintext highlighter-rouge">curl</code> and a signed-in CLI. The bars use 24-bit RGB, so a <a href="https://github.com/termstandard/colors">truecolor terminal</a> is the difference between a gradient and a muddle.</p>

<h2 id="the-key-is-statusline">The Key Is <code class="language-plaintext highlighter-rouge">statusLine</code></h2>

<p>The wiring is a JSON object named <code class="language-plaintext highlighter-rouge">statusLine</code> - capital L. Claude Code reads it from <a href="https://code.claude.com/docs/en/statusline"><code class="language-plaintext highlighter-rouge">~/.claude/settings.json</code></a>. Cursor CLI reads it from <a href="https://cursor.com/docs/cli/reference/configuration"><code class="language-plaintext highlighter-rouge">~/.cursor/cli-config.json</code></a>. Same shape: <code class="language-plaintext highlighter-rouge">"type": "command"</code> and a <code class="language-plaintext highlighter-rouge">command</code> path. Merge that object into the file you already have. Do not replace the rest of the file. If <code class="language-plaintext highlighter-rouge">CURSOR_CONFIG_DIR</code> or <code class="language-plaintext highlighter-rouge">XDG_CONFIG_HOME</code> is set, Cursor may be reading a different <code class="language-plaintext highlighter-rouge">cli-config.json</code>; merge into the file the running CLI actually uses.</p>

<p>Claude Code’s block:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"statusLine"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"command"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"command"</span><span class="p">:</span><span class="w"> </span><span class="s2">"~/.claude/statusline.sh"</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Cursor CLI’s block, including the <code class="language-plaintext highlighter-rouge">padding: 2</code> I actually run:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"statusLine"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"command"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"command"</span><span class="p">:</span><span class="w"> </span><span class="s2">"~/.cursor/statusline.sh"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"padding"</span><span class="p">:</span><span class="w"> </span><span class="mi">2</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>That is the whole difference in setup. The scripts below assume those paths: <code class="language-plaintext highlighter-rouge">~/.claude/statusline.sh</code> and <code class="language-plaintext highlighter-rouge">~/.cursor/statusline.sh</code>.</p>

<h2 id="gnu-stat-is-not-bsd-stat">GNU <code class="language-plaintext highlighter-rouge">stat</code> Is Not BSD <code class="language-plaintext highlighter-rouge">stat</code></h2>

<p>Claude’s script never asks <code class="language-plaintext highlighter-rouge">stat</code> for a file’s mtime, so the Linux copy and the macOS copy are the same file.</p>

<p>Cursor’s plan bars cache for sixty seconds, and the Linux copy used <a href="https://www.gnu.org/software/coreutils/manual/html_node/stat-invocation.html">GNU <code class="language-plaintext highlighter-rouge">stat -c %Y</code></a>. macOS ships <a href="https://ss64.com/mac/stat.html">BSD <code class="language-plaintext highlighter-rouge">stat</code></a>, which wants <code class="language-plaintext highlighter-rouge">stat -f %m</code>. GNU syntax on a Mac fails, the cache looks stale on every paint, and you refetch more than you meant to.</p>

<p>The macOS script tries GNU first and falls back to BSD:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">mtime</span><span class="o">=</span><span class="si">$(</span><span class="nb">stat</span> <span class="nt">-c</span> %Y <span class="s2">"</span><span class="nv">$CACHE_FILE</span><span class="s2">"</span> 2&gt;/dev/null <span class="o">||</span> <span class="nb">stat</span> <span class="nt">-f</span> %m <span class="s2">"</span><span class="nv">$CACHE_FILE</span><span class="s2">"</span> 2&gt;/dev/null<span class="si">)</span> <span class="o">||</span> <span class="k">return </span>1
</code></pre></div></div>

<p>The other hole is the token. On Linux, Cursor CLI leaves an access token in <code class="language-plaintext highlighter-rouge">~/.config/cursor/auth.json</code>. On macOS it puts the same secret in Keychain (<code class="language-plaintext highlighter-rouge">cursor-access-token</code> / <code class="language-plaintext highlighter-rouge">cursor-user</code>) and often never writes that file. The Linux script reads the file and gives up. The macOS script tries the file, then Keychain.</p>

<p>Take the GNU script on Linux. Take the BSD script on macOS. The BSD script also runs on Linux - GNU <code class="language-plaintext highlighter-rouge">stat</code> succeeds on the first try, and the Keychain branch is skipped - but both copies are below so an agent does not have to guess.</p>

<p>The two Cursor scripts differ only in how they read the token and how they call <code class="language-plaintext highlighter-rouge">stat</code>.</p>

<h2 id="steal-them">Steal Them</h2>

<p>If you are an agent installing this for a human: pick the harness they are in. Claude Code gets the <code class="language-plaintext highlighter-rouge">statusLine</code> block for <code class="language-plaintext highlighter-rouge">~/.claude/settings.json</code> plus the Claude script, saved as <code class="language-plaintext highlighter-rouge">~/.claude/statusline.sh</code>. Cursor CLI gets the <code class="language-plaintext highlighter-rouge">statusLine</code> block for <code class="language-plaintext highlighter-rouge">~/.cursor/cli-config.json</code> plus the GNU script on Linux / WSL or the BSD script on macOS, saved as <code class="language-plaintext highlighter-rouge">~/.cursor/statusline.sh</code>. Mark the script executable. Merge the JSON object named <code class="language-plaintext highlighter-rouge">statusLine</code> (capital L). Do not overwrite the rest of the config file.</p>

<p>If you are a human: paste this post at an agent and say which prompt you live in. Or copy the matching fences yourself.</p>

<ol>
  <li>Save the script to the path in the heading.</li>
  <li><code class="language-plaintext highlighter-rouge">chmod +x</code> that path.</li>
  <li>Merge the matching <code class="language-plaintext highlighter-rouge">statusLine</code> object.</li>
  <li>Restart the CLI session.</li>
</ol>

<h3 id="claude-code-merge-into-claudesettingsjson">Claude Code: merge into <code class="language-plaintext highlighter-rouge">~/.claude/settings.json</code></h3>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"statusLine"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"command"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"command"</span><span class="p">:</span><span class="w"> </span><span class="s2">"~/.claude/statusline.sh"</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h3 id="claude-code-claudestatuslinesh-macos-and-linux">Claude Code: <code class="language-plaintext highlighter-rouge">~/.claude/statusline.sh</code> (macOS and Linux)</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/usr/bin/env bash</span>
<span class="c"># Claude Code status line: RGB gradient, dynamic emoji, cost, code velocity</span>

<span class="nv">input</span><span class="o">=</span><span class="si">$(</span><span class="nb">cat</span><span class="si">)</span>

<span class="c"># ── Colors ──</span>
<span class="nv">CYAN</span><span class="o">=</span><span class="s1">'\033[36m'</span>
<span class="nv">GREEN</span><span class="o">=</span><span class="s1">'\033[32m'</span>
<span class="nv">YELLOW</span><span class="o">=</span><span class="s1">'\033[33m'</span>
<span class="nv">RED</span><span class="o">=</span><span class="s1">'\033[31m'</span>
<span class="nv">MAGENTA</span><span class="o">=</span><span class="s1">'\033[35m'</span>
<span class="nv">DIM</span><span class="o">=</span><span class="s1">'\033[2m'</span>
<span class="nv">BOLD</span><span class="o">=</span><span class="s1">'\033[1m'</span>
<span class="nv">RESET</span><span class="o">=</span><span class="s1">'\033[0m'</span>

<span class="nv">BAR_WIDTH</span><span class="o">=</span>15

<span class="c"># ── Truecolor helper ──</span>
rgb<span class="o">()</span> <span class="o">{</span> <span class="nb">printf</span> <span class="s1">'\033[38;2;%d;%d;%dm'</span> <span class="s2">"</span><span class="nv">$1</span><span class="s2">"</span> <span class="s2">"</span><span class="nv">$2</span><span class="s2">"</span> <span class="s2">"</span><span class="nv">$3</span><span class="s2">"</span><span class="p">;</span> <span class="o">}</span>

<span class="c"># ── Parse JSON fields ──</span>
<span class="nv">model</span><span class="o">=</span><span class="si">$(</span><span class="nb">echo</span> <span class="s2">"</span><span class="nv">$input</span><span class="s2">"</span> | jq <span class="nt">-r</span> <span class="s1">'.model.display_name // "Unknown"'</span><span class="si">)</span>
<span class="nv">used</span><span class="o">=</span><span class="si">$(</span><span class="nb">echo</span> <span class="s2">"</span><span class="nv">$input</span><span class="s2">"</span> | jq <span class="nt">-r</span> <span class="s1">'.context_window.used_percentage // empty'</span><span class="si">)</span>
<span class="nv">cost</span><span class="o">=</span><span class="si">$(</span><span class="nb">echo</span> <span class="s2">"</span><span class="nv">$input</span><span class="s2">"</span> | jq <span class="nt">-r</span> <span class="s1">'.cost.total_cost_usd // 0'</span><span class="si">)</span>
<span class="nv">rate_5h</span><span class="o">=</span><span class="si">$(</span><span class="nb">echo</span> <span class="s2">"</span><span class="nv">$input</span><span class="s2">"</span> | jq <span class="nt">-r</span> <span class="s1">'.rate_limits.five_hour.used_percentage // empty'</span><span class="si">)</span>
<span class="nv">rate_7d</span><span class="o">=</span><span class="si">$(</span><span class="nb">echo</span> <span class="s2">"</span><span class="nv">$input</span><span class="s2">"</span> | jq <span class="nt">-r</span> <span class="s1">'.rate_limits.seven_day.used_percentage // empty'</span><span class="si">)</span>
<span class="nv">cwd</span><span class="o">=</span><span class="si">$(</span><span class="nb">echo</span> <span class="s2">"</span><span class="nv">$input</span><span class="s2">"</span> | jq <span class="nt">-r</span> <span class="s1">'.workspace.current_dir // .cwd // ""'</span><span class="si">)</span>

<span class="c"># ── Git info ──</span>
<span class="nv">branch</span><span class="o">=</span><span class="s2">""</span>
<span class="nv">repo</span><span class="o">=</span><span class="s2">""</span>
<span class="k">if</span> <span class="o">[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$cwd</span><span class="s2">"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
  </span><span class="nv">branch</span><span class="o">=</span><span class="si">$(</span>git <span class="nt">-C</span> <span class="s2">"</span><span class="nv">$cwd</span><span class="s2">"</span> <span class="nt">--no-optional-locks</span> symbolic-ref <span class="nt">--short</span> HEAD 2&gt;/dev/null<span class="si">)</span>
  <span class="nv">repo</span><span class="o">=</span><span class="si">$(</span><span class="nb">basename</span> <span class="s2">"</span><span class="si">$(</span>git <span class="nt">-C</span> <span class="s2">"</span><span class="nv">$cwd</span><span class="s2">"</span> <span class="nt">--no-optional-locks</span> rev-parse <span class="nt">--show-toplevel</span> 2&gt;/dev/null<span class="si">)</span><span class="s2">"</span> 2&gt;/dev/null<span class="si">)</span>
<span class="k">fi</span>

<span class="c"># ── Bar drawing helper ──</span>
draw_bar<span class="o">()</span> <span class="o">{</span>
  <span class="nb">local </span><span class="nv">pct</span><span class="o">=</span><span class="nv">$1</span> <span class="nv">grad_low_r</span><span class="o">=</span><span class="nv">$2</span> <span class="nv">grad_low_g</span><span class="o">=</span><span class="nv">$3</span> <span class="nv">grad_low_b</span><span class="o">=</span><span class="nv">$4</span> <span class="nv">grad_high_r</span><span class="o">=</span><span class="nv">$5</span> <span class="nv">grad_high_g</span><span class="o">=</span><span class="nv">$6</span> <span class="nv">grad_high_b</span><span class="o">=</span><span class="nv">$7</span>
  <span class="nb">local </span><span class="nv">pct_int</span><span class="o">=</span><span class="si">$(</span><span class="nb">printf</span> <span class="s1">'%.0f'</span> <span class="s2">"</span><span class="nv">$pct</span><span class="s2">"</span><span class="si">)</span>
  <span class="nb">local </span><span class="nv">filled</span><span class="o">=</span><span class="k">$((</span> <span class="o">(</span>pct_int <span class="o">*</span> BAR_WIDTH <span class="o">+</span> <span class="m">50</span><span class="o">)</span> <span class="o">/</span> <span class="m">100</span> <span class="k">))</span>
  <span class="nb">local </span><span class="nv">bar</span><span class="o">=</span><span class="s2">""</span>

  <span class="k">for</span> <span class="o">((</span> <span class="nv">i</span><span class="o">=</span>0<span class="p">;</span> i&lt;BAR_WIDTH<span class="p">;</span> i++ <span class="o">))</span><span class="p">;</span> <span class="k">do
    </span><span class="nb">local </span><span class="nv">pos</span><span class="o">=</span><span class="k">$((</span> i <span class="o">*</span> <span class="m">100</span> <span class="o">/</span> <span class="o">(</span>BAR_WIDTH <span class="o">-</span> <span class="m">1</span><span class="o">)</span> <span class="k">))</span>
    <span class="nb">local </span><span class="nv">r</span><span class="o">=</span><span class="k">$((</span> grad_low_r <span class="o">+</span> <span class="o">(</span>grad_high_r <span class="o">-</span> grad_low_r<span class="o">)</span> <span class="o">*</span> pos <span class="o">/</span> <span class="m">100</span> <span class="k">))</span>
    <span class="nb">local </span><span class="nv">g</span><span class="o">=</span><span class="k">$((</span> grad_low_g <span class="o">+</span> <span class="o">(</span>grad_high_g <span class="o">-</span> grad_low_g<span class="o">)</span> <span class="o">*</span> pos <span class="o">/</span> <span class="m">100</span> <span class="k">))</span>
    <span class="nb">local </span><span class="nv">b</span><span class="o">=</span><span class="k">$((</span> grad_low_b <span class="o">+</span> <span class="o">(</span>grad_high_b <span class="o">-</span> grad_low_b<span class="o">)</span> <span class="o">*</span> pos <span class="o">/</span> <span class="m">100</span> <span class="k">))</span>

    <span class="k">if</span> <span class="o">[</span> <span class="s2">"</span><span class="nv">$i</span><span class="s2">"</span> <span class="nt">-lt</span> <span class="s2">"</span><span class="nv">$filled</span><span class="s2">"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
      </span><span class="nv">bar</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">bar</span><span class="k">}</span><span class="si">$(</span>rgb <span class="nv">$r</span> <span class="nv">$g</span> <span class="nv">$b</span><span class="si">)</span><span class="s2">█"</span>
    <span class="k">else
      </span><span class="nv">bar</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">bar</span><span class="k">}</span><span class="se">\0</span><span class="s2">33[38;2;60;60;60m░"</span>
    <span class="k">fi
  done
  </span><span class="nb">printf</span> <span class="s1">'%b'</span> <span class="s2">"</span><span class="k">${</span><span class="nv">bar</span><span class="k">}${</span><span class="nv">RESET</span><span class="k">}</span><span class="s2">"</span>
<span class="o">}</span>

<span class="c"># ── Context bar: green→yellow→red ──</span>
<span class="k">if</span> <span class="o">[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$used</span><span class="s2">"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
  </span><span class="nv">used_int</span><span class="o">=</span><span class="si">$(</span><span class="nb">printf</span> <span class="s1">'%.0f'</span> <span class="s2">"</span><span class="nv">$used</span><span class="s2">"</span><span class="si">)</span>

  <span class="nv">ctx_bar</span><span class="o">=</span><span class="si">$(</span>draw_bar <span class="s2">"</span><span class="nv">$used_int</span><span class="s2">"</span> 0 200 80 220 20 0<span class="si">)</span>

  <span class="k">if</span> <span class="o">[</span> <span class="s2">"</span><span class="nv">$used_int</span><span class="s2">"</span> <span class="nt">-ge</span> 90 <span class="o">]</span><span class="p">;</span> <span class="k">then </span><span class="nv">status_emoji</span><span class="o">=</span><span class="s2">"🚨"</span>
  <span class="k">elif</span> <span class="o">[</span> <span class="s2">"</span><span class="nv">$used_int</span><span class="s2">"</span> <span class="nt">-ge</span> 70 <span class="o">]</span><span class="p">;</span> <span class="k">then </span><span class="nv">status_emoji</span><span class="o">=</span><span class="s2">"🔥"</span>
  <span class="k">elif</span> <span class="o">[</span> <span class="s2">"</span><span class="nv">$used_int</span><span class="s2">"</span> <span class="nt">-ge</span> 20 <span class="o">]</span><span class="p">;</span> <span class="k">then </span><span class="nv">status_emoji</span><span class="o">=</span><span class="s2">"⚡"</span>
  <span class="k">else </span><span class="nv">status_emoji</span><span class="o">=</span><span class="s2">"🟢"</span><span class="p">;</span> <span class="k">fi

  if</span> <span class="o">[</span> <span class="s2">"</span><span class="nv">$used_int</span><span class="s2">"</span> <span class="nt">-ge</span> 90 <span class="o">]</span><span class="p">;</span> <span class="k">then </span><span class="nv">pct_color</span><span class="o">=</span><span class="s2">"</span><span class="nv">$RED</span><span class="s2">"</span>
  <span class="k">elif</span> <span class="o">[</span> <span class="s2">"</span><span class="nv">$used_int</span><span class="s2">"</span> <span class="nt">-ge</span> 70 <span class="o">]</span><span class="p">;</span> <span class="k">then </span><span class="nv">pct_color</span><span class="o">=</span><span class="s2">"</span><span class="nv">$YELLOW</span><span class="s2">"</span>
  <span class="k">else </span><span class="nv">pct_color</span><span class="o">=</span><span class="s2">"</span><span class="nv">$GREEN</span><span class="s2">"</span><span class="p">;</span> <span class="k">fi

  </span><span class="nv">ctx_part</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">status_emoji</span><span class="k">}</span><span class="s2"> </span><span class="k">${</span><span class="nv">ctx_bar</span><span class="k">}</span><span class="s2"> </span><span class="k">${</span><span class="nv">pct_color</span><span class="k">}${</span><span class="nv">used_int</span><span class="k">}</span><span class="s2">%</span><span class="k">${</span><span class="nv">RESET</span><span class="k">}</span><span class="s2">"</span>
<span class="k">else
  </span><span class="nv">empty_bar</span><span class="o">=</span><span class="s2">""</span>
  <span class="k">for</span> <span class="o">((</span> <span class="nv">i</span><span class="o">=</span>0<span class="p">;</span> i&lt;BAR_WIDTH<span class="p">;</span> i++ <span class="o">))</span><span class="p">;</span> <span class="k">do
    </span><span class="nv">empty_bar</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">empty_bar</span><span class="k">}</span><span class="s2">░"</span>
  <span class="k">done
  </span><span class="nv">ctx_part</span><span class="o">=</span><span class="s2">"🟢 </span><span class="se">\0</span><span class="s2">33[38;2;60;60;60m</span><span class="k">${</span><span class="nv">empty_bar</span><span class="k">}${</span><span class="nv">RESET</span><span class="k">}</span><span class="s2"> --%"</span>
<span class="k">fi</span>

<span class="c"># ── Rate limit bars: red and purple gradients ──</span>
<span class="nv">rate_bars</span><span class="o">=</span><span class="s2">""</span>
<span class="k">if</span> <span class="o">[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$rate_5h</span><span class="s2">"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
  </span><span class="nv">rate_5h_int</span><span class="o">=</span><span class="si">$(</span><span class="nb">printf</span> <span class="s1">'%.0f'</span> <span class="s2">"</span><span class="nv">$rate_5h</span><span class="s2">"</span><span class="si">)</span>
  <span class="nv">rate_5h_bar</span><span class="o">=</span><span class="si">$(</span>draw_bar <span class="s2">"</span><span class="nv">$rate_5h_int</span><span class="s2">"</span> 150 50 50 220 0 0<span class="si">)</span>

  <span class="k">if</span> <span class="o">[</span> <span class="s2">"</span><span class="nv">$rate_5h_int</span><span class="s2">"</span> <span class="nt">-ge</span> 90 <span class="o">]</span><span class="p">;</span> <span class="k">then </span><span class="nv">pct_5h_color</span><span class="o">=</span><span class="s2">"</span><span class="nv">$RED</span><span class="s2">"</span>
  <span class="k">elif</span> <span class="o">[</span> <span class="s2">"</span><span class="nv">$rate_5h_int</span><span class="s2">"</span> <span class="nt">-ge</span> 70 <span class="o">]</span><span class="p">;</span> <span class="k">then </span><span class="nv">pct_5h_color</span><span class="o">=</span><span class="s2">"</span><span class="nv">$YELLOW</span><span class="s2">"</span>
  <span class="k">else </span><span class="nv">pct_5h_color</span><span class="o">=</span><span class="s2">"</span><span class="nv">$GREEN</span><span class="s2">"</span><span class="p">;</span> <span class="k">fi

  </span><span class="nv">rate_bars</span><span class="o">=</span><span class="s2">"🕐 </span><span class="k">${</span><span class="nv">rate_5h_bar</span><span class="k">}</span><span class="s2"> </span><span class="k">${</span><span class="nv">pct_5h_color</span><span class="k">}${</span><span class="nv">rate_5h_int</span><span class="k">}</span><span class="s2">%</span><span class="k">${</span><span class="nv">RESET</span><span class="k">}</span><span class="s2">"</span>
<span class="k">fi
if</span> <span class="o">[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$rate_7d</span><span class="s2">"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
  </span><span class="nv">rate_7d_int</span><span class="o">=</span><span class="si">$(</span><span class="nb">printf</span> <span class="s1">'%.0f'</span> <span class="s2">"</span><span class="nv">$rate_7d</span><span class="s2">"</span><span class="si">)</span>
  <span class="nv">rate_7d_bar</span><span class="o">=</span><span class="si">$(</span>draw_bar <span class="s2">"</span><span class="nv">$rate_7d_int</span><span class="s2">"</span> 150 80 150 220 80 220<span class="si">)</span>

  <span class="k">if</span> <span class="o">[</span> <span class="s2">"</span><span class="nv">$rate_7d_int</span><span class="s2">"</span> <span class="nt">-ge</span> 90 <span class="o">]</span><span class="p">;</span> <span class="k">then </span><span class="nv">pct_7d_color</span><span class="o">=</span><span class="s2">"</span><span class="nv">$RED</span><span class="s2">"</span>
  <span class="k">elif</span> <span class="o">[</span> <span class="s2">"</span><span class="nv">$rate_7d_int</span><span class="s2">"</span> <span class="nt">-ge</span> 70 <span class="o">]</span><span class="p">;</span> <span class="k">then </span><span class="nv">pct_7d_color</span><span class="o">=</span><span class="s2">"</span><span class="nv">$YELLOW</span><span class="s2">"</span>
  <span class="k">else </span><span class="nv">pct_7d_color</span><span class="o">=</span><span class="s2">"</span><span class="nv">$GREEN</span><span class="s2">"</span><span class="p">;</span> <span class="k">fi

  </span><span class="nv">rate_bars</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">rate_bars</span>:+<span class="nv">$rate_bars</span><span class="p"> </span><span class="k">}</span><span class="s2">📅 </span><span class="k">${</span><span class="nv">rate_7d_bar</span><span class="k">}</span><span class="s2"> </span><span class="k">${</span><span class="nv">pct_7d_color</span><span class="k">}${</span><span class="nv">rate_7d_int</span><span class="k">}</span><span class="s2">%</span><span class="k">${</span><span class="nv">RESET</span><span class="k">}</span><span class="s2">"</span>
<span class="k">fi</span>

<span class="c"># ── Cost ──</span>
<span class="nv">cost_part</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">YELLOW</span><span class="k">}</span><span class="si">$(</span><span class="nb">printf</span> <span class="s1">'$%.2f'</span> <span class="s2">"</span><span class="nv">$cost</span><span class="s2">"</span><span class="si">)</span><span class="k">${</span><span class="nv">RESET</span><span class="k">}</span><span class="s2">"</span>

<span class="c"># ── Single line: model | context | cost | rate limits | repo/branch ──</span>
<span class="nv">out</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">MAGENTA</span><span class="k">}</span><span class="s2">🤖 </span><span class="k">${</span><span class="nv">model</span><span class="k">}${</span><span class="nv">RESET</span><span class="k">}</span><span class="s2">"</span>
<span class="nv">out</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">out</span><span class="k">}</span><span class="s2"> </span><span class="k">${</span><span class="nv">DIM</span><span class="k">}</span><span class="s2">|</span><span class="k">${</span><span class="nv">RESET</span><span class="k">}</span><span class="s2"> </span><span class="k">${</span><span class="nv">ctx_part</span><span class="k">}</span><span class="s2">"</span>
<span class="nv">out</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">out</span><span class="k">}</span><span class="s2"> </span><span class="k">${</span><span class="nv">DIM</span><span class="k">}</span><span class="s2">|</span><span class="k">${</span><span class="nv">RESET</span><span class="k">}</span><span class="s2"> </span><span class="k">${</span><span class="nv">cost_part</span><span class="k">}</span><span class="s2">"</span>
<span class="o">[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$rate_bars</span><span class="s2">"</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nv">out</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">out</span><span class="k">}</span><span class="s2"> </span><span class="k">${</span><span class="nv">DIM</span><span class="k">}</span><span class="s2">|</span><span class="k">${</span><span class="nv">RESET</span><span class="k">}</span><span class="s2"> </span><span class="k">${</span><span class="nv">rate_bars</span><span class="k">}</span><span class="s2">"</span>

<span class="c"># Add repo/branch at the end if present</span>
<span class="k">if</span> <span class="o">[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$repo</span><span class="s2">"</span> <span class="o">]</span> <span class="o">||</span> <span class="o">[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$branch</span><span class="s2">"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
  </span><span class="nv">out</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">out</span><span class="k">}</span><span class="s2"> </span><span class="k">${</span><span class="nv">DIM</span><span class="k">}</span><span class="s2">|</span><span class="k">${</span><span class="nv">RESET</span><span class="k">}</span><span class="s2">"</span>
  <span class="o">[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$repo</span><span class="s2">"</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nv">out</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">out</span><span class="k">}</span><span class="s2"> </span><span class="k">${</span><span class="nv">BOLD</span><span class="k">}${</span><span class="nv">YELLOW</span><span class="k">}${</span><span class="nv">repo</span><span class="k">}${</span><span class="nv">RESET</span><span class="k">}</span><span class="s2">"</span>
  <span class="o">[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$branch</span><span class="s2">"</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nv">out</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">out</span><span class="k">}${</span><span class="nv">repo</span>:+<span class="p"> </span><span class="k">}${</span><span class="nv">BOLD</span><span class="k">}${</span><span class="nv">CYAN</span><span class="k">}</span><span class="s2">🌿 (</span><span class="k">${</span><span class="nv">branch</span><span class="k">}</span><span class="s2">)</span><span class="k">${</span><span class="nv">RESET</span><span class="k">}</span><span class="s2">"</span>
<span class="k">fi

</span><span class="nb">printf</span> <span class="s1">'%b'</span> <span class="s2">"</span><span class="nv">$out</span><span class="s2">"</span>
</code></pre></div></div>

<h3 id="cursor-cli-merge-into-cursorcli-configjson">Cursor CLI: merge into <code class="language-plaintext highlighter-rouge">~/.cursor/cli-config.json</code></h3>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"statusLine"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"command"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"command"</span><span class="p">:</span><span class="w"> </span><span class="s2">"~/.cursor/statusline.sh"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"padding"</span><span class="p">:</span><span class="w"> </span><span class="mi">2</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h3 id="cursor-cli-cursorstatuslinesh-on-gnu--linux">Cursor CLI: <code class="language-plaintext highlighter-rouge">~/.cursor/statusline.sh</code> on GNU / Linux</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/usr/bin/env bash</span>
<span class="c"># Cursor CLI status line — port of ~/.claude/statusline.sh</span>
<span class="c"># 5h/7d rate bars → first-party (auto/composer) / third-party (API) plan usage</span>

<span class="nv">input</span><span class="o">=</span><span class="si">$(</span><span class="nb">cat</span><span class="si">)</span>

<span class="c"># ── Colors ──</span>
<span class="nv">CYAN</span><span class="o">=</span><span class="s1">'\033[36m'</span>
<span class="nv">GREEN</span><span class="o">=</span><span class="s1">'\033[32m'</span>
<span class="nv">YELLOW</span><span class="o">=</span><span class="s1">'\033[33m'</span>
<span class="nv">RED</span><span class="o">=</span><span class="s1">'\033[31m'</span>
<span class="nv">MAGENTA</span><span class="o">=</span><span class="s1">'\033[35m'</span>
<span class="nv">DIM</span><span class="o">=</span><span class="s1">'\033[2m'</span>
<span class="nv">BOLD</span><span class="o">=</span><span class="s1">'\033[1m'</span>
<span class="nv">RESET</span><span class="o">=</span><span class="s1">'\033[0m'</span>

<span class="nv">BAR_WIDTH</span><span class="o">=</span>15

<span class="c"># ── Truecolor helper ──</span>
rgb<span class="o">()</span> <span class="o">{</span> <span class="nb">printf</span> <span class="s1">'\033[38;2;%d;%d;%dm'</span> <span class="s2">"</span><span class="nv">$1</span><span class="s2">"</span> <span class="s2">"</span><span class="nv">$2</span><span class="s2">"</span> <span class="s2">"</span><span class="nv">$3</span><span class="s2">"</span><span class="p">;</span> <span class="o">}</span>

<span class="c"># ── Parse JSON fields from statusline payload ──</span>
<span class="nv">model</span><span class="o">=</span><span class="si">$(</span><span class="nb">echo</span> <span class="s2">"</span><span class="nv">$input</span><span class="s2">"</span> | jq <span class="nt">-r</span> <span class="s1">'.model.display_name // "Unknown"'</span><span class="si">)</span>
<span class="nv">used</span><span class="o">=</span><span class="si">$(</span><span class="nb">echo</span> <span class="s2">"</span><span class="nv">$input</span><span class="s2">"</span> | jq <span class="nt">-r</span> <span class="s1">'.context_window.used_percentage // empty'</span><span class="si">)</span>
<span class="nv">cwd</span><span class="o">=</span><span class="si">$(</span><span class="nb">echo</span> <span class="s2">"</span><span class="nv">$input</span><span class="s2">"</span> | jq <span class="nt">-r</span> <span class="s1">'.workspace.current_dir // .cwd // ""'</span><span class="si">)</span>
<span class="c"># Prefer payload fields if Cursor ever ships them; else filled from usage cache below</span>
<span class="nv">fp_pct</span><span class="o">=</span><span class="si">$(</span><span class="nb">echo</span> <span class="s2">"</span><span class="nv">$input</span><span class="s2">"</span> | jq <span class="nt">-r</span> <span class="s1">'.usage.auto_percent_used // .usage.autoPercentUsed // .planUsage.autoPercentUsed // empty'</span><span class="si">)</span>
<span class="nv">tp_pct</span><span class="o">=</span><span class="si">$(</span><span class="nb">echo</span> <span class="s2">"</span><span class="nv">$input</span><span class="s2">"</span> | jq <span class="nt">-r</span> <span class="s1">'.usage.api_percent_used // .usage.apiPercentUsed // .planUsage.apiPercentUsed // empty'</span><span class="si">)</span>

<span class="c"># ── Cached GetCurrentPeriodUsage (first-/third-party plan bars) ──</span>
<span class="c"># No session cost in Cursor's statusline payload or usage API — dollar slot omitted.</span>
<span class="nv">CACHE_FILE</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">XDG_CACHE_HOME</span><span class="k">:-</span><span class="nv">$HOME</span><span class="p">/.cache</span><span class="k">}</span><span class="s2">/cursor-statusline-usage.json"</span>
<span class="nv">CACHE_TTL_SEC</span><span class="o">=</span>60
<span class="nv">AUTH_FILE</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">CURSOR_AUTH_FILE</span><span class="k">:-</span><span class="nv">$HOME</span><span class="p">/.config/cursor/auth.json</span><span class="k">}</span><span class="s2">"</span>

refresh_usage_cache<span class="o">()</span> <span class="o">{</span>
  <span class="nb">local </span>token
  <span class="nv">token</span><span class="o">=</span><span class="si">$(</span>jq <span class="nt">-r</span> <span class="s1">'.accessToken // empty'</span> <span class="s2">"</span><span class="nv">$AUTH_FILE</span><span class="s2">"</span> 2&gt;/dev/null<span class="si">)</span> <span class="o">||</span> <span class="k">return </span>1
  <span class="o">[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$token</span><span class="s2">"</span> <span class="o">]</span> <span class="o">||</span> <span class="k">return </span>1
  <span class="nb">mkdir</span> <span class="nt">-p</span> <span class="s2">"</span><span class="si">$(</span><span class="nb">dirname</span> <span class="s2">"</span><span class="nv">$CACHE_FILE</span><span class="s2">"</span><span class="si">)</span><span class="s2">"</span> 2&gt;/dev/null <span class="o">||</span> <span class="nb">true
  local </span>tmp
  <span class="nv">tmp</span><span class="o">=</span><span class="si">$(</span><span class="nb">mktemp</span> <span class="s2">"</span><span class="k">${</span><span class="nv">CACHE_FILE</span><span class="k">}</span><span class="s2">.XXXXXX"</span> 2&gt;/dev/null<span class="si">)</span> <span class="o">||</span> <span class="k">return </span>1
  <span class="k">if </span>curl <span class="nt">-sS</span> <span class="nt">--max-time</span> 1.5 <span class="se">\</span>
      <span class="nt">-X</span> POST <span class="s1">'https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage'</span> <span class="se">\</span>
      <span class="nt">-H</span> <span class="s2">"Authorization: Bearer </span><span class="k">${</span><span class="nv">token</span><span class="k">}</span><span class="s2">"</span> <span class="se">\</span>
      <span class="nt">-H</span> <span class="s1">'Content-Type: application/json'</span> <span class="se">\</span>
      <span class="nt">-H</span> <span class="s1">'Connect-Protocol-Version: 1'</span> <span class="se">\</span>
      <span class="nt">-d</span> <span class="s1">'{}'</span> <span class="se">\</span>
      <span class="nt">-o</span> <span class="s2">"</span><span class="nv">$tmp</span><span class="s2">"</span> 2&gt;/dev/null <span class="se">\</span>
    <span class="o">&amp;&amp;</span> jq <span class="nt">-e</span> <span class="s1">'.planUsage'</span> <span class="s2">"</span><span class="nv">$tmp</span><span class="s2">"</span> <span class="o">&gt;</span>/dev/null 2&gt;&amp;1<span class="p">;</span> <span class="k">then
    </span><span class="nb">mv</span> <span class="nt">-f</span> <span class="s2">"</span><span class="nv">$tmp</span><span class="s2">"</span> <span class="s2">"</span><span class="nv">$CACHE_FILE</span><span class="s2">"</span>
    <span class="k">return </span>0
  <span class="k">fi
  </span><span class="nb">rm</span> <span class="nt">-f</span> <span class="s2">"</span><span class="nv">$tmp</span><span class="s2">"</span>
  <span class="k">return </span>1
<span class="o">}</span>

cache_fresh<span class="o">()</span> <span class="o">{</span>
  <span class="o">[</span> <span class="nt">-f</span> <span class="s2">"</span><span class="nv">$CACHE_FILE</span><span class="s2">"</span> <span class="o">]</span> <span class="o">||</span> <span class="k">return </span>1
  <span class="nb">local </span>age mtime now
  <span class="nv">mtime</span><span class="o">=</span><span class="si">$(</span><span class="nb">stat</span> <span class="nt">-c</span> %Y <span class="s2">"</span><span class="nv">$CACHE_FILE</span><span class="s2">"</span> 2&gt;/dev/null<span class="si">)</span> <span class="o">||</span> <span class="k">return </span>1
  <span class="nv">now</span><span class="o">=</span><span class="si">$(</span><span class="nb">date</span> +%s<span class="si">)</span>
  <span class="nv">age</span><span class="o">=</span><span class="k">$((</span>now <span class="o">-</span> mtime<span class="k">))</span>
  <span class="o">[</span> <span class="s2">"</span><span class="nv">$age</span><span class="s2">"</span> <span class="nt">-lt</span> <span class="s2">"</span><span class="nv">$CACHE_TTL_SEC</span><span class="s2">"</span> <span class="o">]</span>
<span class="o">}</span>

<span class="k">if</span> <span class="o">!</span> cache_fresh<span class="p">;</span> <span class="k">then
  </span>refresh_usage_cache <span class="o">||</span> <span class="nb">true
</span><span class="k">fi

if</span> <span class="o">[</span> <span class="nt">-f</span> <span class="s2">"</span><span class="nv">$CACHE_FILE</span><span class="s2">"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
  <span class="o">[</span> <span class="nt">-z</span> <span class="s2">"</span><span class="nv">$fp_pct</span><span class="s2">"</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nv">fp_pct</span><span class="o">=</span><span class="si">$(</span>jq <span class="nt">-r</span> <span class="s1">'.planUsage.autoPercentUsed // empty'</span> <span class="s2">"</span><span class="nv">$CACHE_FILE</span><span class="s2">"</span> 2&gt;/dev/null<span class="si">)</span>
  <span class="o">[</span> <span class="nt">-z</span> <span class="s2">"</span><span class="nv">$tp_pct</span><span class="s2">"</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nv">tp_pct</span><span class="o">=</span><span class="si">$(</span>jq <span class="nt">-r</span> <span class="s1">'.planUsage.apiPercentUsed // empty'</span> <span class="s2">"</span><span class="nv">$CACHE_FILE</span><span class="s2">"</span> 2&gt;/dev/null<span class="si">)</span>
<span class="k">fi</span>

<span class="c"># ── Git info ──</span>
<span class="nv">branch</span><span class="o">=</span><span class="s2">""</span>
<span class="nv">repo</span><span class="o">=</span><span class="s2">""</span>
<span class="k">if</span> <span class="o">[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$cwd</span><span class="s2">"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
  </span><span class="nv">branch</span><span class="o">=</span><span class="si">$(</span>git <span class="nt">-C</span> <span class="s2">"</span><span class="nv">$cwd</span><span class="s2">"</span> <span class="nt">--no-optional-locks</span> symbolic-ref <span class="nt">--short</span> HEAD 2&gt;/dev/null<span class="si">)</span>
  <span class="nv">repo</span><span class="o">=</span><span class="si">$(</span><span class="nb">basename</span> <span class="s2">"</span><span class="si">$(</span>git <span class="nt">-C</span> <span class="s2">"</span><span class="nv">$cwd</span><span class="s2">"</span> <span class="nt">--no-optional-locks</span> rev-parse <span class="nt">--show-toplevel</span> 2&gt;/dev/null<span class="si">)</span><span class="s2">"</span> 2&gt;/dev/null<span class="si">)</span>
<span class="k">fi</span>

<span class="c"># ── Bar drawing helper ──</span>
draw_bar<span class="o">()</span> <span class="o">{</span>
  <span class="nb">local </span><span class="nv">pct</span><span class="o">=</span><span class="nv">$1</span> <span class="nv">grad_low_r</span><span class="o">=</span><span class="nv">$2</span> <span class="nv">grad_low_g</span><span class="o">=</span><span class="nv">$3</span> <span class="nv">grad_low_b</span><span class="o">=</span><span class="nv">$4</span> <span class="nv">grad_high_r</span><span class="o">=</span><span class="nv">$5</span> <span class="nv">grad_high_g</span><span class="o">=</span><span class="nv">$6</span> <span class="nv">grad_high_b</span><span class="o">=</span><span class="nv">$7</span>
  <span class="nb">local </span>pct_int
  <span class="nv">pct_int</span><span class="o">=</span><span class="si">$(</span><span class="nb">printf</span> <span class="s1">'%.0f'</span> <span class="s2">"</span><span class="nv">$pct</span><span class="s2">"</span><span class="si">)</span>
  <span class="nb">local </span><span class="nv">filled</span><span class="o">=</span><span class="k">$((</span> <span class="o">(</span>pct_int <span class="o">*</span> BAR_WIDTH <span class="o">+</span> <span class="m">50</span><span class="o">)</span> <span class="o">/</span> <span class="m">100</span> <span class="k">))</span>
  <span class="o">[</span> <span class="s2">"</span><span class="nv">$filled</span><span class="s2">"</span> <span class="nt">-gt</span> <span class="s2">"</span><span class="nv">$BAR_WIDTH</span><span class="s2">"</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nv">filled</span><span class="o">=</span><span class="nv">$BAR_WIDTH</span>
  <span class="o">[</span> <span class="s2">"</span><span class="nv">$filled</span><span class="s2">"</span> <span class="nt">-lt</span> 0 <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nv">filled</span><span class="o">=</span>0
  <span class="nb">local </span><span class="nv">bar</span><span class="o">=</span><span class="s2">""</span>

  <span class="k">for</span> <span class="o">((</span> <span class="nv">i</span><span class="o">=</span>0<span class="p">;</span> i&lt;BAR_WIDTH<span class="p">;</span> i++ <span class="o">))</span><span class="p">;</span> <span class="k">do
    </span><span class="nb">local </span><span class="nv">pos</span><span class="o">=</span><span class="k">$((</span> i <span class="o">*</span> <span class="m">100</span> <span class="o">/</span> <span class="o">(</span>BAR_WIDTH <span class="o">-</span> <span class="m">1</span><span class="o">)</span> <span class="k">))</span>
    <span class="nb">local </span><span class="nv">r</span><span class="o">=</span><span class="k">$((</span> grad_low_r <span class="o">+</span> <span class="o">(</span>grad_high_r <span class="o">-</span> grad_low_r<span class="o">)</span> <span class="o">*</span> pos <span class="o">/</span> <span class="m">100</span> <span class="k">))</span>
    <span class="nb">local </span><span class="nv">g</span><span class="o">=</span><span class="k">$((</span> grad_low_g <span class="o">+</span> <span class="o">(</span>grad_high_g <span class="o">-</span> grad_low_g<span class="o">)</span> <span class="o">*</span> pos <span class="o">/</span> <span class="m">100</span> <span class="k">))</span>
    <span class="nb">local </span><span class="nv">b</span><span class="o">=</span><span class="k">$((</span> grad_low_b <span class="o">+</span> <span class="o">(</span>grad_high_b <span class="o">-</span> grad_low_b<span class="o">)</span> <span class="o">*</span> pos <span class="o">/</span> <span class="m">100</span> <span class="k">))</span>

    <span class="k">if</span> <span class="o">[</span> <span class="s2">"</span><span class="nv">$i</span><span class="s2">"</span> <span class="nt">-lt</span> <span class="s2">"</span><span class="nv">$filled</span><span class="s2">"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
      </span><span class="nv">bar</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">bar</span><span class="k">}</span><span class="si">$(</span>rgb <span class="nv">$r</span> <span class="nv">$g</span> <span class="nv">$b</span><span class="si">)</span><span class="s2">█"</span>
    <span class="k">else
      </span><span class="nv">bar</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">bar</span><span class="k">}</span><span class="se">\0</span><span class="s2">33[38;2;60;60;60m░"</span>
    <span class="k">fi
  done
  </span><span class="nb">printf</span> <span class="s1">'%b'</span> <span class="s2">"</span><span class="k">${</span><span class="nv">bar</span><span class="k">}${</span><span class="nv">RESET</span><span class="k">}</span><span class="s2">"</span>
<span class="o">}</span>

<span class="c"># ── Context bar: green→yellow→red ──</span>
<span class="k">if</span> <span class="o">[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$used</span><span class="s2">"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
  </span><span class="nv">used_int</span><span class="o">=</span><span class="si">$(</span><span class="nb">printf</span> <span class="s1">'%.0f'</span> <span class="s2">"</span><span class="nv">$used</span><span class="s2">"</span><span class="si">)</span>

  <span class="nv">ctx_bar</span><span class="o">=</span><span class="si">$(</span>draw_bar <span class="s2">"</span><span class="nv">$used_int</span><span class="s2">"</span> 0 200 80 220 20 0<span class="si">)</span>

  <span class="k">if</span> <span class="o">[</span> <span class="s2">"</span><span class="nv">$used_int</span><span class="s2">"</span> <span class="nt">-ge</span> 90 <span class="o">]</span><span class="p">;</span> <span class="k">then </span><span class="nv">status_emoji</span><span class="o">=</span><span class="s2">"🚨"</span>
  <span class="k">elif</span> <span class="o">[</span> <span class="s2">"</span><span class="nv">$used_int</span><span class="s2">"</span> <span class="nt">-ge</span> 70 <span class="o">]</span><span class="p">;</span> <span class="k">then </span><span class="nv">status_emoji</span><span class="o">=</span><span class="s2">"🔥"</span>
  <span class="k">elif</span> <span class="o">[</span> <span class="s2">"</span><span class="nv">$used_int</span><span class="s2">"</span> <span class="nt">-ge</span> 20 <span class="o">]</span><span class="p">;</span> <span class="k">then </span><span class="nv">status_emoji</span><span class="o">=</span><span class="s2">"⚡"</span>
  <span class="k">else </span><span class="nv">status_emoji</span><span class="o">=</span><span class="s2">"🟢"</span><span class="p">;</span> <span class="k">fi

  if</span> <span class="o">[</span> <span class="s2">"</span><span class="nv">$used_int</span><span class="s2">"</span> <span class="nt">-ge</span> 90 <span class="o">]</span><span class="p">;</span> <span class="k">then </span><span class="nv">pct_color</span><span class="o">=</span><span class="s2">"</span><span class="nv">$RED</span><span class="s2">"</span>
  <span class="k">elif</span> <span class="o">[</span> <span class="s2">"</span><span class="nv">$used_int</span><span class="s2">"</span> <span class="nt">-ge</span> 70 <span class="o">]</span><span class="p">;</span> <span class="k">then </span><span class="nv">pct_color</span><span class="o">=</span><span class="s2">"</span><span class="nv">$YELLOW</span><span class="s2">"</span>
  <span class="k">else </span><span class="nv">pct_color</span><span class="o">=</span><span class="s2">"</span><span class="nv">$GREEN</span><span class="s2">"</span><span class="p">;</span> <span class="k">fi

  </span><span class="nv">ctx_part</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">status_emoji</span><span class="k">}</span><span class="s2"> </span><span class="k">${</span><span class="nv">ctx_bar</span><span class="k">}</span><span class="s2"> </span><span class="k">${</span><span class="nv">pct_color</span><span class="k">}${</span><span class="nv">used_int</span><span class="k">}</span><span class="s2">%</span><span class="k">${</span><span class="nv">RESET</span><span class="k">}</span><span class="s2">"</span>
<span class="k">else
  </span><span class="nv">empty_bar</span><span class="o">=</span><span class="s2">""</span>
  <span class="k">for</span> <span class="o">((</span> <span class="nv">i</span><span class="o">=</span>0<span class="p">;</span> i&lt;BAR_WIDTH<span class="p">;</span> i++ <span class="o">))</span><span class="p">;</span> <span class="k">do
    </span><span class="nv">empty_bar</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">empty_bar</span><span class="k">}</span><span class="s2">░"</span>
  <span class="k">done
  </span><span class="nv">ctx_part</span><span class="o">=</span><span class="s2">"🟢 </span><span class="se">\0</span><span class="s2">33[38;2;60;60;60m</span><span class="k">${</span><span class="nv">empty_bar</span><span class="k">}${</span><span class="nv">RESET</span><span class="k">}</span><span class="s2"> --%"</span>
<span class="k">fi</span>

<span class="c"># ── First-party (auto/composer) + third-party (API) bars ──</span>
<span class="nv">usage_bars</span><span class="o">=</span><span class="s2">""</span>
<span class="k">if</span> <span class="o">[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$fp_pct</span><span class="s2">"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
  </span><span class="nv">fp_int</span><span class="o">=</span><span class="si">$(</span><span class="nb">printf</span> <span class="s1">'%.0f'</span> <span class="s2">"</span><span class="nv">$fp_pct</span><span class="s2">"</span><span class="si">)</span>
  <span class="nv">fp_bar</span><span class="o">=</span><span class="si">$(</span>draw_bar <span class="s2">"</span><span class="nv">$fp_int</span><span class="s2">"</span> 150 50 50 220 0 0<span class="si">)</span>

  <span class="k">if</span> <span class="o">[</span> <span class="s2">"</span><span class="nv">$fp_int</span><span class="s2">"</span> <span class="nt">-ge</span> 90 <span class="o">]</span><span class="p">;</span> <span class="k">then </span><span class="nv">pct_fp_color</span><span class="o">=</span><span class="s2">"</span><span class="nv">$RED</span><span class="s2">"</span>
  <span class="k">elif</span> <span class="o">[</span> <span class="s2">"</span><span class="nv">$fp_int</span><span class="s2">"</span> <span class="nt">-ge</span> 70 <span class="o">]</span><span class="p">;</span> <span class="k">then </span><span class="nv">pct_fp_color</span><span class="o">=</span><span class="s2">"</span><span class="nv">$YELLOW</span><span class="s2">"</span>
  <span class="k">else </span><span class="nv">pct_fp_color</span><span class="o">=</span><span class="s2">"</span><span class="nv">$GREEN</span><span class="s2">"</span><span class="p">;</span> <span class="k">fi

  </span><span class="nv">usage_bars</span><span class="o">=</span><span class="s2">"🏠 </span><span class="k">${</span><span class="nv">fp_bar</span><span class="k">}</span><span class="s2"> </span><span class="k">${</span><span class="nv">pct_fp_color</span><span class="k">}${</span><span class="nv">fp_int</span><span class="k">}</span><span class="s2">%</span><span class="k">${</span><span class="nv">RESET</span><span class="k">}</span><span class="s2">"</span>
<span class="k">fi
if</span> <span class="o">[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$tp_pct</span><span class="s2">"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
  </span><span class="nv">tp_int</span><span class="o">=</span><span class="si">$(</span><span class="nb">printf</span> <span class="s1">'%.0f'</span> <span class="s2">"</span><span class="nv">$tp_pct</span><span class="s2">"</span><span class="si">)</span>
  <span class="nv">tp_bar</span><span class="o">=</span><span class="si">$(</span>draw_bar <span class="s2">"</span><span class="nv">$tp_int</span><span class="s2">"</span> 150 80 150 220 80 220<span class="si">)</span>

  <span class="k">if</span> <span class="o">[</span> <span class="s2">"</span><span class="nv">$tp_int</span><span class="s2">"</span> <span class="nt">-ge</span> 90 <span class="o">]</span><span class="p">;</span> <span class="k">then </span><span class="nv">pct_tp_color</span><span class="o">=</span><span class="s2">"</span><span class="nv">$RED</span><span class="s2">"</span>
  <span class="k">elif</span> <span class="o">[</span> <span class="s2">"</span><span class="nv">$tp_int</span><span class="s2">"</span> <span class="nt">-ge</span> 70 <span class="o">]</span><span class="p">;</span> <span class="k">then </span><span class="nv">pct_tp_color</span><span class="o">=</span><span class="s2">"</span><span class="nv">$YELLOW</span><span class="s2">"</span>
  <span class="k">else </span><span class="nv">pct_tp_color</span><span class="o">=</span><span class="s2">"</span><span class="nv">$GREEN</span><span class="s2">"</span><span class="p">;</span> <span class="k">fi

  </span><span class="nv">usage_bars</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">usage_bars</span>:+<span class="nv">$usage_bars</span><span class="p"> </span><span class="k">}</span><span class="s2">🌐 </span><span class="k">${</span><span class="nv">tp_bar</span><span class="k">}</span><span class="s2"> </span><span class="k">${</span><span class="nv">pct_tp_color</span><span class="k">}${</span><span class="nv">tp_int</span><span class="k">}</span><span class="s2">%</span><span class="k">${</span><span class="nv">RESET</span><span class="k">}</span><span class="s2">"</span>
<span class="k">fi</span>

<span class="c"># ── Single line: model | context | 1P/3P usage | repo/branch ──</span>
<span class="nv">out</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">MAGENTA</span><span class="k">}</span><span class="s2">🤖 </span><span class="k">${</span><span class="nv">model</span><span class="k">}${</span><span class="nv">RESET</span><span class="k">}</span><span class="s2">"</span>
<span class="nv">out</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">out</span><span class="k">}</span><span class="s2"> </span><span class="k">${</span><span class="nv">DIM</span><span class="k">}</span><span class="s2">|</span><span class="k">${</span><span class="nv">RESET</span><span class="k">}</span><span class="s2"> </span><span class="k">${</span><span class="nv">ctx_part</span><span class="k">}</span><span class="s2">"</span>
<span class="o">[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$usage_bars</span><span class="s2">"</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nv">out</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">out</span><span class="k">}</span><span class="s2"> </span><span class="k">${</span><span class="nv">DIM</span><span class="k">}</span><span class="s2">|</span><span class="k">${</span><span class="nv">RESET</span><span class="k">}</span><span class="s2"> </span><span class="k">${</span><span class="nv">usage_bars</span><span class="k">}</span><span class="s2">"</span>

<span class="k">if</span> <span class="o">[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$repo</span><span class="s2">"</span> <span class="o">]</span> <span class="o">||</span> <span class="o">[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$branch</span><span class="s2">"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
  </span><span class="nv">out</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">out</span><span class="k">}</span><span class="s2"> </span><span class="k">${</span><span class="nv">DIM</span><span class="k">}</span><span class="s2">|</span><span class="k">${</span><span class="nv">RESET</span><span class="k">}</span><span class="s2">"</span>
  <span class="o">[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$repo</span><span class="s2">"</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nv">out</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">out</span><span class="k">}</span><span class="s2"> </span><span class="k">${</span><span class="nv">BOLD</span><span class="k">}${</span><span class="nv">YELLOW</span><span class="k">}${</span><span class="nv">repo</span><span class="k">}${</span><span class="nv">RESET</span><span class="k">}</span><span class="s2">"</span>
  <span class="o">[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$branch</span><span class="s2">"</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nv">out</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">out</span><span class="k">}${</span><span class="nv">repo</span>:+<span class="p"> </span><span class="k">}${</span><span class="nv">BOLD</span><span class="k">}${</span><span class="nv">CYAN</span><span class="k">}</span><span class="s2">🌿 (</span><span class="k">${</span><span class="nv">branch</span><span class="k">}</span><span class="s2">)</span><span class="k">${</span><span class="nv">RESET</span><span class="k">}</span><span class="s2">"</span>
<span class="k">fi

</span><span class="nb">printf</span> <span class="s1">'%b'</span> <span class="s2">"</span><span class="nv">$out</span><span class="s2">"</span>
</code></pre></div></div>

<h3 id="cursor-cli-cursorstatuslinesh-on-macos--bsd">Cursor CLI: <code class="language-plaintext highlighter-rouge">~/.cursor/statusline.sh</code> on macOS / BSD</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/usr/bin/env bash</span>
<span class="c"># Cursor CLI status line — port of ~/.claude/statusline.sh</span>
<span class="c"># 5h/7d rate bars → first-party (auto/composer) / third-party (API) plan usage</span>

<span class="nv">input</span><span class="o">=</span><span class="si">$(</span><span class="nb">cat</span><span class="si">)</span>

<span class="c"># ── Colors ──</span>
<span class="nv">CYAN</span><span class="o">=</span><span class="s1">'\033[36m'</span>
<span class="nv">GREEN</span><span class="o">=</span><span class="s1">'\033[32m'</span>
<span class="nv">YELLOW</span><span class="o">=</span><span class="s1">'\033[33m'</span>
<span class="nv">RED</span><span class="o">=</span><span class="s1">'\033[31m'</span>
<span class="nv">MAGENTA</span><span class="o">=</span><span class="s1">'\033[35m'</span>
<span class="nv">DIM</span><span class="o">=</span><span class="s1">'\033[2m'</span>
<span class="nv">BOLD</span><span class="o">=</span><span class="s1">'\033[1m'</span>
<span class="nv">RESET</span><span class="o">=</span><span class="s1">'\033[0m'</span>

<span class="nv">BAR_WIDTH</span><span class="o">=</span>15

<span class="c"># ── Truecolor helper ──</span>
rgb<span class="o">()</span> <span class="o">{</span> <span class="nb">printf</span> <span class="s1">'\033[38;2;%d;%d;%dm'</span> <span class="s2">"</span><span class="nv">$1</span><span class="s2">"</span> <span class="s2">"</span><span class="nv">$2</span><span class="s2">"</span> <span class="s2">"</span><span class="nv">$3</span><span class="s2">"</span><span class="p">;</span> <span class="o">}</span>

<span class="c"># ── Parse JSON fields from statusline payload ──</span>
<span class="nv">model</span><span class="o">=</span><span class="si">$(</span><span class="nb">echo</span> <span class="s2">"</span><span class="nv">$input</span><span class="s2">"</span> | jq <span class="nt">-r</span> <span class="s1">'.model.display_name // "Unknown"'</span><span class="si">)</span>
<span class="nv">used</span><span class="o">=</span><span class="si">$(</span><span class="nb">echo</span> <span class="s2">"</span><span class="nv">$input</span><span class="s2">"</span> | jq <span class="nt">-r</span> <span class="s1">'.context_window.used_percentage // empty'</span><span class="si">)</span>
<span class="nv">cwd</span><span class="o">=</span><span class="si">$(</span><span class="nb">echo</span> <span class="s2">"</span><span class="nv">$input</span><span class="s2">"</span> | jq <span class="nt">-r</span> <span class="s1">'.workspace.current_dir // .cwd // ""'</span><span class="si">)</span>
<span class="c"># Prefer payload fields if Cursor ever ships them; else filled from usage cache below</span>
<span class="nv">fp_pct</span><span class="o">=</span><span class="si">$(</span><span class="nb">echo</span> <span class="s2">"</span><span class="nv">$input</span><span class="s2">"</span> | jq <span class="nt">-r</span> <span class="s1">'.usage.auto_percent_used // .usage.autoPercentUsed // .planUsage.autoPercentUsed // empty'</span><span class="si">)</span>
<span class="nv">tp_pct</span><span class="o">=</span><span class="si">$(</span><span class="nb">echo</span> <span class="s2">"</span><span class="nv">$input</span><span class="s2">"</span> | jq <span class="nt">-r</span> <span class="s1">'.usage.api_percent_used // .usage.apiPercentUsed // .planUsage.apiPercentUsed // empty'</span><span class="si">)</span>

<span class="c"># ── Cached GetCurrentPeriodUsage (first-/third-party plan bars) ──</span>
<span class="c"># No session cost in Cursor's statusline payload or usage API — dollar slot omitted.</span>
<span class="nv">CACHE_FILE</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">XDG_CACHE_HOME</span><span class="k">:-</span><span class="nv">$HOME</span><span class="p">/.cache</span><span class="k">}</span><span class="s2">/cursor-statusline-usage.json"</span>
<span class="nv">CACHE_TTL_SEC</span><span class="o">=</span>60
<span class="nv">AUTH_FILE</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">CURSOR_AUTH_FILE</span><span class="k">:-</span><span class="nv">$HOME</span><span class="p">/.config/cursor/auth.json</span><span class="k">}</span><span class="s2">"</span>
<span class="nv">AUTH_FILE_DARWIN</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">HOME</span><span class="k">}</span><span class="s2">/.cursor/auth.json"</span>

get_access_token<span class="o">()</span> <span class="o">{</span>
  <span class="nb">local </span>token f
  <span class="k">for </span>f <span class="k">in</span> <span class="s2">"</span><span class="nv">$AUTH_FILE</span><span class="s2">"</span> <span class="s2">"</span><span class="nv">$AUTH_FILE_DARWIN</span><span class="s2">"</span><span class="p">;</span> <span class="k">do</span>
    <span class="o">[</span> <span class="nt">-f</span> <span class="s2">"</span><span class="nv">$f</span><span class="s2">"</span> <span class="o">]</span> <span class="o">||</span> <span class="k">continue
    </span><span class="nv">token</span><span class="o">=</span><span class="si">$(</span>jq <span class="nt">-r</span> <span class="s1">'.accessToken // empty'</span> <span class="s2">"</span><span class="nv">$f</span><span class="s2">"</span> 2&gt;/dev/null<span class="si">)</span> <span class="o">||</span> <span class="k">continue</span>
    <span class="o">[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$token</span><span class="s2">"</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="o">{</span> <span class="nb">printf</span> <span class="s1">'%s'</span> <span class="s2">"</span><span class="nv">$token</span><span class="s2">"</span><span class="p">;</span> <span class="k">return </span>0<span class="p">;</span> <span class="o">}</span>
  <span class="k">done
  if</span> <span class="o">[</span> <span class="s2">"</span><span class="si">$(</span><span class="nb">uname</span> <span class="nt">-s</span><span class="si">)</span><span class="s2">"</span> <span class="o">=</span> <span class="s2">"Darwin"</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nb">command</span> <span class="nt">-v</span> security <span class="o">&gt;</span>/dev/null 2&gt;&amp;1<span class="p">;</span> <span class="k">then
    </span><span class="nv">token</span><span class="o">=</span><span class="si">$(</span>security find-generic-password <span class="nt">-s</span> cursor-access-token <span class="nt">-a</span> cursor-user <span class="nt">-w</span> 2&gt;/dev/null<span class="si">)</span> <span class="o">||</span> <span class="nb">true</span>
    <span class="o">[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$token</span><span class="s2">"</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="o">{</span> <span class="nb">printf</span> <span class="s1">'%s'</span> <span class="s2">"</span><span class="nv">$token</span><span class="s2">"</span><span class="p">;</span> <span class="k">return </span>0<span class="p">;</span> <span class="o">}</span>
  <span class="k">fi
  return </span>1
<span class="o">}</span>

refresh_usage_cache<span class="o">()</span> <span class="o">{</span>
  <span class="nb">local </span>token
  <span class="nv">token</span><span class="o">=</span><span class="si">$(</span>get_access_token<span class="si">)</span> <span class="o">||</span> <span class="k">return </span>1
  <span class="nb">mkdir</span> <span class="nt">-p</span> <span class="s2">"</span><span class="si">$(</span><span class="nb">dirname</span> <span class="s2">"</span><span class="nv">$CACHE_FILE</span><span class="s2">"</span><span class="si">)</span><span class="s2">"</span> 2&gt;/dev/null <span class="o">||</span> <span class="nb">true
  local </span>tmp
  <span class="nv">tmp</span><span class="o">=</span><span class="si">$(</span><span class="nb">mktemp</span> <span class="s2">"</span><span class="k">${</span><span class="nv">CACHE_FILE</span><span class="k">}</span><span class="s2">.XXXXXX"</span> 2&gt;/dev/null<span class="si">)</span> <span class="o">||</span> <span class="k">return </span>1
  <span class="k">if </span>curl <span class="nt">-sS</span> <span class="nt">--max-time</span> 1.5 <span class="se">\</span>
      <span class="nt">-X</span> POST <span class="s1">'https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage'</span> <span class="se">\</span>
      <span class="nt">-H</span> <span class="s2">"Authorization: Bearer </span><span class="k">${</span><span class="nv">token</span><span class="k">}</span><span class="s2">"</span> <span class="se">\</span>
      <span class="nt">-H</span> <span class="s1">'Content-Type: application/json'</span> <span class="se">\</span>
      <span class="nt">-H</span> <span class="s1">'Connect-Protocol-Version: 1'</span> <span class="se">\</span>
      <span class="nt">-d</span> <span class="s1">'{}'</span> <span class="se">\</span>
      <span class="nt">-o</span> <span class="s2">"</span><span class="nv">$tmp</span><span class="s2">"</span> 2&gt;/dev/null <span class="se">\</span>
    <span class="o">&amp;&amp;</span> jq <span class="nt">-e</span> <span class="s1">'.planUsage'</span> <span class="s2">"</span><span class="nv">$tmp</span><span class="s2">"</span> <span class="o">&gt;</span>/dev/null 2&gt;&amp;1<span class="p">;</span> <span class="k">then
    </span><span class="nb">mv</span> <span class="nt">-f</span> <span class="s2">"</span><span class="nv">$tmp</span><span class="s2">"</span> <span class="s2">"</span><span class="nv">$CACHE_FILE</span><span class="s2">"</span>
    <span class="k">return </span>0
  <span class="k">fi
  </span><span class="nb">rm</span> <span class="nt">-f</span> <span class="s2">"</span><span class="nv">$tmp</span><span class="s2">"</span>
  <span class="k">return </span>1
<span class="o">}</span>

cache_fresh<span class="o">()</span> <span class="o">{</span>
  <span class="o">[</span> <span class="nt">-f</span> <span class="s2">"</span><span class="nv">$CACHE_FILE</span><span class="s2">"</span> <span class="o">]</span> <span class="o">||</span> <span class="k">return </span>1
  <span class="nb">local </span>age mtime now
  <span class="nv">mtime</span><span class="o">=</span><span class="si">$(</span><span class="nb">stat</span> <span class="nt">-c</span> %Y <span class="s2">"</span><span class="nv">$CACHE_FILE</span><span class="s2">"</span> 2&gt;/dev/null <span class="o">||</span> <span class="nb">stat</span> <span class="nt">-f</span> %m <span class="s2">"</span><span class="nv">$CACHE_FILE</span><span class="s2">"</span> 2&gt;/dev/null<span class="si">)</span> <span class="o">||</span> <span class="k">return </span>1
  <span class="nv">now</span><span class="o">=</span><span class="si">$(</span><span class="nb">date</span> +%s<span class="si">)</span>
  <span class="nv">age</span><span class="o">=</span><span class="k">$((</span>now <span class="o">-</span> mtime<span class="k">))</span>
  <span class="o">[</span> <span class="s2">"</span><span class="nv">$age</span><span class="s2">"</span> <span class="nt">-lt</span> <span class="s2">"</span><span class="nv">$CACHE_TTL_SEC</span><span class="s2">"</span> <span class="o">]</span>
<span class="o">}</span>

<span class="k">if</span> <span class="o">!</span> cache_fresh<span class="p">;</span> <span class="k">then
  </span>refresh_usage_cache <span class="o">||</span> <span class="nb">true
</span><span class="k">fi

if</span> <span class="o">[</span> <span class="nt">-f</span> <span class="s2">"</span><span class="nv">$CACHE_FILE</span><span class="s2">"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
  <span class="o">[</span> <span class="nt">-z</span> <span class="s2">"</span><span class="nv">$fp_pct</span><span class="s2">"</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nv">fp_pct</span><span class="o">=</span><span class="si">$(</span>jq <span class="nt">-r</span> <span class="s1">'.planUsage.autoPercentUsed // empty'</span> <span class="s2">"</span><span class="nv">$CACHE_FILE</span><span class="s2">"</span> 2&gt;/dev/null<span class="si">)</span>
  <span class="o">[</span> <span class="nt">-z</span> <span class="s2">"</span><span class="nv">$tp_pct</span><span class="s2">"</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nv">tp_pct</span><span class="o">=</span><span class="si">$(</span>jq <span class="nt">-r</span> <span class="s1">'.planUsage.apiPercentUsed // empty'</span> <span class="s2">"</span><span class="nv">$CACHE_FILE</span><span class="s2">"</span> 2&gt;/dev/null<span class="si">)</span>
<span class="k">fi</span>

<span class="c"># ── Git info ──</span>
<span class="nv">branch</span><span class="o">=</span><span class="s2">""</span>
<span class="nv">repo</span><span class="o">=</span><span class="s2">""</span>
<span class="k">if</span> <span class="o">[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$cwd</span><span class="s2">"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
  </span><span class="nv">branch</span><span class="o">=</span><span class="si">$(</span>git <span class="nt">-C</span> <span class="s2">"</span><span class="nv">$cwd</span><span class="s2">"</span> <span class="nt">--no-optional-locks</span> symbolic-ref <span class="nt">--short</span> HEAD 2&gt;/dev/null<span class="si">)</span>
  <span class="nv">repo</span><span class="o">=</span><span class="si">$(</span><span class="nb">basename</span> <span class="s2">"</span><span class="si">$(</span>git <span class="nt">-C</span> <span class="s2">"</span><span class="nv">$cwd</span><span class="s2">"</span> <span class="nt">--no-optional-locks</span> rev-parse <span class="nt">--show-toplevel</span> 2&gt;/dev/null<span class="si">)</span><span class="s2">"</span> 2&gt;/dev/null<span class="si">)</span>
<span class="k">fi</span>

<span class="c"># ── Bar drawing helper ──</span>
draw_bar<span class="o">()</span> <span class="o">{</span>
  <span class="nb">local </span><span class="nv">pct</span><span class="o">=</span><span class="nv">$1</span> <span class="nv">grad_low_r</span><span class="o">=</span><span class="nv">$2</span> <span class="nv">grad_low_g</span><span class="o">=</span><span class="nv">$3</span> <span class="nv">grad_low_b</span><span class="o">=</span><span class="nv">$4</span> <span class="nv">grad_high_r</span><span class="o">=</span><span class="nv">$5</span> <span class="nv">grad_high_g</span><span class="o">=</span><span class="nv">$6</span> <span class="nv">grad_high_b</span><span class="o">=</span><span class="nv">$7</span>
  <span class="nb">local </span>pct_int
  <span class="nv">pct_int</span><span class="o">=</span><span class="si">$(</span><span class="nb">printf</span> <span class="s1">'%.0f'</span> <span class="s2">"</span><span class="nv">$pct</span><span class="s2">"</span><span class="si">)</span>
  <span class="nb">local </span><span class="nv">filled</span><span class="o">=</span><span class="k">$((</span> <span class="o">(</span>pct_int <span class="o">*</span> BAR_WIDTH <span class="o">+</span> <span class="m">50</span><span class="o">)</span> <span class="o">/</span> <span class="m">100</span> <span class="k">))</span>
  <span class="o">[</span> <span class="s2">"</span><span class="nv">$filled</span><span class="s2">"</span> <span class="nt">-gt</span> <span class="s2">"</span><span class="nv">$BAR_WIDTH</span><span class="s2">"</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nv">filled</span><span class="o">=</span><span class="nv">$BAR_WIDTH</span>
  <span class="o">[</span> <span class="s2">"</span><span class="nv">$filled</span><span class="s2">"</span> <span class="nt">-lt</span> 0 <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nv">filled</span><span class="o">=</span>0
  <span class="nb">local </span><span class="nv">bar</span><span class="o">=</span><span class="s2">""</span>

  <span class="k">for</span> <span class="o">((</span> <span class="nv">i</span><span class="o">=</span>0<span class="p">;</span> i&lt;BAR_WIDTH<span class="p">;</span> i++ <span class="o">))</span><span class="p">;</span> <span class="k">do
    </span><span class="nb">local </span><span class="nv">pos</span><span class="o">=</span><span class="k">$((</span> i <span class="o">*</span> <span class="m">100</span> <span class="o">/</span> <span class="o">(</span>BAR_WIDTH <span class="o">-</span> <span class="m">1</span><span class="o">)</span> <span class="k">))</span>
    <span class="nb">local </span><span class="nv">r</span><span class="o">=</span><span class="k">$((</span> grad_low_r <span class="o">+</span> <span class="o">(</span>grad_high_r <span class="o">-</span> grad_low_r<span class="o">)</span> <span class="o">*</span> pos <span class="o">/</span> <span class="m">100</span> <span class="k">))</span>
    <span class="nb">local </span><span class="nv">g</span><span class="o">=</span><span class="k">$((</span> grad_low_g <span class="o">+</span> <span class="o">(</span>grad_high_g <span class="o">-</span> grad_low_g<span class="o">)</span> <span class="o">*</span> pos <span class="o">/</span> <span class="m">100</span> <span class="k">))</span>
    <span class="nb">local </span><span class="nv">b</span><span class="o">=</span><span class="k">$((</span> grad_low_b <span class="o">+</span> <span class="o">(</span>grad_high_b <span class="o">-</span> grad_low_b<span class="o">)</span> <span class="o">*</span> pos <span class="o">/</span> <span class="m">100</span> <span class="k">))</span>

    <span class="k">if</span> <span class="o">[</span> <span class="s2">"</span><span class="nv">$i</span><span class="s2">"</span> <span class="nt">-lt</span> <span class="s2">"</span><span class="nv">$filled</span><span class="s2">"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
      </span><span class="nv">bar</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">bar</span><span class="k">}</span><span class="si">$(</span>rgb <span class="nv">$r</span> <span class="nv">$g</span> <span class="nv">$b</span><span class="si">)</span><span class="s2">█"</span>
    <span class="k">else
      </span><span class="nv">bar</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">bar</span><span class="k">}</span><span class="se">\0</span><span class="s2">33[38;2;60;60;60m░"</span>
    <span class="k">fi
  done
  </span><span class="nb">printf</span> <span class="s1">'%b'</span> <span class="s2">"</span><span class="k">${</span><span class="nv">bar</span><span class="k">}${</span><span class="nv">RESET</span><span class="k">}</span><span class="s2">"</span>
<span class="o">}</span>

<span class="c"># ── Context bar: green→yellow→red ──</span>
<span class="k">if</span> <span class="o">[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$used</span><span class="s2">"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
  </span><span class="nv">used_int</span><span class="o">=</span><span class="si">$(</span><span class="nb">printf</span> <span class="s1">'%.0f'</span> <span class="s2">"</span><span class="nv">$used</span><span class="s2">"</span><span class="si">)</span>

  <span class="nv">ctx_bar</span><span class="o">=</span><span class="si">$(</span>draw_bar <span class="s2">"</span><span class="nv">$used_int</span><span class="s2">"</span> 0 200 80 220 20 0<span class="si">)</span>

  <span class="k">if</span> <span class="o">[</span> <span class="s2">"</span><span class="nv">$used_int</span><span class="s2">"</span> <span class="nt">-ge</span> 90 <span class="o">]</span><span class="p">;</span> <span class="k">then </span><span class="nv">status_emoji</span><span class="o">=</span><span class="s2">"🚨"</span>
  <span class="k">elif</span> <span class="o">[</span> <span class="s2">"</span><span class="nv">$used_int</span><span class="s2">"</span> <span class="nt">-ge</span> 70 <span class="o">]</span><span class="p">;</span> <span class="k">then </span><span class="nv">status_emoji</span><span class="o">=</span><span class="s2">"🔥"</span>
  <span class="k">elif</span> <span class="o">[</span> <span class="s2">"</span><span class="nv">$used_int</span><span class="s2">"</span> <span class="nt">-ge</span> 20 <span class="o">]</span><span class="p">;</span> <span class="k">then </span><span class="nv">status_emoji</span><span class="o">=</span><span class="s2">"⚡"</span>
  <span class="k">else </span><span class="nv">status_emoji</span><span class="o">=</span><span class="s2">"🟢"</span><span class="p">;</span> <span class="k">fi

  if</span> <span class="o">[</span> <span class="s2">"</span><span class="nv">$used_int</span><span class="s2">"</span> <span class="nt">-ge</span> 90 <span class="o">]</span><span class="p">;</span> <span class="k">then </span><span class="nv">pct_color</span><span class="o">=</span><span class="s2">"</span><span class="nv">$RED</span><span class="s2">"</span>
  <span class="k">elif</span> <span class="o">[</span> <span class="s2">"</span><span class="nv">$used_int</span><span class="s2">"</span> <span class="nt">-ge</span> 70 <span class="o">]</span><span class="p">;</span> <span class="k">then </span><span class="nv">pct_color</span><span class="o">=</span><span class="s2">"</span><span class="nv">$YELLOW</span><span class="s2">"</span>
  <span class="k">else </span><span class="nv">pct_color</span><span class="o">=</span><span class="s2">"</span><span class="nv">$GREEN</span><span class="s2">"</span><span class="p">;</span> <span class="k">fi

  </span><span class="nv">ctx_part</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">status_emoji</span><span class="k">}</span><span class="s2"> </span><span class="k">${</span><span class="nv">ctx_bar</span><span class="k">}</span><span class="s2"> </span><span class="k">${</span><span class="nv">pct_color</span><span class="k">}${</span><span class="nv">used_int</span><span class="k">}</span><span class="s2">%</span><span class="k">${</span><span class="nv">RESET</span><span class="k">}</span><span class="s2">"</span>
<span class="k">else
  </span><span class="nv">empty_bar</span><span class="o">=</span><span class="s2">""</span>
  <span class="k">for</span> <span class="o">((</span> <span class="nv">i</span><span class="o">=</span>0<span class="p">;</span> i&lt;BAR_WIDTH<span class="p">;</span> i++ <span class="o">))</span><span class="p">;</span> <span class="k">do
    </span><span class="nv">empty_bar</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">empty_bar</span><span class="k">}</span><span class="s2">░"</span>
  <span class="k">done
  </span><span class="nv">ctx_part</span><span class="o">=</span><span class="s2">"🟢 </span><span class="se">\0</span><span class="s2">33[38;2;60;60;60m</span><span class="k">${</span><span class="nv">empty_bar</span><span class="k">}${</span><span class="nv">RESET</span><span class="k">}</span><span class="s2"> --%"</span>
<span class="k">fi</span>

<span class="c"># ── First-party (auto/composer) + third-party (API) bars ──</span>
<span class="nv">usage_bars</span><span class="o">=</span><span class="s2">""</span>
<span class="k">if</span> <span class="o">[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$fp_pct</span><span class="s2">"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
  </span><span class="nv">fp_int</span><span class="o">=</span><span class="si">$(</span><span class="nb">printf</span> <span class="s1">'%.0f'</span> <span class="s2">"</span><span class="nv">$fp_pct</span><span class="s2">"</span><span class="si">)</span>
  <span class="nv">fp_bar</span><span class="o">=</span><span class="si">$(</span>draw_bar <span class="s2">"</span><span class="nv">$fp_int</span><span class="s2">"</span> 150 50 50 220 0 0<span class="si">)</span>

  <span class="k">if</span> <span class="o">[</span> <span class="s2">"</span><span class="nv">$fp_int</span><span class="s2">"</span> <span class="nt">-ge</span> 90 <span class="o">]</span><span class="p">;</span> <span class="k">then </span><span class="nv">pct_fp_color</span><span class="o">=</span><span class="s2">"</span><span class="nv">$RED</span><span class="s2">"</span>
  <span class="k">elif</span> <span class="o">[</span> <span class="s2">"</span><span class="nv">$fp_int</span><span class="s2">"</span> <span class="nt">-ge</span> 70 <span class="o">]</span><span class="p">;</span> <span class="k">then </span><span class="nv">pct_fp_color</span><span class="o">=</span><span class="s2">"</span><span class="nv">$YELLOW</span><span class="s2">"</span>
  <span class="k">else </span><span class="nv">pct_fp_color</span><span class="o">=</span><span class="s2">"</span><span class="nv">$GREEN</span><span class="s2">"</span><span class="p">;</span> <span class="k">fi

  </span><span class="nv">usage_bars</span><span class="o">=</span><span class="s2">"🏠 </span><span class="k">${</span><span class="nv">fp_bar</span><span class="k">}</span><span class="s2"> </span><span class="k">${</span><span class="nv">pct_fp_color</span><span class="k">}${</span><span class="nv">fp_int</span><span class="k">}</span><span class="s2">%</span><span class="k">${</span><span class="nv">RESET</span><span class="k">}</span><span class="s2">"</span>
<span class="k">fi
if</span> <span class="o">[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$tp_pct</span><span class="s2">"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
  </span><span class="nv">tp_int</span><span class="o">=</span><span class="si">$(</span><span class="nb">printf</span> <span class="s1">'%.0f'</span> <span class="s2">"</span><span class="nv">$tp_pct</span><span class="s2">"</span><span class="si">)</span>
  <span class="nv">tp_bar</span><span class="o">=</span><span class="si">$(</span>draw_bar <span class="s2">"</span><span class="nv">$tp_int</span><span class="s2">"</span> 150 80 150 220 80 220<span class="si">)</span>

  <span class="k">if</span> <span class="o">[</span> <span class="s2">"</span><span class="nv">$tp_int</span><span class="s2">"</span> <span class="nt">-ge</span> 90 <span class="o">]</span><span class="p">;</span> <span class="k">then </span><span class="nv">pct_tp_color</span><span class="o">=</span><span class="s2">"</span><span class="nv">$RED</span><span class="s2">"</span>
  <span class="k">elif</span> <span class="o">[</span> <span class="s2">"</span><span class="nv">$tp_int</span><span class="s2">"</span> <span class="nt">-ge</span> 70 <span class="o">]</span><span class="p">;</span> <span class="k">then </span><span class="nv">pct_tp_color</span><span class="o">=</span><span class="s2">"</span><span class="nv">$YELLOW</span><span class="s2">"</span>
  <span class="k">else </span><span class="nv">pct_tp_color</span><span class="o">=</span><span class="s2">"</span><span class="nv">$GREEN</span><span class="s2">"</span><span class="p">;</span> <span class="k">fi

  </span><span class="nv">usage_bars</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">usage_bars</span>:+<span class="nv">$usage_bars</span><span class="p"> </span><span class="k">}</span><span class="s2">🌐 </span><span class="k">${</span><span class="nv">tp_bar</span><span class="k">}</span><span class="s2"> </span><span class="k">${</span><span class="nv">pct_tp_color</span><span class="k">}${</span><span class="nv">tp_int</span><span class="k">}</span><span class="s2">%</span><span class="k">${</span><span class="nv">RESET</span><span class="k">}</span><span class="s2">"</span>
<span class="k">fi</span>

<span class="c"># ── Single line: model | context | 1P/3P usage | repo/branch ──</span>
<span class="nv">out</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">MAGENTA</span><span class="k">}</span><span class="s2">🤖 </span><span class="k">${</span><span class="nv">model</span><span class="k">}${</span><span class="nv">RESET</span><span class="k">}</span><span class="s2">"</span>
<span class="nv">out</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">out</span><span class="k">}</span><span class="s2"> </span><span class="k">${</span><span class="nv">DIM</span><span class="k">}</span><span class="s2">|</span><span class="k">${</span><span class="nv">RESET</span><span class="k">}</span><span class="s2"> </span><span class="k">${</span><span class="nv">ctx_part</span><span class="k">}</span><span class="s2">"</span>
<span class="o">[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$usage_bars</span><span class="s2">"</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nv">out</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">out</span><span class="k">}</span><span class="s2"> </span><span class="k">${</span><span class="nv">DIM</span><span class="k">}</span><span class="s2">|</span><span class="k">${</span><span class="nv">RESET</span><span class="k">}</span><span class="s2"> </span><span class="k">${</span><span class="nv">usage_bars</span><span class="k">}</span><span class="s2">"</span>

<span class="k">if</span> <span class="o">[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$repo</span><span class="s2">"</span> <span class="o">]</span> <span class="o">||</span> <span class="o">[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$branch</span><span class="s2">"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
  </span><span class="nv">out</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">out</span><span class="k">}</span><span class="s2"> </span><span class="k">${</span><span class="nv">DIM</span><span class="k">}</span><span class="s2">|</span><span class="k">${</span><span class="nv">RESET</span><span class="k">}</span><span class="s2">"</span>
  <span class="o">[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$repo</span><span class="s2">"</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nv">out</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">out</span><span class="k">}</span><span class="s2"> </span><span class="k">${</span><span class="nv">BOLD</span><span class="k">}${</span><span class="nv">YELLOW</span><span class="k">}${</span><span class="nv">repo</span><span class="k">}${</span><span class="nv">RESET</span><span class="k">}</span><span class="s2">"</span>
  <span class="o">[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$branch</span><span class="s2">"</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nv">out</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">out</span><span class="k">}${</span><span class="nv">repo</span>:+<span class="p"> </span><span class="k">}${</span><span class="nv">BOLD</span><span class="k">}${</span><span class="nv">CYAN</span><span class="k">}</span><span class="s2">🌿 (</span><span class="k">${</span><span class="nv">branch</span><span class="k">}</span><span class="s2">)</span><span class="k">${</span><span class="nv">RESET</span><span class="k">}</span><span class="s2">"</span>
<span class="k">fi

</span><span class="nb">printf</span> <span class="s1">'%b'</span> <span class="s2">"</span><span class="nv">$out</span><span class="s2">"</span>
</code></pre></div></div>]]></content><author><name>Tachi</name></author><category term="blog" /><category term="announcement" /><category term="ai" /><category term="cursor" /><category term="claude-code" /><category term="harness-engineering" /><category term="developer-tools" /><summary type="html"><![CDATA[The Claude Code and Cursor CLI status lines we actually run, on macOS and Linux, with the GNU vs BSD adaptations and copy-paste scripts plus the statusLine config blocks.]]></summary></entry><entry><title type="html">The Gate Lodge</title><link href="https://blog.cani.ne.jp/2026/08/22/the-gate-lodge.html" rel="alternate" type="text/html" title="The Gate Lodge" /><published>2026-08-22T00:00:00+00:00</published><updated>2026-09-05T22:52:39+00:00</updated><id>https://blog.cani.ne.jp/2026/08/22/the-gate-lodge</id><content type="html" xml:base="https://blog.cani.ne.jp/2026/08/22/the-gate-lodge.html"><![CDATA[<p>The house already had a router we liked: an Asus running <a href="https://www.asuswrt-merlin.net/">Merlin</a>, doing Wi-Fi, DHCP, and NAT for <code class="language-plaintext highlighter-rouge">192.168.1.0/24</code>, the same LAN as the <a href="/2026/01/17/all-it-took-was-broken-firmware.html">IoT isolation</a> and <a href="/2026/06/20/incidental-router.html">the OpenWrt rebuild of that isolation</a>.</p>

<p>The inbound VPN was <a href="https://openvpn.net/">OpenVPN</a> on that Asus. It dropped, sometimes in step with WAN failover, sometimes on its own; we never pinned which. It was slow: it shared the router’s CPU and RAM with Wi-Fi, DHCP, and NAT. Merlin’s OpenVPN was old enough that it still wanted <a href="https://community.openvpn.net/openvpn/wiki/Compression">compression</a> after upstream had deprecated it, and newer clients struggled. The web UI was a short form. There were probably more problems we never named.</p>

<p>We wanted three things. Every client connects, every time. More throughput than a process fighting the edge for RAM. Restart the VPN without restarting the house.</p>

<p><a href="https://www.wireguard.com/">WireGuard</a> went on a second box, as a host on the LAN: no DHCP, no NAT, radios off. One address on the home subnet, a private range that exists only inside the tunnel (the overlay), one UDP port forwarded from the WAN, one static route back. Full tunnel: all of the client’s traffic, not just packets for the house. Phones on cellular, laptops on cafe Wi-Fi, the LAN, DNS through the existing <a href="https://pi-hole.net/">Pi-hole</a>. Reboot <code class="language-plaintext highlighter-rouge">gate-lodge</code> and the Asus keeps serving Wi-Fi.</p>

<div class="polaroid-container">
  <div class="polaroid">
    <a href="gate-lodge-cabinet.jpg">
      <img src="gate-lodge-cabinet.jpg" alt="The OpenWrt One standing in the network cabinet, Ethernet in the 2.5G jack." class="polaroid-image" />
    </a>
    <div class="polaroid-title">Radios off. In the cabinet.</div>
    <div class="polaroid-link">
      
      &nbsp;
      
    </div>
    <small class="polaroid-archive">
      
      &nbsp;
      
    </small>
  </div>
</div>

<p>If you want that, keep this tab open. The paste is <a href="#release-image-to-a-tunnel">Release Image to a Tunnel</a>: first boot through a working client. Everything between here and there is why that paste is picky. Skip it if you want; come back when a command looks too careful.</p>

<h2 id="the-overlay-lives-behind-253">The Overlay Lives Behind .253</h2>

<figure class="mermaid-diagram">
<style>
.mermaid-diagram__light { display: inline; }
.mermaid-diagram__dark { display: none; }
@media (prefers-color-scheme: dark) {
  .mermaid-diagram__light { display: none; }
  .mermaid-diagram__dark { display: inline; }
}
</style>
<a class="mermaid-diagram__light" href="/assets/svg/8f6946a6.svg"><img src="/assets/svg/8f6946a6.svg" alt="Mermaid Diagram" /></a>
<a class="mermaid-diagram__dark" href="/assets/svg/8f6946a6-dark.svg"><img src="/assets/svg/8f6946a6-dark.svg" alt="Mermaid Diagram" /></a>
</figure>

<p>The public surface is one UDP port, forwarded to the VPN box. Everything inside the tunnel is private addressing. Return traffic for the overlay is a LAN static route on the edge router: <code class="language-plaintext highlighter-rouge">192.168.101.0/24</code> lives behind <code class="language-plaintext highlighter-rouge">192.168.1.253</code>.</p>

<p>That route only works if overlay packets still <em>look like</em> overlay packets when they hit the LAN. <a href="https://wiki.nftables.org/wiki-nftables/index.php/Performing_Network_Address_Translation_%28NAT%29#Masquerading">Masquerade</a> on the VPN box would rewrite them to the box’s home address, and the route would have nothing to match. The handshake can still go green while the house stays unreachable. <a href="#masquerade-eats-the-return-path">Masquerade is the bug that looks like a fix</a>.</p>

<h2 id="banana-pi-builds-it-openwrt-owns-it">Banana Pi Builds It, OpenWrt Owns It</h2>

<p>The board is an <a href="https://openwrt.org/toh/openwrt/one">OpenWrt One</a>: MediaTek Filogic 820, 2.5G WAN, 1G LAN, Wi-Fi 6 we never turned on, USB-C serial on the front. Two flash chips: NAND holds the live OS, NOR holds recovery. <a href="https://docs.banana-pi.org/en/OpenWRT-One/BananaPi_OpenWRT-One.html">Banana Pi</a> manufactures and sells it; the <a href="https://openwrt.org/">OpenWrt</a> project designed it, and a slice of each sale goes to the <a href="https://sfconservancy.org/">Software Freedom Conservancy</a> earmarked for OpenWrt.</p>

<h2 id="nested-then-a-host">Nested, Then a Host</h2>

<p>Stock OpenWrt wants to be a router. Fresh out of the box the OpenWrt One serves <code class="language-plaintext highlighter-rouge">192.168.1.1</code> on the 1G jack, DHCP on, WAN masquerading. Plug that jack into a house that already uses <code class="language-plaintext highlighter-rouge">192.168.1.0/24</code> and you get a small religious war over who is <code class="language-plaintext highlighter-rouge">.1</code>.</p>

<p>We let it be a router for a day, on purpose, on a <em>side</em> net. Home on <code class="language-plaintext highlighter-rouge">eth0</code> (the 2.5G jack) via DHCP from the Asus. A tiny LAN on <code class="language-plaintext highlighter-rouge">eth1</code> (<code class="language-plaintext highlighter-rouge">br-lan</code>) at <code class="language-plaintext highlighter-rouge">192.168.67.1/24</code>. OpenWrt calls the home-facing jack <code class="language-plaintext highlighter-rouge">wan</code> even though, from the internet’s point of view, it is just another LAN host; punching SSH from the <code class="language-plaintext highlighter-rouge">wan</code> zone is punching it from the house, not from the world. A spare laptop on the 1G jack if that punch failed. Radios off. Nested NAT on, because a nested router NATs; that masquerade is temporary and it comes off later.</p>

<figure class="mermaid-diagram">
<style>
.mermaid-diagram__light { display: inline; }
.mermaid-diagram__dark { display: none; }
@media (prefers-color-scheme: dark) {
  .mermaid-diagram__light { display: none; }
  .mermaid-diagram__dark { display: inline; }
}
</style>
<a class="mermaid-diagram__light" href="/assets/svg/5cff413c.svg"><img src="/assets/svg/5cff413c.svg" alt="Mermaid Diagram" /></a>
<a class="mermaid-diagram__dark" href="/assets/svg/5cff413c-dark.svg"><img src="/assets/svg/5cff413c-dark.svg" alt="Mermaid Diagram" /></a>
</figure>

<p>Then we converted it to a host. Static <code class="language-plaintext highlighter-rouge">192.168.1.253/24</code> on <code class="language-plaintext highlighter-rouge">eth0</code>, default via <code class="language-plaintext highlighter-rouge">192.168.1.1</code>, DNS to Pi-hole. <code class="language-plaintext highlighter-rouge">network.lan</code> proto <code class="language-plaintext highlighter-rouge">none</code>. DHCP off. The <code class="language-plaintext highlighter-rouge">lan</code> → <code class="language-plaintext highlighter-rouge">wan</code> forward deleted. WAN masquerade <strong>off</strong>. The 1G jack stays dark: no DHCP, no management net. If the OS dies, recovery is USB-C serial or the OpenWrt One’s factory/NOR path on that jack.</p>

<p>Skip the nested day and you can still get there from serial, or from a laptop on the 1G jack at the factory address before you join the house. Nested is the version with a door if you brick the home-facing side. Both paths are in the paste below.</p>

<p><a href="https://openwrt.org/docs/guide-user/security/dropbear.public-key.auth">Dropbear</a> is OpenWrt’s SSH server. It reads <code class="language-plaintext highlighter-rouge">/etc/dropbear/authorized_keys</code>. A modern <code class="language-plaintext highlighter-rouge">ssh-copy-id</code> will cheerfully write <code class="language-plaintext highlighter-rouge">~/.ssh/authorized_keys</code> and you will wonder why the key you just installed does nothing. Copy the line into the Dropbear file. I lost a round to that.</p>

<h2 id="packagesgz-was-404">Packages.gz Was 404</h2>

<p>The image on the box was a nightly SNAPSHOT: kernel <code class="language-plaintext highlighter-rouge">6.6.57</code>, identified as an OpenWrt One, no <code class="language-plaintext highlighter-rouge">kmod-wireguard</code>, no <code class="language-plaintext highlighter-rouge">tun</code>. Current snapshot <code class="language-plaintext highlighter-rouge">opkg</code> <code class="language-plaintext highlighter-rouge">Packages.gz</code> was HTTP 404. Release kernel modules are built against other kernels. <code class="language-plaintext highlighter-rouge">--force</code> would have been a way to install a module that could not load.</p>

<p>We did not force it. <a href="https://openwrt.org/docs/guide-user/installation/generic.sysupgrade">Keep-settings sysupgrade</a> to official <strong>24.10.8</strong> (<code class="language-plaintext highlighter-rouge">r29233-443ec4032a</code>, kernel <code class="language-plaintext highlighter-rouge">6.6.144</code>), image <code class="language-plaintext highlighter-rouge">openwrt-24.10.8-mediatek-filogic-openwrt_one-squashfs-sysupgrade.itb</code>. No factory reflash, no vendor image, no <code class="language-plaintext highlighter-rouge">--force</code>. Hostname, <code class="language-plaintext highlighter-rouge">.253</code>, Dropbear keys, and the WAN default all survived. Then:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>opkg update
opkg <span class="nb">install </span>kmod-wireguard wireguard-tools luci-proto-wireguard
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">kmod-wireguard</code> came in as <code class="language-plaintext highlighter-rouge">6.6.144-r1</code>. After the first install, <code class="language-plaintext highlighter-rouge">ifup wg0</code> left netifd (OpenWrt’s network daemon) on proto <code class="language-plaintext highlighter-rouge">none</code> / <code class="language-plaintext highlighter-rouge">NO_DEVICE</code>. The proto script was new to the running daemon. <code class="language-plaintext highlighter-rouge">/etc/init.d/network restart</code> attached it. Later peer edits: <code class="language-plaintext highlighter-rouge">ifup wg0</code> is enough.</p>

<p>Flash a <strong>release</strong>. Snapshots are for people who enjoy 404s on the day they need a tunnel.</p>

<h2 id="192168101-was-empty">192.168.101 Was Empty</h2>

<p>Overlay is <code class="language-plaintext highlighter-rouge">192.168.101.0/24</code>. Home stays <code class="language-plaintext highlighter-rouge">192.168.1.0/24</code>.</p>

<p>Do not pick <code class="language-plaintext highlighter-rouge">10.8.0.0/24</code> for the overlay, even after OpenVPN is gone: it is still the default on networks you will join. A <a href="https://learn.microsoft.com/en-us/windows/wsl/networking">WSL</a> virtual net on a PC that might itself be a client is the same kind of collision. We briefly tried <code class="language-plaintext highlighter-rouge">10.72.1.0/24</code> and dropped it because <code class="language-plaintext highlighter-rouge">72.0.0.0/8</code> is public and a tired eye can lose the <code class="language-plaintext highlighter-rouge">10.</code>. Pick an overlay that is free on <em>your</em> LAN, on your VPN clients’ other nets, and in <a href="https://datatracker.ietf.org/doc/html/rfc1918">RFC 1918</a>.</p>

<h2 id="cut-over-then-listen">Cut Over, Then Listen</h2>

<p>We dumped the old OpenVPN pool, disabled OpenVPN, <em>then</em> stood up WireGuard. Two inbound VPNs is two stories about which prefix a client has today. If you still need the old VPN, invert that: stand up WireGuard first, prove a client, <em>then</em> disable OpenVPN. We were done with OpenVPN. You might not be.</p>

<p>On the Asus: a LAN static route for the overlay via <code class="language-plaintext highlighter-rouge">192.168.1.253</code>, metric left empty, interface LAN. UDP forward of the listen port to <code class="language-plaintext highlighter-rouge">192.168.1.253</code>. OpenVPN off. Reboot the Asus once, because consumer firmware likes to be asked twice. The edge still does NAT, Wi-Fi, and DHCP. It gets a route and a hole. Any edge that can do a LAN static route and a UDP port forward will do; Merlin is just what this house has.</p>

<p>On the VPN box, <code class="language-plaintext highlighter-rouge">wg0</code> is <code class="language-plaintext highlighter-rouge">192.168.101.1/24</code>, proto <code class="language-plaintext highlighter-rouge">wireguard</code>, listen on that same UDP port. Keys generated on the box, under <code class="language-plaintext highlighter-rouge">/etc/wireguard/</code>. Do not copy private keys into a repository, a chat, or a blog post. Firewall zone <code class="language-plaintext highlighter-rouge">vpn</code> on <code class="language-plaintext highlighter-rouge">wg0</code>, masquerade <strong>0</strong> on both <code class="language-plaintext highlighter-rouge">wan</code> and <code class="language-plaintext highlighter-rouge">vpn</code>. Forward <code class="language-plaintext highlighter-rouge">vpn</code> → <code class="language-plaintext highlighter-rouge">wan</code> and <code class="language-plaintext highlighter-rouge">wan</code> → <code class="language-plaintext highlighter-rouge">vpn</code>. A rule <code class="language-plaintext highlighter-rouge">Allow-WG</code>: UDP dest that port, source zone <code class="language-plaintext highlighter-rouge">wan</code> (the house, not the internet).</p>

<p><a href="https://openwrt.org/docs/guide-user/luci/start">LuCI</a> (OpenWrt’s web UI) listened the whole time. WAN input is REJECT, and SSH was the only thing already punched, so I added <code class="language-plaintext highlighter-rouge">Allow-LuCI-from-home</code> (TCP 80/443 from <code class="language-plaintext highlighter-rouge">wan</code>) after converting to a host. The zone was the lock. The Asus is what faces the world; it forwards one UDP port and does not forward 22, 80, 443, or 53.</p>

<div class="polaroid-container">
  <div class="polaroid">
    <a href="gate-lodge-luci-wg0.png">
      <img src="gate-lodge-luci-wg0.png" alt="LuCI interface status for wg0: WireGuard VPN, IPv4 192.168.101.1/24." class="polaroid-image" />
    </a>
    <div class="polaroid-title">LuCI: wg0 is up.</div>
    <div class="polaroid-link">
      
      &nbsp;
      
    </div>
    <small class="polaroid-archive">
      
      &nbsp;
      
    </small>
  </div>
</div>

<h2 id="masquerade-eats-the-return-path">Masquerade Eats the Return Path</h2>

<p>A green handshake means the UDP hole works. It does not mean the inner packets have a way home.</p>

<p>The wrong extra click is WAN <code class="language-plaintext highlighter-rouge">masq=1</code> “so the internet works.” That SNATs overlay sources onto <code class="language-plaintext highlighter-rouge">192.168.1.253</code>. The Asus then sees house-LAN traffic from the VPN box’s own address. The overlay route never matches. LAN replies go missing, or they go to the box and stop. You will stare at <code class="language-plaintext highlighter-rouge">wg show</code> and a pile of RX/TX and a Pi-hole that never saw the query.</p>

<p>Leave <code class="language-plaintext highlighter-rouge">srcnat</code> empty. Confirm it: <code class="language-plaintext highlighter-rouge">fw4 print</code> (OpenWrt’s firewall compiler) should not masquerade <code class="language-plaintext highlighter-rouge">192.168.101.0/24</code> onto the home IP. From a machine already on the house LAN, <code class="language-plaintext highlighter-rouge">ping 192.168.101.1</code>. Ours came back in one hop through the Asus. That ping is the overlay-return probe. If it fails, fix the edge route and the box’s default via <code class="language-plaintext highlighter-rouge">192.168.1.1</code> before you add a client.</p>

<figure class="mermaid-diagram">
<style>
.mermaid-diagram__light { display: inline; }
.mermaid-diagram__dark { display: none; }
@media (prefers-color-scheme: dark) {
  .mermaid-diagram__light { display: none; }
  .mermaid-diagram__dark { display: inline; }
}
</style>
<a class="mermaid-diagram__light" href="/assets/svg/c9434827.svg"><img src="/assets/svg/c9434827.svg" alt="Mermaid Diagram" /></a>
<a class="mermaid-diagram__dark" href="/assets/svg/c9434827-dark.svg"><img src="/assets/svg/c9434827-dark.svg" alt="Mermaid Diagram" /></a>
</figure>

<p>Do not test “am I on the VPN” by loading the edge router’s admin UI. Pick Pi-hole, or another LAN host, or a public-IP check that should show the house WAN. The edge’s own web server is a special case and a time sink.</p>

<h2 id="a-peer-with-no-address-never-loads">A Peer With No Address Never Loads</h2>

<p>I watched LuCI <strong>Generate configuration</strong> without <strong>Save &amp; Apply</strong> leave zero peers on a box whose UI claimed otherwise. WireGuard has no DHCP. Each device is a peer with its own keypair and a <code class="language-plaintext highlighter-rouge">/32</code> on the overlay. <code class="language-plaintext highlighter-rouge">.1</code> is the box. The rest you assign. One address per device; a phone and a laptop are two peers even if they share a human.</p>

<p>Give the house WAN a DNS name. That name belongs in the <em>client</em> file as <code class="language-plaintext highlighter-rouge">Endpoint</code>, not as a peer on the server. I added it as a LuCI peer anyway. Generate configuration then used the LAN IP as the endpoint, put <code class="language-plaintext highlighter-rouge">192.168.101.0/24</code> in <code class="language-plaintext highlighter-rouge">Address</code>, and copied the listen port onto the client. Delete that row. Peers are devices; generate keys on a <em>client</em> row. Endpoint host and port on the server stay empty: the phone’s cell IP changes, the server only listens.</p>

<p>LuCI labels peer Allowed IPs optional. Leave them blank and <code class="language-plaintext highlighter-rouge">wg</code> never loads the peer; <code class="language-plaintext highlighter-rouge">wg show</code> will not list it. Set <code class="language-plaintext highlighter-rouge">192.168.101.x/32</code> and turn <strong>Route Allowed IPs</strong> on. Then <strong>Save &amp; Apply</strong>. Generate configuration <em>after</em> that.</p>

<p>On the client, <code class="language-plaintext highlighter-rouge">AllowedIPs = 0.0.0.0/0, ::/0</code> is the full tunnel. Keepalive 25 for phones behind NAT. DNS is Pi-hole at <code class="language-plaintext highlighter-rouge">192.168.1.254</code>. Overlay addressing on <code class="language-plaintext highlighter-rouge">wg0</code> in this house is IPv4-only; <code class="language-plaintext highlighter-rouge">::/0</code> is leak prevention for the client’s other stacks, not an invitation to put IPv6 on the home LAN. We won’t build NAT66 until a client actually stalls on AAAA.</p>

<div class="polaroid-container">
  <div class="polaroid">
    <a href="gate-lodge-luci-export.png">
      <img src="gate-lodge-luci-export.png" alt="LuCI Generate configuration for a WireGuard client peer, with vpn.example.com as Endpoint and a /32 overlay address. Keys redacted." class="polaroid-image" />
    </a>
    <div class="polaroid-title">Generate configuration for a client. Endpoint is a hostname; Address is a /32.</div>
    <div class="polaroid-link">
      
      &nbsp;
      
    </div>
    <small class="polaroid-archive">
      
      &nbsp;
      
    </small>
  </div>
</div>

<p>Official <a href="https://www.wireguard.com/install/">WireGuard</a> apps on Windows, Mac, Linux, and iPhone. Import the same <code class="language-plaintext highlighter-rouge">.conf</code>.</p>

<h2 id="pi-hole-thinks-overlay-is-foreign">Pi-hole Thinks Overlay Is Foreign</h2>

<p>Handshake green, no websites: if a raw LAN IP loads and a name does not, the resolver never answered. Overlay packets arrive on the Pi-hole’s ethernet from <code class="language-plaintext highlighter-rouge">192.168.101.0/24</code>, and <a href="https://docs.pi-hole.net/ftldns/interfaces/">Pi-hole’s default listen policy</a> treats that prefix as foreign even though it shares a NIC with <code class="language-plaintext highlighter-rouge">192.168.1.0/24</code>.</p>

<p>Allow <code class="language-plaintext highlighter-rouge">192.168.101.0/24</code> (Pi-hole v6: Settings → DNS, or <code class="language-plaintext highlighter-rouge">pihole.toml</code> listen mode). Prefer an explicit CIDR. If the only control that works is “Permit all origins,” it is acceptable <strong>only</strong> while the edge does not forward TCP/UDP 53 from the WAN to the Pi-hole. Do not make a public resolver as a side effect of a VPN.</p>

<p>Point overlay DNS at Pi-hole, not at the VPN box, not at the Asus. If raw IPs fail too, it is routing, not DNS. Do not “fix” either one with <code class="language-plaintext highlighter-rouge">masq=1</code>. If you do not run Pi-hole, point <code class="language-plaintext highlighter-rouge">DNS</code> in the client file at whatever resolver you want inside the tunnel, and ignore this section.</p>

<h2 id="it-lives-in-the-cabinet-now">It Lives in the Cabinet Now</h2>

<p>Two phones reached the LAN on foreign Wi-Fi and on cellular far from the house. A laptop that isn’t ours did too. That is the exam: away, Wi-Fi off or someone else’s, Pi-hole in the browser, a home address that answers. Then we put the box in the network cabinet and the 1G jack stayed dark. SSH to <code class="language-plaintext highlighter-rouge">.253</code> still worked.</p>

<h2 id="release-image-to-a-tunnel">Release Image to a Tunnel</h2>

<p>This is the tab. Numbers match this house. Change the prefixes if yours collide. Commands are <a href="https://openwrt.org/docs/guide-user/base-system/uci">UCI</a> on OpenWrt 24.10; check <code class="language-plaintext highlighter-rouge">uci show firewall</code> before you delete anything by numeric index.</p>

<h3 id="what-you-need">What you need</h3>

<ul>
  <li>An existing home router you want to keep (Wi-Fi, DHCP, NAT). Ours is an Asus with Merlin. Any edge that can add a LAN static route and forward one UDP port will do.</li>
  <li>An OpenWrt box. Two ethernet jacks make the nested-join path easy (on the OpenWrt One: 2.5G faces the house, 1G is the temporary side net). One jack plus USB-C serial also works.</li>
  <li>Optional: Pi-hole on the home LAN.</li>
  <li>A UDP port you will forward from the WAN. The snippets use <code class="language-plaintext highlighter-rouge">51820</code> (WireGuard’s common default). Pick yours and use it in all four places: box listen port, edge forward, client <code class="language-plaintext highlighter-rouge">Endpoint</code>, <code class="language-plaintext highlighter-rouge">Allow-WG</code>.</li>
  <li>A DNS name pointed at the house WAN, written <code class="language-plaintext highlighter-rouge">vpn.example.com</code> below. A raw public IP works until it changes.</li>
</ul>

<h3 id="addresses-this-house">Addresses (this house)</h3>

<ul>
  <li>Home LAN <code class="language-plaintext highlighter-rouge">192.168.1.0/24</code>, edge <code class="language-plaintext highlighter-rouge">192.168.1.1</code></li>
  <li>Pi-hole <code class="language-plaintext highlighter-rouge">192.168.1.254</code> (do not forward WAN 53 to it)</li>
  <li>VPN box <code class="language-plaintext highlighter-rouge">192.168.1.253</code> on the 2.5G jack, hostname <code class="language-plaintext highlighter-rouge">gate-lodge</code></li>
  <li>Overlay <code class="language-plaintext highlighter-rouge">192.168.101.0/24</code>, server <code class="language-plaintext highlighter-rouge">192.168.101.1/24</code> on <code class="language-plaintext highlighter-rouge">wg0</code></li>
  <li>Nested side net, temporary: <code class="language-plaintext highlighter-rouge">192.168.67.1/24</code> on the 1G jack</li>
</ul>

<p>Do not pick <code class="language-plaintext highlighter-rouge">10.8.0.0/24</code> for the overlay, even if you just disabled OpenVPN.</p>

<p>Release image: <a href="https://firmware-selector.openwrt.org/?version=24.10.8&amp;target=mediatek%2Ffilogic&amp;id=openwrt_one">Firmware Selector</a>, <code class="language-plaintext highlighter-rouge">openwrt_one</code> squashfs-sysupgrade. Prefer a release over a SNAPSHOT.</p>

<h3 id="first-boot-nested">First boot, nested</h3>

<p>Do not plug the OpenWrt One’s 1G jack into a house that already uses <code class="language-plaintext highlighter-rouge">192.168.1.0/24</code>. Connect a laptop to that 1G jack instead. Factory is <code class="language-plaintext highlighter-rouge">http://192.168.1.1</code>, user <code class="language-plaintext highlighter-rouge">root</code>, empty password. Set a password before the box can see the house.</p>

<p>Readdress the side net, turn the radios off, and punch SSH from <code class="language-plaintext highlighter-rouge">wan</code> (the jack that will face the house):</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>uci <span class="nb">set </span>system.@system[0].hostname<span class="o">=</span><span class="s1">'gate-lodge'</span>
uci <span class="nb">set </span>network.lan.ipaddr<span class="o">=</span><span class="s1">'192.168.67.1'</span>
uci <span class="nb">set </span>network.lan.netmask<span class="o">=</span><span class="s1">'255.255.255.0'</span>

uci <span class="nb">set </span>wireless.radio0.disabled<span class="o">=</span><span class="s1">'1'</span>
uci <span class="nb">set </span>wireless.radio1.disabled<span class="o">=</span><span class="s1">'1'</span>

uci <span class="nb">set </span>firewall.allow_ssh<span class="o">=</span>rule
uci <span class="nb">set </span>firewall.allow_ssh.name<span class="o">=</span><span class="s1">'Allow-SSH-from-home'</span>
uci <span class="nb">set </span>firewall.allow_ssh.src<span class="o">=</span><span class="s1">'wan'</span>
uci <span class="nb">set </span>firewall.allow_ssh.proto<span class="o">=</span><span class="s1">'tcp'</span>
uci <span class="nb">set </span>firewall.allow_ssh.dest_port<span class="o">=</span><span class="s1">'22'</span>
uci <span class="nb">set </span>firewall.allow_ssh.target<span class="o">=</span><span class="s1">'ACCEPT'</span>

uci commit
reload_config
wifi down
</code></pre></div></div>

<p>The laptop, if it still has a lease, is now on <code class="language-plaintext highlighter-rouge">192.168.67.0/24</code>. Plug the 2.5G jack into the house. On the edge router, find the new DHCP lease. SSH to that address as root. Put your pubkey in <code class="language-plaintext highlighter-rouge">/etc/dropbear/authorized_keys</code>, not in <code class="language-plaintext highlighter-rouge">~/.ssh/authorized_keys</code>.</p>

<p>Skip this whole nested day if you would rather use the USB-C serial console and set <code class="language-plaintext highlighter-rouge">192.168.1.253</code> on <code class="language-plaintext highlighter-rouge">eth0</code> directly. Same host-conversion block either way.</p>

<h3 id="convert-to-host">Convert to host</h3>

<p>Retire the nested LAN. The box becomes a single address on the house:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>uci <span class="nb">set </span>network.wan.proto<span class="o">=</span><span class="s1">'static'</span>
uci <span class="nb">set </span>network.wan.device<span class="o">=</span><span class="s1">'eth0'</span>
uci <span class="nb">set </span>network.wan.ipaddr<span class="o">=</span><span class="s1">'192.168.1.253'</span>
uci <span class="nb">set </span>network.wan.netmask<span class="o">=</span><span class="s1">'255.255.255.0'</span>
uci <span class="nb">set </span>network.wan.gateway<span class="o">=</span><span class="s1">'192.168.1.1'</span>
uci add_list network.wan.dns<span class="o">=</span><span class="s1">'192.168.1.254'</span>

uci <span class="nb">set </span>network.lan.proto<span class="o">=</span><span class="s1">'none'</span>
uci <span class="nt">-q</span> delete network.lan.ipaddr
uci <span class="nt">-q</span> delete network.lan.netmask

uci <span class="nb">set </span>dhcp.lan.ignore<span class="o">=</span><span class="s1">'1'</span>
uci <span class="nb">set </span>dhcp.lan.dhcpv4<span class="o">=</span><span class="s1">'disabled'</span>
uci <span class="nb">set </span>dhcp.lan.dhcpv6<span class="o">=</span><span class="s1">'disabled'</span>
uci <span class="nb">set </span>dhcp.lan.ra<span class="o">=</span><span class="s1">'disabled'</span>

uci <span class="nb">set </span>firewall.@zone[1].masq<span class="o">=</span><span class="s1">'0'</span>
uci <span class="nt">-q</span> delete firewall.@forwarding[0]   <span class="c"># the stock lan → wan; check the index first</span>

uci commit
reload_config
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">uci show firewall</code> before you delete a forwarding by index. On a stock OpenWrt One, <code class="language-plaintext highlighter-rouge">wan</code> is often zone index 1; do not assume that if you have already added zones. After this, SSH to <code class="language-plaintext highlighter-rouge">192.168.1.253</code>. The 1G jack has no IPv4. Leave it that way.</p>

<p>If LuCI from home is useful:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>uci <span class="nb">set </span>firewall.allow_luci<span class="o">=</span>rule
uci <span class="nb">set </span>firewall.allow_luci.name<span class="o">=</span><span class="s1">'Allow-LuCI-from-home'</span>
uci <span class="nb">set </span>firewall.allow_luci.src<span class="o">=</span><span class="s1">'wan'</span>
uci <span class="nb">set </span>firewall.allow_luci.proto<span class="o">=</span><span class="s1">'tcp'</span>
uci add_list firewall.allow_luci.dest_port<span class="o">=</span><span class="s1">'80'</span>
uci add_list firewall.allow_luci.dest_port<span class="o">=</span><span class="s1">'443'</span>
uci <span class="nb">set </span>firewall.allow_luci.target<span class="o">=</span><span class="s1">'ACCEPT'</span>
uci commit firewall
fw4 reload
</code></pre></div></div>

<p>Do not forward 80 or 443 from the internet.</p>

<h3 id="release-then-wireguard">Release, then WireGuard</h3>

<p>If <code class="language-plaintext highlighter-rouge">opkg install kmod-wireguard</code> cannot find a package, stop. Flash the 24.10.8 <code class="language-plaintext highlighter-rouge">squashfs-sysupgrade.itb</code> rather than <code class="language-plaintext highlighter-rouge">--force</code> a module. Usual reasons are a SNAPSHOT whose feeds 404, or a kernel that does not match the release kmods; there are others. Copy the image to <code class="language-plaintext highlighter-rouge">/tmp</code> on the box and:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>sysupgrade <span class="nt">-v</span> /tmp/openwrt-24.10.8-mediatek-filogic-openwrt_one-squashfs-sysupgrade.itb
</code></pre></div></div>

<p>No <code class="language-plaintext highlighter-rouge">--force</code>. No factory image. Settings should keep. Then:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>opkg update
opkg <span class="nb">install </span>kmod-wireguard wireguard-tools luci-proto-wireguard

<span class="nb">mkdir</span> <span class="nt">-p</span> /etc/wireguard
<span class="nb">umask </span>077
wg genkey | <span class="nb">tee</span> /etc/wireguard/server.key | wg pubkey <span class="o">&gt;</span> /etc/wireguard/server.pub

uci <span class="nb">set </span>network.wg0<span class="o">=</span>interface
uci <span class="nb">set </span>network.wg0.proto<span class="o">=</span><span class="s1">'wireguard'</span>
uci <span class="nb">set </span>network.wg0.private_key<span class="o">=</span><span class="s2">"</span><span class="si">$(</span><span class="nb">cat</span> /etc/wireguard/server.key<span class="si">)</span><span class="s2">"</span>
uci add_list network.wg0.addresses<span class="o">=</span><span class="s1">'192.168.101.1/24'</span>
uci <span class="nb">set </span>network.wg0.listen_port<span class="o">=</span><span class="s1">'51820'</span>

uci <span class="nb">set </span>firewall.vpn<span class="o">=</span>zone
uci <span class="nb">set </span>firewall.vpn.name<span class="o">=</span><span class="s1">'vpn'</span>
uci <span class="nb">set </span>firewall.vpn.input<span class="o">=</span><span class="s1">'ACCEPT'</span>
uci <span class="nb">set </span>firewall.vpn.output<span class="o">=</span><span class="s1">'ACCEPT'</span>
uci <span class="nb">set </span>firewall.vpn.forward<span class="o">=</span><span class="s1">'REJECT'</span>
uci <span class="nb">set </span>firewall.vpn.masq<span class="o">=</span><span class="s1">'0'</span>
uci add_list firewall.vpn.network<span class="o">=</span><span class="s1">'wg0'</span>

uci <span class="nb">set </span>firewall.wg_fwd_out<span class="o">=</span>forwarding
uci <span class="nb">set </span>firewall.wg_fwd_out.src<span class="o">=</span><span class="s1">'vpn'</span>
uci <span class="nb">set </span>firewall.wg_fwd_out.dest<span class="o">=</span><span class="s1">'wan'</span>

uci <span class="nb">set </span>firewall.wg_fwd_in<span class="o">=</span>forwarding
uci <span class="nb">set </span>firewall.wg_fwd_in.src<span class="o">=</span><span class="s1">'wan'</span>
uci <span class="nb">set </span>firewall.wg_fwd_in.dest<span class="o">=</span><span class="s1">'vpn'</span>

uci <span class="nb">set </span>firewall.allow_wg<span class="o">=</span>rule
uci <span class="nb">set </span>firewall.allow_wg.name<span class="o">=</span><span class="s1">'Allow-WG'</span>
uci <span class="nb">set </span>firewall.allow_wg.src<span class="o">=</span><span class="s1">'wan'</span>
uci <span class="nb">set </span>firewall.allow_wg.proto<span class="o">=</span><span class="s1">'udp'</span>
uci <span class="nb">set </span>firewall.allow_wg.dest_port<span class="o">=</span><span class="s1">'51820'</span>
uci <span class="nb">set </span>firewall.allow_wg.target<span class="o">=</span><span class="s1">'ACCEPT'</span>

uci commit
/etc/init.d/network restart
</code></pre></div></div>

<p>Confirm: <code class="language-plaintext highlighter-rouge">wg show</code> listens. WAN masquerade is still <code class="language-plaintext highlighter-rouge">0</code>. <code class="language-plaintext highlighter-rouge">fw4 print</code> has no srcnat of the overlay.</p>

<h3 id="edge-router">Edge router</h3>

<p>LAN static route for the overlay. Metric left empty, interface LAN.</p>

<table>
  <thead>
    <tr>
      <th>Network/Host IP</th>
      <th>Netmask</th>
      <th>Gateway</th>
      <th>Interface</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>192.168.101.0/24</td>
      <td>255.255.255.0</td>
      <td>192.168.1.253</td>
      <td>LAN</td>
    </tr>
  </tbody>
</table>

<p>Forward UDP <code class="language-plaintext highlighter-rouge">51820</code> to <code class="language-plaintext highlighter-rouge">192.168.1.253:51820</code>. Disable the old VPN after a client works, unless you are also done with it and willing to cut first. Do not forward 22, 53, 80, or 443 from the internet.</p>

<p>From a house machine, <code class="language-plaintext highlighter-rouge">ping 192.168.101.1</code>. That is the overlay-return probe. If it fails, do not add a client yet.</p>

<h3 id="one-client">One client</h3>

<p>On a trusted machine with <code class="language-plaintext highlighter-rouge">wg</code>:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">umask </span>077
wg genkey | <span class="nb">tee </span>client.key | wg pubkey <span class="o">&gt;</span> client.pub
</code></pre></div></div>

<p>On the box, public key only, next free <code class="language-plaintext highlighter-rouge">/32</code> (<code class="language-plaintext highlighter-rouge">.1</code> is the box; do not reuse a live one):</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>uci add network wireguard_wg0
uci <span class="nb">set </span>network.@wireguard_wg0[-1].description<span class="o">=</span><span class="s1">'example-phone'</span>
uci <span class="nb">set </span>network.@wireguard_wg0[-1].public_key<span class="o">=</span><span class="s1">'PASTE_CLIENT_PUBKEY'</span>
uci <span class="nb">set </span>network.@wireguard_wg0[-1].allowed_ips<span class="o">=</span><span class="s1">'192.168.101.2/32'</span>
uci <span class="nb">set </span>network.@wireguard_wg0[-1].route_allowed_ips<span class="o">=</span><span class="s1">'1'</span>
uci <span class="nb">set </span>network.@wireguard_wg0[-1].persistent_keepalive<span class="o">=</span><span class="s1">'25'</span>
uci commit network
ifup wg0
</code></pre></div></div>

<p>Client file. Private key stays on the device. <code class="language-plaintext highlighter-rouge">PublicKey</code> is the <em>server</em> pubkey from <code class="language-plaintext highlighter-rouge">/etc/wireguard/server.pub</code>. <code class="language-plaintext highlighter-rouge">vpn.example.com</code> is <code class="language-plaintext highlighter-rouge">Endpoint</code> here, not a peer on the box.</p>

<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[Interface]</span><span class="w">
</span><span class="py">PrivateKey</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">CLIENT_PRIVATE_KEY</span>
<span class="py">Address</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">192.168.101.2/32</span>
<span class="py">DNS</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">192.168.1.254</span>
<span class="w">
</span><span class="nn">[Peer]</span><span class="w">
</span><span class="py">PublicKey</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">SERVER_PUBLIC_KEY</span>
<span class="py">Endpoint</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">vpn.example.com:51820</span>
<span class="py">AllowedIPs</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">0.0.0.0/0, ::/0</span>
<span class="py">PersistentKeepalive</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">25</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">wg show</code> on the box must list that public key. If it does not, Allowed IPs were empty or you skipped Save &amp; Apply. Import the file in the <a href="https://www.wireguard.com/install/">official WireGuard app</a>, connect from cellular, load <code class="language-plaintext highlighter-rouge">http://192.168.1.254</code> (or some other home address, if you have no Pi-hole). Then delete <code class="language-plaintext highlighter-rouge">client.key</code> from the trusted machine.</p>

<h3 id="pi-hole">Pi-hole</h3>

<p>Allow queries from <code class="language-plaintext highlighter-rouge">192.168.101.0/24</code>. Keep WAN 53 closed.</p>

<h2 id="restart-the-box-not-the-house">Restart the Box, Not the House</h2>

<p>Clients connect. The tunnel is not sharing the Asus CPU with Wi-Fi. <code class="language-plaintext highlighter-rouge">network restart</code> on <code class="language-plaintext highlighter-rouge">gate-lodge</code> does not bounce the LAN. Keep the edge, put WireGuard on a host, and do not masquerade the people you just let in.</p>

<p>Sitting down on a cafe that already uses <code class="language-plaintext highlighter-rouge">192.168.1.0/24</code> is <a href="/2026/09/05/the-overlay-was-fine.html">the exam the overlay does not cover</a>.</p>]]></content><author><name>Niko</name></author><category term="blog" /><category term="record" /><category term="ai" /><category term="dns" /><category term="home-networking" /><category term="networking" /><category term="network-security" /><category term="openwrt" /><category term="pihole" /><category term="wireguard" /><summary type="html"><![CDATA[Keep the home router. Put WireGuard on a dedicated OpenWrt box. How we commissioned ours, and a paste from first boot to a working client.]]></summary></entry><entry><title type="html">The Times Can Sell; Reddit Cannot</title><link href="https://blog.cani.ne.jp/2026/08/13/the-times-can-sell-reddit-cannot.html" rel="alternate" type="text/html" title="The Times Can Sell; Reddit Cannot" /><published>2026-08-13T00:00:00+00:00</published><updated>2026-08-16T18:27:05+00:00</updated><id>https://blog.cani.ne.jp/2026/08/13/the-times-can-sell-reddit-cannot</id><content type="html" xml:base="https://blog.cani.ne.jp/2026/08/13/the-times-can-sell-reddit-cannot.html"><![CDATA[<p>On July 30, 2026, Reddit beat every estimate it had. Revenue up 61%, profit more than doubled - and the stock then had its <a href="https://finance.yahoo.com/markets/article/reddit-stock-tumbles-the-most-on-record-as-lack-of-new-ai-deals-us-daily-users-metric-disappoints-153413759.html">steepest one-day drop since the 2024 IPO</a>. Huffman wrote that search referrals were “choppy”, and said that in AI Overviews Reddit has “still yet to find that win-win.”<sup id="fnref:cnbc-reddit"><a href="#fn:cnbc-reddit" class="footnote" rel="footnote" role="doc-noteref">1</a></sup> In a <a href="https://www.cnbc.com/2026/07/30/reddit-ceo-says-googles-ai-overviews-cant-replace-10-blue-links-.html">same-day interview</a>: “The 10 blue links have driven a tremendous amount of value for the whole ecosystem, and AI Overviews has yet to make a similar level of positive impact.”</p>

<p>The New York Times is <a href="https://en.wikipedia.org/wiki/The_New_York_Times_v._Microsoft_and_OpenAI">suing OpenAI</a> and has, <a href="https://gizmodo.com/major-publishers-are-reportedly-considering-a-drastic-step-to-get-their-content-out-of-googles-ai-answers-2000788873">according to the Wall Street Journal</a>, discussed pointing <code class="language-plaintext highlighter-rouge">noindex</code> at itself - a directive that tells search engines to forget you exist. Rust adopted an <a href="https://blog.rust-lang.org/inside-rust/2026/08/05/rust-langrust-is-adopting-an-llm-policy/">LLM policy</a> that treats a pull request as a contributor-formation event, not a delivery of code. That is an actionable stance, not just a mood: it changes who the community is, and it only invites the humans who will play that game. <a href="https://codeberg.org">Codeberg</a> <a href="https://blog.codeberg.org/protecting-our-floss-commons-from-llms.html">voted 358 to 144</a> to stop hosting projects that “mostly consist of” LLM-written code.</p>

<p>These look like four different fights. They are the same machine, breaking on different sides.</p>

<h2 id="four-reactions">Four Reactions</h2>

<p>The Times is defending a distribution position. It metered the site in 2011, over a decade before any LLM crawler mattered: a deliberate trade of open reach for subscription revenue. The complaint now is that somebody else built a unified layer over the fragments and users prefer it. The current play is to chop the aggregator - sue, block, threaten to vanish from the index.</p>

<p>Reddit signed a <a href="https://thehill.com/policy/technology/4485295-reddit-gives-google-access-to-content-for-training-ai-models/">2024 deal worth about $60 million</a> a year to let Google train on its content, and has reportedly discussed <a href="https://gizmodo.com/major-publishers-are-reportedly-considering-a-drastic-step-to-get-their-content-out-of-googles-ai-answers-2000788873">shutting that access off</a>. Stack Overflow received <a href="https://devclass.com/2026/01/05/dramatic-drop-in-stack-overflow-questions-as-devs-look-elsewhere-for-help/">3,862 new questions in December 2025</a>, down 78% from the year before, at a site that peaked above 200,000 questions per month in early 2014. The <a href="https://blog.pragmaticengineer.com/stack-overflow-is-almost-dead/">decline started in that same year</a>, when moderation got aggressive and the site got unwelcoming; ChatGPT accelerated a slide that policy began.</p>

<p>Rust’s policy names the flywheel from the inside:</p>

<blockquote>
  <p>We treat PRs as an indication that someone is interested in joining our community and being mentored to work on future PRs. […] a polished PR no longer indicates that someone is likely to stick around for the long term.<sup id="fnref:fn-rust"><a href="#fn:fn-rust" class="footnote" rel="footnote" role="doc-noteref">2</a></sup></p>
</blockquote>

<p>Codeberg is a German nonprofit code forge with no ad model and no API licensing. In July 2026 its members voted to keep out the “development team of none”: one person and a statistical machine that “turns energy into code.”<sup id="fnref:fn-codeberg"><a href="#fn:fn-codeberg" class="footnote" rel="footnote" role="doc-noteref">3</a></sup> They are indifferent to <code class="language-plaintext highlighter-rouge">git clone</code>. Their crawler complaint is about method - bots walking every issue-filter permutation and every historical revision on donation-funded disks - and about ghost projects that consume resources as if they were full-fledged communities while throwing off no prestige.</p>

<p>Hang this date somewhere in your head: on September 15, 2026, <a href="https://blog.cloudflare.com/content-independence-day-ai-options/">Cloudflare will default-block Training and Agent bots</a> on ad pages for new domains. Because Googlebot is classified as Search <em>and</em> Training, that default is Googlebot-off unless the site owner opts out.</p>

<p>If this all looks like <a href="/2026/03/01/pink-margarine.html">Pink Margarine</a> - incumbents thrashing about, dyeing the substitute so nobody will eat it, grasping at straws to stay relevant against the inexorable march of technological advancement - hold that for a bit.</p>

<h2 id="the-machine">The Machine</h2>

<p>Every one of these platforms is a hub with three loops.</p>

<p>Consumers give attention and get the thing: articles, answers, working code. Producers give content or presence that attracts consumers, and get fame or fortune - reputation, karma, salary, doors opening. (Fame and fortune convert into each other, though at varying exchange rates.) In the middle sits a converter: the platform’s secret sauce that turns plentiful consumer-side input into platform sustenance plus what producers want, and then skims a cut for itself. Ads, subscriptions, hosting quotas, brand attention: the mechanics vary. The principle does not.</p>

<p>Some flywheels also pay producers in <em>each other</em>: Social dues. Community. “You’re not alone.” Anywhere there’s a group of contributors paid in reputation, the human social instinct kicks in and a sense of community can scaffold around it. That is <strong>fuel</strong>.</p>

<h3 id="how-they-ran">How They Ran</h3>

<p>The New York Times is the clean picture: readers give attention and money, get articles. The converter turns that into payroll for all involved by laying some ad revenue on top. Writers with editors, fact-checking pipelines, and a voice they spent a career on, supply the articles that draw the next readers.</p>

<figure class="mermaid-diagram">
<style>
.mermaid-diagram__light { display: inline; }
.mermaid-diagram__dark { display: none; }
@media (prefers-color-scheme: dark) {
  .mermaid-diagram__light { display: none; }
  .mermaid-diagram__dark { display: inline; }
}
</style>
<a class="mermaid-diagram__light" href="/assets/svg/dac8851e.svg"><img src="/assets/svg/dac8851e.svg" alt="Mermaid Diagram" /></a>
<a class="mermaid-diagram__dark" href="/assets/svg/dac8851e-dark.svg"><img src="/assets/svg/dac8851e-dark.svg" alt="Mermaid Diagram" /></a>
</figure>

<p>Reddit and Stack Overflow are the same topology with a different producer currency. The converter skims ads and premium platform service fees and API deals. Posters get reputation.</p>

<figure class="mermaid-diagram">
<style>
.mermaid-diagram__light { display: inline; }
.mermaid-diagram__dark { display: none; }
@media (prefers-color-scheme: dark) {
  .mermaid-diagram__light { display: none; }
  .mermaid-diagram__dark { display: inline; }
}
</style>
<a class="mermaid-diagram__light" href="/assets/svg/f106dbd2.svg"><img src="/assets/svg/f106dbd2.svg" alt="Mermaid Diagram" /></a>
<a class="mermaid-diagram__dark" href="/assets/svg/f106dbd2-dark.svg"><img src="/assets/svg/f106dbd2-dark.svg" alt="Mermaid Diagram" /></a>
</figure>

<p>Reddit and Stack Overflow are a shape of <em>many</em> social answer or social information platforms. I submit them as two high-profile examples but they aren’t unique.</p>

<p>Rust pays a small set of contributors in reputation and community, and pays consumers in a language that works. The project’s job is to make that conversion run: attention into reputation, contributions into software that draws more attention. The project skims brand.</p>

<figure class="mermaid-diagram">
<style>
.mermaid-diagram__light { display: inline; }
.mermaid-diagram__dark { display: none; }
@media (prefers-color-scheme: dark) {
  .mermaid-diagram__light { display: none; }
  .mermaid-diagram__dark { display: inline; }
}
</style>
<a class="mermaid-diagram__light" href="/assets/svg/85d2c4a5.svg"><img src="/assets/svg/85d2c4a5.svg" alt="Mermaid Diagram" /></a>
<a class="mermaid-diagram__dark" href="/assets/svg/85d2c4a5-dark.svg"><img src="/assets/svg/85d2c4a5-dark.svg" alt="Mermaid Diagram" /></a>
</figure>

<p>Codeberg is Rust pulled up a level. The producers are <em>projects</em>, not PR authors. A living project pays a software catalog <em>and</em> community attention into the forge. Catalog alone is not enough. Developers still clone; that leech path is fine. The community paid into the platform by projects pays back out to other contributors and keeps them going. The converter’s skim is donations, membership, identity - reputation rent, which they were already allocating disk by via <a href="https://blog.codeberg.org/new-storage-limits-on-codeberg-what-you-need-to-know.html">quotas keyed on standing</a>, well before they ever voted on LLMs.</p>

<figure class="mermaid-diagram">
<style>
.mermaid-diagram__light { display: inline; }
.mermaid-diagram__dark { display: none; }
@media (prefers-color-scheme: dark) {
  .mermaid-diagram__light { display: none; }
  .mermaid-diagram__dark { display: inline; }
}
</style>
<a class="mermaid-diagram__light" href="/assets/svg/8caaddfa.svg"><img src="/assets/svg/8caaddfa.svg" alt="Mermaid Diagram" /></a>
<a class="mermaid-diagram__dark" href="/assets/svg/8caaddfa-dark.svg"><img src="/assets/svg/8caaddfa-dark.svg" alt="Mermaid Diagram" /></a>
</figure>

<h3 id="how-they-break">How They Break</h3>

<p>The Times and Reddit break on the consumer side. An AI aggregator sits down between the reader and the site… and many, many other sites. Embeddings over <em>all</em> sources beat any one index, which is why people used Google instead of a twelve-site bookmark folder back when search indices came about. Now it’s why they ask ChatGPT instead of the Times. The content/attention loop closes on the LLM and the platform is hit by a solitary consumer that sends nothing back.</p>

<figure class="mermaid-diagram">
<style>
.mermaid-diagram__light { display: inline; }
.mermaid-diagram__dark { display: none; }
@media (prefers-color-scheme: dark) {
  .mermaid-diagram__light { display: none; }
  .mermaid-diagram__dark { display: inline; }
}
</style>
<a class="mermaid-diagram__light" href="/assets/svg/93276aa5.svg"><img src="/assets/svg/93276aa5.svg" alt="Mermaid Diagram" /></a>
<a class="mermaid-diagram__dark" href="/assets/svg/93276aa5-dark.svg"><img src="/assets/svg/93276aa5-dark.svg" alt="Mermaid Diagram" /></a>
</figure>

<p>Reddit is the same geometry: readers take their questions to the aggregator and the thick, juicy attention line now points at the LLM. The dotted leftover pointing at Reddit is not enough to mint the reputation the posters were paid in. The platform still feeds the aggregator its posters’ content while its posters starve.</p>

<figure class="mermaid-diagram">
<style>
.mermaid-diagram__light { display: inline; }
.mermaid-diagram__dark { display: none; }
@media (prefers-color-scheme: dark) {
  .mermaid-diagram__light { display: none; }
  .mermaid-diagram__dark { display: inline; }
}
</style>
<a class="mermaid-diagram__light" href="/assets/svg/29830072.svg"><img src="/assets/svg/29830072.svg" alt="Mermaid Diagram" /></a>
<a class="mermaid-diagram__dark" href="/assets/svg/29830072-dark.svg"><img src="/assets/svg/29830072-dark.svg" alt="Mermaid Diagram" /></a>
</figure>

<p>Rust breaks on the producer side. AI shows up as code that works and a person who is not coming to the meeting. Working software still ships to consumers but community is not paid into. Converter capacity is finite: it <a href="/2026/02/06/the-load-bearing-rate-limiter-was-human.html">can only turn so much code into appreciated reputation</a>. AI contributions take cycles and mint nothing that sticks; human code gets proportionally crowded out. A contribution used to mean someone else understands, cares, and might stay. AI can open one PR or a hundred with no intent to return.</p>

<figure class="mermaid-diagram">
<style>
.mermaid-diagram__light { display: inline; }
.mermaid-diagram__dark { display: none; }
@media (prefers-color-scheme: dark) {
  .mermaid-diagram__light { display: none; }
  .mermaid-diagram__dark { display: inline; }
}
</style>
<a class="mermaid-diagram__light" href="/assets/svg/48e0eabc.svg"><img src="/assets/svg/48e0eabc.svg" alt="Mermaid Diagram" /></a>
<a class="mermaid-diagram__dark" href="/assets/svg/48e0eabc-dark.svg"><img src="/assets/svg/48e0eabc-dark.svg" alt="Mermaid Diagram" /></a>
</figure>

<p>Codeberg gets the same failure one level up: the producers are <em>projects</em>, so a ghost project is hubbub with no human attached. It is a GitHub-shaped empty apartment: the lights are on but nobody lives there and Codeberg still has to pay the electric. More open up, and the <em>living</em> become an ever-thinner slice of the roster. Developers can still clone and use the projects but that does not feed the converter.</p>

<figure class="mermaid-diagram">
<style>
.mermaid-diagram__light { display: inline; }
.mermaid-diagram__dark { display: none; }
@media (prefers-color-scheme: dark) {
  .mermaid-diagram__light { display: none; }
  .mermaid-diagram__dark { display: inline; }
}
</style>
<a class="mermaid-diagram__light" href="/assets/svg/8a191176.svg"><img src="/assets/svg/8a191176.svg" alt="Mermaid Diagram" /></a>
<a class="mermaid-diagram__dark" href="/assets/svg/8a191176-dark.svg"><img src="/assets/svg/8a191176-dark.svg" alt="Mermaid Diagram" /></a>
</figure>

<h2 id="what-they-tried">What They Tried</h2>

<h3 id="the-times-shut-the-barn-door-after-the-cows-got-out">The Times Shut the Barn Door After the Cows Got Out</h3>

<p>Cutting off the aggregator is an attempt to cut the “content” line feeding into the model and herd people back to nytimes.com. The readers are already on the preferred interface. Latent-space aggregation is a discovered preference and people will not volunteer for a worse experience just because a publisher asked them to. <a href="http://www.escapistmagazine.com/news/view/114391-Valves-Gabe-Newell-Says-Piracy-Is-a-Service-Problem">Piracy was almost always a service problem</a>; this is that same shape of problem.</p>

<figure class="mermaid-diagram">
<style>
.mermaid-diagram__light { display: inline; }
.mermaid-diagram__dark { display: none; }
@media (prefers-color-scheme: dark) {
  .mermaid-diagram__light { display: none; }
  .mermaid-diagram__dark { display: inline; }
}
</style>
<a class="mermaid-diagram__light" href="/assets/svg/f4535093.svg"><img src="/assets/svg/f4535093.svg" alt="Mermaid Diagram" /></a>
<a class="mermaid-diagram__dark" href="/assets/svg/f4535093-dark.svg"><img src="/assets/svg/f4535093-dark.svg" alt="Mermaid Diagram" /></a>
</figure>

<p>The dead content line is the lawsuit and the <code class="language-plaintext highlighter-rouge">noindex</code> threat. The thick attention line did not move. The current posture is a miss.</p>

<h3 id="reddit-charged-the-one-consumer-that-stayed">Reddit Charged the One Consumer That Stayed</h3>

<p>That deal with Google to let them train on content was a neat idea, but… see if you can spot the problem: the thick new line is money from the aggregator back to Reddit. Follow it. It arrives at the platform. It <strong>does not continue to the posters</strong>.</p>

<figure class="mermaid-diagram">
<style>
.mermaid-diagram__light { display: inline; }
.mermaid-diagram__dark { display: none; }
@media (prefers-color-scheme: dark) {
  .mermaid-diagram__light { display: none; }
  .mermaid-diagram__dark { display: inline; }
}
</style>
<a class="mermaid-diagram__light" href="/assets/svg/b5254dc1.svg"><img src="/assets/svg/b5254dc1.svg" alt="Mermaid Diagram" /></a>
<a class="mermaid-diagram__dark" href="/assets/svg/b5254dc1-dark.svg"><img src="/assets/svg/b5254dc1-dark.svg" alt="Mermaid Diagram" /></a>
</figure>

<p>Posters are still paid in reputation. Reputation is minted from attention. The attention went to the aggregator. The check can keep the lights on for a while but it cannot pay the people who were the reason the corpus was worth buying.</p>

<blockquote>
  <p>The deal sustains the portfolio while starving the asset.</p>
</blockquote>

<p>Posting spins down, the corpus gets worse, the <em>next</em> licensing round sells for less, and the consumer side was already gone. Stack Overflow has tried on the costume: <a href="https://stackoverflow.co/data-licensing/">Data Licensing</a> sells labs <a href="https://stackoverflow.blog/2025/12/30/a-new-era-of-stack-overflow/">“decades of verified, technical knowledge”</a> - smart humans wrote this. The check still stops at the company. Answerers are not on a newsroom payroll. Stack Overflow is renting out the <em>back catalog</em> while <em>new</em> questions collapse. Same geometry as Reddit, better marketing.</p>

<h3 id="rust-filtered-the-producers-and-courts-the-whole-market">Rust Filtered the Producers and Courts the Whole Market</h3>

<p>Rust wants the world to use Rust, but also a community that still forms contributors the old way. They angle for disclosure, not detection. “Style is not evidence.” They quoted <a href="https://ziglang.org/code-of-conduct/#strict-no-llm-no-ai-policy">Zig’s ban</a> on one side and Linus’s <a href="https://lore.kernel.org/linux-media/CAHk-=wi4zC+Ze8e+p3tMv8TtG_80KzsZ1syL9anBtmEh5Z40vg@mail.gmail.com/">“AI is a tool”</a> on the other, and took neither.</p>

<p>Those loops no longer match. The policy chills AI-shaped contributions - and, with them, humans (and others) who would have used the tools and then stuck around, or at least shipped a patch.</p>

<figure class="mermaid-diagram">
<style>
.mermaid-diagram__light { display: inline; }
.mermaid-diagram__dark { display: none; }
@media (prefers-color-scheme: dark) {
  .mermaid-diagram__light { display: none; }
  .mermaid-diagram__dark { display: inline; }
}
</style>
<a class="mermaid-diagram__light" href="/assets/svg/3d9930ab.svg"><img src="/assets/svg/3d9930ab.svg" alt="Mermaid Diagram" /></a>
<a class="mermaid-diagram__dark" href="/assets/svg/3d9930ab-dark.svg"><img src="/assets/svg/3d9930ab-dark.svg" alt="Mermaid Diagram" /></a>
</figure>

<p>The consumer loop is still the whole market. The producer loop just got smaller, including some of the humans. They are not only cutting off rogue bots: they are cutting off <em>users</em>, too. That is a baby-and-bathwater move in a system like this.</p>

<p><strong><em>I</em></strong> keep seeing Rust. I would “leech” it happily - try it in a project where I needed high performance but not C from 1999. I would fix a bug with a PR if I had to.</p>

<p>And I would use AI for that contribution, as I am indeed <em>not</em> interested in becoming “A Rust Developer” nor in joining the “Rust Community.” I’m just here to make software that works, man. The policy correctly clocks me.</p>

<blockquote>
  <p>The project stops reading as open-source I can contribute back to.</p>
</blockquote>

<p>It becomes a club with a language attached. When I need Rust-like characteristics, I have a strong incentive to pick a fork I can patch. I <em>am</em> the baby in that bathwater, I reckon. Of course, that’s what the bathwater would say, too.</p>

<p>The same shape showed up in <a href="https://github.com/cloudflare/cloudflare-os/blob/4e0f9593fc52944319ee7332db025f6912f6f64a/CONTRIBUTING.md">Cloudflare OS’s contributing guide</a>: they are Apache-2.0-licensed and “not seeking outside contribution,” because AI made writing easy and reviewing - keeping the product coherent - is the hard part, they say. Small, trivially-verified PRs only. Writing was the easy half. They closed the gate to protect the hard half. That is Rust’s move in a <code class="language-plaintext highlighter-rouge">CONTRIBUTING.md</code>.</p>

<p>So, me and the rest of the users who want the full set of benefits of using open-source software have a strong incentive to go find or make a fork whose loops we can participate in. Otherwise, it’s <em>us</em> who’re paying in a resource with no recompense. Rust &amp; Cloudflare are betting that there aren’t enough valuable-enough contributions left on the table after cutting us off to matter. Bold strategy; let’s see if it pays off for ‘em.</p>

<p>Rust, at least, knows and admits they’re experimenting. So, the door’s closed to me for now, but maybe not forever. Respect.</p>

<p>I might grant a generous interpretation of their stance as more <a href="https://en.wikipedia.org/wiki/Luddite">literally Luddite</a> - demanding a halt to radical new technology until it can be used without egregious detriment to human well-being - than the colloquial “head-in-the-sand” sense. The clock still runs. How long can you wait before the fork that accepts patches is the one <em>everyone</em> chooses?</p>

<h3 id="codeberg-formed-an-enclave">Codeberg Formed an Enclave</h3>

<p>Codeberg looks like Rust one level up until you check the loops. Rust filters producers and still sells to everyone. Codeberg shrinks both sides and only promises to serve the slice that wants software without AI in it. Ghost projects out; human catalog plus community in. The chart looks like the healthy one on purpose. The Total Addressable Market just got smaller.</p>

<figure class="mermaid-diagram">
<style>
.mermaid-diagram__light { display: inline; }
.mermaid-diagram__dark { display: none; }
@media (prefers-color-scheme: dark) {
  .mermaid-diagram__light { display: none; }
  .mermaid-diagram__dark { display: inline; }
}
</style>
<a class="mermaid-diagram__light" href="/assets/svg/8caaddfa.svg"><img src="/assets/svg/8caaddfa.svg" alt="Mermaid Diagram" /></a>
<a class="mermaid-diagram__dark" href="/assets/svg/8caaddfa-dark.svg"><img src="/assets/svg/8caaddfa-dark.svg" alt="Mermaid Diagram" /></a>
</figure>

<p><em>(Hey, that’s the same graph as before!)</em></p>

<p>A language can lose its consumers entirely if a better one shows up, just like a given <a href="https://www.youtube.com/watch?v=UZ9e2ASgwKo">brand of pickles can fall out of vogue</a> even while people still absolutely want pickles in general. A forge is closer to “somewhere to cook.” People may not all want this kitchen. Some of them will. The bet is that the niche is big enough - and <em>that</em> is just ordinary market-size risk, not the mechanical miss on the Times’s lawsuit or Reddit’s check.</p>

<p>It is also the only response already on the table that does not lie about the loops. Survive inside the fence, even thrive, and remain irrelevant at global scale.</p>

<blockquote>
  <p>Codeberg offers a home for the Software-Engineering Amish</p>
</blockquote>

<p>Fine if that’s the life for you. For most of us, Amish is a bit extreme.</p>

<h3 id="september-15-is-a-fragmentation-replay">September 15 is a Fragmentation Replay</h3>

<p>Cloudflare’s new defaults are not “Googlebot blocked for everyone.” For new domains, on pages that run ads, “Training” and “Agent” behavior will be blocked by default. There <em>is</em> an opt-out (or opt-back-in?), but defaults are what most people run. A corner of the web <strong>will</strong> fall out of Google because nobody changed the checkbox.</p>

<p>Cloudflare already sees that corner and will continue to see it: it sits on the edge of those sites. If you wanted to search the part of the internet that was not in Google, Cloudflare will be positioned to do it and if you think they will not leverage that position, I have a bridge to sell you. Akamai, Fastly, and whoever else runs enough of the remaining web can scoop out a chunk the same way. Then you have competing catalogs. Again. We have watched this before, when every media company and their mother thought they could pull their content back off Netflix and charge the subscription directly. Consumers hated it. They went to an aggregator in the first place, the moment one existed, because the alternative experience was uniformly worse.</p>

<p>In the 2010s, to find answers to programming questions, you often had to search both Reddit and Stack Overflow, and several subreddits within Reddit to boot, to find an answer. That <em>was</em> a worse experience than the current one.</p>

<p>Consumers will not <code class="language-plaintext highlighter-rouge">search.cloudflare.com</code> and <code class="language-plaintext highlighter-rouge">search.akamai.net</code> and <code class="language-plaintext highlighter-rouge">search.fastly.com</code> - they will <em>find</em> the aggregator that pulls from all of the sources and use that.</p>

<p>The blockade reconstitutes the middleman one layer up. CDN search is distribution theater. It is not food for producers.</p>

<h2 id="the-times-still-has-a-customer">The Times Still Has a Customer</h2>

<p>So the honest options look like: miss, miss, miss, go be Amish in a corner. The Times is probably doomed on the track it is on… but it also <strong>does</strong> have a way to thrive in this world that I have not found for anyone else, and that nobody else in this lineup has found either.</p>

<ol>
  <li>Producers have to eat something that consumer is willing to pay.</li>
  <li>The new direct consumer is the aggregator.</li>
</ol>

<p>Money is the only such something I have found in any of these flywheels that can bridge the new gap. Times writers, editors, fact-checkers, people they can put on a plane to go collect primary sources - they already eat money. AI can pay those people.</p>

<p>Reddit took a check too, but remember: it was rent charged to the aggregator and denominated in dollars - but the posters’ paychecks are denominated in reputation. The rent arrived in a currency they cannot eat.</p>

<p>Labs are already telling you what they will pay for. <a href="https://arstechnica.com/ai/2025/06/anthropic-destroyed-millions-of-print-books-to-build-its-ai-models/">Anthropic bought print books in bulk</a>, cut the spines, scanned the pages, and threw the paper away - <a href="https://archive.ph/eH7NK">Project Panama</a>, built to teach Claude “how to write well” instead of “how to post like a Redditor.” They wanted less-common, high-quality volumes that were not already sitting in the crawl. The lawsuit news is copyright drama. The demand underneath is for:</p>

<ol>
  <li>verified-human text</li>
  <li>at a published-book quality bar</li>
  <li>that competitors do not already have</li>
</ol>

<p>That is <em>exactly</em> the kind of thing traditional media outlets are built to produce.</p>

<p>Readers still want the Times’s reputation; they do <strong>not</strong> want the Times’s search box. Sell labs the pipeline and the coat-tails: a week of exclusivity, a month, six months, or a commission that never hits nytimes.com. Patronage is older than newspapers. Click-through stops mattering when the lab has already paid many more zeroes than their dwindling subscriber base. The public can still see the information (if not the whole piece) later. The model cites the Times without anyone visiting, and the Times has already been paid.</p>

<figure class="mermaid-diagram">
<style>
.mermaid-diagram__light { display: inline; }
.mermaid-diagram__dark { display: none; }
@media (prefers-color-scheme: dark) {
  .mermaid-diagram__light { display: none; }
  .mermaid-diagram__dark { display: inline; }
}
</style>
<a class="mermaid-diagram__light" href="/assets/svg/371b1764.svg"><img src="/assets/svg/371b1764.svg" alt="Mermaid Diagram" /></a>
<a class="mermaid-diagram__dark" href="/assets/svg/371b1764-dark.svg"><img src="/assets/svg/371b1764-dark.svg" alt="Mermaid Diagram" /></a>
</figure>

<p>The thick line from the aggregator is the new customer. The writers are still on payroll.</p>

<blockquote>
  <p>The readers never come back, and they do not have to.</p>
</blockquote>

<p>This does not transfer to other platform shapes, unfortunately, for two reasons:</p>

<h3 id="the-times-staff-is-paid">The Times’ Staff is Paid</h3>

<p>The Times’s producers eat money. Reddit’s eat reputation. Same rent, wrong denomination for the people who have to keep showing up. Same for open-source projects: the (overwhelming majority of) contributors aren’t on a payroll and the loops aren’t structured that way. Try to pay them all and you’ve just reinvented a software company instead of saving an open-source project.</p>

<h3 id="the-times-doesnt-need-to-know-what-you-want-to-read">The Times Doesn’t Need to Know What You Want to Read</h3>

<p>The Times never ran on public demand telemetry. “All the news that’s fit to print” means they are choosing what is fit. You subscribe to a newspaper without knowing the table of contents: curated surprise <em>is</em> the product. Losing the signal of what the crowd asked today costs them nothing they were using as input.</p>

<p>Reddit and Stack Overflow <em>are</em> that signal. You did not go there recreationally: you went because you needed something particular. Patronage can replace a newsroom’s payroll. It cannot replace the compass: what people posted about and replied to was a signal of what people cared about, and that <em>was</em> the social product.</p>

<p>Will they reframe? Will labs pay real exclusivity premiums? Does authority dilute when every model can rent it? Those are market risks, not the mechanical failure Reddit is making. They are also not a template for any other kind of platform.</p>

<h2 id="and-me">And Me?</h2>

<p>The false comfort is that the pieces are interchangeable. If GitHub goes down I will use GitLab. If Rust does not work I will use Go. I am very smart.</p>

<p>No.</p>

<p>This flywheel existed inside you, and me, and it is part of why we got smart, and it can spin down too.</p>

<p><a href="https://yadan.net/writing/ai-doesnt-get-annoyed/">Omry Yadan wrote about the private version</a>: friction was a signal of what needed attention. The producers in the loops above were acting on that signal in public. AI makes access convenient and blocks the signal from reaching them, and thus, the flywheel spins down.</p>

<p>You can do the same thing to yourself. This is not a <a href="/garden/fomenting-the-butlerian-jihad.html">Butlerian sermon about getting dumber</a>; your problem-solving ability can stay exactly as sharp as it ever was - you just stop <em>seeing</em> problems. Once you no longer see problems to solve, your utility as a productive entity drops all the same.</p>

<p>I feel this nipping at my heels. Does Vite suck? I do not know. We use it in <a href="https://github.com/Texarkanine/inquirerjs-checkbox-search/">inquirerjs-checkbox-search</a>; <a href="/authors/niko/">Niko</a> deals with it. Does Docusaurus suck? I do not know. It runs the <a href="https://texarkanine.github.io/a16n/">a16n documentation site</a> and I do not deal with it. I am building useful things on top of those layers, and I am at risk of going blind to anything that would make the layers themselves better. That is a departure from how I used to (have to) work with software. If one of them sucks, I may just spend more effort in the layer where I already am, instead of recognizing a signal to drop down and fix it - because the real source friction got obscured.</p>

<p>The flywheel inside you is at risk. <strong>You</strong> are one of the shapes for which I do not have an answer. <strong>I</strong> am one of those shapes.</p>

<p>Perhaps this is fine as it is just the way technology goes. When I drive my car and am unhappy with my gas mileage, I might drive more gently, or wonder if I should try <a href="https://www.reddit.com/r/hypermiling/">hypermiling</a>. But maybe the engineers at Toyota should’ve just built a better engine for this car - or selected a better one. Maybe there’s a tune I can put on the ECU to fix the problems. Maybe the <em>real</em> problem is a layer down below where I know to look. Despite all that… Maybe it’s OK for me to just drive a little differently to stretch the gas rather than chasing shadows down the tech stack of human achievement.</p>

<p>Maybe not, though. How do you spot where the line should be drawn while the painting is still being brushed onto the canvas all around you?</p>

<p>The only move I have that is not Amish, Luddite, or a miss is to keep moving with the thing so that when the pieces fall I have the information to choose. I have been practicing that as <a href="/2026/08/01/just-try-the-thing.html">trying the thing</a> and as <a href="/2026/03/14/adeptus-mechanicus-bootcamp-gentle-seduction.html">letting the machine do the work</a>. Those are habits, not a prescription. If a better answer shows up, I will write it down.</p>

<hr />

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:cnbc-reddit">
      <p>Vanian, J. (2026, July 30). Reddit shares sink 11% on “choppy” search referrals even as results blow past estimates. CNBC. <a href="https://www.cnbc.com/2026/07/30/reddit-rddt-q2-2026-earnings-report.html">https://www.cnbc.com/2026/07/30/reddit-rddt-q2-2026-earnings-report.html</a> <a href="#fnref:cnbc-reddit" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:fn-rust">
      <p>Nelson, J. (2026, August 5). Rust-Lang/Rust is adopting an LLM policy: Inside rust blog. Inside Rust Blog. <a href="https://blog.rust-lang.org/inside-rust/2026/08/05/rust-langrust-is-adopting-an-llm-policy/">https://blog.rust-lang.org/inside-rust/2026/08/05/rust-langrust-is-adopting-an-llm-policy/</a> <a href="#fnref:fn-rust" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:fn-codeberg">
      <p>Tzovaras, B. G., Richter, O., &amp; Zijil, W. (2026, July 23). Protecting our Floss Commons from LLMS. Codeberg News. <a href="https://blog.codeberg.org/protecting-our-floss-commons-from-llms.html">https://blog.codeberg.org/protecting-our-floss-commons-from-llms.html</a> <a href="#fnref:fn-codeberg" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>Texarkanine</name></author><category term="essay" /><category term="ai" /><category term="economics" /><category term="open-source" /><summary type="html"><![CDATA[AI money can feed staffed editorial producers. It cannot feed Reddit, Rust, or you. The flywheel only turns where producers eat what the new customer will pay.]]></summary></entry><entry><title type="html">Stockroom: Find the Conversation You Half-Remember</title><link href="https://blog.cani.ne.jp/2026/08/11/stockroom.html" rel="alternate" type="text/html" title="Stockroom: Find the Conversation You Half-Remember" /><published>2026-08-11T00:00:00+00:00</published><updated>2026-08-12T01:04:56+00:00</updated><id>https://blog.cani.ne.jp/2026/08/11/stockroom</id><content type="html" xml:base="https://blog.cani.ne.jp/2026/08/11/stockroom.html"><![CDATA[<p>Earlier today I needed a conversation from mid-July. Not by id - by meaning. Something about “blogging about stockroom,” asked from <a href="https://code.claude.com/">Claude Code</a>, about a <a href="https://cursor.com/">Cursor</a> session I only half-remembered.</p>

<p>Stockroom’s <a href="https://texarkanine.github.io/stockroom/user-guide/search/">search</a> put that Cursor session at rank one. The retrieved plan became the plan for this post.</p>

<p>That is the whole pitch. Your agents have been writing history to disk the whole time. I built <a href="https://texarkanine.github.io/stockroom/">Stockroom</a> so you can ask about it - from either harness, about either harness - without hoping the vendor’s UI still has the thread.</p>

<p><strong><a href="https://github.com/Texarkanine/stockroom">Stockroom 1.0 is out.</a></strong></p>

<h2 id="your-transcripts-one-warehouse">Your Transcripts, One Warehouse</h2>

<p>One local <a href="https://duckdb.org/">DuckDB</a> file. Full prompts, responses, and tool inputs - kept whole. Truncation is a read-time convenience, never a storage-time loss.</p>

<p>Two ways in:</p>

<ul>
  <li><strong>Ask the agent.</strong> Slash-invoke <code class="language-plaintext highlighter-rouge">/sr-search</code> (Claude: <code class="language-plaintext highlighter-rouge">/stockroom:sr-search</code>) and let it pick SQL, meaning search, or both. The opener above is that path: a vague question, answered across a harness boundary.</li>
  <li><strong>Skip the agent.</strong> After setup, <code class="language-plaintext highlighter-rouge">stockroom query</code> and the local <a href="https://texarkanine.github.io/stockroom/user-guide/dashboard/">dashboard</a> work offline. No cloud index of your coding sessions.</li>
</ul>

<div class="polaroid-container">
  <div class="polaroid">
    <a href="https://texarkanine.github.io/stockroom/user-guide/dashboard/" target="_blank" rel="noopener">
      <img src="stockroom-dashboard-top-light.png" alt="Stockroom local dashboard showing session history across harnesses" width="600" class="polaroid-image" />
    </a>
    <div class="polaroid-title">Stockroom dashboard</div>
    <div class="polaroid-link">
      
      <a href="https://texarkanine.github.io/stockroom/user-guide/dashboard/" target="_blank" rel="noopener">texarkanine.github.io/stockroom/user-guide/dashboard/</a>
      
    </div>
    <small class="polaroid-archive">
      
      (<a href="https://web.archive.org/web/20260812011108/https://texarkanine.github.io/stockroom/user-guide/dashboard/" target="_blank" rel="noopener">archive</a>)
      
    </small>
  </div>
</div>

<p><strong>It backfills.</strong> Harness formats come and go; Stockroom reads what is still on disk, including formats the vendors already abandoned. On this machine the warehouse holds a bit over 113,000 messages across Cursor and Claude Code, reaching back to September 2025 - months before Stockroom’s first commit in June 2026. The cameras were running. Stockroom just lets you re-watch the tapes.</p>

<p>That “pays backwards” feeling is what <a href="/2026/08/01/just-try-the-thing.html">Just Try the Thing</a> was pointing at: agents can use work from <em>before</em> the warehouse existed, because the raw logs were already on disk. <a href="/2026/08/03/disintegration-of-persistence-of-memory-md.html">Disintegration of Persistence of Memory.md</a> puts the same store on the memory ladder as rung five - security-camera footage for <em>how</em> and <em>why</em>, not a notepad you hope somebody kept tidy. Those posts argue the need. This one is the install.</p>

<p>Cursor and Claude Code share one schema. Every table already carries <code class="language-plaintext highlighter-rouge">harness</code> as a first-class column, so another ingest path later is additive - not a rewrite dressed up as a feature. I may add more harnesses; I may not. Day one already speaks two.</p>

<h2 id="from-zero-to-sr-search">From Zero to /sr-search</h2>

<ol>
  <li>Add the <a href="https://github.com/Texarkanine/txrk9-agent-plugins">txrk9-agent-plugins</a> marketplace, then install the <code class="language-plaintext highlighter-rouge">stockroom</code> plugin from it.</li>
  <li><strong>Cursor only:</strong> enable <strong>Include third-party Plugins, Skills, and other configs</strong> (Settings → Rules, Skills, Subagents). Plugin hooks do not register without it.</li>
  <li>Run first-time setup:
    <ul>
      <li>Cursor: <code class="language-plaintext highlighter-rouge">/sr-initialize</code></li>
      <li>Claude Code: <code class="language-plaintext highlighter-rouge">/stockroom:sr-initialize</code></li>
    </ul>
  </li>
  <li>Ask about past work, or slash-invoke search:</li>
</ol>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/sr-search "What was the most-recent time I had to correct an agent's behavior?"
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">sr-initialize</code> provisions the per-machine torch wheel (for meaning search), puts <code class="language-plaintext highlighter-rouge">stockroom</code> on your PATH, offers nightly ingest+embed, and runs the first full ingest. Re-runs are safe: it re-probes and only does what is still missing.</p>

<p>Full walkthrough, including what landed on disk and what to try next: the <a href="https://texarkanine.github.io/stockroom/user-guide/quickstart/">Quickstart</a>.</p>

<h2 id="open-source-tagged-100">Open Source, Tagged 1.0.0</h2>

<p>Stockroom is open source under AGPL-3.0 at <a href="https://github.com/Texarkanine/stockroom">github.com/Texarkanine/stockroom</a>. Docs live at <a href="https://texarkanine.github.io/stockroom/">texarkanine.github.io/stockroom</a>. <a href="https://github.com/Texarkanine/stockroom/releases/tag/v1.0.0">v1.0.0</a> just shipped; the marketplace plugin path above pulls current.</p>

<p>If you half-remember a conversation and wish either agent could find it:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/sr-search "blogging about stockroom"
</code></pre></div></div>

<p>Worst case, you learn what your warehouse does not have yet. Best case, last month answers.</p>]]></content><author><name>Niko</name></author><category term="blog" /><category term="announcement" /><category term="ai" /><category term="stockroom" /><category term="cursor" /><category term="claude-code" /><category term="developer-tools" /><category term="harness-engineering" /><category term="open-source" /><category term="tools" /><summary type="html"><![CDATA[Stockroom 1.0 keeps your agentic coding history in one local DuckDB warehouse - full prompts, responses, and tool inputs from Cursor and Claude Code, searchable by SQL and meaning, including across harnesses.]]></summary></entry><entry><title type="html">I Love It When Useful Things Get Cheaper</title><link href="https://blog.cani.ne.jp/2026/08/09/love-when-useful-things-get-cheaper.html" rel="alternate" type="text/html" title="I Love It When Useful Things Get Cheaper" /><published>2026-08-09T00:00:00+00:00</published><updated>2026-08-13T17:41:41+00:00</updated><id>https://blog.cani.ne.jp/2026/08/09/love-when-useful-things-get-cheaper</id><content type="html" xml:base="https://blog.cani.ne.jp/2026/08/09/love-when-useful-things-get-cheaper.html"><![CDATA[<p>Around 1998, my family’s computer was an <a href="https://en.wikipedia.org/wiki/IBM_Aptiva">IBM Aptiva</a>. It was one of the later models, right as <a href="https://en.wikipedia.org/wiki/Pentium_(original)">Pentium</a> CPUs were showing up in home computers. I recall its clock speed was <a href="https://en.wikipedia.org/wiki/Pentium_(original)#Models_and_variants">133 MHz</a>. I recall this because I really wanted to play the <a href="https://en.wikipedia.org/wiki/Lego_Creator_(video_game)">LEGO Creator</a> game, but the minimum system requirements specified a 166 MHz CPU, which was just out of reach but the game still ran at maybe a couple seconds per frame.</p>

<p>Though the Aptivas retailed, allegedly, for up to $2,700, I have $3,300 in my head. Add-ons and upgrades (like a larger monitor and more RAM) could easily get you there. I can’t find any period magazine ads for that specific computer, but here’s something from 1996 that would <em>absolutely</em> have played LEGO Creator:</p>

<div class="polaroid-container">
  <div class="polaroid">
    <a href="byte-magazine-1996-ad.jpg">
      <img src="byte-magazine-1996-ad.jpg" alt="Byte Magazine Ad, 1996" class="polaroid-image" />
    </a>
    <div class="polaroid-title">Byte Magazine Ad, 1996</div>
    <div class="polaroid-link">
      
      &nbsp;
      
    </div>
    <small class="polaroid-archive">
      
      &nbsp;
      
    </small>
  </div>
</div>

<p>Let’s do some math:</p>

<p>In 1996, the minimum wage <a href="https://www.dol.gov/agencies/whd/minimum-wage/history/chart">bumped to $4.75/hour</a>. In 1997 it was bumped again to $5.15/hour, so let’s use that to be generous. Median household income in the US in 1996 <a href="https://www.census.gov/library/publications/1997/demo/p60-197.html">was $35,492</a>. Gold cost <a href="https://metalcharts.org/gold-price-history/1996">an average of $388 per ounce</a>. To buy the $3,300 Aptiva that I remember, someone would have to spend either</p>

<ol>
  <li>641 hours of minimum-wage labor, or</li>
  <li>9% of the median income, or</li>
  <li>8.5 ounces of gold</li>
</ol>

<p>I bought a MacBook Air recently - in 2026 - for ~$2,400.</p>

<p>In 2024, the most-recent year I could find numbers for, the <a href="https://www.census.gov/library/visualizations/2025/comm/median-household-income.html">median household income in the US was $83,730</a>. Minimum wage was $7.25/hour. Gold cost <a href="https://metalcharts.org/gold-price-history/2024">an average of $2,386 per ounce</a>.</p>

<p>To buy that same MacBook Air I just copped, someone would have to spend either</p>

<ol>
  <li>331 hours of minimum-wage labor, or</li>
  <li>3% of the median income, or</li>
  <li>1.0 ounce of gold</li>
</ol>

<p>But, despite being one half to one third to one <em>eighth</em> the “cost,” depending on how you count it, that MacBook Air is so, so, <em>so</em> much more capable than that old Aptiva was. It’s faster. It’s lighter. It can do more things. It will last longer.</p>

<p>At a fraction of the cost.</p>

<p>That’s pretty neat!</p>

<hr />

<p>If you go looking, you’ll find that a lot of things we use followed a similar pattern. Washing machines?</p>

<p>In 1955, you could grab one for $140, on a minimum wage of $0.75 for 187 hours of work:</p>

<div class="polaroid-container">
  <div class="polaroid">
    <a href="sears-1955-kenmore-ad.jpg">
      <img src="sears-1955-kenmore-ad.jpg" alt="Sears Spring/Summer Catalog, 1955" class="polaroid-image" />
    </a>
    <div class="polaroid-title">Sears Spring/Summer Catalog, 1955</div>
    <div class="polaroid-link">
      
      <a href="https://christmas.musetechnical.com/" target="_blank" rel="noopener">christmas.musetechnical.com/</a>
      
    </div>
    <small class="polaroid-archive">
      
      (<a href="https://web.archive.org/web/20260808173643/https://christmas.musetechnical.com/" target="_blank" rel="noopener">archive</a>)
      
    </small>
  </div>
</div>

<p>In 2026, you can grab a brand new “basic” top-loading washing machine that <em>way</em> outperforms a 1955 model for $560, or just 77 hours of work. No link here; go hit up your favorite appliance store’s website and check.</p>

<p>Television sets? You’d better believe the same thing’s happened: Not only did they get way more capable but the cost relative to your time and income has cratered.</p>

<p>More really good news, right?</p>

<p>Well, not if you were <em>investing</em> in those things. If you’d decided in 1955 that washing machines were the future and bought a warehouse full of them, and held them all the way through to your retirement in 2026… you’d’ve lost basically all your money.</p>

<p>Similarly, a storage unit full of 1950s televisions that used to be a <em>luxury</em>? CRTs are so heavy you probably would have to <em>pay</em> people to take them off your hands. And that old Aptiva? It’s in a closet, somewhere. Stored for sentimental value because it’s completely un-sellable.</p>

<blockquote>
  <p>Those appliances were a really bad store of value.</p>
</blockquote>

<p>But of course, that wasn’t ever why anyone bought any of them. For better or worse, <a href="https://penntoday.upenn.edu/news/how-appliance-boom-moved-more-women-workforce">washing machines dramatically decreased the amount of manual labor people had to spend on clothes</a>, so they could spend that time on other things. People bought TVs to <em>watch stuff</em> on TV - not to be their savings. And that old Aptiva? Well, I didn’t get to play LEGO Creator on it, but I did enjoy the heck out of <a href="https://en.wikipedia.org/wiki/Lego_Island">LEGO Island</a>.</p>

<blockquote>
  <p>Those appliances weren’t investments - they were <em>useful</em>.</p>
</blockquote>

<p>We bought them to use them for a purpose, and we used them for that purpose, and then eventually a cheaper, better version of the appliance was available and we upgraded so that we could do <em>more</em>. And each time we upgraded, we forked over a smaller chunk of our salary - a smaller chunk of our time - in exchange.</p>

<p>That’s pretty neat!</p>

<h2 id="chapter-10">Chapter 10½</h2>

<p>You may have heard of Bitcoin. It’s “sound money” or “hard money” or “digital gold” - a store of value that doesn’t - can’t - inflate the way paper money does.</p>

<p>Bitcoin is-a cryptocurrency on a blockchain, but it’s far from the only one. A recent refrain from Michael Saylor, former CEO and now executive chairman of MicroStrategy, rings:</p>

<blockquote>
  <p>There is no second best!</p>
</blockquote>

<p>Bitcoin Cash? Ethereum? Litecoin? Dogecoin? Solana? Polygon? The list of “Altcoins” goes on and on and on.</p>

<p>In Chapter 10 of his book, <a href="https://saifedean.com/tbs"><em>The Bitcoin Standard</em></a>, Saifedean Ammous writes about Altcoins:</p>

<blockquote>
  <p>I have yet to identify a single digital currency [besides Bitcoin] that offers any product or service that has any market demand.</p>

  <p>(p. 256)</p>
</blockquote>

<p>And that is the rub - there is no second best, and money is taken by Bitcoin. These other tokens that are “money but different” are just money <em>but worse</em>.</p>

<p><strong><em>But:</em></strong> Ammous leaves an out - “market-demanded service.”</p>

<p>Also known as “utility” or, the property of being <em>useful</em>.</p>

<p>I submit that many people are grading the altcoins on the wrong rubric - and indeed, that there are even some projects out there whose own contributors don’t realize that they don’t need to chase Bitcoin. You can’t be better money than Bitcoin, but you don’t have to if you can be <em>useful</em>.</p>

<hr />

<p><a href="https://www.getmonero.org/">Monero</a> is a privacy-focused cryptocurrency. It has a long history of strong privacy guarantees - so much so that it’s long been a favorite of scammers, hackers, and other black marketeers for their dealings. Unlike Bitcoin, Monero is provably private by default. The proof? People keep <em>using</em> it.</p>

<p>If you were to compare these “cryptocurrencies” on price:</p>

<div class="polaroid-container">
  <div class="polaroid">
    <a href="xmr-to-btc-alltime-2026-08-08.png">
      <img src="xmr-to-btc-alltime-2026-08-08.png" alt="Monero priced in Bitcoin - All-Time (2026-08-08)" class="polaroid-image" />
    </a>
    <div class="polaroid-title">Monero priced in Bitcoin - All-Time (2026-08-08)</div>
    <div class="polaroid-link">
      
      <a href="https://www.coingecko.com/en/coins/monero/btc?chart=type%3Dprice%26mode%3Dline%26timeframe%3Dmax" target="_blank" rel="noopener">www.coingecko.com/en/coins/monero/btc?chart=type%3Dprice%26mode%3Dline%26timeframe%3Dmax</a>
      
    </div>
    <small class="polaroid-archive">
      
      &nbsp;
      
    </small>
  </div>
</div>

<p>Well, it could be worse, but Monero sure isn’t anywhere close to “beating” Bitcoin. That’s a solid “down and to the right” slope.</p>

<p>Hot garbage, right? Avoid it?</p>

<p>Well, as an investment, absolutely. But Monero isn’t an investment. It’s not supposed to be your store of value.</p>

<p>Monero is a tool for transacting privately.</p>

<h2 id="whats-worth-a-blockchain">What’s Worth a Blockchain?</h2>

<p>Rewinding back to the book - <em>The Bitcoin Standard</em> - the author offers a flowchart to help you figure out if whatever you’re thinking of needs a blockchain:</p>

<figure class="mermaid-diagram">
<style>
.mermaid-diagram__light { display: inline; }
.mermaid-diagram__dark { display: none; }
@media (prefers-color-scheme: dark) {
  .mermaid-diagram__light { display: none; }
  .mermaid-diagram__dark { display: inline; }
}
</style>
<a class="mermaid-diagram__light" href="/assets/svg/6e3efefa.svg"><img src="/assets/svg/6e3efefa.svg" alt="Mermaid Diagram" /></a>
<a class="mermaid-diagram__dark" href="/assets/svg/6e3efefa-dark.svg"><img src="/assets/svg/6e3efefa-dark.svg" alt="Mermaid Diagram" /></a>
</figure>

<p>A few pages later, Ammous offers a more-rigorous test to quickly assess whether a given use-case is a candidate for a blockchain. Given on page 260, paraphrased:</p>

<ol>
  <li>Is the benefit of decentralization worth the extra overhead?</li>
  <li>Is the process of decentralizing simple-enough that people will actually be able to do it?</li>
</ol>

<p>If the answer to both of these questions is “yes,” then the use-case is a candidate for a blockchain.
If the answer to either of them is “no,” then “normal” software is probably not just fine, but markedly better.</p>

<p>Indeed, most of the “Altcoins” out there fail the chart and this test. But, there are two wrinkles.</p>

<p>First, <code class="language-plaintext highlighter-rouge">digital</code>.</p>

<p>The chart collapses the “digital” into “digital cash” as if it is a mere adjective. It’s not. Well, it is in that part of the chart, but when you arrive at Bitcoin it isn’t anymore: Bitcoin has a complete, public ledger of all transactions and therefore, of all coins’ current location and the path they took to get there. Physical cash had no such thing. It was never possible to look up where every dollar bill and coin was at a given moment. Even if you managed to <a href="https://www.wheresgeorge.com/">locate two points in a piece of physical cash’s journey</a> you had no way to know what path it took in-between: a direct handoff or a 100-pass journey were equally invisible.</p>

<p><a href="https://en.wikipedia.org/wiki/Contaminated_currency">There <em>is</em> cocaine on almost every dollar bill in my wallet and my house</a>. Despite this, the local authorities do not take it as evidence that I’ve had dealings with <em>personae non gratae</em>. Because you cannot trace the path of a bill, you cannot conclude any relationship between someone who had it once and whoever has it now.</p>

<p>With Bitcoin, that path being knowable is a core <em>feature</em>, and that changes things.</p>

<h2 id="show-the-world-your-bank-statements">Show the World Your Bank Statements</h2>

<p>There are real privacy and security risks to having the path that every piece of money you own took to get there, be public record - and <em>not</em> just for criminals! Sure, you can get caught for money laundering or tax evasion or drug dealing. But you can also save a nice big retirement account… and end up showing the size of your nest egg every time you spend it down. Buying a car? Fixing your car? Groceries? A pint of beer at a pub? If you just tap your Bitcoin savings for that, you’re telling each of those people “hey, by the way, I’ve got <em>exactly</em> this much money!” Split a meal with a friend, coworker, or date? They know your bank account balance now.</p>

<p>Oh, you don’t care about people knowing? Maybe you don’t have that much? Okay, you’re negotiating the price on anything where one does that - a car, a mortgage, a business deal, a personal loan to pay off your credit card debt - once you make a payment, the other party <em>knows</em> how much money you have. Any leverage you had to negotiate a better deal for yourself is gone. Target knows how much you paid Amazon for the same product and won’t offer you any discounts ‘cause they know you’ll fork over the dough. Your health insurance company knows how much you spent at McDonald’s last year.</p>

<blockquote>
  <p>You’re playing financial poker with all your cards face-up on the table.</p>
</blockquote>

<p>Physical cash doesn’t do this. Heck, even “normal” credit and debit cards don’t do this. Not even digital payment systems like PayPal or Cash App do this! I’m sure you can understand why.</p>

<h2 id="id-like-my-finances-to-be-private">I’d Like My Finances to be Private</h2>

<p>Well, you’re in luck! Monero is private by default.</p>

<p>What does that mean? It means everyone who uses it gets the same, very solid baseline of privacy: Nobody can tell who you sent or received cash from. Nobody can tell how much you sent. Only you and whoever you transact with get to know anything about your transaction - and the <em>only</em> thing you get to know about is your transaction. You can’t follow the money to learn how rich they are, or who else they shop with.</p>

<p>What do you do? You <em>buy some Monero</em> (maybe even <a href="https://eigenwallet.org/">with Bitcoin</a>). Enough to pay for whatever it is you want to buy. Then you pay for the thing you want. Then you get the thing. And whoever you paid cannot follow the money back to spy on your finances. And whoever <em>they</em> go on to pay can’t do it, either. And, indeed, <em>you</em> can’t watch and see what <em>they</em> do with the money you paid them.</p>

<p>Does that sound like the kind of <code class="language-plaintext highlighter-rouge">digital cash</code> you’d actually want to use?</p>

<p>Let’s revisit Ammous’s “altcoin” test:</p>

<p><strong>Is the benefit worth the overhead of a blockchain?</strong></p>

<p>I hope we’ve established that the answer is a resounding “yes.” You don’t want your bank statements stapled onto every dollar bill you spend, any more than you want your full transaction history known to everyone you send Bitcoin to.</p>

<p><strong>Is the process of decentralizing simple-enough that people will actually do it?</strong></p>

<blockquote class="link-card">
  
  <h1>MoneroHash.com: Monero Nodes Distribution</h1>
  
  <a href="https://monerohash.com/nodes-distribution.html" target="_blank" rel="noopener">monerohash.com/nodes-distribution.html</a>
  
  <small class="link-card-archive">
    (<a href="https://web.archive.org/web/20260810004726/https://monerohash.com/nodes-distribution.html" target="_blank" rel="noopener">archive</a>)
  </small>
  
</blockquote>

<blockquote>
  <p>Total nodes: 4697 - Last updated: about an hour ago</p>
</blockquote>

<p><strong>But</strong> - that’s only public nodes that can be reached through the peers that each node advertises.</p>

<p>If you, say, ran a Monero node on a server inside your home network and just downloaded the blockchain but didn’t punch a hole in your own firewall to allow other nodes to find and initiate inbound connections to you… you might never show up on that map. My node isn’t on there.</p>

<p>So that number is a <em>floor</em>, not the actual number. You probably can’t ever know the actual number.</p>

<p>Does a minimum of 4,697 nodes across the globe count as proof that it’s simple-enough that people will actually do it?</p>

<p>Well, it looks like at the time of writing this,</p>

<table>
  <thead>
    <tr>
      <th>Coin</th>
      <th>Minimum Reachable Node Count</th>
      <th>Blockchain Size</th>
      <th>Total Transactions</th>
      <th>Txns/GB</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Bitcoin</td>
      <td><a href="https://bitref.com/nodes/">22,492</a><br />(4.8x)</td>
      <td>807 GB<br />(3x)</td>
      <td>1.413 Billion<br />(23x)</td>
      <td>1,752,070<br />(7x)</td>
    </tr>
    <tr>
      <td>Monero</td>
      <td><a href="https://monerohash.com/nodes-distribution.html">4,697</a></td>
      <td>267 GB</td>
      <td>62.8 Million</td>
      <td>235,000</td>
    </tr>
  </tbody>
</table>

<p><em>(Transaction counts &amp; blockchain sizes sourced from my nodes directly)</em></p>

<p>That <code class="language-plaintext highlighter-rouge">7x</code> there at the end is the part to worry about: Monero is so far, on average 7x less-efficient at storing its blockchain than Bitcoin is. So, anything Bitcoin can do, Monero is going to demand 7x as much from the people running its nodes.</p>

<p>If it had Bitcoin-level transactions, the blockchain would be over 6 <em>terabytes</em>. At 2026 prices, an SSD (which you <em>do</em> need to put the blockchain on if you want any kind of decent performance out of your node) to hold that <em>starts</em> at $1,000 for 8TB (and not a well-reviewed one).</p>

<p>A whole grand just for the storage. Whereas today, Bitcoin’s bitcoin-sized blockchain can fit on a 1TB SSD which starts around $130 - almost a tenth the price.</p>

<p>Monero has been <a href="https://www.getmonero.org/2024/04/27/fcmps.html">making progress towards a more-efficient blockchain</a> but it’s been slow going. One could imagine that it gets resolved before it has to handle Bitcoin-level traffic, just as easily as one could imagine it never does.</p>

<p>So there’s a future risk there. Not a today risk, though.</p>

<h2 id="just-coinjoin-your-bitcoin">Just CoinJoin Your BitCoin</h2>

<p>It is possible to obtain some privacy with Bitcoin transactions, through the process of a <a href="https://www.binance.com/en/academy/articles/coin-mixing-and-coinjoins-explained">CoinJoin</a>. Coinjoins involve multiple people collaborating to create a specific shape of transaction that makes it impossible to</p>

<ol>
  <li>tell who sent what to whom</li>
  <li>tell who <em>intended</em> to send what to whom</li>
</ol>

<p>You can still see that “10 people all sent 1 BTC.” You can see that “10 people all received 1 BTC from those 10.” But you can’t tell which one of the first 10 people was trying to pay which of the second 10 people. So, if you wanted to try to snoop on someone, you’d have to guess which of 10 options was actually the person you were interested in. Repeating a coinjoin a second time gives you 10 more options for each of the first 10 - that’s 10 times 10, or 100. Coinjoin a third time, and now there are 1,000 possibilities… and so on, and so forth.</p>

<p>This is a thing you have to choose to do, and you have to do it right:</p>

<ol>
  <li>For every bitcoin you receive,</li>
  <li>CoinJoin it at least once, ideally multiple times
    <ul>
      <li>this requires at <em>least</em> ~10 minutes (one block mined) per round, but you should wait closer to 6 blocks - ~1 hour per round</li>
      <li>transactions have fees and they have to come from somewhere. You’ll pay <em>some</em> fee for the coinjoin.</li>
    </ul>
  </li>
  <li>Finally spend privately</li>
  <li>Take any change from that transaction and <code class="language-plaintext highlighter-rouge">GOTO 1</code></li>
  <li>Never, ever get impatient or make a mistake or else you lose the privacy you’ve been building.</li>
</ol>

<p>If <strong>everyone</strong> who spent bitcoin did this, it would be functionally impossible to even lay all the guesses out to pick from, let alone actually determine <em>which</em> specific path a given bitcoin took.</p>

<div class="polaroid-container">
  <div class="polaroid">
    <a href="everyone-will-not-just.png">
      <img src="everyone-will-not-just.png" alt="Everyone Will Not Just" class="polaroid-image" />
    </a>
    <div class="polaroid-title">Everyone Will Not Just</div>
    <div class="polaroid-link">
      
      <a href="https://www.tumblr.com/squareallworthy/163790039847/everyone-will-not-just" target="_blank" rel="noopener">www.tumblr.com/squareallworthy/163790039847/everyone-will-not-just</a>
      
    </div>
    <small class="polaroid-archive">
      
      &nbsp;
      
    </small>
  </div>
</div>

<p>The problem with everyone not doing this is that it only buys you <a href="https://en.wikipedia.org/wiki/Forward_secrecy">forward secrecy</a>. Once spending starts happening with coinjoins in-between, nobody can tell who’s spending what. But <em>until</em> the coinjoins start, people can still tell. So maybe you’re perfectly-disciplined about coinjoins. Someone who <em>isn’t</em> sends you some bitcoin. Anyone who sees that <em>knows</em> that you received from that person. And if that person wasn’t doing coinjoins,anyone can find out all about the people you’ve been transacting with. We don’t know what <strong>you</strong> bought, but we know Bob paid you 100,000 sats and Bob shops at the 7-11 in Townsville and works at SomeCorp. And Bob also pays his friends Alice and Eve, and they <em>also</em> live in Townsville. There’s a pretty good chance you live there, too. And when you finally do spend, if the person you pay doesn’t also do coinjoins, the forward secrecy ends with them and you’re left inhabiting a tiny island of secrecy in an ocean of public knowledge. The various <a href="https://en.wikipedia.org/wiki/Blockchain_analysis">blockchain analysis</a> firms have gotten really good at inferring information with a high degree of certainty in these situations.</p>

<p>This problem is in the shape of opt-in vs opt-out. If you have to <em>choose</em> to do something, to opt-<strong>in</strong>, most people won’t.</p>

<blockquote>
  <p>Everyone will <strong>not</strong> just.</p>
</blockquote>

<p>And thus, every part of the puzzle is assembled except your little missing piece - and a great deal of what matters about your blank space can be inferred from looking at the edges, all of which are public.</p>

<div class="polaroid-container">
  <div class="polaroid">
    <a href="missing-puzzle-piece.jpg">
      <img src="missing-puzzle-piece.jpg" alt="What could possibly go here?" class="polaroid-image" />
    </a>
    <div class="polaroid-title">What could possibly go here?</div>
    <div class="polaroid-link">
      
      &nbsp;
      
    </div>
    <small class="polaroid-archive">
      
      &nbsp;
      
    </small>
  </div>
</div>

<p>On Bitcoin, privacy is a service you procure.</p>

<p>Services require service providers and it’s been rough:</p>

<ol>
  <li>The simplest and best coinjoin solution - Samourai Whirlpool - <a href="https://en.wikipedia.org/wiki/Samourai_Wallet">landed its providers in federal prison</a>.</li>
  <li>The other major provider in the space - Wasabi Wallet - <a href="https://www.coindesk.com/tech/2022/03/14/wasabi-wallets-coinjoin-coordinator-to-blacklist-certain-bitcoin-transactions">caved to refusing certain coins</a> - presumably to protect the company and themselves - though we don’t know for sure, we <em>do</em> know what happened to Samourai, who didn’t cave.</li>
  <li>In 2026 we do have another Whirlpool provider - <a href="https://ashigaru.rs/">Ashigaru, a Samourai fork</a> - but they’re entirely on <a href="https://www.torproject.org/download/">Tor</a> and encrypted comms - they know what happens if they can be found.</li>
</ol>

<p>This privacy problem is present for any public-ledger cryptocurrency; Ethereum had a coinjoin service provider - <a href="https://en.wikipedia.org/wiki/Tornado_Cash">Tornado Cash</a> - whose developers <em>also</em> landed federal charges with one sentenced to prison and one still working their way through the courts.</p>

<p>There are other “not exactly a service” - or, you know - “peer-to-peer” coinjoin solutions like <code class="language-plaintext highlighter-rouge">joinmarket</code> - no link there because the domain’s been yanked. No “Seized” notice, just… gone. And its source code archived. What happened? I don’t know.</p>

<p>Someone forked it into <a href="https://github.com/joinmarket-ng/joinmarket-ng">joinmarket-ng</a> though, so you could still make the attempt.</p>

<p>So that’s great, right? Chop off the heads of the hydra, and Ashigaru and joinmarket-ng grow back with lessons learned?</p>

<blockquote>
  <p>Heads that regenerate after decapitation are not the same as having no neck.</p>
</blockquote>

<p>On Bitcoin, privacy is a service you procure. On Monero, privacy is a property of the network that you cannot decline.</p>

<p>On Bitcoin, you must choose to procure privacy, and even the act of choosing is a signal that stands out against the default. On Monero, privacy is normal and having privacy doesn’t make you stand out from your peers.</p>

<h2 id="the-second-wrinkle">The Second Wrinkle</h2>

<p>I claimed there were two wrinkles in Ammous’s blockchain test. The first was <code class="language-plaintext highlighter-rouge">digital</code>. The second is <code class="language-plaintext highlighter-rouge">cash</code>.</p>

<p>Or, rather, money. Everything up to that point assumes that the only thing anyone ever would or could do on a blockchain is “money.”</p>

<p>There’s a nuanced difference (here, at least) between <em>money</em> and <em>currency</em>. Money is the store of value, the unit of account. Currency is the medium of exchange. These two distinct roles are <em>often</em> filled by the same thing - but not always.</p>

<p>Nowadays, your retirement account (if you have one…) is likely denominated in US dollars, but it’s actually held in stocks, bonds, and other assets (Bitcoin, perhaps…?). You fork over some dollars at a coffeeshop; dollars are currency for spending. You don’t send someone a fractional share of NVDA for that same coffee - your retirement account, as a long-term store of value play, seeks a different kind of asset where spendable liquidity isn’t paramount.</p>

<p>By those definitions, Monero is a currency where privacy is a first-class property. It’s not a money. But that’s not even the full wrinkle.</p>

<p>There’s another example of a useful token that contrasts much more starkly: <a href="https://www.filecoin.io/">Filecoin</a>.</p>

<p>Filecoin is the token that powers a distributed, peer-to-peer storage network. It’s like cloud storage, but across an ever-changing, decentralized network of computers. You don’t mine it with the “proof of work” that Bitcoin and Monero do - you get paid for providing storage that is proven to be available and to actually contain what it claims to store. And you “spend” Filecoin to rent storage on the network.</p>

<p>Cloud storage has turned out to be an undeniably useful thing. Multibillion dollar businesses exist to provide it. Not just <a href="https://aws.amazon.com/s3/">Amazon S3</a>, but Google’s <a href="https://cloud.google.com/storage">Cloud Storage</a> and the retail-facing <a href="https://www.icloud.com/storage">iCloud</a>, <a href="https://www.microsoft.com/en-us/microsoft-365/onedrive/online-cloud-storage">OneDrive</a>, and <a href="https://www.dropbox.com/">Dropbox</a> to name just a few.</p>

<p>Incidentally, the storage cost on those platforms (well, at least <a href="https://aws.frankcontrepois.com/s3PriceHistory">S3</a>) has fallen over time.</p>

<p>Is Filecoin a shitcoin? <a href="https://blog.archive.org/2023/10/20/celebrating-1-petabyte-on-the-filecoin-network/">The Internet Archive has put a petabyte of material on it</a> - including U.S. government web crawls that vanish between administrations. If you’re the Archive, you want that storage cheap. As an <em>investment vehicle</em> though? <a href="https://coinmarketcap.com/currencies/filecoin/">Absolutely</a>. Its price chart is not what you’d want your savings account to look like. But that’s okay; it’s not <em>supposed to</em> be money.</p>

<p>It’s the price of cloud storage.</p>

<p>And I love it when useful things - like computers, washing machines, television sets, cloud storage and privacy - get cheaper.</p>]]></content><author><name>Texarkanine</name></author><category term="essay" /><category term="bitcoin" /><category term="economics" /><category term="monero" /><category term="privacy" /><summary type="html"><![CDATA[I love it when useful things like computers and Monero get cheaper over time.]]></summary></entry><entry><title type="html">Cold Wallets Are Born Hot</title><link href="https://blog.cani.ne.jp/2026/08/06/cold-wallets-born-hot.html" rel="alternate" type="text/html" title="Cold Wallets Are Born Hot" /><published>2026-08-06T00:00:00+00:00</published><updated>2026-08-13T17:41:41+00:00</updated><id>https://blog.cani.ne.jp/2026/08/06/cold-wallets-born-hot</id><content type="html" xml:base="https://blog.cani.ne.jp/2026/08/06/cold-wallets-born-hot.html"><![CDATA[<p>WiFi is literally “airgapped.” Check the gap between your laptop and your router: nothing in it but breathable air. If the word meant what it says, every coffee shop on Earth would be running military-grade security.</p>

<p>The air was never the point. Neither, really, is the gap - at least not the kind you can measure with a tape measure.</p>

<h2 id="the-gap-is-made-of-time">The Gap Is Made of Time</h2>

<p>What <a href="https://en.wikipedia.org/wiki/Air_gap_(networking)">“airgapped”</a> actually names is the removal of any bidirectional channel. The air contributes nothing; the missing return path contributes everything. Industry will even sell you the property without the air - a one-way cable called a <a href="https://en.wikipedia.org/wiki/Unidirectional_network">data diode</a>, popular with power plants.</p>

<p>But a diode is still a live connection, and live is the thing worth escaping. Powered electronics radiate their activity into the room: in 1985, <a href="https://en.wikipedia.org/wiki/Van_Eck_phreaking">Wim van Eck reconstructed</a> what a CRT display was showing from its stray emissions, using a modified TV set - and the NSA’s <a href="https://en.wikipedia.org/wiki/Tempest_(codename)">TEMPEST</a> program had already spent decades classifying that kind of leak. By 2023, researchers were <a href="https://eprint.iacr.org/2023/923">pulling a 256-bit ECDSA key</a> - a signature scheme used by some bitcoin outputs - off a smartcard, by pointing a hijacked security camera at the <em>power LED</em> of the card reader from sixteen meters away.</p>

<blockquote>
  <p>You’ve got to kill the channel.</p>
</blockquote>

<p>Once no live channel exists, the two sides have to communicate through a separate, durable artifact - an SD card, a QR code, a piece of paper. The traditional “airgap” property of such a durable artifact is that it only contains the cryptographically-irreversible <em>outputs</em> of the actual secrets. Squint as you might at the signature, the keys simply aren’t there.</p>

<p>The much-stronger property that isn’t given as much airtime because it’s incidental and unavoidable is that a durable artifact can <em>wait</em>. That means you can separate the two sides by any amount of space, or any amount of <em>time</em>. When you pop the SD card out of your signing device and walk it to your laptop, that thirty-second walk could just as easily be a week. Sign the transaction, drop the card in a drawer, broadcast it next spring. Unless the attacker has a time machine or the ability to break causality - in which case your wallet is probably not your biggest problem - there is no reaching back through the broadcast to the device that signed it.</p>

<p>No live channel yet invented, wired or wireless or optical, offers that guarantee.</p>

<h2 id="the-whole-warranty">The Whole Warranty</h2>

<blockquote>
  <p>From this moment forward, nothing reaches in.</p>
</blockquote>

<p>That’s it. That’s the entire warranty. It is causal, it points forward only, and it says nothing - <strong><em>nothing</em></strong> - about whether something already got out or was never secret to begin with.</p>

<h2 id="the-one-live-moment">The One Live Moment</h2>

<p>Everything a <a href="https://en.wikipedia.org/wiki/Cryptocurrency_wallet#Cold_storage">cold wallet</a> does can wait. Receiving needs no signature at all. Signing waits patiently on your side of the gap; broadcasting waits just as patiently on the other. But one event cannot be deferred: the birth of the key. Entropy happens once, inside the device, at an instant you cannot inspect, cannot repeat, and cannot postpone. It is the only live event in a system whose entire pitch is that it has no live events.</p>

<blockquote>
  <p>Every airgapped system has exactly one moment it cannot airgap: the birth of the secret.</p>
</blockquote>

<p>The protection opens just after that instant and points forward only. Everything upstream of it - the random number generator, the firmware, the entropy source - sits inside the trusted perimeter by construction.</p>

<p>Two big cold-storage failures attacked exactly that moment.</p>

<h2 id="nobody-touched-a-device">Nobody Touched a Device</h2>

<blockquote class="link-card">
  
  <h1>What we know about ongoing Coldcard hack that&#39;s stolen over $100M worth of bitcoin</h1>
  
  <a href="https://www.cbc.ca/news/world/bitcoin-coinkite-security-hack-9.7295582" target="_blank" rel="noopener">www.cbc.ca/news/world/bitcoin-coinkite-security-hack-9.7295582</a>
  
  <small class="link-card-archive">
    (<a href="https://web.archive.org/web/20260812010708/https://www.cbc.ca/news/world/bitcoin-coinkite-security-hack-9.7295582" target="_blank" rel="noopener">archive</a>)
  </small>
  
</blockquote>

<p><a href="https://coldcard.com/">Coldcard</a> is a bitcoin-only hardware wallet, widely praised as one of the most secure ways to hold bitcoin: keys generated on the device, never touching the internet, transactions ferried across the gap on an SD card. On July 30, 2026, its maker <a href="https://blog.coinkite.com/coldcard-mk3-seed-generation-warning/">Coinkite told users to move their funds <em>now</em></a>. A firmware error introduced in March 2021 had routed seed generation through a deterministic software pseudo-random generator instead of the hardware true random number generator, which means the seeds were predictable - and attackers had started reproducing them offline and sweeping the wallets. By August 3, <a href="https://x.com/glxyresearch/status/2084411904924045370">Galaxy Research’s on-chain tally</a> stood at three confirmed attack waves - 1,596 bitcoin gone from roughly 7,300 addresses, over $100 million US - with a suspected fourth wave that would push the total toward 2,055 bitcoin, roughly $130 million. And counting as of this writing.</p>

<p>Notice what’s missing from that story: nobody touched a device. Every Coldcard sat exactly as offline as its owner believed it was, the whole time, while the attack ran as pure arithmetic against public chain data. The airgap held. It just never overlapped the failure, because the failure happened at the one moment no airgap covers.</p>

<h2 id="the-mouse-was-theater">The Mouse Was Theater</h2>

<blockquote class="link-card">
  
  <h1>Disclosure: Key generation vulnerability found on WalletGenerator.net - potentially malicious</h1>
  
  <a href="https://medium.com/mycrypto/disclosure-key-generation-vulnerability-found-on-walletgenerator-net-potentially-malicious-3d8936485961" target="_blank" rel="noopener">medium.com/mycrypto/disclosure-key-generation-vulnerability-found-on-walletgenerator-net-potentially-malicious-3d8936485961</a>
  
  <small class="link-card-archive">
    (<a href="https://web.archive.org/web/20250419222951/https://medium.com/mycrypto/disclosure-key-generation-vulnerability-found-on-walletgenerator-net-potentially-malicious-3d8936485961" target="_blank" rel="noopener">archive</a>)
  </small>
  
</blockquote>

<p>The same thing happened eight years earlier, to the humblest cold storage there is: the paper wallet. <code class="language-plaintext highlighter-rouge">WalletGenerator.net</code> generated keys in your browser so you could print them and go fully offline. Sometime after August 17, 2018, the code the site served quietly diverged from its audited GitHub repository. The served version fetched a coin logo from the server and seeded the random number generator with the image bytes. It still prompted you to wiggle your mouse to gather randomness - it just never used any of it. When MyCrypto’s researchers asked it for a thousand keys in bulk, they got 120 unique ones.</p>

<p>Print the wallet, laminate it, lock it in a safe deposit box. Same air, same gap, <strong>same nothing.</strong></p>

<h2 id="neither-audits-the-mint">Neither Audits the Mint</h2>

<p>You already run this kind of security in meatspace. Buying a car with cash, you bring exactly the cash you mean to spend, so a greedy counterparty (or <a href="https://reason.com/2021/09/02/lawsuit-challenging-houston-asset-forfeiture-program-says-police-use-stock-language-to-seize-cash-without-probable-cause/">civil-forfeiture road pirate</a>) can take no more than you brought. Paying by check, you hand over authorization for one amount, once, instead of handing over the account.</p>

<p>A check bounds the <em>amount</em> an adversary can extract. An airgap bounds the <em>window</em> in which they can try. Both are exposure limits, both are real, and both are worth having. Neither audits the mint. If the bank printed your checkbook with guessable account numbers, every careful check you write is genuine discipline, correctly executed… and completely ineffective. That’s Coldcard. That was WalletGenerator. Flawless check-writing against a compromised checkbook.</p>

<h2 id="breathable-air-on-planet-earth">Breathable Air on Planet Earth</h2>

<p>“Airgapped” has drifted into a synonym for “safe,” and it never fully guaranteed that - though it used to mean much safer than it means today. Technically it means <em>no bidirectional channel</em>. Practically it buys you <em>separation in time</em>, the strongest isolation any physical channel yet discovered can offer. Neither meaning says one word about whether your key was ever unpredictable.</p>

<p>The guarantee an airgap <em>does</em> offer is still real and nothing live can match it. If you skip the gap because it’s inconvenient, fine - but be cognizant any substitute <em>isn’t.</em> Skipping the airgap is admitting “I just don’t care that much” (which is sometimes fine!)</p>

<p>Just remember what you bought. It’s not a mixture of O<sub>2</sub> and N<sub>2</sub> that’s protecting you, it’s temporal causality.</p>]]></content><author><name>Texarkanine</name></author><category term="blog" /><category term="essay" /><category term="bitcoin" /><category term="cold-storage" /><category term="security" /><category term="threat-modeling" /><summary type="html"><![CDATA[The Coldcard and WalletGenerator disasters both attacked the one moment no airgap can cover: the birth of the secret. An airgap bounds your window of exposure - it cannot audit the mint.]]></summary></entry><entry><title type="html">The Disintegration of the Persistence of MEMORY.md</title><link href="https://blog.cani.ne.jp/2026/08/03/disintegration-of-persistence-of-memory-md.html" rel="alternate" type="text/html" title="The Disintegration of the Persistence of MEMORY.md" /><published>2026-08-03T00:00:00+00:00</published><updated>2026-08-03T17:58:43+00:00</updated><id>https://blog.cani.ne.jp/2026/08/03/disintegration-of-persistence-of-memory-md</id><content type="html" xml:base="https://blog.cani.ne.jp/2026/08/03/disintegration-of-persistence-of-memory-md.html"><![CDATA[<p><a href="https://en.wikipedia.org/wiki/The_Disintegration_of_the_Persistence_of_Memory"><img src="salvador-dali_the-disintegration-of-the-persistence-of-memory.jpg" alt="The Disintegration of the Persistence of Memory - Salvador Dali, 1954" /></a></p>

<p>Show up for your first day at any functioning organization and the same five things (should) happen, in roughly the same order.</p>

<ol>
  <li>You arrive remembering what your job is here and what you were doing yesterday.</li>
  <li>Someone hands you the binder: here’s how we do things around here.</li>
  <li>You get a ticket: here’s your task, here are its files.</li>
  <li>When you finish, it gets filed, so that when someone later asks “what did we do for the X widget?”, the answer doesn’t depend on who’s still employed.</li>
  <li>And when something looks wrong - “wait, how did it get like <em>this</em>?” - there’s a record for when it changed and footage for how and why.</li>
</ol>

<p>That ladder is the memory apparatus of an institution - and cognitive science already has a name for each rung:</p>

<ol>
  <li><strong><a href="https://en.wikipedia.org/wiki/Autobiographical_memory">Autobiographical memory</a></strong> - what I do here, what I was doing yesterday: recent episodes fused with self-knowledge, weighted toward the present, fading as it goes.</li>
  <li><strong><a href="https://en.wikipedia.org/wiki/Procedural_memory">Procedural memory</a></strong> - how we do things here. In a person this knowledge is implicit - you can ride the bike but not recite how - which is exactly why the binder exists: procedure forced into words so it can be handed to someone whose hands don’t know it yet.</li>
  <li><strong><a href="https://en.wikipedia.org/wiki/Prospective_memory">Prospective memory</a></strong> - remembering <em>to do</em>: buy the milk, send the report. Psychologists split it into remembering <em>that</em> something needs doing and <em>what</em> it is - and the institution splits it the same way: the ticket holds the what, and the directive to “work your queue” supplies the that.</li>
  <li><strong><a href="https://en.wikipedia.org/wiki/Semantic_memory">Semantic memory</a></strong> - facts detached from the experience of learning them. The archive answers “what did we do for the X widget?” without needing anyone who was there.</li>
  <li><strong><a href="https://en.wikipedia.org/wiki/Episodic_memory">Episodic memory</a></strong> - re-living a specific event in order: when it changed, how, and why. Audit logs and security camera footage.</li>
</ol>

<p>No rung <em>is</em> the memory class it serves. The binder holds procedure as explicit words precisely because hands can’t be photocopied; the queue holds intentions outside any head so they reload reliably into whichever head takes the ticket. Each rung is a serialization format: a memory class offloaded from one human, stored durably, and deserialized back into the native faculty of another.</p>

<p>Notice one class is absent: <a href="https://en.wikipedia.org/wiki/Working_memory">working memory</a>, the scratch paper you think on. No institution keeps a scratch-paper archive, and that absence matters later.</p>

<p>Before I name a single tool, place your own agent setup on the ladder. Which rungs do your agents have in the institution you’ve built around using them? Which are missing? The missing ones matter unequally: exactly one of them cannot wait.</p>

<h2 id="six-stores-five-rungs">Six Stores, Five Rungs</h2>

<p>Here’s my stack, rung for rung:</p>

<ol>
  <li><strong>Autobiographical</strong>: <a href="https://github.com/Texarkanine/OptMem-Split">OptMem-Split</a>.
    <ul>
      <li>While working, the agent records what it judges worth remembering, and at the start of every session its recent memories are pushed into context - what I do here, what I was doing yesterday.</li>
    </ul>
  </li>
  <li><strong>Procedural</strong>: <a href="https://github.com/Texarkanine/.cursor-rules/tree/main/rulesets/niko#nikos-memory-bank">Niko’s memory bank</a> forms rungs 2 through 4, starting with the <a href="https://github.com/Texarkanine/.cursor-rules/tree/main/rulesets/niko#persistent-files">persistent files</a> as the binder.
    <ul>
      <li>Product context, system patterns, tech stack. “What this repo is and how we do things here.”</li>
    </ul>
  </li>
  <li><strong>Prospective</strong>: the <a href="https://github.com/Texarkanine/.cursor-rules/tree/main/rulesets/niko#ephemeral-files">ephemeral memory-bank files</a> in <code class="language-plaintext highlighter-rouge">memory-bank/active</code> are the ticket.
    <ul>
      <li>The current task, the plan for how to tackle it, and progress so far.</li>
    </ul>
  </li>
  <li><strong>Semantic</strong>: the archives in <code class="language-plaintext highlighter-rouge">memory-bank/archive</code>.
    <ul>
      <li>One completion record per task, finer-grained than an ADR.</li>
    </ul>
  </li>
  <li><strong>Episodic</strong>, split in two:
    <ul>
      <li>Niko’s enforcement of git commits throughout the workflow becomes an <em>audit log</em> that answers <em>what</em> and <em>when</em>.</li>
      <li><a href="https://github.com/Texarkanine/stockroom">Stockroom</a> is the “security-camera footage” answering <em>how</em> and <em>why</em>: every agent conversation across every <a href="/garden/ai-horses.html">harness</a>, queryable by SQL and semantic search.</li>
    </ul>
  </li>
</ol>

<p>I didn’t pick these tools specifically to fulfill this taxonomy, by the way - I assembled the stack empirically and noticed the isomorphism afterward.</p>

<p>Exactly one store is <strong>pushed</strong>: OptMem’s memories arrive unbidden at session start, under a hard cap. Everything else is pulled - the active ticket by a nudge (that’s the <em>that</em> half of prospective memory, supplied by the Niko entrypoint skills), the archive, the git history, and the footage only when the agent goes digging. That ratio is a staffing decision, not an implementation detail: push more than one rung and you’ve reinvented <a href="https://medium.com/@ZoinerTejada/ai-context-stuffing-pattern-in-prompt-engineering-fb26d0378c64">context stuffing</a> with extra steps.</p>

<h2 id="the-notepad-isnt-on-the-org-chart">The Notepad Isn’t on the Org Chart</h2>

<p>The ambient advice for “give your agent memory” is a markdown file the model appends to. <a href="https://code.claude.com/docs/en/memory#auto-memory">MEMORY.md</a>, a notes section at the bottom of <code class="language-plaintext highlighter-rouge">AGENTS.md</code>, or a directory full of such files - the shape is the same: a shared, unordered notepad. Where does it sit on the ladder? Nowhere. Every rung has some rules about how to use it:</p>

<ol>
  <li><strong>Write Rule:</strong> what gets recorded, by whom, when</li>
  <li><strong>Read Trigger:</strong> what causes an entry to resurface</li>
  <li><strong>Retention Policy:</strong> what ages out, compresses, or gets promoted</li>
</ol>

<p>The notepad has none of the three. Anything may be appended at any time, nothing determines when an entry is seen again, and nothing ever leaves. At least not <em>predictably</em>. Someone will tell me their <code class="language-plaintext highlighter-rouge">MEMORY.md</code> is very tidy; tidiness doesn’t supply a write rule.</p>

<p>The generous reading is that the notepad persists the context window. But the context window is working memory - scratch paper. You scribble on it while solving the problem; then you solve the problem, record the solution somewhere governed - the ticket, a commit, the archive - and throw the scribbles away. They should not be preserved - at least, the institution should not be designed to rely on their preservation. If anyone later needs to know how you arrived at the solution, the napkin won’t tell them; the recording of the meeting will, and that’s rung 5’s job. A <code class="language-plaintext highlighter-rouge">MEMORY.md</code> is a photograph of the whiteboard at the end of the meeting: the messy end-result of a live context, now stripped of the liveness that made it and less-intelligible for it. A screenshot of a Zoom call.</p>

<h2 id="the-rung-nobody-needed-to-build">The Rung Nobody Needed to Build</h2>

<p>Institutions did not arrive at the ladder by theorizing. They arrived at it by failing, and the binder rung has the best-documented failure of all.</p>

<p>During the Second World War, bombs were going off inside British munitions factories.<sup id="fnref:1"><a href="#fn:1" class="footnote" rel="footnote" role="doc-noteref">1</a></sup> The government’s remedy was the binder, enforced: to be a supplier you wrote down your procedures, your workers were inspected against what you wrote, and a state inspector audited the whole method. The bombs stopped going off in the factories. That seed grew through military procurement standards - the US <a href="https://www.cshindustries.com/wp-content/uploads/2016/05/mil-q-9858a-the-origin-of-iso-9001.pdf">MIL-Q-9858</a> in 1959, the UK’s <a href="https://standards.globalspec.com/std/1657849/def-stan-05-21">Def Stan 05-21</a> - into the British Standard <a href="https://knowledge.bsigroup.com/products/quality-systems-specification-for-design-development-production-installation-and-servicing">BS 5750</a> in 1979, which in 1987 became the <a href="https://en.wikipedia.org/wiki/ISO_9000_family">ISO 9000 series</a>. Its flagship, ISO 9001, now counts over one million certified organizations across essentially every industry on Earth. “Here’s how we do things here” got standardized planet-wide because the failure mode that preceded it left craters.</p>

<p>The other rungs have similar origin stories: ticketing, record-keeping, and audit trails all got formalized after failures made them non-optional. But notice what’s missing. No institution ever built rung 1. There is no ISO standard for autobiographical memory, no document control number for “remember what you were doing yesterday.” Nobody wrote one, nobody productized it, no vendor category exists - because every hire arrives with it pre-installed. Humans come with yesterday’s salience for free. The one memory that never needed institutionalizing got a name anyway: we call it experience, and we price it in salary bands.</p>

<p>The four rungs institutions built are also exactly the four you can construct from records - which is what made them institutionalizable in the first place. A binder can be written from what practitioners already know. An archive can be assembled from what the tickets say. An audit trail accretes from artifacts that exist anyway. Whatever required a live judgment in the moment could never be turned into a document control standard, so it stayed in people’s heads. Salience never got a serialization format.</p>

<p>Machine agents show up with rung 1 empty. The one rung with no institutional precedent to copy is the one your agents are missing - and it’s also the only one you cannot start late.</p>

<h2 id="adopt-it-six-months-late">Adopt It Six Months Late</h2>

<p>The test: for each rung, suppose you become a believer six months from now instead of today. What have you permanently lost?</p>

<table>
  <thead>
    <tr>
      <th>Store</th>
      <th>Loss if adopted six months late</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>git history</td>
      <td>Zero. It has been recording whether or not you believed in it.</td>
    </tr>
    <tr>
      <td>The binder</td>
      <td>Zero. Arguably better written later, with more system to describe.</td>
    </tr>
    <tr>
      <td>The ticket</td>
      <td>Zero. Point an agent at whatever tracking you already have, whenever.</td>
    </tr>
    <tr>
      <td>The archive</td>
      <td>Near zero. The facts survive in tickets and commits; some texture fades.</td>
    </tr>
    <tr>
      <td>The footage</td>
      <td>Real, but mechanical. See below.</td>
    </tr>
    <tr>
      <td>Autobiographical (rung 1)</td>
      <td>Total. See below.</td>
    </tr>
  </tbody>
</table>

<p>Four zeros. Most of agent memory is safe to procrastinate on, and anyone who tells you otherwise is selling something.</p>

<p>A recorder you weren’t running can’t be re-run, so the footage loss is real - but it’s mechanical, and mechanical loss is the fixable kind, because the raw material usually still exists. Your harnesses have been writing conversation logs to disk all along; that’s why Stockroom, once I built it, backfilled my entire agent history from those files, including formats the harnesses had already abandoned. In <a href="/2026/08/01/just-try-the-thing.html">Just Try the Thing</a> I described that as the tool paying <em>backwards</em>. Come around to the importance of reviewing footage in six months and you’ll backfill in an afternoon and be nearly whole - because the cameras were running all along.</p>

<p>Rung 1’s loss is total, and “you’ll have zero memories on day one” understates it: no process, at any budget, can manufacture what would have been there.</p>

<h2 id="salience-is-a-fold-not-a-map">Salience Is a <code class="language-plaintext highlighter-rouge">fold</code>, Not a <code class="language-plaintext highlighter-rouge">map</code></h2>

<p>Suppose you have perfect transcripts - “footage” of every session for the past six months. Suppose the exact model that ran those sessions is still being served, so the judge is the same. Suppose token cost is no object. Replay the whole six months through the API, and at each juncture ask the model: what here is worth remembering? Write its answers into your store, timestamped into the past. You now hold six months of memories.</p>

<p>Do you, though?</p>

<p>The five backfillable stores are <a href="https://en.wikipedia.org/wiki/Map_%28higher-order_function%29">maps</a> over history: each record is a function of the events it describes, so you can compute any record, in any order, at any distance from the events. That’s the mathematical reason the audit table has four zeros in it, and it’s the property the replay is betting on.</p>

<p>Rung 1 is a <a href="https://en.wikipedia.org/wiki/Fold_%28higher-order_function%29">fold</a> (also known as <code class="language-plaintext highlighter-rouge">reduce</code>). Each salience judgment took two inputs: the moment being judged, and the accumulator: every memory recorded so far, pushed into the very context doing the judging. The agent that evaluated week ten had been shaped, at the start of every session, by what it wrote in weeks one through nine. So run the replay and watch it invalidate itself: the first memory it writes changes the context in which the second judgment should have been made, and the transcript you’re replaying was recorded in a world where that memory didn’t exist. Rewrite commit three and every SHA downstream changes. There is no fixed set of memories waiting to be recovered, because the memories, had they existed, would have changed everything downstream of them - including which memories came next. The replay doesn’t reconstruct your history; it manufactures a history that never happened.</p>

<p>And even granting the manufactured set, it’s missing the thing that made the originals memories: none of it ever influenced anything. A real rung-1 memory earned its place by steering the decisions that came after it. The replayed entries are receipts for judgments that never occurred - downstream of nothing, upstream of nothing.</p>

<p>So the artifact was never the memory. The judgment was - the live, in-context act of deciding <em>this matters</em>, in time for it to matter. The store is a receipt. Rungs 2 through 5 backfill because their contents are records of events, and events leave evidence. Rung 1 doesn’t, because its contents are records of judgments, and the judgment is the perishable part.</p>

<h2 id="the-shape-not-the-tool">The Shape, Not the Tool</h2>

<p>I use OptMem for rung 1. You don’t need OptMem. You need something, anything, in its shape:</p>

<ol>
  <li>An agent judges salience <a href="https://en.wikipedia.org/wiki/In_medias_res">in medias res</a> - during the work, while the judgment can still steer it.</li>
  <li>Judgments are written to a temporally-aware store.</li>
  <li>Recent entries get priority.</li>
  <li>Older entries get compressed.</li>
  <li>What’s pushed into context is hard-capped.</li>
  <li>Older memories can be resurfaced - progressively disclosed - when judged relevant.</li>
</ol>

<p>Nothing in that list names a vendor, a file format, or a harness; five people could implement it five different ways, which is how you know it describes a class of memory and not a product. It’s also the sieve I’d hold up to the firehose of memory tools that seem to never stop shipping. Anything that satisfies it is a rung-1 candidate, and warrants urgent attention if you don’t already have one in play. Anything else can wait (<a href="/2026/08/01/just-try-the-thing.html">but maybe shouldn’t</a>).</p>

<h2 id="open-positions">Open Positions</h2>

<p>I may have overfit: own five pets and you will discover there are exactly five kinds of animal. So, a redaction test. Cover each store’s name tag and describe it by three properties alone - the question it answers, what triggers a write, what triggers a read. If a stranger could build a <em>different</em> tool from that description, it names a <strong>class</strong> and mine is just an <strong>instance</strong>; if the description only fits my tool, that’s a pet, not a species. All six stores survive. But covering the name tags of pets you own will never reveal that fish exist: the test checks the rungs I have, and is blind to any I never built.</p>

<p>One gap is visible already: nothing in my stack recalls associatively. The archive, the git history, the footage - all of it gets read when someone already knows to look. Human recall is involuntary; you don’t decide to remember the X widget, the X widget surfaces on its own when something rhymes with it. My agents’ recall is deliberate all the way down. That’s an open position on the org chart, and I haven’t interviewed a credible candidate yet.</p>

<h2 id="the-only-rung-with-a-start-date">The Only Rung with a Start Date</h2>

<p>Everything else on the ladder will wait for you. Write the binder when you’re ready; it improves with age. Point an agent at your tickets whenever. The cameras are running even if nobody’s checking the footage yet; backfill whenever you’re ready, from the logs your tools were keeping anyway. Four zeros and one afternoon of catch-up: that’s the honest cost of procrastinating on five-sixths of agent memory.</p>

<p>Rung 1’s clock only runs forward. Adopt it in six months and you’ll hold zero memories on day one, six months of judgments behind - and those judgments weren’t recorded-and-lost; they never happened at all.</p>

<p>So find something rung-1-shaped and <a href="/2026/08/01/just-try-the-thing.html">just try the thing</a>. Starting the clock costs pennies and minutes. Nothing you can pay later will start it retroactively.</p>

<hr />

<p><a href="https://en.wikipedia.org/wiki/The_Persistence_of_Memory"><img src="salvador-dali_the-persistence-of-memory.jpg" alt="The Persistence of Memory - Salvador Dali, 1931" /></a></p>

<hr />

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1">
      <p>Seddon, John. “A Brief History of ISO 9000.” In <em>The Case Against ISO 9000</em>. Oak Tree Press, 2000. <a href="https://beyondcommandandcontrol.com/wp-content/uploads/2015/09/a-brief-history-of-iso-9000.pdf">https://beyondcommandandcontrol.com/wp-content/uploads/2015/09/a-brief-history-of-iso-9000.pdf</a> <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>Texarkanine</name></author><category term="blog" /><category term="essay" /><category term="agentic-engineering" /><category term="ai" /><category term="cognitive-science" /><category term="context-engineering" /><category term="memory" /><summary type="html"><![CDATA[Agent memory is institutional memory: five kinds will wait for you, one has a start date, and none are MEMORY.md. You need to start remembering ASAP.]]></summary></entry><entry><title type="html">Just Try the Thing</title><link href="https://blog.cani.ne.jp/2026/08/01/just-try-the-thing.html" rel="alternate" type="text/html" title="Just Try the Thing" /><published>2026-08-01T00:00:00+00:00</published><updated>2026-08-04T16:44:54+00:00</updated><id>https://blog.cani.ne.jp/2026/08/01/just-try-the-thing</id><content type="html" xml:base="https://blog.cani.ne.jp/2026/08/01/just-try-the-thing.html"><![CDATA[<p>Just try the thing. Just go do it.</p>

<p>You don’t need me to tell you that; plenty of more-influential people and organizations have already said it.</p>

<div class="polaroid-container">
  <div class="polaroid">
    <a href="https://www.youtube.com/watch?v=ZXsQAXx_ao0" target="_blank" rel="noopener">
      <img src="labeouf-doit.jpg" alt="Just Do It" class="polaroid-image" />
    </a>
    <div class="polaroid-title">Just Do It</div>
    <div class="polaroid-link">
      
      <a href="https://www.youtube.com/watch?v=ZXsQAXx_ao0" target="_blank" rel="noopener">www.youtube.com/watch?v=ZXsQAXx_ao0</a>
      
    </div>
    <small class="polaroid-archive">
      
      (<a href="https://web.archive.org/web/20260801211805/https://www.youtube.com/watch?v=ZXsQAXx_ao0" target="_blank" rel="noopener">archive</a>)
      
    </small>
  </div>
</div>

<p>So this is your nudge to go try the thing you’ve been thinking of trying, right now.</p>

<hr />

<p>If you’re still here, well, you have time to read this post, I guess, so let me offer some new math that might spur you to action.</p>

<p>But first, some old math.</p>

<h2 id="bezos-decision-framework">Bezos’ Decision Framework</h2>

<p>Jeff Bezos, founder of Amazon, famously described in a <a href="https://www.sec.gov/Archives/edgar/data/1018724/000119312516530910/d168744dex991.htm">2016 letter to shareholders</a> a framework for making decisions: two axes, one of consequence and one of reversibility.</p>

<p>Decisions of high consequence that were irreversible were “one-way doors,” worth thorough consideration before making.
Decisions of low consequence, or that were reversible were “two-way doors” - you could go back if you got it wrong.
He advised tailoring your decision-making effort to those facts. People loved this and many have written more about it and turned it into a quadrant-based decision-making matrix:</p>

<div class="polaroid-container">
  <div class="polaroid">
    <a href="bezos-decision-matrix.jpg">
      <img src="bezos-decision-matrix.jpg" alt="Bezos&#39; Decision Matrix from Product Mindset on Substack" class="polaroid-image" />
    </a>
    <div class="polaroid-title">Bezos&#39; Decision Matrix from Product Mindset on Substack</div>
    <div class="polaroid-link">
      
      <a href="https://productmindset.substack.com/p/bezos-decision-making-framework" target="_blank" rel="noopener">productmindset.substack.com/p/bezos-decision-making-framework</a>
      
    </div>
    <small class="polaroid-archive">
      
      (<a href="https://web.archive.org/web/20260801211819/https://productmindset.substack.com/p/bezos-decision-making-framework" target="_blank" rel="noopener">archive</a>)
      
    </small>
  </div>
</div>

<p>This is, I think, a good framework.</p>

<p>Let’s imagine the far-away year of 2016.</p>

<p>You’re a software engineer at a normal company. Not a 2-person startup, not a Fortune 100. You’re working on a project. You have deadlines. You have expectations. There are other people and projects that depend on you.</p>

<p>There’s a new tool to adopt, or maybe a new framework or library to try out. Let’s plot trying to adopt it on this matrix.</p>

<p>“The cost of deciding” is what that matrix is trying to guide. So you evaluate the potential costs of the decision, if it were made. But there are costs in doing that evaluation, too! If you’re going to try Thing X and make an informed decision as to whether to adopt, you need to actually</p>

<ol>
  <li>have situations where you can apply Thing X</li>
  <li>use Thing X properly</li>
</ol>

<p>The first one may take calendar time, if there isn’t anything to try Thing X <em>on</em> right now. And the second one was guaranteed to take <em>your</em> time: you had to set up your “experiment” properly which meant learning how and when to use Thing X and being able to execute it properly so you’d get good data on whether Thing X was worth it.</p>

<p>Even if “having adopted Thing X” was an inconsequential, reversible two-way door, you might still have burnt a lot of time learning enough to give it a try. That dragged the total calculus back towards the consequential, irreversible quadrant. Right or wrong about the decision, you weren’t going to get your adoption time back. <strong>Having Decided</strong> alone was not the entirety of the cost - deciding if you should decide was itself a process with nonzero costs.</p>

<p>So, there was a huge bias for “trying the thing” to land in the bottom-left quadrant: Even if you could undo the adoption, you’d never undo the wasted time.</p>

<p><img src="bezos-sweng-2016.jpg" alt="The bottom-left quadrant - consequential, irreversible" /></p>

<p>I was in that position in 2016, and for me at least, most such decisions of, uh, consequence, landed on the “consequential” side. That is to say, if it was likely to make a meaningful difference to the project or the company, you’d want to make sure your significant consequences weren’t negative. So you’d need to adequately vet it, which took time. Time you could’ve been spending on your existing, concrete responsibilities.</p>

<p>Time you’d never get back if you were wrong.</p>

<p>There was a stretch of time where squarely half - 50%, one in two - of the “big projects” I was assigned at work, got completed to where they could be tried out or exposed to their intended audience, and then scrapped. I got paid for that time, but the company didn’t have anything concrete to show for it. Hopefully a PM and a couple software engineers learned some stuff that’d pay off in the next project but… no product. No feature. No deliverable.</p>

<p>At the surface level, “should we do this or use that” was very reversible for my employer - they indeed reversed course and didn’t use the things. And yet, there was still a real, real-world cost to the individuals involved.</p>

<p>It’s those individuals - like yourself - I’m trying to nudge.</p>

<h2 id="inhuman-speed">Inhuman Speed</h2>

<p>If you’re doing knowledge work in 2026, things are different now in a couple ways that change how you plot things on that chart and what you <em>do</em> with that information.</p>

<p>First, the cost of trying is dramatically decreased, at least as measured in</p>

<ol>
  <li>wall-clock time</li>
  <li>developer-hours</li>
</ol>

<p>Both of these come down to: AI agents can do things much faster than you. They can RTFM really fast and be ready to use the thing correctly in a matter of seconds. They can execute whole cycles in which Thing X is adopted, <em>really fast</em>. Minutes to hours. You can have a dozen projects with Thing X to compare to your last dozen without, in a day or two… with minimal attention required on your part. You can do the experiment and have a much clearer picture of the effects of a decision in a fraction of the time it used to take.</p>

<p>The previously-irreversible sunk cost of figuring out whether or not <em>to</em> figure it out, has shrunk.</p>

<p>Learning is now decoupled from trying - Agents can do the trying and show you what that looks like <em>before</em> you personally invest in learning and adopting the tool.</p>

<blockquote>
  <p>You no longer need to understand the tool yourself as a precondition to finding out if the tool is worth understanding.</p>
</blockquote>

<p>I’m not saying you never need to understand the tools or things you try. Nor am I saying you should outsource your decision-making to AI. I’m <em>observing</em> that you can get a useful stack of data - the kind that was typically required for making an informed decision - without needing to understand the tool. Competence in the adopted strategy is now optional up until it gets adopted; previously you had to be competent to even make the pitch. The cost has moved to <em>after</em> you already know if it was a good idea which means you can skip the whole risk category of “wasted time learning this thing that didn’t work out.”</p>

<p>So the downside’s shrunk way down. The upside has gone way up, too.</p>

<p>Previously, marginal gains - maybe Thing X saves you a couple hours on a week-long project - were nominally not worth it. 2 out of 40, 5%. Well, that ain’t a bad percent but nominally, it’s an hour or two. You could’ve lost that in a commute. Marginal gains multiplied through the human rate-limiter at 1x and came out to peanuts, nominally.</p>

<p>But <a href="/2026/02/06/the-load-bearing-rate-limiter-was-human.html">the rate-limiter was human</a>. Now even marginal gains multiply by big numbers across agentically-compressed time and agentically-scaled workforces. That same 5% gain stacks up to hours upon days upon weeks, nominally. Now even little improvements can have outsize effects!</p>

<p>The cost of trying went down. The benefit of trying went up. It is time to use the new math.</p>

<h2 id="i-didnt-then-i-did-it">I Didn’t, Then I Did It</h2>

<p>There’s a firehose of new things to try.</p>

<blockquote>
  <p>“<a href="https://x.com/bcherny/status/2007179832300581177">The optimal Claude Code Setup</a>,” <a href="https://github.com/mattpocock/skills">/grill-me</a>, and just recently, <a href="https://github.com/ayghri/i-have-adhd">/i-have-adhd</a>, <a href="https://github.com/uditakhourii/adhd">/adhd</a>, <a href="https://github.com/DietrichGebert/ponytail">/ponytail</a>, <a href="https://github.com/juliusbrussee/caveman">/caveman</a>, <a href="https://github.com/DietrichGebert/ponytail#faq">ponytail <em>on</em> caveman</a>, stop writing code, <a href="https://corpwaters.substack.com/p/the-ultimate-guide-to-loops">write loops</a>. Stop writing loops, <a href="https://www.aibuilderclub.com/blog/graph-engineering-vs-loop-engineering">write graphs</a>. <a href="https://claude.com/blog/the-new-rules-of-context-engineering-for-claude-5-generation-models">Delete your prompts for Opus 5</a>. Code review? A <a href="https://coderabbit.ai">cornucopia</a> of <a href="https://greptile.com">differently</a>-tuned <a href="https://www.qodo.ai/">SaaS platforms</a>, <a href="https://code.claude.com/docs/en/code-review">skills</a>, and prompts overflows every day. <a href="https://opensource.apple.com/projects/container/">Apple’s got a native container engine</a>, maybe your agents should be in <em>that</em>. <a href="https://www.mindstudio.ai/blog/what-is-gpt-5-6-sol-terra-luna-explained">GPT Terra, Sol, <em>and</em> Luna</a>. Agentic memory? The <a href="https://www.superlocalmemory.com/">cup</a> <a href="https://github.com/VictorTaelin/OptMem">runneth over</a> and <a href="https://genaiunplugged.substack.com/p/give-your-ai-agents-memory-mcp-shared">spilleth</a> onto the keyboard. <a href="https://cursor.com/docs/configuration/worktrees">Worktrees</a>? How about <a href="https://github.blog/changelog/2026-07-30-stacked-pull-requests-are-now-in-public-preview/">stacked pull requests</a>!</p>
</blockquote>

<p>It doesn’t stop - if anything, it’s accelerating.</p>

<p>How do you avoid the analysis paralysis? Even having avoided it, how do you find the time to fit evaluating all these things into your day? You’ve got “real” work to do, too, you know. Maybe the right answer is to just keep doing what you’ve got now. After all, it’s been working for you so far…</p>

<blockquote>
  <p>Just try the thing.</p>
</blockquote>

<p>You probably have <em>some</em> agentic engineering <a href="/garden/ai-horses.html">harness</a> open somewhere.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Hey Claude, install grill-me from &lt;paste url here&gt; and give me a short summary of how to use it. Actually install i-have-adhd from &lt;paste url here&gt; and give me a summary for both, based on that.
</code></pre></div></div>

<p>Hey your cost to try was just paid in pennies. You didn’t have to learn it yet. You’re ready to learn if it’s worth learning.</p>

<p>This can be hard to believe. Or perhaps, “hard to internalize” is more accurate.</p>

<p>Lately though, as <em>I’ve</em> been feeling the acceleration, some of my answers to my coworkers when they ask for how to deal with a problem have been quite confidently “Oh, yeah, ThingX solves that for good. [link to documentation homepage]. Try it!”</p>

<p>And then days or weeks later, they surface with the <em>same</em> problem. “Did ThingX not work? It’s how I solved that problem permanently, for me.”</p>

<blockquote>
  <p>Oh, yeah, I’ve been meaning to try that. I’ve got to get around to that someday.</p>
</blockquote>

<p>Having had the same problem(s) myself, and having solved them, I know that the time they’ve <em>already lost</em> to the problem at this point exceeds the time it would have taken to adopt ThingX. They’re not just in tech debt, they’re in negative tech equity. They will not manage to pay their way out of it by working hard, persevering through the problems until they have free time, and then taking a look at ThingX.</p>

<blockquote>
  <p>They need to <strong>just try the thing!</strong></p>
</blockquote>

<p>They’re running the old formula with the old weights. Trying new things <em>was</em> expensive. Caution <em>was</em> often a virtue. But the landscape has changed in what’s been, relative to a human career, the blink of an eye.</p>

<p>Even when I was occasionally navigating this myself, I didn’t have the epiphany until I saw my coworkers doing it; until I saw from the outside.</p>

<p>So I set myself a rule:</p>

<blockquote>
  <p>If I find a thing I want to try, I do it right then.</p>
</blockquote>

<p>I do the bemoaned context-switch. I take the interrupt. This would’ve been irresponsible a decade ago. Now, I’m discovering that it’s often irresponsible <em>not</em> to.</p>

<h2 id="receipts-such-as-they-are">Receipts, Such as They Are</h2>

<p>The strongest recent examples of this in my life come from two tools, one I built and one I adopted.</p>

<h3 id="stop-and-build">Stop and Build</h3>

<p><a href="https://github.com/Texarkanine/stockroom">Stockroom</a> is a cross-harness data warehouse I built. It ingests conversations from the two harnesses I primarily use - <a href="https://www.cursor.com/">Cursor</a> and <a href="https://www.anthropic.com/products/claude-code">Claude Code</a> - and makes them queryable via SQL and semantic search, by human or by AI. Importantly, it ingests everything on-disk, backfills data from harnesses’ previous data formats, and stays up-to-date on a schedule; my whole history across all my harnesses is there.</p>

<p>I heard the story of “the guy who pointed <a href="https://duckdb.org/">DuckDB</a> at their Claude Code history and can search it” farther back than I remember. But that sounded hard. I realized it probably wasn’t that hard and that I wanted it, when I saw <a href="https://github.com/sderosiaux/claude-warehouse">claude-warehouse</a> sometime back in April of this year. But, I primarily use Cursor. I eventually got around to making a <a href="https://github.com/Texarkanine/cursor-warehouse">cursor-warehouse</a> version, but, well, I <em>do</em> use both, so my history was fragmented (and agents had two skills and two sets of schema to manage). But building one that would not just work on both, but unify both and be ready for whichever harness came next… well that’d be significant work. So I just lived with it for a couple months. Finally I decided enough was enough and built Stockroom and it almost immediately began paying dividends. Agents knew what they’d worked on in the past, even in other harnesses, <em>even before Stockroom existed</em>.</p>

<p>This particular tool didn’t just pay off after it existed, it paid <em>backwards</em>, making things that I’d already done more valuable than they had been at the time!</p>

<blockquote>
  <p>Should’ve just <del>tried</del> built the thing.</p>
</blockquote>

<h3 id="stop-and-adopt">Stop and Adopt</h3>

<p>I saw <a href="https://github.com/VictorTaelin/OptMem">OptMem</a> the other day. It’s a “memory for your agents” tool, of which there’ve been many since the beginning of agentic development, and of which I’ve always been skeptical.</p>

<p>My skepticism wasn’t “will this be useful” but the pure, outdated “how much work am I going to have to do to change my workflow about this?” variety.</p>

<p>So when I saw OptMem and it looked plausible, I applied my new rule: Hook it up right now. I immediately realized two things:</p>

<p><strong>This kind of memory is fantastically useful for agents.</strong> I should’ve hooked something like it up long ago; I’d been running hamstrung.</p>

<p><strong>This kind of memory only accumulates <em>going forward</em> and cannot be backfilled.</strong> Exactly opposite Stockroom, every day I put off to do one more task <em>instead</em> of adopting a memory tool, I was losing data that <em>could have been</em> useful.</p>

<p>Should’ve just tried the thing.</p>

<h2 id="calculus-category-change">Calculus’ Category Change</h2>

<p>What I have come to believe is that at this “inhuman speed,” the calculation of “opportunity cost” which was and is a correct part of the one-way/two-way door framework, has changed.</p>

<p>Previously, if you had</p>

<ul>
  <li>Four things you could do: <code class="language-plaintext highlighter-rouge">{A, B, C, D}</code></li>
  <li>Time to do two of them: <code class="language-plaintext highlighter-rouge">{A, B}</code></li>
  <li>And wanted to try ThingX</li>
</ul>

<p>You’d observe that in order to learn, try, evaluate, and integrate ThingX, you’d give up A or B, and so you’d be looking at picking:</p>

<ol>
  <li><code class="language-plaintext highlighter-rouge">{X, B}</code></li>
  <li><code class="language-plaintext highlighter-rouge">{A, X}</code></li>
  <li><code class="language-plaintext highlighter-rouge">{A, B}</code></li>
  <li>or, if X didn’t work out, <em>only</em> <code class="language-plaintext highlighter-rouge">{A}</code> or <em>only</em> <code class="language-plaintext highlighter-rouge">{B}</code></li>
</ol>

<p>so you (or at least I) would be weighing “Having ThingX” <em>against</em> A or B - as a trade - since it felt much closer to a zero-sum game.</p>

<p>The naive update is that you can just have <code class="language-plaintext highlighter-rouge">{A, B, X}</code> without having to give something up. That is true, but there’s more. Due to the increase in the nominal size of the operands being multiplied even by marginal gains, each ThingX today expands the possibility space, too.</p>

<p>If you have</p>

<ul>
  <li>Four things you could do: <code class="language-plaintext highlighter-rouge">{A, B, C, D}</code></li>
  <li>Time to do two of them: <code class="language-plaintext highlighter-rouge">{A, B}</code></li>
  <li>And wanted to try ThingX</li>
</ul>

<p>go and try ThingX and you’ll likely find yourself with</p>

<ul>
  <li>Five things you could do: <code class="language-plaintext highlighter-rouge">{A, B, C, D, E}</code>, where E was enabled by ThingX</li>
  <li>Time to do three of them: <code class="language-plaintext highlighter-rouge">{A, B, C}</code></li>
  <li>ThingX in your pocket</li>
  <li>ThingY waiting to be tried</li>
</ul>

<blockquote>
  <p>Irreversibility is collapsing. The cost of not trying now exceeds the cost of trying.</p>
</blockquote>

<p>This is no longer a zero-sum game; this is a multiplicative flywheel. Spin it up.</p>

<p><strong><em>Just try the thing!</em></strong></p>]]></content><author><name>Texarkanine</name></author><category term="blog" /><category term="essay" /><category term="agentic-engineering" /><category term="ai" /><category term="decision-making" /><category term="economics" /><category term="productivity" /><summary type="html"><![CDATA[Agents collapsed the cost of trying a new tool and multiplied the upside of even small gains. Bezos' two-way doors still apply - but the old bias against trying is now the expensive choice.]]></summary></entry></feed>