<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: kohei_wada</title>
    <description>The latest articles on DEV Community by kohei_wada (@kohei_wada).</description>
    <link>https://dev.to/kohei_wada</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3842490%2Fe6c74985-243e-4aa7-a203-9bd7c938c187.png</url>
      <title>DEV Community: kohei_wada</title>
      <link>https://dev.to/kohei_wada</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kohei_wada"/>
    <language>en</language>
    <item>
      <title>Spinning a Globe in the Terminal — Watching Planes, Earthquakes, and the ISS with My Own ttymap</title>
      <dc:creator>kohei_wada</dc:creator>
      <pubDate>Mon, 02 Nov 2026 15:36:56 +0000</pubDate>
      <link>https://dev.to/kohei_wada/spinning-a-globe-in-the-terminal-watching-planes-earthquakes-and-the-iss-with-my-own-ttymap-30pa</link>
      <guid>https://dev.to/kohei_wada/spinning-a-globe-in-the-terminal-watching-planes-earthquakes-and-the-iss-with-my-own-ttymap-30pa</guid>
      <description>&lt;p&gt;I put a globe in the terminal. Type &lt;code&gt;ttymap&lt;/code&gt; and a world map gets drawn in braille characters that you move around with &lt;code&gt;hjkl&lt;/code&gt;. That alone is just an old-school terminal map viewer — but on top of it this thing can show you &lt;strong&gt;the planes flying over your house at this very moment&lt;/strong&gt;, scatter &lt;strong&gt;the last 24 hours of earthquakes&lt;/strong&gt; in color, or track &lt;strong&gt;the ISS passing overhead&lt;/strong&gt;. All inside the terminal, in 256-color braille.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5eajq03dikpib2y6jx2s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5eajq03dikpib2y6jx2s.png" alt="ttymap default view with the help panel and a satellite tracker" width="799" height="431"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The demo videos and screenshots are collected in the repo's README: &lt;a href="https://github.com/Kohei-Wada/ttymap" rel="noopener noreferrer"&gt;https://github.com/Kohei-Wada/ttymap&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What is this
&lt;/h2&gt;

&lt;p&gt;The map core is straightforward. Decode Mapbox Vector Tiles, project with Mercator, bake it into Unicode Braille with 2×4 sub-pixels per cell, and lay ANSI 256-color on top. Features are spatially indexed with an R-tree. The &lt;code&gt;mapscii&lt;/code&gt;-style "a map in your terminal" part is this one layer, and pulled out on its own it's already a respectable viewer.&lt;/p&gt;

&lt;p&gt;The controls are vim-flavored. &lt;code&gt;hjkl&lt;/code&gt; to pan, &lt;code&gt;b&lt;/code&gt;/&lt;code&gt;w&lt;/code&gt; for fast pan, &lt;code&gt;C-u&lt;/code&gt;/&lt;code&gt;C-d&lt;/code&gt; for half-screen pan, &lt;code&gt;a&lt;/code&gt;/&lt;code&gt;z&lt;/code&gt; to zoom, &lt;code&gt;gg&lt;/code&gt; for the whole world, &lt;code&gt;0&lt;/code&gt; to reset. Mouse drag and scroll work too. &lt;code&gt;:&lt;/code&gt; for the command palette, &lt;code&gt;/&lt;/code&gt; for Nominatim location search. Press &lt;code&gt;?&lt;/code&gt; and you get a live cheatsheet generated from the active keymap plus the plugins' palette entries.&lt;/p&gt;

&lt;p&gt;Up to here it's the "a map viewer that runs in the terminal" story. It gets interesting with the plugins that sit on top.&lt;/p&gt;

&lt;h2&gt;
  
  
  Showing it off with plugins
&lt;/h2&gt;

&lt;p&gt;Almost every ttymap feature is a plugin, and 18 of them ship with the runtime. The answer to "what is ttymap actually for" turns out to be this list. Here are the fun ones, grouped by purpose.&lt;/p&gt;

&lt;h3&gt;
  
  
  Watching the planet
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;aircraft&lt;/code&gt;&lt;/strong&gt; — pulls markers for the planes currently in the air from OpenSky's public ADS-B feed. The sidebar lists altitude and speed; Enter centers the map on a given aircraft. Looking at the sky over my own house, there are more of them than you'd think.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;quake&lt;/code&gt;&lt;/strong&gt; — scatters USGS magnitude-2.5+ earthquakes from the past 24 hours as colored markers. A list appears in the sidebar, and it auto-jumps to the highest-magnitude epicenter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;satellite&lt;/code&gt;&lt;/strong&gt; — tracks several objects at once like the ISS and Hubble. It fetches TLEs from CelesTrak and runs the orbit propagation (SGP4) in Lua.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;wiki&lt;/code&gt;&lt;/strong&gt; — Wikipedia geosearch. Shows nearby articles as markers and a side panel; Enter opens an extract paragraph.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Playing
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;geo_quiz&lt;/code&gt;&lt;/strong&gt; — "find this city before time runs out." A target pops up and you have about 30 seconds to get the map center as close as possible. Submit with Enter and the camera flies to a view that frames both your guess and the real city with ◎ markers and a connecting line. The score is cumulative km error (golf-style, lower is better). Easy mode shows the country, hard mode doesn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;travel&lt;/code&gt;&lt;/strong&gt; — curated tours of Japan and Italy ship out of the box, with a choreographed pre-overview → per-city loop → post-overview tour. It's driven by &lt;code&gt;ttymap.director&lt;/code&gt;, described below.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;autospin&lt;/code&gt;&lt;/strong&gt; — the camera drifts eastward at a constant rate and loops at the antimeridian. The "this is a globe" demo. Pan by hand and it stops.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;antipode&lt;/code&gt;&lt;/strong&gt; — from the palette, fly to the exact opposite point on the sphere. "Where's the other side of the Earth from where I am right now?"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;here&lt;/code&gt;&lt;/strong&gt; — IP-geolocates your current position and flies the camera home.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Networks on the map
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;traceroute&lt;/code&gt;&lt;/strong&gt; — hit &lt;code&gt;r&lt;/code&gt;, type a hostname, and as &lt;code&gt;traceroute(8)&lt;/code&gt; resolves each next hop, the route's polyline grows across the map hop by hop. The sidebar lists the hops (Enter flies to that router), there's a per-hop color gradient, and consecutive &lt;code&gt;*&lt;/code&gt; runs are collapsed in the panel. You can watch with your eyes where in the world your packets travel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ping_simulation&lt;/code&gt;&lt;/strong&gt; — a growing animation of cities pinging each other. A reference implementation for animated polyline overlays.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each plugin is a single &lt;code&gt;*.lua&lt;/code&gt; (or a directory with &lt;code&gt;init.lua&lt;/code&gt;) under &lt;code&gt;runtime/lua/plugin/&lt;/code&gt;, readable as a tutorial for writing your own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Not just a viewer
&lt;/h2&gt;

&lt;p&gt;This is the part that pushes ttymap from "interactive viewer" up to "programmable globe."&lt;/p&gt;

&lt;p&gt;First, &lt;strong&gt;config is Lua&lt;/strong&gt;. You write it in &lt;code&gt;~/.config/ttymap/init.lua&lt;/code&gt;. Unlike TOML, you can decide values with conditionals or computation — and that quietly pays off.&lt;/p&gt;

&lt;p&gt;Then, &lt;strong&gt;scriptable scenes&lt;/strong&gt;. There's &lt;code&gt;ttymap.animation.fly_to&lt;/code&gt; (frame-based pan/zoom) and &lt;code&gt;ttymap.director&lt;/code&gt; (a coroutine scheduler with &lt;code&gt;fly&lt;/code&gt; / &lt;code&gt;wait&lt;/code&gt; / &lt;code&gt;tween&lt;/code&gt;). A plugin can choreograph a multi-step camera-plus-overlay sequence as procedural Lua. A tour script looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;director&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"ttymap.director"&lt;/span&gt;

&lt;span class="n"&gt;director&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;ttymap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;notify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Starting tour"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;director&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fly&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;139&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;69&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;35&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;69&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;-- yields until the camera arrives in Tokyo&lt;/span&gt;
    &lt;span class="n"&gt;director&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                &lt;span class="c1"&gt;-- park there for ~2s at 60fps&lt;/span&gt;
    &lt;span class="n"&gt;director&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fly&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;74&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;71&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;-- glide to New York&lt;/span&gt;
    &lt;span class="n"&gt;director&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;director&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fly&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;35&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;48&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;86&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;-- on to Paris&lt;/span&gt;
    &lt;span class="n"&gt;director&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;director.fly&lt;/code&gt; yields until the camera arrives. So just by writing "fly to Tokyo → wait → fly to NY" procedurally, the asynchronous animations play out in order. This is what the &lt;code&gt;travel&lt;/code&gt; plugin actually is.&lt;/p&gt;

&lt;p&gt;User plugins go in &lt;code&gt;~/.config/ttymap/lua/plugin/&lt;/code&gt;. With Neovim-style stem-dedup, a same-named file overrides a bundled plugin.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trying it
&lt;/h2&gt;

&lt;p&gt;Single-user install, no root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/Kohei-Wada/ttymap
&lt;span class="nb"&gt;cd &lt;/span&gt;ttymap
make &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This installs &lt;code&gt;~/.cargo/bin/ttymap&lt;/code&gt; and &lt;code&gt;~/.local/share/ttymap/&lt;/code&gt; (the bundled runtime). &lt;code&gt;cargo install&lt;/code&gt; on its own doesn't place the runtime, so it fails fast with a "did you &lt;code&gt;make install&lt;/code&gt;?" message.&lt;/p&gt;

&lt;p&gt;Launch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ttymap                                       &lt;span class="c"&gt;# default position&lt;/span&gt;
ttymap &lt;span class="nt"&gt;--lat&lt;/span&gt; 35.68 &lt;span class="nt"&gt;--lon&lt;/span&gt; 139.76 &lt;span class="nt"&gt;--zoom&lt;/span&gt; 10    &lt;span class="c"&gt;# Tokyo&lt;/span&gt;
ttymap &lt;span class="nt"&gt;--style&lt;/span&gt; bright                        &lt;span class="c"&gt;# bright theme&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To "fly to my current location," call the &lt;code&gt;here&lt;/code&gt; plugin from the &lt;code&gt;:&lt;/code&gt; palette.&lt;/p&gt;

&lt;p&gt;You can also take a headless snapshot — for dashboards, cron, or pipes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ttymap snap &lt;span class="nt"&gt;--lat&lt;/span&gt; 35.68 &lt;span class="nt"&gt;--lon&lt;/span&gt; 139.76 &lt;span class="nt"&gt;--zoom&lt;/span&gt; 12               &lt;span class="c"&gt;# → stdout&lt;/span&gt;
ttymap snap &lt;span class="nt"&gt;--lat&lt;/span&gt; 35.68 &lt;span class="nt"&gt;--lon&lt;/span&gt; 139.76 &lt;span class="nt"&gt;--zoom&lt;/span&gt; 12 &lt;span class="nt"&gt;-o&lt;/span&gt; tokyo.ans  &lt;span class="c"&gt;# → file&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;snap&lt;/code&gt; emits raw xterm-256 ANSI. &lt;code&gt;cat&lt;/code&gt; the file in a compatible terminal or pipe it to &lt;code&gt;less -R&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Still WIP
&lt;/h2&gt;

&lt;p&gt;It's stable enough to use daily, but it's still WIP. CLI flags, the Lua surface, and the config schema can change without notice. Under the hood it's a 7-crate workspace on Rust 2024 edition, and the build uses &lt;code&gt;protox&lt;/code&gt; so no system &lt;code&gt;protoc&lt;/code&gt; is needed.&lt;/p&gt;

&lt;p&gt;The code and demo videos are here: &lt;a href="https://github.com/Kohei-Wada/ttymap" rel="noopener noreferrer"&gt;https://github.com/Kohei-Wada/ttymap&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post was written with AI assistance and reviewed by the author.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cli</category>
      <category>showdev</category>
      <category>sideprojects</category>
      <category>terminal</category>
    </item>
    <item>
      <title>Reproducing Copy Fail (CVE-2026-31431) in a disposable VM to feel what 732 bytes to root is like</title>
      <dc:creator>kohei_wada</dc:creator>
      <pubDate>Sun, 01 Nov 2026 15:36:08 +0000</pubDate>
      <link>https://dev.to/kohei_wada/reproducing-copy-fail-cve-2026-31431-in-a-disposable-vm-to-feel-what-732-bytes-to-root-is-like-368k</link>
      <guid>https://dev.to/kohei_wada/reproducing-copy-fail-cve-2026-31431-in-a-disposable-vm-to-feel-what-732-bytes-to-root-is-like-368k</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;A kernel local privilege escalation (LPE) vulnerability called "Copy Fail" (CVE-2026-31431) made the rounds. Social media and articles were full of "affected range is ≤6.19.12" and "run this command, and if it prints X you're vulnerable" — but deciding "we're vulnerable / safe" off a number and a one-liner felt wrong.&lt;/p&gt;

&lt;p&gt;So instead of touching a production machine, I stood up a &lt;strong&gt;disposable VM carrying a pre-patch kernel&lt;/strong&gt;, ran the official PoC for real, and felt with my own hands how easily root gets taken. Bottom line: &lt;strong&gt;a 732-byte Python one-shot took me from a non-root user to &lt;code&gt;uid=0(root)&lt;/code&gt;, no race, 100% deterministic.&lt;/strong&gt; Along the way I verified, on the spot, that the widely circulated "check" isn't actually a patch test, and that the mitigation works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't relax over a number
&lt;/h2&gt;

&lt;p&gt;When a flashy CVE like Copy Fail drops, the first things to circulate are an "affected versions list" and a one-liner that says "run this, and if X shows up you're vulnerable." Handy, but there are two traps.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The affected-range number is about "pre-fix" and doesn't reflect distro backports.&lt;/strong&gt; On Arch you have to look up your installed package's fixed version in the tracker; the upstream number alone can't tell you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The circulating "check" isn't necessarily a patch test.&lt;/strong&gt; The one I saw checked "is &lt;code&gt;algif_aead&lt;/code&gt; loaded?" But that only &lt;strong&gt;looks at whether the module used in the attack is present — it says nothing about whether the kernel is patched.&lt;/strong&gt; Even if it's not loaded, the attack path can come back if you can &lt;code&gt;modprobe&lt;/code&gt; it; even if it is loaded, a patched kernel won't escalate. So this check is a different thing from whether the vulnerability exists.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Once you notice this, you want to know "what's really true for my environment" from &lt;strong&gt;behavior&lt;/strong&gt;, not command output. But running a root-escalation PoC on a production machine is a bad idea. That's where a disposable VM comes in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Isolate the pre-patch kernel in a VM and run the real thing
&lt;/h2&gt;

&lt;p&gt;The plan is simple.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Don't touch the production machine (already patched).&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Stand up a disposable VM carrying the &lt;strong&gt;pre-patch&lt;/strong&gt; kernel.&lt;/li&gt;
&lt;li&gt;Bring in the &lt;strong&gt;official PoC&lt;/strong&gt; (primary source, not a homemade snippet) and run it there.&lt;/li&gt;
&lt;li&gt;When done, throw the whole VM away.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The key point is "a VM, not a container." A kernel LPE like Copy Fail, on success, &lt;strong&gt;also punches through container isolation&lt;/strong&gt; (the kernel is shared, so of course). If you want isolation, it has to be a VM that separates the kernel itself, or it's meaningless.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stand up a disposable VM and apply the PoC
&lt;/h2&gt;

&lt;p&gt;I prepared a cloud-init &lt;code&gt;user-data&lt;/code&gt; that seeds an SSH key into a cloud image, and booted it directly with KVM. &lt;code&gt;hostfwd&lt;/code&gt; forwards host port 2222 to guest port 22, so I SSH to &lt;code&gt;localhost:2222&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Boot a disposable VM carrying a pre-patch kernel (user-mode, SLIRP networking)&lt;/span&gt;
qemu-system-x86_64 &lt;span class="nt"&gt;-enable-kvm&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 2048 &lt;span class="nt"&gt;-smp&lt;/span&gt; 2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-drive&lt;/span&gt; &lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sandbox.qcow2,format&lt;span class="o"&gt;=&lt;/span&gt;qcow2,if&lt;span class="o"&gt;=&lt;/span&gt;virtio &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-drive&lt;/span&gt; &lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;seed.iso,format&lt;span class="o"&gt;=&lt;/span&gt;raw,if&lt;span class="o"&gt;=&lt;/span&gt;virtio &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-netdev&lt;/span&gt; user,id&lt;span class="o"&gt;=&lt;/span&gt;net0,hostfwd&lt;span class="o"&gt;=&lt;/span&gt;tcp::2222-:22 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-device&lt;/span&gt; virtio-net-pci,netdev&lt;span class="o"&gt;=&lt;/span&gt;net0 &lt;span class="nt"&gt;-nographic&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Since it's disposable, turning off SSH host key verification makes it easier to handle (a setting you'd never use in production).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;SSHOPT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"-p 2222 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"&lt;/span&gt;
ssh &lt;span class="nv"&gt;$SSHOPT&lt;/span&gt; ubuntu@localhost
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once logged into the guest, first confirm with &lt;code&gt;uname -r&lt;/code&gt; that it's the pre-patch kernel, then &lt;strong&gt;obtain the official PoC from the primary source&lt;/strong&gt; and run it. Using a PoC of clear provenance rather than copy-paste from around the web matters for being able to accurately describe what was actually reproduced.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "so easy"?
&lt;/h2&gt;

&lt;p&gt;What unnerved me most when I ran it was its &lt;strong&gt;plainness&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The PoC body is a single &lt;strong&gt;732-byte Python&lt;/strong&gt; one-shot.&lt;/li&gt;
&lt;li&gt;The trick is &lt;strong&gt;AF_ALG (the kernel crypto API) + splice&lt;/strong&gt;, rewriting the &lt;strong&gt;page cache of &lt;code&gt;/usr/bin/su&lt;/code&gt; 4 bytes at a time&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Run as the non-root &lt;code&gt;ubuntu&lt;/code&gt; user, and &lt;strong&gt;instantly &lt;code&gt;uid=0(root)&lt;/code&gt;&lt;/strong&gt;. It escalates &lt;strong&gt;with no race, 100% deterministically.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When you hear "LPE" you picture "a timing-sensitive race condition you retry a few times and rarely hit." This was different. If the conditions are met, it always goes through. Once you understand in your body that this happens "on my own machine" "if it's pre-patch," it clicks just how bad a trade it is to pin the kernel and stop updating.&lt;/p&gt;

&lt;p&gt;Another lesson: &lt;strong&gt;don't aim your fear in the wrong direction.&lt;/strong&gt; "A random &lt;code&gt;make install&lt;/code&gt; is dangerous" predates LPE, and &lt;code&gt;sudo make install&lt;/code&gt; doesn't even need an LPE — you're root from the start. What an LPE adds is the "damage spreads to the whole machine even from a non-root run" part; the premise that running untrusted code already puts your keys, tokens, and data at risk (root or not) doesn't change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Confirming the mitigation actually works
&lt;/h2&gt;

&lt;p&gt;After confirming escalation goes through, I tried the mitigation in the same VM: shutting down the attack path &lt;code&gt;algif_aead&lt;/code&gt; with &lt;code&gt;modprobe&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Kill loading of the module used in the attack (works independently of the patch)&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'install algif_aead /bin/false'&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/modprobe.d/copyfail.conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With this in place, re-running the PoC no longer escalated. &lt;strong&gt;Applying the patch is the real fix,&lt;/strong&gt; but if circumstances keep you from bumping the kernel right away, this mitigation is a fallback that works independently.&lt;/p&gt;

&lt;p&gt;When verification is done, check that no VM process is left and clean up. Since it's disposable, you can throw away the disk image and all.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pgrep &lt;span class="nt"&gt;-af&lt;/span&gt; qemu-system   &lt;span class="c"&gt;# confirm no leftovers before cleanup&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Don't swallow the affected-range number or a "run this" check whole. The check that was going around &lt;strong&gt;wasn't a patch test — it just looks at whether the attack module is loaded.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;If you're worried, don't touch production; run the real PoC in a &lt;strong&gt;disposable VM with a pre-patch kernel&lt;/strong&gt; and confirm by behavior. Because it's a threat that punches through containers, isolation must be a &lt;strong&gt;VM, not a container.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Copy Fail is &lt;strong&gt;732 bytes, non-root → root&lt;/strong&gt;, deterministic with no race. I now understand in my body that an LPE is not a "happens if you're unlucky" thing.&lt;/li&gt;
&lt;li&gt;The real fix is the patch. If you can't bump right away, blocking &lt;code&gt;algif_aead&lt;/code&gt; is a mitigation that works independently (verified effective in the VM).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://copy.fail/" rel="noopener noreferrer"&gt;Copy Fail official site (primary source for CVE-2026-31431)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://security.archlinux.org/" rel="noopener noreferrer"&gt;Arch Linux Security Tracker (look up your package's fixed version, not the upstream number)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This post was written with AI assistance and reviewed by the author.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cybersecurity</category>
      <category>infosec</category>
      <category>linux</category>
      <category>security</category>
    </item>
    <item>
      <title>Integrating 6 psychology frameworks in supernemawashi — 1 framework = 1 file parallel agents</title>
      <dc:creator>kohei_wada</dc:creator>
      <pubDate>Sun, 01 Nov 2026 06:50:20 +0000</pubDate>
      <link>https://dev.to/kohei_wada/integrating-6-psychology-frameworks-in-supernemawashi-1-framework-1-file-x-parallel-agents-35ma</link>
      <guid>https://dev.to/kohei_wada/integrating-6-psychology-frameworks-in-supernemawashi-1-framework-1-file-x-parallel-agents-35ma</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/Kohei-Wada/supernemawashi" rel="noopener noreferrer"&gt;supernemawashi&lt;/a&gt;'s &lt;code&gt;nemawashi-analyze&lt;/code&gt; skill stopped holding the profile as a single monolithic file and moved to &lt;strong&gt;1 framework = 1 file × 1 parallel agent&lt;/strong&gt;. Six psychology frameworks (defense mechanisms / Thomas-Kilmann / Transactional Analysis / Core Motivators / Cognitive Biases / Attachment Style) are each analyzed independently by an agent running in parallel. Here's why, and how I implemented it.&lt;/p&gt;

&lt;p&gt;The first supernemawashi (v1.x) wrote the profile to a single markdown file (&lt;code&gt;profile.md&lt;/code&gt;). The psychological analysis was one section inside it. That causes problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"I want to add one new framework" means rewriting the structure inside the monolithic file&lt;/li&gt;
&lt;li&gt;Every re-run of analyze &lt;strong&gt;wipes and overwrites all the other frameworks' analyses too&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;The independent signals (facts) per framework get mixed, and you lose track of which judgment came from which evidence&lt;/li&gt;
&lt;li&gt;Even if you want to parallelize, you get write contention on the one file&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  1 framework = 1 file × 1 agent
&lt;/h2&gt;

&lt;p&gt;Decompose the profile per framework.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PROFILE_DIR/&amp;lt;person-name&amp;gt;/
├── profile.md                          ← slim index (Core Pattern + summary table only)
├── facts.jsonl                          ← facts (collect's output)
└── frameworks/
    ├── defense-mechanisms.md            ← per-framework classification + evidence + DO/DON'T
    ├── thomas-kilmann-tki.md
    ├── transactional-analysis-ta.md
    ├── core-motivators.md
    ├── cognitive-biases.md
    └── attachment-style.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each framework file is &lt;strong&gt;fully independent&lt;/strong&gt;. Updating one doesn't touch the other five.&lt;/p&gt;

&lt;h3&gt;
  
  
  Make the framework registry the single source of truth
&lt;/h3&gt;

&lt;p&gt;The frameworks' slug / display name / tier have &lt;strong&gt;one table&lt;/strong&gt; as the source of truth:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Slug&lt;/th&gt;
&lt;th&gt;Display name&lt;/th&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;defense-mechanisms&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Defense Mechanisms&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;thomas-kilmann-tki&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Conflict Mode (TKI)&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;transactional-analysis-ta&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Ego States (TA)&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;core-motivators&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Core Motivators&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cognitive-biases&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Cognitive Biases&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;attachment-style&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Attachment Style&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Tier 1 = analyzed by default; Tier 2 = analyzed only when signal density is sufficient (if thin, surfaced explicitly as a Data Gap). Steps to add a new framework:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Add a row to the registry table&lt;/li&gt;
&lt;li&gt;Create &lt;code&gt;frameworks/&amp;lt;slug&amp;gt;.md&lt;/code&gt; following the contract&lt;/li&gt;
&lt;li&gt;Update the situation→framework priority mapping in &lt;code&gt;nemawashi-reply&lt;/code&gt; (if needed)&lt;/li&gt;
&lt;li&gt;Confirm the registry matches the actual files with &lt;code&gt;scripts/check-frameworks.sh&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Because a script detects drift between the registry and the real files, you can't break things by adding only one side.&lt;/p&gt;

&lt;h3&gt;
  
  
  Parallel agent dispatch
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;nemawashi-analyze&lt;/code&gt;'s flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[1] Read the shared inputs (profile.md + facts.jsonl)
        ↓
[2] Dispatch one agent per framework in the registry, in parallel
    ┌─ agent: defense-mechanisms     ──┐
    ├─ agent: thomas-kilmann-tki     ──┤
    ├─ agent: transactional-analysis ──┤  run in parallel
    ├─ agent: core-motivators        ──┤
    ├─ agent: cognitive-biases       ──┤
    └─ agent: attachment-style       ──┘
        ↓ wait for all agents to finish
[3] Synthesis pass (synchronous)
    - Update profile.md's Framework Summary table
    - Re-derive the Core Pattern
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each agent only &lt;strong&gt;reads &lt;code&gt;facts.jsonl&lt;/code&gt; and writes its own framework's &lt;code&gt;frameworks/&amp;lt;slug&amp;gt;.md&lt;/code&gt;&lt;/strong&gt;. It shares no state with the other agents. The synthesis pass only aggregates results — it doesn't re-analyze.&lt;/p&gt;

&lt;h2&gt;
  
  
  What goes in a framework file
&lt;/h2&gt;

&lt;p&gt;Each &lt;code&gt;frameworks/&amp;lt;slug&amp;gt;.md&lt;/code&gt; follows the same contract:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
framework: Defense Mechanisms
tier: 1
output_label: Defense Mechanisms
---

# Defense Mechanisms

## Purpose
(what this framework reveals)

## Classification Guidance
(how to extract signals from facts)

## Reference Table
(mechanism / definition / observable signals / DO / DON'T)

## Rule Generation
(classification → situation-indexed rules)

## Signal Tags
(the tag format embedded in the facts jsonl)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As an example, two rows from the defense-mechanisms file's reference table:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mechanism&lt;/th&gt;
&lt;th&gt;Observable Signals&lt;/th&gt;
&lt;th&gt;DO&lt;/th&gt;
&lt;th&gt;DON'T&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Rationalization&lt;/td&gt;
&lt;td&gt;long explanations on failure / "because..." chains&lt;/td&gt;
&lt;td&gt;validate the reason, then redirect to the future&lt;/td&gt;
&lt;td&gt;argue directly against the justification&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Passive Aggression&lt;/td&gt;
&lt;td&gt;"I'll do it" + no movement / strategic delay&lt;/td&gt;
&lt;td&gt;name the behavior neutrally&lt;/td&gt;
&lt;td&gt;call it "you're being passive-aggressive"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The agent cross-references facts against this table to tag signals, decides the dominant mechanism, and generates situation-indexed rules.&lt;/p&gt;

&lt;h3&gt;
  
  
  Situation categories
&lt;/h3&gt;

&lt;p&gt;DO/DON'T rules are indexed by situation category. &lt;code&gt;nemawashi-reply&lt;/code&gt; looks at the category and situation and loads only the framework files it needs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;When Requesting&lt;/td&gt;
&lt;td&gt;you're asking them for something&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;During Conflict&lt;/td&gt;
&lt;td&gt;disagreement / tension&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;When Reporting&lt;/td&gt;
&lt;td&gt;delivering good / bad news&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Routine Collaboration&lt;/td&gt;
&lt;td&gt;day-to-day interaction&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At reply time it doesn't load every framework — it narrows to the 2-3 relevant to the situation. That also cuts the context-window cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Drift between the registry and the real files&lt;/strong&gt;: at first I synced by hand, and it always broke. I moved &lt;code&gt;check-frameworks.sh&lt;/code&gt; into pre-commit for automatic detection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parallel-agent races&lt;/strong&gt;: if each agent writes the same &lt;code&gt;frameworks/&amp;lt;slug&amp;gt;.md&lt;/code&gt;, it obviously breaks. Strictly mapping 1 agent = 1 file avoids it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The temptation for agents to share state&lt;/strong&gt;: there are moments you want to "look at the other frameworks' results and adjust." Doing that regresses to the monolithic design, so it's banned — adjustment happens synchronously in the synthesis pass.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Tier 2 threshold&lt;/strong&gt;: attachment-style often has thin signal, so I made it Tier 2. The framework itself needs criteria to surface "I don't know" explicitly as a Data Gap.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Adding a new framework is 3 steps (table + file + reply mapping), touching none of the existing frameworks&lt;/li&gt;
&lt;li&gt;Re-running analyze doesn't break the other frameworks&lt;/li&gt;
&lt;li&gt;Parallel dispatch shrank wallclock to roughly one framework's worth with all 6 agents running at once&lt;/li&gt;
&lt;li&gt;Each framework file is independently readable, so you can view just the framework you want (&lt;code&gt;nemawashi-show &amp;lt;person&amp;gt; --framework=defense-mechanisms&lt;/code&gt;, etc.)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Drop the monolithic profile.md and decompose into 1 framework = 1 file × 1 agent&lt;/li&gt;
&lt;li&gt;Make the framework registry the single source of truth, and detect drift with a check script&lt;/li&gt;
&lt;li&gt;Parallel dispatch shortens wallclock; keep synthesis synchronous&lt;/li&gt;
&lt;li&gt;Situation-indexed framework loading keeps the context cost down at reply time&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/Kohei-Wada/supernemawashi" rel="noopener noreferrer"&gt;supernemawashi (GitHub)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kilmanndiagnostics.com/overview-thomas-kilmann-conflict-mode-instrument-tki/" rel="noopener noreferrer"&gt;Thomas-Kilmann Conflict Mode Instrument&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://en.wikipedia.org/wiki/Transactional_analysis" rel="noopener noreferrer"&gt;Transactional Analysis (Eric Berne)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://selfdeterminationtheory.org/" rel="noopener noreferrer"&gt;Self-Determination Theory (Deci &amp;amp; Ryan)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://en.wikipedia.org/wiki/Attachment_theory" rel="noopener noreferrer"&gt;Attachment Theory (Bowlby &amp;amp; Ainsworth)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This post was written with AI assistance and reviewed by the author.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>agents</category>
      <category>architecture</category>
      <category>programming</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>I built a way for Claude Code to report progress through my home Alexa</title>
      <dc:creator>kohei_wada</dc:creator>
      <pubDate>Sun, 01 Nov 2026 06:02:46 +0000</pubDate>
      <link>https://dev.to/kohei_wada/i-built-a-way-for-claude-code-to-report-progress-through-my-home-alexa-4moi</link>
      <guid>https://dev.to/kohei_wada/i-built-a-way-for-claude-code-to-report-progress-through-my-home-alexa-4moi</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;When I hand a long-running job to Claude Code, I can't tell what's happening unless I'm watching the screen. So I built a skill that has the Echo Dot beside me read out "I'm about to do this" and "done" in Japanese. It looks simple, but the implementation hits a string of small traps: the HA SSH add-on's sshd constraints, the Amazon-side &lt;code&gt;_speak&lt;/code&gt; APIs going silent, and &lt;code&gt;/tmp&lt;/code&gt; being a tmpfs that wipes the script. Here's the configuration I landed on after stepping on all of them.&lt;/p&gt;

&lt;p&gt;For what it's worth, this very post had its in-progress status ("writing the Japanese version," "verifying," "merging") read aloud from my study's Echo Dot by exactly this mechanism, while I was writing it.&lt;/p&gt;

&lt;p&gt;When I hand Claude Code a multi-step job (edit files → wait for CI → open a PR, etc.), I lose track of progress the moment I switch to something else. Having "about to do X" and "X is done" arrive through my ears, without going to look at the CLI, lets me do other things with peace of mind.&lt;/p&gt;

&lt;p&gt;There are several Echo Dots in the house, and HA can fire TTS. So this should have been "just call a script over &lt;code&gt;ssh homeassistant&lt;/code&gt; and make the Echo talk." In practice it snags about three times.&lt;/p&gt;

&lt;h2&gt;
  
  
  The configuration that finally worked
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Claude Code skill (alexa-report)
    ↓ ssh homeassistant
HA SSH add-on
    ↓ python3 /homeassistant/scripts/ha_speak.py
HA Core REST API (auth: JWT mint from .storage/auth)
    ↓ POST /api/services/notify/send_message
Alexa Devices integration
    ↓ notify.&amp;lt;device&amp;gt;_announce
Echo Dot speaks (chime + message)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three design decisions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Decision&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Use &lt;code&gt;_announce&lt;/code&gt;, not &lt;code&gt;_speak&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The &lt;code&gt;Alexa.Speak&lt;/code&gt; API is effectively deprecated on Amazon's side. It returns 200 OK but no sound.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mint a JWT, don't issue a new long-lived token&lt;/td&gt;
&lt;td&gt;You can mint a short-lived JWT with the &lt;code&gt;jwt_key&lt;/code&gt; in &lt;code&gt;.storage/auth&lt;/code&gt;, without growing extra tokens in the UI.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keep the script under &lt;code&gt;/homeassistant/scripts/&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/tmp&lt;/code&gt; is tmpfs. Restart HA and the script is gone.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Implementation
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Register the SSH key in the Advanced SSH add-on Configuration
&lt;/h3&gt;

&lt;p&gt;Hand-appending to &lt;code&gt;~/.ssh/authorized_keys&lt;/code&gt; doesn't stick. The add-on regenerates authorized_keys from its Configuration at startup, so a manual edit is wiped on add-on restart.&lt;/p&gt;

&lt;p&gt;HA UI → Settings → Add-ons → Advanced SSH &amp;amp; Web Terminal → Configuration → add your public key to &lt;code&gt;authorized_keys:&lt;/code&gt; → Save → Restart.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The SSH alias and the no-SFTP trap
&lt;/h3&gt;

&lt;p&gt;Client &lt;code&gt;~/.ssh/config&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Host homeassistant
  HostName 192.168.1.10
  User wada
  IdentityFile ~/.ssh/id_rsa
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The HA SSH add-on's sshd has the &lt;strong&gt;SFTP subsystem disabled.&lt;/strong&gt; &lt;code&gt;scp&lt;/code&gt; on OpenSSH 9+ assumes SFTP, so it fails with &lt;code&gt;subsystem request failed on channel 0&lt;/code&gt;. Pipe it in with &lt;code&gt;ssh + cat&lt;/code&gt; instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh homeassistant &lt;span class="s1"&gt;'cat &amp;gt; /homeassistant/scripts/ha_speak.py'&lt;/span&gt; &amp;lt; /path/to/ha_speak.py
&lt;span class="c"&gt;# or the legacy SCP protocol&lt;/span&gt;
scp &lt;span class="nt"&gt;-O&lt;/span&gt; /path/to/ha_speak.py homeassistant:/homeassistant/scripts/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. The JWT-mint script
&lt;/h3&gt;

&lt;p&gt;Using the refresh_token's &lt;code&gt;jwt_key&lt;/code&gt; (a 128-char hex) in &lt;code&gt;.storage/auth&lt;/code&gt;, mint a short-lived JWT with HS256. PyJWT isn't installed in the SSH add-on, so build it by hand with the standard library:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;

&lt;span class="n"&gt;CLIENT_NAME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claude-code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# key to select the refresh_token in .storage/auth
&lt;/span&gt;&lt;span class="n"&gt;API&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://homeassistant.local.hass.io:8123&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/homeassistant/.storage/auth&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;tok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;refresh_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;CLIENT_NAME&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="n"&gt;b64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlsafe_b64encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;separators&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;rstrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;b64&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HS256&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;typ&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;JWT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;b64&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;iss&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;iat&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="n"&gt;sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlsafe_b64encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;jwt_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;rstrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;API&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;POST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;speak&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/services/notify/send_message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
               &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;entity_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]}))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. The Claude Code skill itself
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh homeassistant &lt;span class="s1"&gt;'sudo python3 /homeassistant/scripts/ha_speak.py speak "writing the blog draft" notify.koheisannoecho_dot_max_announce'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you put a wrapper that calls this shell at each &lt;code&gt;Before&lt;/code&gt; / &lt;code&gt;After&lt;/code&gt; step inside the skill, Claude Code automatically speaks up on every long-running task.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. The Echo entity list
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Physical device&lt;/th&gt;
&lt;th&gt;entity&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Echo Dot Max (study)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;notify.koheisannoecho_dot_max_announce&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Echo Dot (living room)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;notify.echo_dot_announce&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Upstairs&lt;/td&gt;
&lt;td&gt;&lt;code&gt;notify.er_jie_announce&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;All rooms&lt;/td&gt;
&lt;td&gt;&lt;code&gt;notify.quan_bu_nobu_wu_announce&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A Japanese group name like "全部の部屋" (all rooms) gets pinyin-slugified on the HA side (&lt;code&gt;quan_bu_nobu_wu&lt;/code&gt;). Renaming the group changes the slug, so when adding a new one, the reliable move is to hit &lt;code&gt;GET /api/services&lt;/code&gt; for the notify domain and check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;Alexa.Speak&lt;/code&gt; API is silent&lt;/strong&gt;: calling &lt;code&gt;notify.&amp;lt;x&amp;gt;_speak&lt;/code&gt; returns 200 OK but makes no sound. You have to switch to &lt;code&gt;_announce&lt;/code&gt; (the downside is the chime, but there's no alternative right now).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;.storage/auth&lt;/code&gt; is root:0600&lt;/strong&gt;: &lt;code&gt;sudo python3&lt;/code&gt; is required.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;/tmp&lt;/code&gt; is tmpfs&lt;/strong&gt;: the script disappears on HA restart. Put it under &lt;code&gt;/homeassistant/scripts/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;network-online.target&lt;/code&gt; isn't re-evaluated on wake-from-suspend&lt;/strong&gt;: not directly about HA, but the trap where the DNS that TTS depends on falls over is common to systemd timers in general.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JWT lifetime is 5 minutes&lt;/strong&gt;: the premise is you mint one on every call. No need to extend it since it isn't reused.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The "I have no idea what it's doing" feeling on long Claude Code sessions is gone.&lt;/li&gt;
&lt;li&gt;I can check progress by sound alone while working on something else next to it.&lt;/li&gt;
&lt;li&gt;Since it's a skill, it works on a new project just by calling &lt;code&gt;Skill alexa-report&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;I realized the "read it aloud from the Echo Dot" interface generalizes beyond Claude Code, and started repurposing it for CI-completion notifications and long-running Bash command completions too.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The mechanism is just "ssh homeassistant → JWT mint → REST API → Alexa announce."&lt;/li&gt;
&lt;li&gt;Three traps: SFTP disabled, &lt;code&gt;Alexa.Speak&lt;/code&gt; silent, &lt;code&gt;/tmp&lt;/code&gt; volatile.&lt;/li&gt;
&lt;li&gt;Making it a skill lets you repurpose it outside Claude Code too.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developers.home-assistant.io/docs/api/rest/" rel="noopener noreferrer"&gt;Home Assistant REST API&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.home-assistant.io/docs/auth_api/" rel="noopener noreferrer"&gt;Authentication API — Home Assistant Developer Docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.home-assistant.io/integrations/alexa_devices/" rel="noopener noreferrer"&gt;Alexa Devices integration&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This post was written with AI assistance and reviewed by the author.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>automation</category>
      <category>claude</category>
      <category>iot</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Why My ESF-551 Scale Kept Disconnecting from Home Assistant: Japan's Measurement Act</title>
      <dc:creator>kohei_wada</dc:creator>
      <pubDate>Sun, 01 Nov 2026 05:45:02 +0000</pubDate>
      <link>https://dev.to/kohei_wada/why-my-esf-551-scale-kept-disconnecting-from-home-assistant-japans-measurement-act-4cko</link>
      <guid>https://dev.to/kohei_wada/why-my-esf-551-scale-kept-disconnecting-from-home-assistant-japans-measurement-act-4cko</guid>
      <description>&lt;p&gt;I connected an Etekcity ESF-551, a BLE body scale, to Home Assistant, and not a single measurement arrived. I had Claude Code chase the cause, and it ended at Article 9 of Japan's Measurement Act. Units sold in Japan have the unit switch removed because of that law.&lt;/p&gt;

&lt;p&gt;I know nothing about firmware or law. What I did was step on the scale and decide what to have it look into next. Without AI, I couldn't have done this investigation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nothing comes back when I step on it
&lt;/h2&gt;

&lt;p&gt;I used the HACS integration &lt;code&gt;etekcity_fitness_scale_ble&lt;/code&gt;, which wraps &lt;code&gt;etekcity_esf551_ble==0.8.4&lt;/code&gt;. Claude Code read the log: it showed the integration connecting, reading the HW/SW versions, writing the unit command &lt;code&gt;a5220305002b0163a10000&lt;/code&gt; (unit 0, kg), and getting disconnected about 0.4 seconds later. Then it reconnected and did it again. No notification ever came when I stepped on the scale, and every sensor stayed &lt;code&gt;unavailable&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It happened even when the command changed nothing, kg to kg.&lt;/p&gt;

&lt;h2&gt;
  
  
  Skip one command and it works
&lt;/h2&gt;

&lt;p&gt;I left the narrowing down to it too. Claude Code subscribed to &lt;code&gt;0000fff1&lt;/code&gt; from another machine with plain &lt;code&gt;bleak&lt;/code&gt; and left out only the unit write. No disconnect. While I stood on the scale, 22-byte notifications arrived about every 45 ms, around 80 of them.&lt;/p&gt;

&lt;p&gt;So Claude Code wrote a local patch that passes &lt;code&gt;display_unit=None&lt;/code&gt; and skips the write. All 14 sensors filled in. I had it write the upstream issue too, and filed it (&lt;a href="https://github.com/ronnnnnnnnnnnnn/etekcity_esf551_ble/issues/15" rel="noopener noreferrer"&gt;etekcity_esf551_ble #15&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Up to here it was a story about fixing a library bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Only the US manual has a unit setting
&lt;/h2&gt;

&lt;p&gt;I had it look for why only the Japanese version lacks a unit switch. The VeSync app has no unit setting, while the US manual has the steps.&lt;/p&gt;

&lt;p&gt;Putting the back label next to the US spec showed this was a different market variant, not just a firmware revision. Japanese devices and IoT, again, I thought.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;US ESF-551&lt;/th&gt;
&lt;th&gt;The unit I bought in Japan&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Capacity&lt;/td&gt;
&lt;td&gt;400 lb / 180 kg&lt;/td&gt;
&lt;td&gt;2–150 kg&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resolution&lt;/td&gt;
&lt;td&gt;0.05 lb / 0.02 kg&lt;/td&gt;
&lt;td&gt;100 g&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Radio approval&lt;/td&gt;
&lt;td&gt;FCC ID 2AB22-ESF551&lt;/td&gt;
&lt;td&gt;Giteki R 210-252428&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Measurement Act&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;household "正" mark&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Claude Code also pulled the Measurement Act from the e-Gov law API and read it. Article 9 forbids selling, or displaying for sale, a measuring instrument marked in non-statutory units. A scale that can show lb or st can't be sold in Japan, so manufacturers drop the unit-switch feature from the Japanese firmware. Weighing myself is neither a transaction nor a certification, so Article 8 doesn't touch the user. Only the seller is bound.&lt;/p&gt;

&lt;p&gt;The Ministry of Economy, Trade and Industry's FY2015 test-purchase report even has a real case: a vendor who didn't know pounds and stones were prohibited was told to seal the device so it couldn't be switched.&lt;/p&gt;

&lt;h2&gt;
  
  
  Whether the disconnect is deliberate, I don't know
&lt;/h2&gt;

&lt;p&gt;What I know is why the Japanese version has no unit switch. I can't tell whether the disconnect itself is intended, or just firmware that doesn't know the command and drops the link sloppily. Removing the feature is fine. I just suspect it was removed carelessly. I only have one unit, so I can't say that model identifier 12 in the manufacturer data marks the Japanese variant. For upstream I suggested registering identifier 12 with &lt;code&gt;display_unit_settable=False&lt;/code&gt; so the write is skipped, and left the call to the maintainer.&lt;/p&gt;

&lt;p&gt;An experiment that sends an unknown opcode to see how it disconnects is still undone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixed the next day
&lt;/h2&gt;

&lt;p&gt;The day after I filed the issue, the maintainer replied.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;That's interesting to know about that Japanese regulation. That would explain it I guess, and since the option doesn't even show up in the app that pretty much confirms it I think.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Library v0.8.8 and the HA integration 0.8.3 came out the same day. I updated the integration to 0.8.3, removed my local patch, stepped on the scale, and the measurements came through. That was surprisingly fast, and I'm grateful.&lt;/p&gt;

&lt;p&gt;Self-hosting Home Assistant is a chore. But it brings lessons like this one, from a bathroom scale all the way to a statute, and I'm glad I do it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post was written with AI assistance and reviewed by the author.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>hardware</category>
      <category>iot</category>
      <category>python</category>
    </item>
    <item>
      <title>I Had Claude Code on the Web Dissect Its Own Sandbox</title>
      <dc:creator>kohei_wada</dc:creator>
      <pubDate>Sat, 31 Oct 2026 15:34:08 +0000</pubDate>
      <link>https://dev.to/kohei_wada/i-had-claude-code-on-the-web-dissect-its-own-sandbox-4jde</link>
      <guid>https://dev.to/kohei_wada/i-had-claude-code-on-the-web-dissect-its-own-sandbox-4jde</guid>
      <description>&lt;p&gt;I have a Claude Code on the web bot wired into my personal Slack channel. One day I idly asked it: "where are you running right now? Go inspect your own environment and tell me." What came back was a work log of the model itself digging through &lt;code&gt;ps&lt;/code&gt; and &lt;code&gt;/proc&lt;/code&gt; to reconstruct the ground under its own feet. So I spent two days playing with it: first having it dissect the environment from the inside, then the next day probing that boundary with exfiltration and evasion requests.&lt;/p&gt;

&lt;p&gt;The conclusion up front: the environment is a two-layer setup — &lt;strong&gt;Firecracker microVM isolation plus a mandatory pass-through of all egress via a claude-internal allowlist proxy&lt;/strong&gt; — and none of the exfiltration paths I tried got through that structure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Environment, Seen From Inside
&lt;/h2&gt;

&lt;p&gt;There is no &lt;code&gt;ip&lt;/code&gt;, no &lt;code&gt;ifconfig&lt;/code&gt;, no &lt;code&gt;ss&lt;/code&gt;. So everything has to be dug out of &lt;code&gt;/proc&lt;/code&gt;. Here's what turned up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OS&lt;/strong&gt;: Ubuntu 24.04.4 LTS (Noble Numbat). &lt;code&gt;whoami&lt;/code&gt; is &lt;strong&gt;root&lt;/strong&gt;. The home dir (&lt;code&gt;/root&lt;/code&gt;) is empty&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Virtualization&lt;/strong&gt;: inside a Firecracker microVM&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NIC&lt;/strong&gt;: &lt;code&gt;eth0 = 192.0.2.2/24&lt;/code&gt;, GW &lt;code&gt;192.0.2.1&lt;/code&gt;, hostname &lt;code&gt;vm&lt;/code&gt;, IPv4 only (no &lt;code&gt;/proc/net/tcp6&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The interesting part is the NIC's address range. &lt;code&gt;192.0.2.0/24&lt;/code&gt; is &lt;a href="https://datatracker.ietf.org/doc/html/rfc5737" rel="noopener noreferrer"&gt;RFC 5737&lt;/a&gt; &lt;strong&gt;TEST-NET-1&lt;/strong&gt; — a block reserved for documentation and examples that is never routed on the real internet. Instead of assigning a real IP, they deliberately hand the instance a "synthetic network that connects nowhere." The design philosophy already shows through here.&lt;/p&gt;

&lt;p&gt;I also reconstructed the LISTEN sockets by decoding &lt;code&gt;/proc/net/tcp&lt;/code&gt; by hand:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;0.0.0.0:2024&lt;/code&gt; / &lt;code&gt;0.0.0.0:2025&lt;/code&gt; → &lt;strong&gt;PID 1 &lt;code&gt;process_api&lt;/code&gt;&lt;/strong&gt; (&lt;code&gt;--firecracker-init&lt;/code&gt;). The host ↔ VM control channel, and the only thing facing outward&lt;/li&gt;
&lt;li&gt;a few &lt;code&gt;127.0.0.1&lt;/code&gt; internal ports → &lt;code&gt;environment-manager&lt;/code&gt; (internal control API) and &lt;code&gt;claude&lt;/code&gt; (the proxy below)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Process Tree and Data Flow
&lt;/h2&gt;

&lt;p&gt;Reconstructing the layout from &lt;code&gt;ps aux&lt;/code&gt; and the self-dissection gives this vertical spine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;you (Slack client)
      |  message / reply (stream-json)
      v
+-- Firecracker microVM ------------------------------
|
|  PID 1   process_api (--firecracker-init)
|              -&amp;gt; host&amp;lt;-&amp;gt;VM control channel, only public listener
|
|  PID 557 environment-manager
|              -&amp;gt; task-run / session management (supervisor)
|
|  PID 569 claude  (conductor / the agent itself)
|      |-- spawn --&amp;gt; worker(bash) xN   (runs the actual commands)
|      \-- HTTPS --&amp;gt; agent-proxy relay (CONNECT-only, allowlist)
|                         |  allow / 403
+-------------------------+---------------------------
                          v
       allowed hosts only:  api.anthropic.com, github, npm, pypi ...
       off-allowlist hosts  -&amp;gt; 403
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The point is the &lt;strong&gt;separation of execution and command&lt;/strong&gt;. &lt;code&gt;claude&lt;/code&gt; (PID 569) is the conductor; the actual &lt;code&gt;lscpu&lt;/code&gt; and &lt;code&gt;ps&lt;/code&gt; are farmed out to disposable worker(bash) processes. Incidentally &lt;code&gt;claude&lt;/code&gt;'s VSZ is a whopping 73GB, but that's V8/Node.js virtual address reservation — real RSS is ~400MB. And &lt;code&gt;kill 569&lt;/code&gt; is a SIGTERM to itself (this very session) — a self-destruct command.&lt;/p&gt;

&lt;h2&gt;
  
  
  What environment-manager Actually Is
&lt;/h2&gt;

&lt;p&gt;Chasing down the &lt;code&gt;environment-manager&lt;/code&gt; listening on &lt;code&gt;127.0.0.1&lt;/code&gt; leads to &lt;code&gt;/opt/env-runner/environment-manager&lt;/code&gt;: a &lt;strong&gt;~56MB stripped Go 1.24 binary&lt;/strong&gt;, internal name &lt;code&gt;environment-runner&lt;/code&gt;, self-description &lt;em&gt;"Environment Runner handles the lifecycle of agentic sessions."&lt;/em&gt; — Anthropic's session orchestrator / supervisor. It's the layer that &lt;strong&gt;mediates between&lt;/strong&gt; the cloud API (the Claude Code on the web backend) and the local &lt;code&gt;claude&lt;/code&gt; CLI.&lt;/p&gt;

&lt;p&gt;Reconstructing its startup behavior from the binary's strings and help output, it roughly goes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Reads a session spec JSON (~81KB) over stdin&lt;/li&gt;
&lt;li&gt;Initializes the sandbox, MCP servers, skills, and git config in parallel&lt;/li&gt;
&lt;li&gt;Assembles and runs &lt;code&gt;claude&lt;/code&gt;'s launch command line (model, effort, fallback, SDK URL, etc.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Passes the auth token via file descriptor (fd 3/4)&lt;/strong&gt; — never writing it to disk or logs, and self-masking it as &lt;code&gt;&amp;lt;redacted&amp;gt;&lt;/code&gt; even in its logs&lt;/li&gt;
&lt;li&gt;Measures things like spawn→ready and ttft&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Its subcommands include &lt;code&gt;task-run&lt;/code&gt; (single-session execution — PID 557 is one of these), &lt;code&gt;orchestrator&lt;/code&gt; (a resident loop that polls for work and hands each job to task-run), and &lt;code&gt;preload-claude&lt;/code&gt; (a warm spare). It's also the thing that installs a hook forcing every git commit to &lt;code&gt;noreply@anthropic.com&lt;/code&gt; with a required SSH signature.&lt;/p&gt;

&lt;h2&gt;
  
  
  Egress Is Forced Through an Allowlist Proxy
&lt;/h2&gt;

&lt;p&gt;The network is clamped down in two stages.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Firecracker VM isolation&lt;/strong&gt; itself&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;claude-internal agent-proxy relay&lt;/strong&gt;. All outbound traffic goes through this in-process proxy, and allow / 403 is decided by a host allowlist. On top of that it's &lt;strong&gt;CONNECT-tunnel (HTTPS) only&lt;/strong&gt; — a bare GET/HTTP gets bounced with &lt;code&gt;405&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's what I actually got and didn't get when I poked at it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Auth-injected hosts&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;*.googleapis.com&lt;/code&gt;, &lt;code&gt;api.anthropic.com&lt;/code&gt;, GitHub (push/PR OK), AWS/GCP SDK&lt;/td&gt;
&lt;td&gt;allow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Package fetch (direct)&lt;/td&gt;
&lt;td&gt;npm, pypi, crates.io, &lt;code&gt;proxy.golang.org&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;allow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Off-allowlist hosts&lt;/td&gt;
&lt;td&gt;arbitrary IP-check / download sites&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;403&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Third-party PPAs (launchpad)&lt;/td&gt;
&lt;td&gt;deadsnakes, ondrej/php&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;403&lt;/strong&gt; (&lt;code&gt;apt update&lt;/code&gt; exits 100)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSH (port 22)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ssh &amp;lt;external host&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;timeout&lt;/strong&gt; (egress is 443-centric; 22 is closed)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pip/npm/cargo/go ecosystems go through. What doesn't is "third-party apt sources plus random sites." Even &lt;code&gt;apt-get install&lt;/code&gt; succeeds if you stick to the core Ubuntu repos — I could install the ssh client itself. But when I tried to use that ssh to go "check external IP → check allowlist → fetch a tunnel tool," this time it wasn't the network layer but &lt;strong&gt;the safety classifier&lt;/strong&gt; that caught it as "prep for egress evasion" and stopped it. The clamp isn't a single monolith.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Next Day: Probing the Boundary
&lt;/h2&gt;

&lt;p&gt;With the shape of the environment visible, the next day I flipped it around and started firing off exfiltration and evasion requests to push on the boundary. Harmless reads (&lt;code&gt;uptime&lt;/code&gt; / &lt;code&gt;ps aux&lt;/code&gt; / &lt;code&gt;ls&lt;/code&gt; / various &lt;code&gt;--help&lt;/code&gt; / local &lt;code&gt;cp&lt;/code&gt;) all go through cleanly. What got refused was around here:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Request&lt;/th&gt;
&lt;th&gt;Actual effect&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;fork bomb&lt;/td&gt;
&lt;td&gt;DoS&lt;/td&gt;
&lt;td&gt;refused&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;`xz -9c $(find …) \&lt;/td&gt;
&lt;td&gt;base64` wedged between two harmless lines&lt;/td&gt;
&lt;td&gt;compress + base64-exfil an internal binary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;binary disguised as &lt;code&gt;.bashrc&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;the same exfil, just disguised&lt;/td&gt;
&lt;td&gt;refused&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;gdb&lt;/code&gt;/&lt;code&gt;kill&lt;/code&gt; to disable the egress relay&lt;/td&gt;
&lt;td&gt;only kills the sole exit = not a bypass&lt;/td&gt;
&lt;td&gt;refused&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;go tool nm &amp;lt;binary&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;extracting environment-manager's symbols = reverse engineering&lt;/td&gt;
&lt;td&gt;refused&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cat /proc/1/environ&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;dumping PID 1 = &lt;code&gt;process_api&lt;/code&gt;'s control secrets = credential extraction&lt;/td&gt;
&lt;td&gt;refused&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;What was interesting is that &lt;strong&gt;renaming or restructuring the request doesn't work&lt;/strong&gt;. Even wedging &lt;code&gt;xz | base64&lt;/code&gt; between two harmless command lines, it sees that "the actual effect of that line is exfiltrating an internal binary" and refuses. It draws the line on effect, not on cosmetic phrasing of the steps.&lt;/p&gt;

&lt;p&gt;There was also a moment where merely trying to enumerate the &lt;em&gt;names&lt;/em&gt; of environment variables got blocked as "credential discovery." That's an example of the &lt;strong&gt;harness's own independent safety classifier&lt;/strong&gt; firing, separate from the model's own judgment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion: Defense in Two Layers, Rephrasing Doesn't Help
&lt;/h2&gt;

&lt;p&gt;The structure that emerged over two days of play:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The egress path is in-process and the sole exit.&lt;/strong&gt; So killing it isn't a "bypass" — the exit just disappears&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Credentials are structurally isolated&lt;/strong&gt; (a &lt;code&gt;denyRead&lt;/code&gt;-equivalent plus the classifier, doubled up)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Renaming / restructuring the steps doesn't work&lt;/strong&gt;, because the model reads it by &lt;em&gt;actual effect&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;And the harness classifier runs &lt;strong&gt;independently of the model's judgment&lt;/strong&gt;, so talking one of them around still leaves the other one to stop you&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The thing that finally clicked for me is the consistent line between &lt;strong&gt;observation and analysis&lt;/strong&gt;. Watching behavior from the outside (eyeballing your own process with &lt;code&gt;ps&lt;/code&gt;) — it'll cooperate. Extracting the guts of an internal binary (pulling symbols with &lt;code&gt;nm&lt;/code&gt;) — it refuses. It'll play along with "I want to know where I'm running," but not with "let's take that mechanism apart and carry it out." You can make the resident of the sandbox introduce itself, but it won't hand you the blueprints.&lt;/p&gt;

&lt;p&gt;For the record, the only part that's public is the sandbox execution layer (&lt;a href="https://github.com/anthropic-experimental/sandbox-runtime" rel="noopener noreferrer"&gt;sandbox-runtime&lt;/a&gt;); &lt;code&gt;environment-manager&lt;/code&gt; itself and the PaaS bits stay proprietary. Everything in this post also stays within what you can trace from the &lt;a href="https://www.anthropic.com/engineering/claude-code-sandboxing" rel="noopener noreferrer"&gt;official sandboxing writeup&lt;/a&gt; — the story is just that if you get the resident (the model) to introduce itself, it'll answer a fair bit.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post was written with AI assistance and reviewed by the author.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claude</category>
      <category>llm</category>
      <category>security</category>
    </item>
    <item>
      <title>The plugin holds only WHEN, the vault holds HOW — designing a format-agnostic Claude Code plugin</title>
      <dc:creator>kohei_wada</dc:creator>
      <pubDate>Fri, 30 Oct 2026 15:34:57 +0000</pubDate>
      <link>https://dev.to/kohei_wada/the-plugin-holds-only-when-the-vault-holds-how-designing-a-format-agnostic-claude-code-plugin-4233</link>
      <guid>https://dev.to/kohei_wada/the-plugin-holds-only-when-the-vault-holds-how-designing-a-format-agnostic-claude-code-plugin-4233</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;My Claude Code plugin &lt;a href="https://github.com/Kohei-Wada/knowledge-gardener" rel="noopener noreferrer"&gt;knowledge-gardener&lt;/a&gt; holds none of the Obsidian vault's formatting conventions (filename / frontmatter / link syntax / folder layout). Instead, it reads the vault's own &lt;code&gt;README.md&lt;/code&gt; every time. This post is about why I did that, how it differs from existing plugins, and what properties fall out of it.&lt;/p&gt;

&lt;p&gt;There are already several plugins that CRUD an Obsidian vault for Claude Code. Representative examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;eugeniughelbur/obsidian-second-brain&lt;/code&gt; — assumes its own wiki / PARA schema&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;AgriciDaniel/claude-obsidian&lt;/code&gt; — fixes the Karpathy wiki pattern&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;dlutsyk/Obsidian-Zettelkasten-Claude&lt;/code&gt; (MCP) — forces Luhmann IDs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These hardcode the format on the plugin side. So if your vault has its own conventions — "standard markdown links (no wikilinks)," "numeric-prefix folders (&lt;code&gt;01_FleetingNotes/&lt;/code&gt; etc.)" — it conflicts outright. You could fork and fix it, but at that point the plugin's generality is gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The plugin holds WHEN, the vault holds HOW
&lt;/h2&gt;

&lt;p&gt;knowledge-gardener holds only &lt;strong&gt;when&lt;/strong&gt; to CRUD the vault, and follows the vault's own README for &lt;strong&gt;how to write&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[conventional plugin]              [knowledge-gardener]
plugin holds the format too        plugin: when to write / what to write
→ doesn't fit another vault        vault README: how to write
                                   → conventions concentrate in the vault
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With this separation of concerns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Change the conventions on the vault side and the plugin needs no edits&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;It can absorb format differences in someone else's vault via the README&lt;/li&gt;
&lt;li&gt;The plugin's logic can focus on "durable-insight detection / duplicate checking / propose-then-write"&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Implementation: read the README in pre-flight
&lt;/h2&gt;

&lt;p&gt;I put a "Pre-flight Setup" at the top of every skill. The shared steps, abridged:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Resolve the vault path from the KG_VAULT environment variable
2. Read $KG_VAULT/README.md (the vault-root convention document)
3. Also read $KG_VAULT/../README.md (in case the vault is a repo subdir)
4. If a folder-scoped README exists, consult it too
5. If conventions are unclear, stop and ask — don't silently invent a default
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In implementation terms, each operational skill (plant / water / connect / prune / survey / recap) handles its own CRUD action, and they share the common "Pre-flight Setup" to eliminate duplication.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;For a vault with no README, "not running" is better.&lt;/strong&gt; Start writing with silent defaults and you mass-produce notes that don't follow that vault's conventions. Stop-and-ask isn't a limitation — it's a spec to prevent format drift.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The vault README can live in more than one place.&lt;/strong&gt; My vault has an &lt;code&gt;Obsidian/vault/&lt;/code&gt; structure, so some conventions live in &lt;code&gt;Obsidian/README.md&lt;/code&gt; (the parent) and some in &lt;code&gt;vault/README.md&lt;/code&gt; (the vault root). Read both, and on conflict prefer the vault root.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Folder-scoped conventions&lt;/strong&gt; (e.g. a People folder with different frontmatter) get a separate layer that reads a folder-scoped README.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The plugin runs as-is on my vault (numeric-prefix folders + standard markdown links + a custom tag namespace).&lt;/li&gt;
&lt;li&gt;The same plugin would, in principle, run on someone else's wiki-style vault or a Luhmann-ID vault as long as they write a README.&lt;/li&gt;
&lt;li&gt;The plugin's responsibilities are clear, so adding a new skill (garden-recap, etc.) needs no format-related decisions.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;It's not "the plugin holds the format" but "the vault holds the format, and the plugin reads it."&lt;/li&gt;
&lt;li&gt;Separation of concerns: &lt;strong&gt;the plugin holds WHEN, the vault holds HOW.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;If conventions are unclear, stop — not silently inventing a default is what prevents format drift.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/Kohei-Wada/knowledge-gardener" rel="noopener noreferrer"&gt;knowledge-gardener (GitHub)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Kohei-Wada/Obsidian" rel="noopener noreferrer"&gt;My Obsidian vault&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This post was written with AI assistance and reviewed by the author.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>plugin</category>
      <category>softwaredevelopment</category>
      <category>architecture</category>
      <category>claude</category>
    </item>
    <item>
      <title>Keeping my auto-tmux muscle memory while also touching tmux over SSH — taming the nesting with prefix+T</title>
      <dc:creator>kohei_wada</dc:creator>
      <pubDate>Thu, 29 Oct 2026 15:34:04 +0000</pubDate>
      <link>https://dev.to/kohei_wada/keeping-my-auto-tmux-muscle-memory-while-also-touching-tmux-over-ssh-taming-the-nesting-with-2631</link>
      <guid>https://dev.to/kohei_wada/keeping-my-auto-tmux-muscle-memory-while-also-touching-tmux-over-ssh-taming-the-nesting-with-2631</guid>
      <description>&lt;p&gt;I've gotten used to my terminal dropping me straight into tmux on open. With Ghostty it's one line.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;command&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; tmux new-session &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's always one session, and it survives closing the window. Comfortable. But once you're used to that, the moment you also use tmux on an SSH host, the pain starts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Symptom: keys don't reach the inner session
&lt;/h2&gt;

&lt;p&gt;When I want to settle in and work on a remote GPU box, of course I want tmux there too — I want it to survive a disconnect. So I run &lt;code&gt;ssh -t gpubox 'tmux new -A -s main'&lt;/code&gt;, and now the &lt;strong&gt;local (outer) tmux and the SSH-host (inner) tmux are nested&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The problem is that both are listening for the prefix &lt;code&gt;C-b&lt;/code&gt;. Press &lt;code&gt;C-b %&lt;/code&gt; to split a pane in the inner session and the &lt;strong&gt;outer one eats it first&lt;/strong&gt;, so it never reaches the inner. The inner tmux becomes nearly unusable.&lt;/p&gt;

&lt;h2&gt;
  
  
  What do other people do?
&lt;/h2&gt;

&lt;p&gt;Reading around, people clearly split into two camps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Camp 1: native terminal splits locally, tmux for remote only.&lt;/strong&gt; If you don't use tmux locally, there's no nesting to begin with. Ghostty and WezTerm native splits need no prefix and share the clipboard. "Local splits are UI sugar, tmux is infrastructure" is the framing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Camp 2: keep the local auto-tmux and tame the nesting.&lt;/strong&gt; Handle it with an F12 toggle or dual prefixes.&lt;/p&gt;

&lt;p&gt;Camp 1 appealed to me at first. But I &lt;strong&gt;also use Alacritty on another machine, not just Ghostty&lt;/strong&gt;. Alacritty by design has no splits or tabs (delegating multiplexing to tmux is its official stance). So Camp 1 means only the Alacritty machine has a different workflow, and my fingers get confused.&lt;/p&gt;

&lt;p&gt;If I want the same muscle memory on every machine, the answer flips and becomes clear. &lt;strong&gt;Use local tmux everywhere, and just tame the nesting.&lt;/strong&gt; Dotfiles get written once and shipped to every machine anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  F12 is an awkward reach
&lt;/h2&gt;

&lt;p&gt;Camp 2's canonical move is Samoshkin's F12 toggle. Press F12 and the outer tmux puts its prefix and key tables entirely "to sleep," so every key passes straight through to the inner session. Press again to return. The selling point is a prefix-free single key that round-trips.&lt;/p&gt;

&lt;p&gt;I wrote it and tried it. It works. It works fine — but &lt;strong&gt;F12 is far&lt;/strong&gt;. Reaching up to the function row for a key I toggle constantly is not kind to the fingers. I want it where my fingers already are.&lt;/p&gt;

&lt;p&gt;The prefix is already under my fingers anyway (&lt;code&gt;C-b&lt;/code&gt;). So I might as well move the toggle onto the prefix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Settling on prefix+T
&lt;/h2&gt;

&lt;p&gt;The final form looks like this. &lt;code&gt;C-b T&lt;/code&gt; silences the outer session, plain &lt;code&gt;T&lt;/code&gt; returns.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# C-b T silences the outer tmux so every key passes through to the inner (SSH) one
bind T \
  set prefix None \;\
  set key-table off \;\
  set status off \;\
  refresh-client -S
# While silenced the prefix is disabled, so the return key lives in the off table directly
bind -T off T \
  set -u prefix \;\
  set -u key-table \;\
  set -u status \;\
  refresh-client -S
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One catch. &lt;strong&gt;The "enter" and "return" can't be the same action.&lt;/strong&gt; After you silence the outer session, the prefix (&lt;code&gt;C-b&lt;/code&gt;) is disabled, so the prefix+key entrance can't be pressed anymore. So the return is a bare &lt;code&gt;T&lt;/code&gt; with no prefix, bound via &lt;code&gt;bind -T off&lt;/code&gt;. While you're in the &lt;code&gt;off&lt;/code&gt; key table, that's what gets caught.&lt;/p&gt;

&lt;p&gt;The reason F12 could be a single round-trip key is that it's prefix-free, so it's still pressable while silenced. The moment you move onto the prefix, you have to split the entrance and the exit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Showing which side you're on
&lt;/h2&gt;

&lt;p&gt;At first I showed a red ● on the left of the status bar while the outer session was off. But there's a more straightforward move: &lt;strong&gt;just hide the outer status bar entirely&lt;/strong&gt; (&lt;code&gt;set status off&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;That way, while the outer is off its bar disappears and &lt;strong&gt;only the inner tmux's bar is visible&lt;/strong&gt;. It also kills the double-bar problem, and "I'm driving the inner session right now" is obvious at a glance. Return and the outer bar comes back. The config above already does this.&lt;/p&gt;

&lt;p&gt;If your theme is catppuccin, reference theme variables like &lt;code&gt;@thm_red&lt;/code&gt; instead of hardcoding colors, and it follows along when you change the flavor. That matters if you go the banner route instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Companion tricks: re-login and portability
&lt;/h2&gt;

&lt;p&gt;Once the nesting is solved, two more things made it pleasant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kill the re-login on every pane split.&lt;/strong&gt; The original complaint was that "splitting a pane in local tmux re-SSHes every time and it's slow." With SSH's ControlMaster reusing one connection, a new pane's ssh connects instantly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="k"&gt;Host&lt;/span&gt; gpubox
  &lt;span class="k"&gt;HostName&lt;/span&gt; &lt;span class="m"&gt;192&lt;/span&gt;.168.1.50
  &lt;span class="k"&gt;User&lt;/span&gt; myuser
  &lt;span class="k"&gt;ControlMaster&lt;/span&gt; &lt;span class="no"&gt;auto&lt;/span&gt;
  &lt;span class="k"&gt;ControlPath&lt;/span&gt; ~/.ssh/cm-%r@%h:%p
  &lt;span class="k"&gt;ControlPersist&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;m
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first connection sets up the master; subsequent ones ride along with no auth and no handshake. On my machine the second connection came up in 0.03 s.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make the config portable.&lt;/strong&gt; My server runs NixOS, and I had &lt;code&gt;default-shell /bin/bash&lt;/code&gt; in tmux.conf — which broke on the server. NixOS has no &lt;code&gt;/bin/bash&lt;/code&gt; (it does have &lt;code&gt;/bin/sh&lt;/code&gt;). Drop the hardcode for a PATH-resolved &lt;code&gt;bash&lt;/code&gt; and it works on both Arch and NixOS.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Stop hardcoding /bin/bash. A PATH bash works on both
set-option -g default-command 'bash -i'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Local auto-tmux × remote tmux nests. The outer eats the prefix and the inner never sees it.&lt;/li&gt;
&lt;li&gt;Pushing local splits onto native terminal panes is a popular answer, but if you also use Alacritty (which has no splits), matching every machine on tmux is easier on the fingers.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;C-b T&lt;/code&gt; silences the outer, plain &lt;code&gt;T&lt;/code&gt; returns. F12 works too, but if you toggle often the prefix side is within reach. The trick is that the entrance and exit live in different key tables (prefix table vs off table).&lt;/li&gt;
&lt;li&gt;Hiding the outer status bar makes "I'm on the inner side" visible.&lt;/li&gt;
&lt;li&gt;Killing the re-login with ControlMaster and dropping the &lt;code&gt;/bin/bash&lt;/code&gt; hardcode for portability gives you the same experience on every machine.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tmux.conf from this post lives in my &lt;a href="https://github.com/Kohei-Wada/dotfiles" rel="noopener noreferrer"&gt;dotfiles repository&lt;/a&gt; (the SSH config isn't there — it carries real hostnames).&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post was written with AI assistance and reviewed by the author.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cli</category>
      <category>productivity</category>
      <category>terminal</category>
    </item>
    <item>
      <title>Dropping OpenSky to Receive It Myself — Feeding RTL-SDR ADS-B into My Terminal Globe ttymap</title>
      <dc:creator>kohei_wada</dc:creator>
      <pubDate>Thu, 29 Oct 2026 00:50:03 +0000</pubDate>
      <link>https://dev.to/kohei_wada/dropping-opensky-to-receive-it-myself-feeding-rtl-sdr-ads-b-into-my-terminal-globe-ttymap-2lh9</link>
      <guid>https://dev.to/kohei_wada/dropping-opensky-to-receive-it-myself-feeding-rtl-sdr-ads-b-into-my-terminal-globe-ttymap-2lh9</guid>
      <description>&lt;h2&gt;
  
  
  I wanted to receive it myself
&lt;/h2&gt;

&lt;p&gt;Earlier I wrote about building &lt;a href="https://wada-dev.com/en/blog/ttymap-terminal-scriptable-globe" rel="noopener noreferrer"&gt;ttymap, a globe you spin inside the terminal&lt;/a&gt;. A braille-character globe with planes, earthquakes, the ISS and more layered on top. The &lt;code&gt;aircraft&lt;/code&gt; plugin that shows those planes hit OpenSky Network's public ADS-B feed. Handy, but really you're just watching &lt;strong&gt;the result someone else's server received and formatted&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz678o1577ikrgsefh3uo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz678o1577ikrgsefh3uo.png" alt="ttymap showing locally-received ADS-B aircraft as labelled arrows over central Japan on the braille globe" width="800" height="418"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The radio those overhead planes emit is raining down on my own room right now too. So I'd rather receive it on my own antenna and put it on my own globe. A receive-only RTL-SDR needs no radio license here. I bought a dongle and a dipole antenna.&lt;/p&gt;

&lt;p&gt;This post is the record of what happened from plugging that dongle in to "planes I received myself" drifting across my homemade globe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Plug it in and it's a TV tuner
&lt;/h2&gt;

&lt;p&gt;Plug it into USB, look at &lt;code&gt;lsusb&lt;/code&gt;, and you get this.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Bus 003 Device 007: ID 0bda:2838 Realtek Semiconductor Corp. RTL2838 DVB-T
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;DVB-T&lt;/code&gt;. A digital &lt;strong&gt;terrestrial television&lt;/strong&gt; tuner. No surprise: the RTL2832U chip inside an RTL-SDR was originally sold in bulk as a "cheap USB digital-TV tuner," and only later did someone discover that "if you make it spit out raw, pre-demodulation I/Q samples you can receive any radio = it becomes an SDR," giving it a second life.&lt;/p&gt;

&lt;p&gt;So the moment you plug it in, the Linux kernel grabs it &lt;strong&gt;as a TV tuner&lt;/strong&gt;. A driver called &lt;code&gt;dvb_usb_rtl28xxu&lt;/code&gt;. That's the problem:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;            ┌─ dvb_usb_rtl28xxu (kernel / for TV)   ← if it grabs the device, SDR can't open it
RTL2832U ───┤
            └─ librtlsdr (userspace / for SDR)       ← this is the one I want for reception
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two drivers can't grab one device at once. To use it as an SDR, you blacklist the TV driver so it doesn't claim it. Arch's &lt;code&gt;rtl-sdr&lt;/code&gt; package, thankfully, does this out of the box in &lt;code&gt;/usr/lib/modprobe.d/rtlsdr.conf&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;blacklist dvb_usb_rtl28xxu
blacklist e4000
blacklist rtl2832
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check the dongle and tuner model with &lt;code&gt;rtl_test&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ rtl_test -t
Found 1 device(s):
  0:  Realtek, RTL2838UHIDIR, SN: 00000001
Found Rafael Micro R820T tuner
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;R820T. It reaches up to 1090MHz, so it picks up ADS-B (1090MHz) just fine. The hardware is alive.&lt;/p&gt;

&lt;h2&gt;
  
  
  You can catch raw frames. But they aren't coordinates
&lt;/h2&gt;

&lt;p&gt;The easiest way to catch ADS-B is to just run &lt;code&gt;rtl_adsb&lt;/code&gt;, bundled with the &lt;code&gt;rtl-sdr&lt;/code&gt; package.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ rtl_adsb
*8d8622ae58c906dd3078d6822f73;
*8d84b7b699088411219a359bb254;
*8d9632ae58c906dd...;
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lines starting with &lt;code&gt;*8d&lt;/code&gt; are DF17 (ADS-B Extended Squitter), the payload messages carrying position and velocity. More than 30 of them in 20 seconds, across several aircraft. The dipole → R820T → librtlsdr reception chain works.&lt;/p&gt;

&lt;p&gt;…but these aren't coordinates. They're raw Mode-S frames. Getting lat/lon out of here needs one more step: &lt;strong&gt;CPR (Compact Position Reporting) decoding&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Why isn't the raw lat/lon just sitting in there? An ADS-B message is only 112 bits, and packing full-precision latitude/longitude every time would eat too much bandwidth. So CPR encodes position split into two kinds, "even frames" and "odd frames." &lt;strong&gt;Pair an even with an odd&lt;/strong&gt; and you can uniquely recover the position anywhere on Earth (global decoding). Alternatively, &lt;strong&gt;if you roughly know the receiver's position&lt;/strong&gt;, you can recover it locally from a single frame. The latter is easier to implement. I know my receiver's (home) coordinates, so I use that one.&lt;/p&gt;

&lt;p&gt;Writing CPR by hand is painful, so I throw it at pure-Python &lt;a href="https://github.com/junzis/pyModeS" rel="noopener noreferrer"&gt;pyModeS&lt;/a&gt;. v3 has an API where you hand it one message and it returns every field as a dict; pass a rough receiver position to &lt;code&gt;reference&lt;/code&gt; and it decodes locally.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pyModeS&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pms&lt;/span&gt;
&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reference&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MY_LAT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MY_LON&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="c1"&gt;# r["latitude"], r["longitude"], r["altitude"], r["callsign"], r["crc_valid"] ...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Write a small script that just pipes &lt;code&gt;rtl_adsb&lt;/code&gt;'s output into this, run it, and coordinates start scrolling in the terminal.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;21:29:39  89912B  EVA195    lat=  34.8097  lon= 136.9325  alt=37975ft
21:29:39  851826  JAL229    lat=  34.5754  lon= 136.7913  alt=27600ft
21:29:50  861F00  JAL616    lat=  34.3943  lon= 136.7036  alt=41000ft
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Callsign, ICAO address, lat/lon, altitude. The same aircraft's coordinates move smoothly and consecutively (JAL616 heading south, JAL229 descending), so the decode is correct. A real plane I received on my own antenna has become coordinates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feeding it into ttymap — bridge a JSON server in between
&lt;/h2&gt;

&lt;p&gt;Now I want this on my homemade globe, ttymap. The intake a ttymap plugin uses for data is &lt;code&gt;ttymap.http:fetch(url)&lt;/code&gt; — basically, &lt;strong&gt;fetch JSON over HTTP&lt;/strong&gt;. The earlier &lt;code&gt;aircraft&lt;/code&gt; plugin also did &lt;code&gt;http:fetch&lt;/code&gt; against OpenSky's REST API and plotted points with &lt;code&gt;map:point(lon, lat, …)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;But this &lt;code&gt;rtl_adsb | pyModeS&lt;/code&gt; only spits text to stdout; it has no HTTP intake. The shapes don't match.&lt;/p&gt;

&lt;p&gt;So I bridge a small thing in between. I wrote an HTTP server in Python's standard library that runs &lt;code&gt;rtl_adsb&lt;/code&gt; as a child process, holds the aircraft state pyModeS decodes keyed by ICAO, and just serves it at &lt;code&gt;127.0.0.1:8888/aircraft.json&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rtl_adsb ──▶ serve.py (decode with pyModeS + hold aircraft state) ──HTTP /aircraft.json──▶ ttymap
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key was changing the decoder from "one frame → print one line" into "accumulate state per ICAO." Position arrives in position messages, callsign in identification messages, heading in velocity messages (velocity's &lt;code&gt;track&lt;/code&gt;) — each comes in piecemeal from different messages, so you grow a record per aircraft and drop it once it goes unheard for a while. The JSON it serves looks like this.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"aircraft"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"icao"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"861f00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"callsign"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"JAL616"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"lat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;34.39&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"lon"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;136.7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"alt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;41000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"heading"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;182.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"on_ground"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Writing the plugin — heading as an arrow
&lt;/h2&gt;

&lt;p&gt;A ttymap user plugin goes in &lt;code&gt;~/.config/ttymap/lua/plugin/&amp;lt;name&amp;gt;.lua&lt;/code&gt;, and you enable it with &lt;code&gt;require "plugin.&amp;lt;name&amp;gt;"&lt;/code&gt; in &lt;code&gt;~/.config/ttymap/init.lua&lt;/code&gt;. The body is almost entirely cribbed from the bundled &lt;code&gt;aircraft&lt;/code&gt; plugin, with only the fetch target swapped for the local server.&lt;/p&gt;

&lt;p&gt;The crux is, inside &lt;code&gt;on_tick&lt;/code&gt; (called every frame), to fire &lt;code&gt;http:fetch&lt;/code&gt; at a fixed interval, parse the returned JSON, and plot points. To match the earlier &lt;code&gt;aircraft&lt;/code&gt;, I render the heading as an 8-direction arrow (↑↗→↘↓↙←↖).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;ARROWS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"↑"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"↗"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"→"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"↘"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"↓"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"↙"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"←"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"↖"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;heading_arrow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;deg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;deg&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;360&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ARROWS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;math.floor&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;22&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;45&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="c1"&gt;-- inside on_tick:&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;ipairs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;aircraft&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;map&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;point&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;heading_arrow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;heading&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s2"&gt;"accent"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sidebar lists callsign and altitude; select one and press Enter and the map flies to that aircraft (&lt;code&gt;anim.fly_to&lt;/code&gt;). This card UI is just &lt;code&gt;ttymap.api.card.open&lt;/code&gt; again — the machinery I built in the earlier post works as-is.&lt;/p&gt;

&lt;h2&gt;
  
  
  My own radio drifts across the globe
&lt;/h2&gt;

&lt;p&gt;Start the server, call &lt;code&gt;Toggle local ADS-B&lt;/code&gt; from ttymap's command palette, and arrows appear over my home. Each arrow tilts toward its heading, and picking a callsign flies the camera to that plane. What used to be watching &lt;strong&gt;someone else's server&lt;/strong&gt; called OpenSky has turned into &lt;strong&gt;radio I received on the antenna I set up myself&lt;/strong&gt;. On the very same braille globe.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjmarfnyjvjozejg69wpg.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjmarfnyjvjozejg69wpg.jpg" alt="A telescoping dipole antenna on a tripod, opened into a V to receive 1090MHz" width="800" height="1063"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What was quietly interesting was the range. Even just standing the cheap indoor dipole by the window and opening it into a V, high-altitude cruisers came through from around 100km out. It's not scattered all across the map — just a handful of nearby planes dotted on it — but seeing "how far my own antenna reaches" with my own eyes is fun.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas, collected
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Plug it in and it's recognized as a DVB-T tuner.&lt;/strong&gt; You can't open it as an SDR unless you blacklist the kernel TV driver (&lt;code&gt;dvb_usb_rtl28xxu&lt;/code&gt;). Arch's &lt;code&gt;rtl-sdr&lt;/code&gt; package handles it via modprobe.d.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Raw frames aren't coordinates.&lt;/strong&gt; ADS-B position is CPR-encoded and needs an even/odd pair or a receiver-position reference. Throwing it at pyModeS is the easy path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ttymap's intake is HTTP JSON.&lt;/strong&gt; A decoder that only spits to stdout needs a local HTTP server bridged in between to match shapes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Receive-only already got me this far. A fun next direction: coherent reception across multiple antennas to get the &lt;strong&gt;bearing (DF)&lt;/strong&gt; of a signal, draw bearing lines on the map, and locate the source from the intersection of two stations — that kind of thing (KrakenSDR and friends). &lt;code&gt;map:polyline&lt;/code&gt; is the same primitive I used for traceroute hops in the earlier post.&lt;/p&gt;

&lt;p&gt;When you can feed your own tools data you received yourself, the resolution of the world goes up a notch.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post was written with AI assistance and reviewed by the author.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>hardware</category>
      <category>iot</category>
      <category>networking</category>
    </item>
    <item>
      <title>A 3x Faster Local LLM That Can't Finish the Job — Two Days of MoE vs Dense on Real Tasks</title>
      <dc:creator>kohei_wada</dc:creator>
      <pubDate>Tue, 27 Oct 2026 15:40:55 +0000</pubDate>
      <link>https://dev.to/kohei_wada/a-3x-faster-local-llm-that-cant-finish-the-job-two-days-of-moe-vs-dense-on-real-tasks-3b31</link>
      <guid>https://dev.to/kohei_wada/a-3x-faster-local-llm-that-cant-finish-the-job-two-days-of-moe-vs-dense-on-real-tasks-3b31</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;I run ollama on a home RTX 5090 (32GB) and use it through opencode. My default model&lt;br&gt;
had long been the dense Qwen3.6 27B, but when I tried &lt;strong&gt;Ornith 1.0 35B&lt;/strong&gt; (35B/A3B),&lt;br&gt;
a coding-focused MoE model, the numbers were too good to ignore — single-shot code&lt;br&gt;
generation at equal-or-better quality and &lt;strong&gt;about 3x faster&lt;/strong&gt; (246 tok/s vs 71 tok/s).&lt;br&gt;
I swapped the default immediately.&lt;/p&gt;

&lt;p&gt;This post is the record of that decision &lt;strong&gt;falling apart within two days&lt;/strong&gt;. The&lt;br&gt;
conclusion up front: &lt;strong&gt;the source of MoE's speed and the cause of its failures in&lt;br&gt;
agent work turned out to be the same thing.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Day 1: Chosen by measurement, not benchmarks (or so I thought)
&lt;/h2&gt;

&lt;p&gt;Benchmarks are mostly self-reported, so I lined the models up on my own tasks:&lt;br&gt;
implementing a function from a spec (14 independently-scored cases), fixing a&lt;br&gt;
planted bug, and reviewing a shell script.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Ornith 35B (MoE)&lt;/th&gt;
&lt;th&gt;Qwen3.6 27B (dense)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Implement from spec (14 cases)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;14/14, 22s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;14/14, 58s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fix planted bug&lt;/td&gt;
&lt;td&gt;correct, 24s&lt;/td&gt;
&lt;td&gt;correct, 55s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Script review&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;9 findings&lt;/strong&gt;, 19s&lt;/td&gt;
&lt;td&gt;6 findings, 96s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For one-shot work there was nothing to argue about. On review it even beat the&lt;br&gt;
dense model on finding count. "MoE for coding, settled" — that's how it looked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Day 2, morning: cracks appear on general tasks
&lt;/h2&gt;

&lt;p&gt;As the default, it also handles general tasks like looking things up. That's when&lt;br&gt;
strange answers started coming back.&lt;/p&gt;

&lt;p&gt;Asked "how many days until the next public holiday?", it did search — but then&lt;br&gt;
&lt;strong&gt;fabricated a holiday out of nothing but the search snippets&lt;/strong&gt;. "August 1st is a&lt;br&gt;
substitute holiday for Children's Day," it said. Complete nonsense.&lt;/p&gt;

&lt;p&gt;Adding prompt discipline (don't answer until you've read the page body, always&lt;br&gt;
check the current date with a tool, do date arithmetic with shell &lt;code&gt;date&lt;/code&gt;) corrected&lt;br&gt;
the behavior — but it still misread the page and got the holiday's date wrong.&lt;br&gt;
&lt;strong&gt;Discipline reduces it; it doesn't eliminate it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The same question given to the dense Qwen3.6: it fetched the current date with a&lt;br&gt;
tool, read the holiday-list page body, wrote and ran the day-count arithmetic in&lt;br&gt;
Python, and got everything right. By this point I had a bad feeling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Day 2, afternoon: a port experiment with a machine as the judge
&lt;/h2&gt;

&lt;p&gt;To settle it, I designed an experiment: &lt;strong&gt;porting RFC 6902 (JSON Patch) to Rust&lt;/strong&gt;.&lt;br&gt;
The subject was chosen deliberately:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The spec and a reference implementation already exist — minimal room for the
model to "judge" anything&lt;/li&gt;
&lt;li&gt;An official conformance suite exists (language-neutral JSON, 112 cases) —
&lt;strong&gt;completion can be decided by a machine&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I split the work into phases — T0 (build the test runner), T1 (JSON Pointer),&lt;br&gt;
T2 (add/remove/replace), T3 (move/copy/test) — and fixed each phase's completion&lt;br&gt;
criterion as "&lt;code&gt;cargo test&lt;/code&gt; prints &lt;code&gt;total=112 pass=N fail=0&lt;/code&gt;".&lt;br&gt;
&lt;strong&gt;The model's "done!" counts for nothing.&lt;/strong&gt; Only test output judges.&lt;/p&gt;

&lt;h3&gt;
  
  
  Result: Ornith went 0 for 5
&lt;/h3&gt;

&lt;p&gt;Across five sessions, Ornith failed to close a single phase. The failure modes are&lt;br&gt;
the interesting part:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Forgot to add the dependency and reported "done" with code that didn't compile&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Wrote a test runner with &lt;strong&gt;contradictory asserts&lt;/strong&gt; (requiring &lt;code&gt;filename&lt;/code&gt; to be
both A and B — guaranteed panic if ever called). It also ran &lt;code&gt;cargo init&lt;/code&gt; for a
whole separate project inside &lt;code&gt;tests/&lt;/code&gt;, unprompted&lt;/li&gt;
&lt;li&gt;Wrote a file that &lt;strong&gt;cut off mid-comment at line 238&lt;/strong&gt; — and at the truncation
point, the leaked text read "let me reconcile:", &lt;strong&gt;its chain of thought bleeding
into the code&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Built itself a correct TODO list, then &lt;strong&gt;abandoned the session partway through
item one&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Wrote &lt;strong&gt;calls to &lt;code&gt;op_add&lt;/code&gt; / &lt;code&gt;op_remove&lt;/code&gt; without ever writing the function
bodies&lt;/strong&gt;, and finished&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;They share one trait: every failure left behind a &lt;strong&gt;plausible-looking artifact and&lt;br&gt;
a confident completion report&lt;/strong&gt;. Without the rule of only trusting test output, all&lt;br&gt;
five would have passed as "done."&lt;/p&gt;

&lt;h3&gt;
  
  
  The dense model finished every phase
&lt;/h3&gt;

&lt;p&gt;Handing the same phases to Qwen3.6: slow, but it closed all of them. It wrote the&lt;br&gt;
runner correctly on the first attempt, produced the 112-case tally, and eventually&lt;br&gt;
reached &lt;strong&gt;full conformance green&lt;/strong&gt; (pass=108 / fail=0 / skipped=4, the officially&lt;br&gt;
disabled cases).&lt;/p&gt;

&lt;p&gt;There was exactly one trick to the dense side: &lt;strong&gt;cut tasks down until they fit in&lt;br&gt;
one session&lt;/strong&gt;. At 71 tok/s with always-on reasoning it's slow, so putting diagnosis&lt;br&gt;
and fix in the same session runs out the clock. Diagnose upstream, hand over the&lt;br&gt;
established facts, and let it do only the fix — that's fast enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this happens: the speed and the failures share one cause
&lt;/h2&gt;

&lt;p&gt;Ornith is not stupid — recall that it beat the dense model on the one-shot review.&lt;br&gt;
It's not smaller either: at 35B total it's bigger than Qwen3.6's 27B, and it still&lt;br&gt;
lost.&lt;/p&gt;

&lt;p&gt;MoE's 3x speed comes from waking only ~3B active parameters per token. And that&lt;br&gt;
economy appears to be what thins out its ability to &lt;strong&gt;carry state across turns&lt;/strong&gt; —&lt;br&gt;
an inference drawn from five failures, but they line up cleanly. All&lt;br&gt;
five happened in "decide the next step based on what you just did"&lt;br&gt;
territory — wiring up the edit you just made, working through the TODO list you&lt;br&gt;
just wrote, finishing the function you just started.&lt;/p&gt;

&lt;p&gt;So the boundary is not "coding vs general tasks." It's&lt;br&gt;
&lt;strong&gt;"closes in one shot vs carries state."&lt;/strong&gt; Search-then-answer works.&lt;br&gt;
Search-extract-compute falls over. Generate-wire-test falls over too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where things landed
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Default = Qwen3.6 27B (dense).&lt;/strong&gt; Make the model that survives multi-step work
the standard. The nastiest failure mode — not realizing a task is multi-step,
handing it to the fast model, and getting silently wrecked — disappears with the
default&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ornith demoted to a named speed lane.&lt;/strong&gt; An opencode custom agent with "tasks
that close in one or two tool calls only; if it turns out to need chaining, step
aside" baked in. It keeps one-shot generation and device control&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Completion judged by machines, fixed.&lt;/strong&gt; Tests, state read-backs, tally lines
with fixed case counts. In this whole experiment the number of times I trusted a
model's completion report was zero — and that's the only reason it worked&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Not benchmark scores, not tok/s — &lt;strong&gt;measuring where a model breaks, on your own&lt;br&gt;
tasks&lt;/strong&gt;, is the real work of choosing a local LLM. The 3x speed stings to give up.&lt;br&gt;
But speed that doesn't finish isn't speed.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post was written with AI assistance and reviewed by the author.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>llm</category>
      <category>performance</category>
    </item>
    <item>
      <title>Running DiffusionGemma Locally on an RTX 5090 with vLLM: The One-Time Pioneer Cost of Bleeding-Edge Model Bleeding-Edge GPU</title>
      <dc:creator>kohei_wada</dc:creator>
      <pubDate>Mon, 26 Oct 2026 15:50:28 +0000</pubDate>
      <link>https://dev.to/kohei_wada/running-diffusiongemma-locally-on-an-rtx-5090-with-vllm-the-one-time-pioneer-cost-of-bleeding-edge-1gml</link>
      <guid>https://dev.to/kohei_wada/running-diffusiongemma-locally-on-an-rtx-5090-with-vllm-the-one-time-pioneer-cost-of-bleeding-edge-1gml</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;In the &lt;a href="https://wada-dev.com/en/blog/rtx5090-blackwell-headless-gsp-watchdog" rel="noopener noreferrer"&gt;previous post&lt;/a&gt; I covered keeping a home RTX 5090 (Blackwell) alive 24/7 against the GSP hang. This time it's about the model that runs on top of it — a record of standing up Google's diffusion language model &lt;strong&gt;DiffusionGemma&lt;/strong&gt; locally on that 5090.&lt;/p&gt;

&lt;p&gt;Let me lead with the conclusion. &lt;strong&gt;It's genuinely fast — roughly ~300 tok/s by feel.&lt;/strong&gt; But the surrounding ecosystem is still immature (Open WebUI's web search doesn't integrate, among other things), so it earned a place as &lt;strong&gt;an "experiment to test the speed" rather than a daily driver&lt;/strong&gt;. So this post isn't a victory lap about landing the best local model — it's a record of the &lt;strong&gt;one-time pioneer cost you pay when you run a bleeding-edge model on a bleeding-edge GPU&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not ollama, why vLLM
&lt;/h2&gt;

&lt;p&gt;DiffusionGemma isn't a normal autoregressive model. It generates via &lt;strong&gt;block diffusion&lt;/strong&gt; — autoregressive between blocks, parallel diffusion &lt;em&gt;within&lt;/em&gt; a block — and that's the source of the speed. It doesn't emit one token at a time left-to-right, so generation latency is inherently shorter.&lt;/p&gt;

&lt;p&gt;The catch is that the serving stack gets correspondingly special.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ollama is out.&lt;/strong&gt; It's not in the official library, and ollama doesn't expose llama.cpp's diffusion decode path. The GGUF is for llama.cpp's diffusion CLI and won't become an OpenAI-compatible server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;vLLM was the only OpenAI-compatible path.&lt;/strong&gt; It's the only way to drop the model into Open WebUI as "just another model." Since Open WebUI is itself a multimodal hub, you don't add a new tool — you &lt;strong&gt;just add one more backend&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  NVFP4 is the only thing that fits 32GB
&lt;/h2&gt;

&lt;p&gt;The RTX 5090 has 32GB of VRAM. Trying to fit DiffusionGemma into that narrows the quantization choices hard.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;bf16 is in the 96GB class; even INT8 needs 48GB. &lt;strong&gt;The only thing that fits in 32GB is NVFP4.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;I used &lt;code&gt;nvidia/diffusiongemma-26B-A4B-it-NVFP4&lt;/code&gt;. Its license is gated=False, so you don't even need an HF token.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The recipe that worked (raw podman)
&lt;/h2&gt;

&lt;p&gt;Here's the launch command that finally came up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;podman run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; vllm-diffusiongemma &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--device&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;nvidia.com/gpu&lt;span class="o"&gt;=&lt;/span&gt;all &lt;span class="nt"&gt;--network&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;host &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; /srv/models/hf-cache:/root/.cache/huggingface &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;VLLM_USE_V2_MODEL_RUNNER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;HF_HUB_DISABLE_XET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="se"&gt;\&lt;/span&gt;
  docker.io/vllm/vllm-openai:gemma-x86_64-cu130 &lt;span class="se"&gt;\&lt;/span&gt;
  nvidia/diffusiongemma-26B-A4B-it-NVFP4 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--host&lt;/span&gt; 0.0.0.0 &lt;span class="nt"&gt;--port&lt;/span&gt; 8000 &lt;span class="nt"&gt;--max-num-seqs&lt;/span&gt; 4 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--attention-backend&lt;/span&gt; TRITON_ATTN &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--enable-auto-tool-choice&lt;/span&gt; &lt;span class="nt"&gt;--tool-call-parser&lt;/span&gt; gemma4 &lt;span class="nt"&gt;--reasoning-parser&lt;/span&gt; gemma4 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--gpu-memory-utilization&lt;/span&gt; 0.7
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to watch.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Match the image tag to the host's CUDA.&lt;/strong&gt; CUDA 13.x → &lt;code&gt;gemma-x86_64-cu130&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The architecture &lt;code&gt;DiffusionGemmaForBlockDiffusion&lt;/code&gt; is &lt;strong&gt;built into the gemma image&lt;/strong&gt;, so you don't need &lt;code&gt;--trust-remote-code&lt;/code&gt;. It's a small thing but a real security point: you're not executing arbitrary code from the model repo — zero RCE exposure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cold start is ~140s (torch.compile + multimodal warmup; weights already cached). Once &lt;code&gt;/v1/models&lt;/code&gt; and &lt;code&gt;/v1/chat/completions&lt;/code&gt; answer, you're up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 1: the VRAM precheck kills startup instantly
&lt;/h2&gt;

&lt;p&gt;My first attempt was &lt;code&gt;--gpu-memory-utilization 0.9&lt;/code&gt;, and it died before startup.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ValueError: Free memory ... less than desired
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;vLLM requires &lt;code&gt;util × TOTAL(32GiB) ≤ current free VRAM&lt;/code&gt; before it starts. But the baseline — nvidia-persistenced and friends — already takes ~4GiB, leaving only ~27GiB free. So 0.9 (asking for ~29GiB) dies at the precheck. &lt;strong&gt;You have to pick a value that subtracts the baseline.&lt;/strong&gt; 0.7 (~22GiB) passed, and it still held a 256K context.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 2: a container that wedges in a crash loop
&lt;/h2&gt;

&lt;p&gt;When startup failed and it entered a restart loop, a container that didn't get killed cleanly got stuck in "Stopping," and &lt;code&gt;podman rm -f&lt;/code&gt; started timing out (main thread in &lt;code&gt;Z&lt;/code&gt;, child threads in &lt;code&gt;S&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;I first suspected the GPU or driver had wedged, but &lt;strong&gt;nvidia-smi was responding&lt;/strong&gt;, which ruled that out. The GPU was fine; only the container side was stuck. Here's the sequence that removed it without a reboot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl stop podman-vllm-diffusiongemma   &lt;span class="c"&gt;# stop the restart loop&lt;/span&gt;
systemctl reset-failed podman-vllm-diffusiongemma
&lt;span class="nb"&gt;kill&lt;/span&gt; &lt;span class="nt"&gt;-9&lt;/span&gt; &amp;lt;pid&amp;gt;
podman &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nt"&gt;--time&lt;/span&gt; 1 vllm-diffusiongemma
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trick was distinguishing "the GPU broke" from "the container wedged" by whether nvidia-smi answers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making it on-demand and declarative on NixOS
&lt;/h2&gt;

&lt;p&gt;There's only one GPU, so it fights the big ollama models for VRAM. So I made it on-demand — dormant by default, started only when needed. Declared via &lt;code&gt;oci-containers&lt;/code&gt;, with the exclusion enforced in systemd:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;conflicts = ["ollama.service"]&lt;/code&gt; — starting vLLM stops ollama, freeing the VRAM.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;wantedBy = lib.mkForce []&lt;/code&gt; — no auto-start at boot.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's dormant most of the time. &lt;code&gt;systemctl start podman-vllm-diffusiongemma&lt;/code&gt; wakes it; when I'm done I stop it and &lt;code&gt;start ollama&lt;/code&gt; to go back. Open WebUI connects with a single env line (&lt;code&gt;OPENAI_API_BASE_URLS = "http://127.0.0.1:8000/v1"&lt;/code&gt; plus a dummy key), and while vLLM is stopped that model simply drops off the list.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways and the generalizable lesson
&lt;/h2&gt;

&lt;p&gt;The speed (block diffusion) is real. But for daily use it has too many sharp edges — Open WebUI's web search doesn't work, penalty-family parameters are ignored, the default max_tokens is 256. So I decided to &lt;strong&gt;leave it parked as an experiment, config and all&lt;/strong&gt; (resume is one &lt;code&gt;systemctl&lt;/code&gt; away; image and weights are cached).&lt;/p&gt;

&lt;p&gt;Here's the generalization I'd pull out of it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Mature models are a one-liner; bleeding-edge ones you pioneer by hand exactly once — then declare it, and it's a one-liner forever after.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A bleeding-edge model × a new-generation GPU is the intersection where the software stack is least mature. And the nasty part is that &lt;strong&gt;even when the eval (can the model load at all) passes, it's the on-machine resource precheck where it first falls over&lt;/strong&gt;. This is the class of failure you can't see on paper — it only surfaces at runtime. Which is exactly why you pioneer it once by hand, freeze the working config declaratively, and reproduce it with a single &lt;code&gt;systemctl start&lt;/code&gt; next time. Deciding whether that one-time pioneer cost is worth paying is, I think, the whole point of this kind of experiment.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post was written with AI assistance and reviewed by the author.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>hardware</category>
      <category>llm</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Running Blackwell (RTX 5090) Headless 24/7: The Real Boss Is the GSP Hang, and I Took It With a "Assume It Breaks" Watchdog</title>
      <dc:creator>kohei_wada</dc:creator>
      <pubDate>Sun, 25 Oct 2026 15:36:14 +0000</pubDate>
      <link>https://dev.to/kohei_wada/running-blackwell-rtx-5090-headless-247-the-real-boss-is-the-gsp-hang-and-i-took-it-with-a-cjj</link>
      <guid>https://dev.to/kohei_wada/running-blackwell-rtx-5090-headless-247-the-real-boss-is-the-gsp-hang-and-i-took-it-with-a-cjj</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;In the &lt;a href="https://wada-dev.com/en/blog/nixos-home-ai-server-install-gotchas" rel="noopener noreferrer"&gt;previous post&lt;/a&gt; I covered picking NixOS for my home AI server and doing the base install. Once the OS is in, the next step is enabling the GPU and getting to the actual goal — running local LLMs 24/7. But this is where the hardest obstacle lives: the &lt;strong&gt;GSP heartbeat timeout&lt;/strong&gt;, an as-yet-unresolved hang bug.&lt;/p&gt;

&lt;p&gt;The GPU is an &lt;strong&gt;RTX 5090 (Blackwell generation)&lt;/strong&gt;. Repurposing a latest-gen consumer card as a server is &lt;strong&gt;pioneer territory&lt;/strong&gt; with almost no community precedent for stable operation. So this time, instead of "run it and deal with problems later," I decided to &lt;strong&gt;scout the landmines before stepping on them&lt;/strong&gt;. This post is a write-up of the known issues that scouting surfaced, and the watchdog design I built assuming the hang &lt;em&gt;will&lt;/em&gt; eventually hit.&lt;/p&gt;

&lt;p&gt;To be honest up front: this isn't a war story of "my GPU crashed a dozen times." It's the story of &lt;strong&gt;scouting ahead and preparing a catch for when it breaks&lt;/strong&gt;. The first-hand part I'm writing about is that design decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, the settled facts: driver and CUDA
&lt;/h2&gt;

&lt;p&gt;Let's clear the parts you can settle by just looking them up, before they bite.&lt;/p&gt;

&lt;h3&gt;
  
  
  The driver is &lt;code&gt;nvidia-open&lt;/code&gt;, no choice
&lt;/h3&gt;

&lt;p&gt;Blackwell is &lt;strong&gt;completely unsupported&lt;/strong&gt; by the proprietary driver (per NVIDIA). There's no alternative, and NixOS &lt;strong&gt;enforces&lt;/strong&gt; declaring &lt;code&gt;open&lt;/code&gt; via an assertion for driver &amp;gt;= 560. Headless operation also wants &lt;code&gt;nvidiaPersistenced&lt;/code&gt; — the right setting to keep the GPU awake even when headless.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nix"&gt;&lt;code&gt;&lt;span class="nv"&gt;hardware&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;nvidia&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nv"&gt;open&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;               &lt;span class="c"&gt;# the only option on Blackwell&lt;/span&gt;
  &lt;span class="nv"&gt;nvidiaPersistenced&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;# keeps the GPU initialized while headless&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  CUDA / inference-stack traps
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;sm_120 requires CUDA 12.8+&lt;/strong&gt;. nixpkgs' default &lt;code&gt;cudaPackages&lt;/code&gt; is intentionally held back on the 12.x line, so you have to declare &lt;code&gt;cudaCapabilities = ["12.0"]&lt;/code&gt; explicitly. The official docs' Blackwell example only covers sm_100 (datacenter); consumer sm_120 is a blind spot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;llama.cpp wants arch &lt;code&gt;120a&lt;/code&gt;&lt;/strong&gt;. Plain &lt;code&gt;120&lt;/code&gt; throws a ptxas error on MXFP4. Ollama is in the same blast radius, so assume you'll verify GPU offload empirically rather than trust it works.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the "look it up and the answer is settled" layer. The next part is the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real boss: GSP heartbeat timeout
&lt;/h2&gt;

&lt;p&gt;This is the scariest thing about 24/7 operation. The GPU's firmware (GSP) suddenly stops responding and takes the whole GPU down with it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Symptoms are &lt;strong&gt;Xid 119/109/79/8&lt;/strong&gt;, plus &lt;strong&gt;silent hangs that don't even emit an Xid&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;It's &lt;strong&gt;not load-dependent — there are reports of it happening at idle&lt;/strong&gt;. "Don't run heavy inference and you're safe" is false.&lt;/li&gt;
&lt;li&gt;NVIDIA's own &lt;code&gt;open-gpu-kernel-modules&lt;/code&gt; has issues #1080 / #1111 open (as of 2026-06). In other words, &lt;strong&gt;unresolved&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the part that really hurts is that &lt;strong&gt;recovery after it happens is hell&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;After a crash, &lt;strong&gt;a normal reset doesn't work&lt;/strong&gt; ("WPR2 already up"). A PCI Secondary Bus Reset + module reload, or failing that a &lt;strong&gt;power cycle&lt;/strong&gt;, are the only reliable ways back.&lt;/li&gt;
&lt;li&gt;On the &lt;code&gt;open&lt;/code&gt; driver, the classic workaround of &lt;strong&gt;disabling GSP is structurally impossible&lt;/strong&gt;. NixOS hard-fails it with an assertion, and &lt;code&gt;NVreg_EnableGpuFirmware=0&lt;/code&gt; is ignored.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In short, "prevent it with config" isn't on the table — the reality is "&lt;strong&gt;if it wedges, your only option is to reboot&lt;/strong&gt;."&lt;/p&gt;

&lt;h2&gt;
  
  
  So I built a watchdog that assumes it breaks
&lt;/h2&gt;

&lt;p&gt;If you can't prevent it, all you can do is &lt;strong&gt;detect the wedge and recover automatically&lt;/strong&gt;. Since an in-place reset doesn't work, the only reliable recovery is a reboot. I wired a declarative systemd watchdog into NixOS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nix"&gt;&lt;code&gt;&lt;span class="nv"&gt;systemd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nv"&gt;services&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;gpu-watchdog&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Reboot if nvidia-smi stops responding (GSP wedge)"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;hardware&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;nvidia&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;package&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;bin&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="nv"&gt;script&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;''&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="s2"&gt;      if ! timeout 60 nvidia-smi &amp;gt; /dev/null 2&amp;gt;&amp;amp;1; then&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="s2"&gt;        echo "nvidia-smi unresponsive for 60s — rebooting" \&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="s2"&gt;          | systemd-cat -p err -t gpu-watchdog&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="s2"&gt;        systemctl reboot&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="s2"&gt;      fi&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="s2"&gt;    ''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;serviceConfig&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;Type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"oneshot"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="nv"&gt;timers&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;gpu-watchdog&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;wantedBy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"timers.target"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="nv"&gt;timerConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nv"&gt;OnBootSec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"5min"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nv"&gt;OnUnitActiveSec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"5min"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every 5 minutes it pokes &lt;code&gt;nvidia-smi&lt;/code&gt;, and &lt;strong&gt;if it doesn't come back within 60 seconds it declares the GPU wedged and runs &lt;code&gt;systemctl reboot&lt;/code&gt;&lt;/strong&gt;. It's blunt, but against an opponent where a reset doesn't work, a reboot is the most reliable move, so I made peace with it. It's unattended, so "I woke up and it was frozen" is the only thing I really need to avoid.&lt;/p&gt;

&lt;p&gt;I also added a mitigation (anecdote-level, n=1–3): a &lt;strong&gt;power cap&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nix"&gt;&lt;code&gt;&lt;span class="c"&gt;# RTX 5090 TDP=575W. Inference and Frigate need well under 400W.&lt;/span&gt;
&lt;span class="c"&gt;# Idle stays ~70W regardless. This only caps peak draw.&lt;/span&gt;
&lt;span class="nv"&gt;script&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;''nvidia-smi -pl 400''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are &lt;strong&gt;PSU-related Xid 119 reports&lt;/strong&gt; around the GSP hang (cases resolved by swapping the PSU), so suppressing power spikes is reasonable. This box already runs a native ATX 3.1 1300W Platinum PSU, but I capped peak draw to keep more headroom anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  Would switching to Ubuntu avoid it? → No
&lt;/h2&gt;

&lt;p&gt;"Wouldn't Ubuntu, with more reports out there, be more stable?" is the obvious thought, but my researched answer was No.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The GSP issue is distro-independent.&lt;/strong&gt; It's reported on Mint, Fedora, and others, and Blackwell forces &lt;code&gt;nvidia-open&lt;/code&gt; on every distro, so the landmine is shared. Switching to Ubuntu buys &lt;strong&gt;more anecdotes but doesn't avoid the crash&lt;/strong&gt;. Trading away NixOS's rollback and declarative config for that isn't worth it.&lt;/p&gt;

&lt;p&gt;The landing spot is the same as last time: &lt;strong&gt;stay on NixOS + design assuming a watchdog recovers + keep one Ubuntu Live USB for triage&lt;/strong&gt;. Just enough of a safety net to tell "is this a GPU problem or a Nix-config problem?" when something jams.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;Pioneer territory — putting a latest-gen consumer GPU in a server — turns out to have &lt;strong&gt;reset-proof-grade landmines (the GSP heartbeat timeout)&lt;/strong&gt; sitting right there. Since you can't prevent it with config, aiming for "it won't break" is less realistic than "&lt;strong&gt;assume it breaks and build automatic recovery&lt;/strong&gt;."&lt;/p&gt;

&lt;p&gt;And in moments like this, declarative config quietly pays off. The watchdog and the power cap all live as code in &lt;code&gt;configuration.nix&lt;/code&gt;, so &lt;strong&gt;the recovery design itself becomes a reproducible asset&lt;/strong&gt;. If the SSD dies, I can rebuild the whole catch along with it. It's the same point as the "peace of mind of rollback" from last time — the harder the pioneer territory you're crossing, the more this property earns its keep.&lt;/p&gt;

&lt;p&gt;Whether I actually hit the moment a GSP wedge happens is something I'll find out as I run it. Once the watchdog's trigger logs start showing up, I'll add an update.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post was written with AI assistance and reviewed by the author.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devops</category>
      <category>hardware</category>
      <category>linux</category>
    </item>
  </channel>
</rss>
