<?xml version="1.0" encoding="utf-8"?>
<?xml-stylesheet type="text/xml" href="https://jonnyzzz.com/feed.xslt.xml"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <link href="https://jonnyzzz.com/feed.xml" rel="self" type="application/atom+xml" />
  <link href="https://jonnyzzz.com/" rel="alternate" type="text/html" />
  <updated>2026-08-06T11:54:40+00:00</updated>
  <id>/</id>

  
  <title type="html">Eugene Petrenko</title>
  

  
  <subtitle>Founding Engineering Leader | Agentic AI DevTools &amp; Experience</subtitle>
  

  

  
  
  <entry>
    <title type="html">Install, Launch, Update: Shipping a CLI on Five Platforms — the devrig Approach</title>
    <link href="https://jonnyzzz.com/blog/2026/08/06/devrig-install-launch-autoupdate/" rel="alternate" type="text/html" title="Install, Launch, Update: Shipping a CLI on Five Platforms — the devrig Approach" />
    <published>2026-08-06T00:00:00+00:00</published>
    <updated>2026-08-06T00:00:00+00:00</updated>
    <id>/blog/2026/08/06/devrig-install-launch-autoupdate</id>
    <content type="html" xml:base="https://jonnyzzz.com/blog/2026/08/06/devrig-install-launch-autoupdate/">&lt;p class=&quot;notice&quot;&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — This post is about an approach to making CLI tools installable and upgradable: one pasted line
installs the tool on five platforms, and after that it keeps itself current. The recipe: generate the install
script at build time with every URL and SHA-256 baked in, install versions content-addressed and side-by-side,
let the binary own the launcher on your &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PATH&lt;/code&gt;, and define “update” as “run today’s install script” — so update
logic never ages with old installs. Built for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt;, a JVM CLI where the runtime is bigger than the program,
but most of it applies to any CLI you distribute yourself.&lt;/p&gt;

&lt;p&gt;Writing a command-line tool on the JVM is a pleasant afternoon. Kotlin, a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main&lt;/code&gt;, a few subcommands,
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;./gradlew installDist&lt;/code&gt;, done.&lt;/p&gt;

&lt;p&gt;Shipping it to a stranger is the hard part. That stranger is on Windows on ARM, or on a Mac that has never
had a JDK, or inside a container that turns out to be Alpine. They paste one line from your website into a
terminal, and whatever happens next is your product’s first impression. In three weeks you ship an update, and
every one of those machines has to get there without you.&lt;/p&gt;

&lt;p&gt;We hit this wall with &lt;a href=&quot;https://mcp-steroid.jonnyzzz.com/docs/devrig/&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt;&lt;/a&gt;, the CLI that bridges AI Agents to IntelliJ-family IDEs in
&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid&quot;&gt;MCP Steroid&lt;/a&gt;. When I wrote about &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt; reaching
&lt;a href=&quot;/blog/2026/06/02/devrig-mcp-steroid-level-3/&quot;&gt;Level 3 autonomy&lt;/a&gt;, the honest “cons” list
opened with this:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;JDK 25 is a hard requirement.&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt; doesn’t bundle a JVM in 0.100; the host must provide
Java 25.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Open question #3 in that same post was &lt;em&gt;“Bundled-JRE devrig: ship a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;~/.mcp-steroid/binaries/&amp;lt;os&amp;gt;-&amp;lt;cpu&amp;gt;-&amp;lt;sha&amp;gt;/&lt;/code&gt;
cache with a small wrapper, so the JDK-25 prerequisite stops being a prerequisite.”&lt;/em&gt; This post is what that
turned into: not a bundled JRE, but a small distribution system, and the interesting part is how little of
it lives inside the product.&lt;/p&gt;

&lt;p&gt;The first generation of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt; (&lt;a href=&quot;https://github.com/jonnyzzz/old-devrig-private&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;jonnyzzz/old-devrig-private&lt;/code&gt;&lt;/a&gt;, still public) was written in
&lt;strong&gt;Go&lt;/strong&gt; — a single static binary, nothing to provision — and its bootstrap script was still 182 lines of
POSIX &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sh&lt;/code&gt;. Everything below exists because we moved to Kotlin and inherited the JVM’s one real
distribution problem: the thing that runs your program is bigger than your program. If you read one section,
read &lt;a href=&quot;#the-updater-that-cannot-age&quot;&gt;the updater that cannot age&lt;/a&gt; — the idea the rest exists to support.&lt;/p&gt;

&lt;p&gt;One note on vintage, because this post mixes two. Quoted scripts and on-disk paths are the &lt;strong&gt;released
devrig&lt;/strong&gt; — you can &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;curl&lt;/code&gt; them yourself and check every hash against my directory names. Where &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main&lt;/code&gt; has
since moved on, I say so and name the issue. The gap between those two is where most of the interesting
mistakes live.&lt;/p&gt;

&lt;ul id=&quot;markdown-toc&quot;&gt;
  &lt;li&gt;&lt;a href=&quot;#the-problem-stated-properly&quot; id=&quot;markdown-toc-the-problem-stated-properly&quot;&gt;The problem, stated properly&lt;/a&gt;    &lt;ul&gt;
      &lt;li&gt;&lt;a href=&quot;#just-require-a-jdk-on-path&quot; id=&quot;markdown-toc-just-require-a-jdk-on-path&quot;&gt;“Just require a JDK on PATH”&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href=&quot;#publish-to-homebrew-apt-winget-chocolatey-scoop&quot; id=&quot;markdown-toc-publish-to-homebrew-apt-winget-chocolatey-scoop&quot;&gt;“Publish to Homebrew, apt, winget, Chocolatey, Scoop”&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href=&quot;#curl--sh-and-figure-it-out-at-install-time&quot; id=&quot;markdown-toc-curl--sh-and-figure-it-out-at-install-time&quot;&gt;“&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;curl | sh&lt;/code&gt; and figure it out at install time”&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href=&quot;#install-over-the-previous-version&quot; id=&quot;markdown-toc-install-over-the-previous-version&quot;&gt;“Install over the previous version”&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href=&quot;#put-all-the-update-logic-inside-the-binary&quot; id=&quot;markdown-toc-put-all-the-update-logic-inside-the-binary&quot;&gt;“Put all the update logic inside the binary”&lt;/a&gt;&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#the-architecture&quot; id=&quot;markdown-toc-the-architecture&quot;&gt;The architecture&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#the-jdk-data-model-resolved-live-pgp-verified-nothing-hand-pinned&quot; id=&quot;markdown-toc-the-jdk-data-model-resolved-live-pgp-verified-nothing-hand-pinned&quot;&gt;The JDK data model: resolved live, PGP-verified, nothing hand-pinned&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#the-generator-a-template-a-table-and-a-refusal-to-guess&quot; id=&quot;markdown-toc-the-generator-a-template-a-table-and-a-refusal-to-guess&quot;&gt;The generator: a template, a table, and a refusal to guess&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#the-generated-script-what-a-good-curl--sh-looks-like&quot; id=&quot;markdown-toc-the-generated-script-what-a-good-curl--sh-looks-like&quot;&gt;The generated script: what a good &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;curl | sh&lt;/code&gt; looks like&lt;/a&gt;    &lt;ul&gt;
      &lt;li&gt;&lt;a href=&quot;#the-powershell-twin-and-why-it-is-not-a-translation&quot; id=&quot;markdown-toc-the-powershell-twin-and-why-it-is-not-a-translation&quot;&gt;The PowerShell twin, and why it is not a translation&lt;/a&gt;&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#content-addressed-side-by-side-installs&quot; id=&quot;markdown-toc-content-addressed-side-by-side-installs&quot;&gt;Content-addressed, side-by-side installs&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#the-handoff-the-script-does-not-write-the-launcher&quot; id=&quot;markdown-toc-the-handoff-the-script-does-not-write-the-launcher&quot;&gt;The handoff: the script does not write the launcher&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#the-launcher-and-self-healing-on-every-start&quot; id=&quot;markdown-toc-the-launcher-and-self-healing-on-every-start&quot;&gt;The launcher, and self-healing on every start&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#update-the-updater-that-knows-nothing&quot; id=&quot;markdown-toc-update-the-updater-that-knows-nothing&quot;&gt;Update: the updater that knows nothing&lt;/a&gt;    &lt;ul&gt;
      &lt;li&gt;&lt;a href=&quot;#and-then-the-upgrade-is-run-the-install-script-again&quot; id=&quot;markdown-toc-and-then-the-upgrade-is-run-the-install-script-again&quot;&gt;And then the upgrade is: run the install script again&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href=&quot;#the-updater-that-cannot-age&quot; id=&quot;markdown-toc-the-updater-that-cannot-age&quot;&gt;The updater that cannot age&lt;/a&gt;&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#distribution-and-the-release-ordering-trap&quot; id=&quot;markdown-toc-distribution-and-the-release-ordering-trap&quot;&gt;Distribution, and the release-ordering trap&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#signing-implemented-once-designed-twice-not-shipped-yet&quot; id=&quot;markdown-toc-signing-implemented-once-designed-twice-not-shipped-yet&quot;&gt;Signing: implemented once, designed twice, not shipped yet&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#testing-scripts-and-generators&quot; id=&quot;markdown-toc-testing-scripts-and-generators&quot;&gt;Testing scripts and generators&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#where-this-was-built-and-tested&quot; id=&quot;markdown-toc-where-this-was-built-and-tested&quot;&gt;Where this was built and tested&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#what-is-not-solved-yet&quot; id=&quot;markdown-toc-what-is-not-solved-yet&quot;&gt;What is not solved yet&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#is-this-an-open-source-project-on-its-own&quot; id=&quot;markdown-toc-is-this-an-open-source-project-on-its-own&quot;&gt;Is this an open-source project on its own?&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;the-problem-stated-properly&quot;&gt;The problem, stated properly&lt;/h2&gt;

&lt;p&gt;The whole job in one sentence; every clause hides a trap.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Deliver a reusable JVM command-line tool to five platforms, with a runtime the user does not have,
installed by one pasteable line, updatable without breaking a running copy, and reproducible enough
that you can tell a security team exactly what got executed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Concretely: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;macos-arm64&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;linux-x64&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;linux-arm64&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;windows-x64&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;windows-arm64&lt;/code&gt;. Two script languages
(POSIX &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sh&lt;/code&gt; and PowerShell), two archive formats, two JDK vendors, one binary. Every obvious answer is wrong
in a way you only discover on someone else’s laptop.&lt;/p&gt;

&lt;h3 id=&quot;just-require-a-jdk-on-path&quot;&gt;“Just require a JDK on PATH”&lt;/h3&gt;

&lt;p&gt;Where &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt; 0.100 was, and the most defensible wrong answer: it cost us more support time than every
other issue combined. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;JAVA_HOME&lt;/code&gt; points at a JDK 17 the user forgot about. The shell that launches an AI
Agent has a different &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PATH&lt;/code&gt; than the shell the user tested in. And “please install Java 25 first” is a
hostile first step for a tool whose pitch is &lt;em&gt;removing&lt;/em&gt; setup steps.&lt;/p&gt;

&lt;h3 id=&quot;publish-to-homebrew-apt-winget-chocolatey-scoop&quot;&gt;“Publish to Homebrew, apt, winget, Chocolatey, Scoop”&lt;/h3&gt;

&lt;p&gt;Only wrong as a &lt;em&gt;first&lt;/em&gt; channel. Five package managers means five review processes, five release lags and
five sets of packaging conventions — a part-time job for a project releasing every few weeks — and the
versions drift, so you end up debugging a bug that exists only in one downstream package. Not worthless: a
package manager would hand us uninstall, proxy configuration, enterprise policy and inventory, four things
this post later admits we have not solved. A desirable second channel; not a viable first one.&lt;/p&gt;

&lt;h3 id=&quot;curl--sh-and-figure-it-out-at-install-time&quot;&gt;“&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;curl | sh&lt;/code&gt; and figure it out at install time”&lt;/h3&gt;

&lt;p&gt;The natural install script asks the network what to install: the GitHub API for the latest release, the JDK
vendor for the current build, resolve URLs, download. It demos well. Then: GitHub’s unauthenticated API
rate-limits you to 60 requests per hour per IP, so a whole office behind one NAT fails at once. A vendor
changes a URL shape and every historical install line breaks retroactively. Two users running the one-liner
an hour apart get different versions with no way to tell. And you cannot answer “what exactly did that
script download?” — the answer was decided at runtime, on their machine, by a server you do not control.&lt;/p&gt;

&lt;h3 id=&quot;install-over-the-previous-version&quot;&gt;“Install over the previous version”&lt;/h3&gt;

&lt;p&gt;Overwrite the install directory and you have a window where the tree is half old and half new. If a copy is
running — and for an MCP server bridging an AI Agent to an IDE, a copy is &lt;em&gt;always&lt;/em&gt; running — you have just
swapped its files underneath it. Rollback means re-downloading the old version, which you did not keep.&lt;/p&gt;

&lt;h3 id=&quot;put-all-the-update-logic-inside-the-binary&quot;&gt;“Put all the update logic inside the binary”&lt;/h3&gt;

&lt;p&gt;The tempting one: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig self-update&lt;/code&gt; checks a version endpoint, downloads, swaps, restarts. The problem is
subtle and expensive: &lt;strong&gt;your update logic ages with the oldest installed copy.&lt;/strong&gt; A machine running 0.99
updates using 0.99’s updater. If that updater had a bug, or predates the archive format you switched to, or
hardcodes a URL you retired, that machine is stranded — the only code that could fix it is the code that is
broken. A bootstrap problem discovered exactly once, in public. That failure mode shaped everything below.&lt;/p&gt;

&lt;h2 id=&quot;the-architecture&quot;&gt;The architecture&lt;/h2&gt;

&lt;p&gt;Our answer inverts the usual layering: the install script is a &lt;strong&gt;generated, self-contained artifact&lt;/strong&gt;, not a
thin wrapper around a smart product, and the product’s only job on the install path is to register its own
launcher. Four ideas carry the design:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Resolve at build time, not install time.&lt;/strong&gt; Every URL and SHA-256 for every platform is computed in CI
and baked into the script, which detects &lt;em&gt;your&lt;/em&gt; platform and &lt;em&gt;your&lt;/em&gt; tools but never asks a server what
to install.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Content-address everything.&lt;/strong&gt; Each artifact unpacks into &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;kind&amp;gt;-&amp;lt;os&amp;gt;-&amp;lt;cpu&amp;gt;-&amp;lt;version&amp;gt;-&amp;lt;sha12&amp;gt;/&lt;/code&gt;, so
two versions cannot collide: installs are side-by-side and re-installs free.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The binary owns its launcher.&lt;/strong&gt; The script never writes the thing on your &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PATH&lt;/code&gt;; it hands off to
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig install devrig&lt;/code&gt;, and the binary re-checks and repairs that launcher on &lt;em&gt;every&lt;/em&gt; start.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Update is install.&lt;/strong&gt; Upgrading is running the current install script, so upgrade logic is always the
newest code, never the oldest. When we later automated updates, the in-binary updater’s whole job stayed
“download today’s script and exec it”.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here is the whole system (click to zoom)&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;graph TB
    subgraph BUILD[&quot;① Build time — CI, per release&quot;]
        VEND[&quot;Vendor metadata&amp;lt;br/&amp;gt;corretto.aws latest alias · Azul Metadata API&amp;lt;br/&amp;gt;GitHub Releases API&quot;]
        MODEL[&quot;:installer-gen&amp;lt;br/&amp;gt;resolveAllJdks() → JdkModel&amp;lt;br/&amp;gt;url · sha256 · javaHome · archive&quot;]
        PGP[&quot;PgpVerifier — BouncyCastle&amp;lt;br/&amp;gt;detached vendor signature vs&amp;lt;br/&amp;gt;PINNED fingerprint · gates every resolve&quot;]
        GEN[&quot;:installer-gen · InstallerGenerator&amp;lt;br/&amp;gt;install.sh.tmpl / install.ps1.tmpl&amp;lt;br/&amp;gt;+ generation-time validation&quot;]
        WGEN[&quot;:website-gen · WebsiteArtifacts&amp;lt;br/&amp;gt;reads VERSION + the GitHub release&quot;]
        VEND --&amp;gt; MODEL
        PGP -.-&amp;gt;|&quot;fail-fast, incl. cache hits&quot;| MODEL
        MODEL --&amp;gt; GEN
        GEN --&amp;gt; SCRIPTS[&quot;install.sh + install.ps1&amp;lt;br/&amp;gt;every URL + SHA-256 baked in&quot;]
        WGEN --&amp;gt; VJSON[&quot;version.json&amp;lt;br/&amp;gt;updatePlugins.xml&quot;]
    end

    subgraph DIST[&quot;② Distribution&quot;]
        SITE[&quot;devrig.dev — GitHub Pages&amp;lt;br/&amp;gt;deployed from the &apos;website&apos; branch&amp;lt;br/&amp;gt;(lags main until the release exists)&quot;]
        REL[&quot;GitHub Release&amp;lt;br/&amp;gt;devrig-&amp;amp;lt;version&amp;amp;gt;-&amp;amp;lt;hash&amp;amp;gt;.zip&quot;]
        CDN[&quot;Vendor CDNs, never mirrored&amp;lt;br/&amp;gt;corretto.aws · cdn.azul.com&quot;]
        SCRIPTS --&amp;gt; SITE
        VJSON --&amp;gt; SITE
    end

    subgraph INSTALL[&quot;③ Install — one pasted line&quot;]
        ONE[&quot;curl -fsSL https://devrig.dev/install.sh #124; sh&amp;lt;br/&amp;gt;irm https://devrig.dev/install.ps1 #124; iex&quot;]
        PRE[&quot;preflight&amp;lt;br/&amp;gt;os/arch detect · musl reject&amp;lt;br/&amp;gt;downloader · hasher · extractor&quot;]
        DL[&quot;download → SHA-256 verify → unpack verbatim&quot;]
        CA[&quot;~/.mcp-steroid/binaries/&amp;lt;br/&amp;gt;&amp;amp;lt;kind&amp;amp;gt;-&amp;amp;lt;os&amp;amp;gt;-&amp;amp;lt;cpu&amp;amp;gt;-&amp;amp;lt;version&amp;amp;gt;-&amp;amp;lt;sha12&amp;amp;gt;/&amp;lt;br/&amp;gt;atomic promote · side-by-side&quot;]
        HAND[&quot;devrig install devrig&amp;lt;br/&amp;gt;--install-script --jdk-home&quot;]
        SITE --&amp;gt; ONE --&amp;gt; PRE --&amp;gt; DL --&amp;gt; CA --&amp;gt; HAND
        REL --&amp;gt; DL
        CDN --&amp;gt; DL
    end

    subgraph RUN[&quot;④ Every run&quot;]
        SHIM[&quot;~/.mcp-steroid/bin/devrig · devrig.cmd&amp;lt;br/&amp;gt;pins DEVRIG_JAVA_HOME → exec install tree&quot;]
        HEAL[&quot;ensureBinLauncher()&amp;lt;br/&amp;gt;repair if stale · never overwrite a newer one&amp;lt;br/&amp;gt;PATH symlink / HKCU PATH&quot;]
        CHK[&quot;DevrigUpdateChecker + AutoUpdater&amp;lt;br/&amp;gt;GET version.json → promoted &amp;amp;gt; current?&quot;]
        HAND --&amp;gt; SHIM
        SHIM --&amp;gt; HEAL
        SHIM --&amp;gt; CHK
    end

    VJSON -.-&amp;gt;|&quot;version-base&quot;| CHK
    CHK -.-&amp;gt;|&quot;3–8 h tick: fetch today&apos;s script, exec it&quot;| ONE

    style BUILD fill:#e1f5ff
    style DIST fill:#e8f5e9
    style INSTALL fill:#fff4e6
    style RUN fill:#f3e5f5
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;em&gt;The diagram is dense by design — &lt;strong&gt;click it&lt;/strong&gt; to open full-screen at readable size (Esc closes), or zoom the
page in.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Now the parts, one at a time.&lt;/p&gt;

&lt;h2 id=&quot;the-jdk-data-model-resolved-live-pgp-verified-nothing-hand-pinned&quot;&gt;The JDK data model: resolved live, PGP-verified, nothing hand-pinned&lt;/h2&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:installer-gen&lt;/code&gt; is a build-tooling Gradle module with no IntelliJ dependencies. Its
&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/installer-gen/src/main/kotlin/com/jonnyzzz/mcpSteroid/installer/JdkModel.kt&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;resolveAllJdks(cache, http)&lt;/code&gt;&lt;/a&gt; produces a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;JdkModel&lt;/code&gt; — one &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;JdkArtifact&lt;/code&gt; per platform, every field
&lt;em&gt;computed&lt;/em&gt; from the live vendor source:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Amazon Corretto 25&lt;/strong&gt; for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;linux-x64&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;linux-arm64&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;macos-arm64&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;windows-x64&lt;/code&gt; (&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/installer-gen/src/main/kotlin/com/jonnyzzz/mcpSteroid/installer/CorrettoJdk.kt&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CorrettoJdk.kt&lt;/code&gt;&lt;/a&gt;):
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HEAD&lt;/code&gt; the vendor’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;latest&lt;/code&gt; alias, record where it redirects, pull the version out of the resolved URL.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Azul Zulu 25&lt;/strong&gt; for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;windows-arm64&lt;/code&gt; (&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/installer-gen/src/main/kotlin/com/jonnyzzz/mcpSteroid/installer/AzulJdk.kt&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AzulJdk.kt&lt;/code&gt;&lt;/a&gt;) — the one platform Corretto does not publish — via
the Azul Metadata API with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;latest=true&amp;amp;release_status=ga&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;em&gt;major&lt;/em&gt; version is pinned in source; the patch level is not. “Latest 25” is resolved on every run,
because a pinned &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;25.0.4.7.1&lt;/code&gt; in source is a lie that rots quietly and the generator’s &lt;em&gt;output&lt;/em&gt; is fully
pinned anyway.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;javaHome&lt;/code&gt; is the field that breaks naive implementations. Every vendor lays out its archive differently —
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;amazon-corretto-25.jdk/Contents/Home&lt;/code&gt; on macOS, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;jdk25.0.4_7&lt;/code&gt; on Windows,
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;zulu25.36.15-ca-jdk25.0.4-win_aarch64&lt;/code&gt; for Azul — so it is not guessed from a convention: the model
streams the archive entries and &lt;strong&gt;takes the shallowest &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;bin/java[.exe]&lt;/code&gt;&lt;/strong&gt;, which is also proof against
archives nesting a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;jre/bin/java&lt;/code&gt; inside. Archives are downloaded and cached, never extracted.&lt;/p&gt;

&lt;p&gt;Then the part that matters most:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Both vendors publish detached OpenPGP signatures. The signing key &lt;strong&gt;fingerprint is pinned in source&lt;/strong&gt;
and asserted before the signature is trusted — and the key itself is fetched live over HTTPS, so the
pin is what defeats a compromised key endpoint.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/installer-gen/src/main/kotlin/com/jonnyzzz/mcpSteroid/installer/PgpVerifier.kt&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PgpVerifier&lt;/code&gt;&lt;/a&gt; (BouncyCastle &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;bcpg-jdk18on&lt;/code&gt;) checks Corretto’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;file&amp;gt;.sig&lt;/code&gt; and Azul’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;signature-binary&lt;/code&gt;
against constants that live in the source:&lt;/p&gt;

&lt;div class=&quot;language-kotlin highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;val&lt;/span&gt; &lt;span class=&quot;py&quot;&gt;CORRETTO_KEY_FINGERPRINT&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;6dc3636dae534049c8b94623a122542ab04f24e3&quot;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;val&lt;/span&gt; &lt;span class=&quot;py&quot;&gt;AZUL_KEY_FINGERPRINT&lt;/span&gt;     &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;27bc0c8cb3d81623f59bdadcb1998361219bd9c9&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;It also insists the signature is a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PGPSignature.BINARY_DOCUMENT&lt;/code&gt;, and on a mismatch refuses with
&lt;em&gt;“refusing to trust the signature (possible compromised key endpoint)”&lt;/em&gt;. It is &lt;strong&gt;fail-fast and runs on
every resolve, including cache hits&lt;/strong&gt; — the cache directory is shared, and a cache is not a trust boundary.
The cache is built the same way: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;downloadWithEtag&lt;/code&gt; &lt;strong&gt;fails the build if the host exposes no &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ETag&lt;/code&gt;&lt;/strong&gt;, and
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;downloadVerifyingSha256&lt;/code&gt; re-hashes on every return.&lt;/p&gt;

&lt;h2 id=&quot;the-generator-a-template-a-table-and-a-refusal-to-guess&quot;&gt;The generator: a template, a table, and a refusal to guess&lt;/h2&gt;

&lt;p&gt;&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/installer-gen/src/main/kotlin/com/jonnyzzz/mcpSteroid/installer/InstallerGenerator.kt&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;InstallerGenerator&lt;/code&gt;&lt;/a&gt; does something unglamorous: it renders two text templates.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;installer-gen/src/main/resources/templates/install.sh.tmpl
installer-gen/src/main/resources/templates/install.ps1.tmpl
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The templates hold all the &lt;em&gt;logic&lt;/em&gt;; the generator injects only the &lt;em&gt;data&lt;/em&gt; — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@@VERSION@@&lt;/code&gt;,
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@@DEVRIG_URL@@&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@@DEVRIG_SHA256@@&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@@DEVRIG_BINSUB@@&lt;/code&gt;, and the per-platform JDK table
(&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@@PLATFORM_CASE_SH@@&lt;/code&gt; as POSIX &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;case&lt;/code&gt; arms, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@@PLATFORM_TABLE_PS@@&lt;/code&gt; as a PowerShell hashtable). Only the
three POSIX platforms go into the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sh&lt;/code&gt; table, only the two Windows ones into the PowerShell table;
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@@DEVRIG_BINSUB@@&lt;/code&gt; is filled with a &lt;em&gt;different&lt;/em&gt; value per template (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;bin/devrig&lt;/code&gt; vs &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;bin/devrig.bat&lt;/code&gt;). If
any &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@@PLACEHOLDER@@&lt;/code&gt; survives rendering, generation fails.&lt;/p&gt;

&lt;p&gt;What matters is what it refuses to do:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;All five platforms or nothing.&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;validateScriptTable&lt;/code&gt; requires exactly &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ALL_PLATFORMS&lt;/code&gt;; missing &lt;em&gt;and&lt;/em&gt;
extra platforms both fail the build.&lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Shell-safety validation on vendor-controlled strings.&lt;/strong&gt; URLs and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;javaHome&lt;/code&gt; values are baked verbatim
into single-quoted shell and PowerShell literals in a script people run via &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;curl | sh&lt;/code&gt;, where a quote,
backtick, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;$&lt;/code&gt;, backslash or control character would break out of the string:&lt;/p&gt;

    &lt;div class=&quot;language-kotlin highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;val&lt;/span&gt; &lt;span class=&quot;py&quot;&gt;SHELL_UNSAFE_CHARS&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;charArrayOf&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sc&quot;&gt;&apos;\&apos;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;sc&quot;&gt;&apos;&quot;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;sc&quot;&gt;&apos;`&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;sc&quot;&gt;&apos;$&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;sc&quot;&gt;&apos;\\&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;fun&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;requireShellSafe&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;label&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;String&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;value&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;String&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;
    &lt;span class=&quot;nf&quot;&gt;require&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;value&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;none&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;it&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;SHELL_UNSAFE_CHARS&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;||&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;it&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;isISOControl&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;})&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;$label contains a shell-unsafe character: &apos;$value&apos;&quot;&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;    &lt;/div&gt;

    &lt;p&gt;It runs on every URL, every &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;javaHome&lt;/code&gt;, the devrig URL, both launcher subpaths and the version string.
Today’s vendor values are clean; the check exists so a vendor URL change or a crafted archive entry name
cannot produce an injectable installer, and &lt;em&gt;generation time&lt;/em&gt; is the only place a human still sees the
failure.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The launcher path inside the zip is discovered, not assumed.&lt;/strong&gt; The obvious guess is
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig-&amp;lt;version&amp;gt;/bin/devrig&lt;/code&gt;, but the real top-level directory carries the build hash —
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig-0.101-40690055&lt;/code&gt; — so &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrigLaunchers()&lt;/code&gt; scans the actual archive entries for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;*/bin/devrig&lt;/code&gt; and
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;*/bin/devrig.bat&lt;/code&gt; and &lt;em&gt;asserts&lt;/em&gt; they exist. The devrig zip itself is resolved from the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;v&amp;lt;version&amp;gt;&lt;/code&gt;
GitHub release, deliberately &lt;strong&gt;not&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;latest&lt;/code&gt;, and the archive’s top directory must start with
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig-&amp;lt;version&amp;gt;-&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;ASCII-only output.&lt;/strong&gt; Both rendered scripts are scanned and rejected if any character is ≥ 128.
PowerShell 5.1 misreads BOM-less UTF-8, and legacy Windows consoles corrupt Unicode. A tidy &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;→&lt;/code&gt; in a
log message is not worth a broken install on Windows 10&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And one seam that matters more than it looks:&lt;/p&gt;

&lt;div class=&quot;language-kotlin highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;fun&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;writeInstallerScripts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;outDir&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;Path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;table&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;Map&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;nc&quot;&gt;String&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;JdkScriptEntry&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;gt;,&lt;/span&gt;
                          &lt;span class=&quot;n&quot;&gt;devrig&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;DevrigEntry&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;version&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;String&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;writeInstallerScripts&lt;/code&gt; is pure — no network, no vendor lookups — so tests can render a &lt;em&gt;real&lt;/em&gt; installer
from a &lt;em&gt;synthetic&lt;/em&gt; model pointing at local fixtures. That is why end-to-end installer tests run in seconds
instead of downloading 200 MB JDKs.&lt;/p&gt;

&lt;h2 id=&quot;the-generated-script-what-a-good-curl--sh-looks-like&quot;&gt;The generated script: what a good &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;curl | sh&lt;/code&gt; looks like&lt;/h2&gt;

&lt;p&gt;The live one is &lt;a href=&quot;https://devrig.dev/install.sh&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https://devrig.dev/install.sh&lt;/code&gt;&lt;/a&gt;: 207 lines, starting with a note that it is
generated. Every design choice in it exists because something broke.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The whole body is a function, invoked on the last line.&lt;/strong&gt;&lt;/p&gt;

&lt;div class=&quot;language-sh highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;main&lt;span class=&quot;o&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;{&lt;/span&gt;
  ...
&lt;span class=&quot;o&quot;&gt;}&lt;/span&gt;

main &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$@&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;curl | sh&lt;/code&gt; streams into a shell that executes as it reads, so a connection dropped at 60 % executes 60 %
of your installer — a half-configured machine with no error message. Wrapping everything in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main()&lt;/code&gt; called
at the very end makes a truncated transfer a no-op: the highest-value line in the file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Platform detection, then a hard stop on musl.&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;uname -s&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;uname -m&lt;/code&gt; detect and normalize the
platform, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DEVRIG_OS&lt;/code&gt; / &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DEVRIG_CPU&lt;/code&gt; override both, which is how the tests drive every platform arm. MCP
Steroid drives IntelliJ IDEs, which need glibc, so Alpine is not a platform we can serve: the script detects
musl three ways — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ldd --version&lt;/code&gt; output, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/lib/ld-musl-x86_64.so.1&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/lib/ld-musl-aarch64.so.1&lt;/code&gt; — and
fails &lt;em&gt;before any download&lt;/em&gt;, with a message that says why.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Preflight, and a promise never to install packages.&lt;/strong&gt;&lt;/p&gt;

&lt;div class=&quot;language-sh highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;have_any curl wget        &lt;span class=&quot;o&quot;&gt;||&lt;/span&gt; add_missing &lt;span class=&quot;s1&quot;&gt;&apos;curl (or wget) - to download files&apos;&lt;/span&gt;
have_any &lt;span class=&quot;nb&quot;&gt;sha256sum &lt;/span&gt;shasum &lt;span class=&quot;o&quot;&gt;||&lt;/span&gt; add_missing &lt;span class=&quot;s1&quot;&gt;&apos;sha256sum (or shasum) - to verify downloads&apos;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;If anything is missing, the script prints the complete list plus the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;apt-get&lt;/code&gt; / &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;apk&lt;/code&gt; / &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dnf&lt;/code&gt; / &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;brew&lt;/code&gt;
lines — all four, unconditionally, because guessing a distro badly is worse than printing an extra line —
and stops. It never installs system packages on your behalf: a tool that &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sudo&lt;/code&gt;s during install has lost the
argument about trust before it starts.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;install.ps1&lt;/code&gt; has no equivalent preflight and does not need one: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Invoke-WebRequest&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Get-FileHash&lt;/code&gt; and
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Expand-Archive&lt;/code&gt; are built in. It also has no &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main()&lt;/code&gt; wrapper — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;irm | iex&lt;/code&gt; parses the whole string before
executing any of it, so truncation safety comes free. Two asymmetries that look like oversights until you
ask why.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Content-addressed install, with an atomic promote.&lt;/strong&gt;&lt;/p&gt;

&lt;div class=&quot;language-sh highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nv&quot;&gt;ia_sha12&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;$(&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;printf&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;%s&apos;&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$ia_sha&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt; | &lt;span class=&quot;nb&quot;&gt;cut&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-c1-12&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;nv&quot;&gt;ia_target&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$BINARIES_DIR&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;/&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;${&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;ia_kind&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;${&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;key&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;${&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;VERSION&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;${&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;ia_sha12&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;[&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-d&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$ia_target&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;then
  &lt;/span&gt;log &lt;span class=&quot;s2&quot;&gt;&quot;already installed: &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;${&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;ia_target&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;##*/&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;
  &lt;span class=&quot;nb&quot;&gt;printf&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;%s\n&apos;&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$ia_target&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;return &lt;/span&gt;0
&lt;span class=&quot;k&quot;&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Download to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.tmp.$$.…&lt;/code&gt;, verify SHA-256, unpack into a staging directory, then &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mv&lt;/code&gt; the finished tree into
its final content-addressed name, so a reader only ever sees a complete tree. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;promote_tree&lt;/code&gt; tolerates
&lt;em&gt;losing&lt;/em&gt; that move — two installers running concurrently is not theoretical, an agent swarm does it
routinely — and since the target name comes from the archive’s hash, whoever won unpacked the same archive,
so “someone beat me to it” is a success, not a conflict. There is no lock anywhere in this path; content
addressing makes one unnecessary. The PowerShell peer needs one extra clause: unlike POSIX &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mv&lt;/code&gt;,
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Move-Item&lt;/code&gt; &lt;em&gt;nests&lt;/em&gt; the source into an existing destination directory instead of failing, so it detects and
cleans the nested result.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Everything goes to stderr&lt;/strong&gt;, via one &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;log()&lt;/code&gt; helper. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt; speaks MCP over stdio, and a single stray
byte on stdout corrupts the protocol frame — the agent then reports an unexplained handshake failure.
&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/npx-kt/src/main/kotlin/com/jonnyzzz/mcpSteroid/devrig/Main.kt&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Main.kt&lt;/code&gt;&lt;/a&gt; takes the rule to its conclusion: stdout is redirected to stderr before arguments are parsed, and
restored only for non-MCP commands. An invariant that has to hold in the &lt;em&gt;shell&lt;/em&gt;, not just in the code.&lt;/p&gt;

&lt;h3 id=&quot;the-powershell-twin-and-why-it-is-not-a-translation&quot;&gt;The PowerShell twin, and why it is not a translation&lt;/h3&gt;

&lt;p&gt;&lt;a href=&quot;https://devrig.dev/install.ps1&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;install.ps1&lt;/code&gt;&lt;/a&gt; is generated from the same model and mirrors the same pipeline, but its
comments read like a field report. Two examples that were real, user-facing bugs:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;$ProgressPreference&lt;/code&gt;&lt;/strong&gt; (&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/issues/274&quot;&gt;#274&lt;/a&gt;). PowerShell’s progress bar redraws on every byte
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Invoke-WebRequest&lt;/code&gt; reads and every entry &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Expand-Archive&lt;/code&gt; unpacks, turning a multi-hundred-MB download
into a UI-bound crawl. The script silences it — and because &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;irm | iex&lt;/code&gt; runs &lt;em&gt;in the caller’s scope&lt;/em&gt;, it
saves the previous value and restores it in a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;finally&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Architecture detection&lt;/strong&gt; (&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/issues/273&quot;&gt;#273&lt;/a&gt;). &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[RuntimeInformation]::OSArchitecture&lt;/code&gt; does not exist on
the .NET Framework 4.6.x that ships with base Windows 10, and under &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Set-StrictMode -Version Latest&lt;/code&gt; a
missing property is a &lt;em&gt;hard abort&lt;/em&gt;: a user ran &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;irm | iex&lt;/code&gt; and got “OSArchitecture cannot be found on
this object”. The fix reads &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PROCESSOR_ARCHITEW6432&lt;/code&gt; first (WoW64 sets &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PROCESSOR_ARCHITECTURE=x86&lt;/code&gt; on
64-bit Windows but exposes the true arch there), then &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PROCESSOR_ARCHITECTURE&lt;/code&gt;, and only then falls back
to the .NET property, inside a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;try&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It also pins &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[Net.ServicePointManager]::SecurityProtocol&lt;/code&gt; to TLS 1.2 explicitly, because PowerShell 5.1
does not necessarily negotiate it. Both scripts close the child’s stdin explicitly — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt; /dev/null&lt;/code&gt; in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sh&lt;/code&gt;,
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;$null |&lt;/code&gt; in PowerShell — because a subprocess that inherits the bootstrapping shell’s stdin and decides to
prompt will hang forever.&lt;/p&gt;

&lt;h2 id=&quot;content-addressed-side-by-side-installs&quot;&gt;Content-addressed, side-by-side installs&lt;/h2&gt;

&lt;p&gt;The on-disk layout does most of the work:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;~/.mcp-steroid/
├── bin/
│   └── devrig                                    # the stable launcher on your PATH
└── binaries/
    ├── devrig-macos-arm64-0.101-a7c8cf0d16e2/    # &amp;lt;kind&amp;gt;-&amp;lt;os&amp;gt;-&amp;lt;cpu&amp;gt;-&amp;lt;version&amp;gt;-&amp;lt;sha12&amp;gt;
    │   └── devrig-0.101-40690055/bin/devrig      #   &amp;lt;- the zip&apos;s own top dir: version + git hash
    └── jdk-macos-arm64-0.101-614107ed76e9/
        └── amazon-corretto-25.jdk/Contents/Home
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;That &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;a7c8cf0d16e2&lt;/code&gt; is the first 12 hex characters of the SHA-256 of the &lt;em&gt;downloaded archive&lt;/em&gt; — the same
value that appears as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DEVRIG_SHA256&lt;/code&gt; in the install script. You can verify the claim from your own shell,
which is the point.&lt;/p&gt;

&lt;p&gt;The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0.101&lt;/code&gt; in the &lt;em&gt;JDK&lt;/em&gt; directory name is a bug, and we shipped it. Both artifacts got stamped with the
devrig release version, so a Corretto 25.0.4 tree ended up in a directory labelled &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0.101&lt;/code&gt;. It survived
review because there is a story you can tell about it — “the directory records which release chose this
JDK” — and that story is wrong: two consecutive releases pinning the &lt;em&gt;same&lt;/em&gt; Corretto build download and
store it twice, and the name lies about its contents. &lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/issues/362&quot;&gt;#362&lt;/a&gt; fixed it; each artifact now carries
its own vendor-native version, and the integration test pins a JDK version deliberately different from the
devrig version so the two can never silently re-merge. A plausible rationalization is the most expensive
kind of comment.&lt;/p&gt;

&lt;p&gt;What the naming buys: the target directory existing &lt;em&gt;is&lt;/em&gt; the “already installed” check, so re-running the
one-liner on a current machine touches the network only for the script itself; 0.101 and 0.102 have
different names, so a new install cannot touch an old one and a running process keeps its files, which makes
rollback a path edit rather than a download; and the unpack is verbatim — no &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--strip-components&lt;/code&gt;, no
rearranging, which is why &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;javaHome&lt;/code&gt; has to ride along in the baked table. The weak spot is that the check
is &lt;em&gt;directory existence&lt;/em&gt;, not content: a tree a user edited, or one a half-finished &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;rm -rf&lt;/code&gt; left behind, is
trusted forever. A sentinel file recording the full hash would fix that cheaply and does not exist yet.&lt;/p&gt;

&lt;p&gt;The honest cost: &lt;strong&gt;nothing garbage-collects old versions.&lt;/strong&gt; Every upgrade leaves a full devrig tree and a
full JDK behind — the 0.101 devrig zip alone is 237 MB, most of it the bundled IDE plugin. And the naming
scheme is &lt;strong&gt;not parseable by splitting on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-&lt;/code&gt;&lt;/strong&gt;, because the version segment can itself contain a hyphen
(&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0.0.0-test&lt;/code&gt;, in the tests), so the GC has a parsing problem before it even reaches a policy problem. The
v7 deployment spec designs the sweep, with a keep-set of current plus most-recent-previous and per-directory
locks; none of it is implemented. Retention is only safe once you know nothing points at a tree, and the
whole point of content addressing was to not track that.&lt;/p&gt;

&lt;h2 id=&quot;the-handoff-the-script-does-not-write-the-launcher&quot;&gt;The handoff: the script does not write the launcher&lt;/h2&gt;

&lt;p&gt;The install script does &lt;strong&gt;not&lt;/strong&gt; create &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;~/.mcp-steroid/bin/devrig&lt;/code&gt;, does not touch &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PATH&lt;/code&gt;, does not edit
shell profiles. Its last real action is:&lt;/p&gt;

&lt;div class=&quot;language-sh highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nv&quot;&gt;DEVRIG_JAVA_HOME&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$jdk_home&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$launcher&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;install &lt;/span&gt;devrig &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--install-script&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$launcher&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--jdk-home&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$jdk_home&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt; &amp;lt; /dev/null &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;o&quot;&gt;||&lt;/span&gt; fail &lt;span class=&quot;s2&quot;&gt;&quot;&apos;devrig install devrig&apos; failed - the launcher could not register itself&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;It runs the freshly-unpacked binary and asks &lt;em&gt;it&lt;/em&gt; to register itself. Note the variable: the handoff runs
under &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DEVRIG_JAVA_HOME&lt;/code&gt;, &lt;strong&gt;never&lt;/strong&gt; by setting the user’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;JAVA_HOME&lt;/code&gt; — process-scoped, so the user’s
environment is untouched. (The 0.101 script still uses &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;JAVA_HOME&lt;/code&gt;; the contract below fixed it, and there
is a test pinning it.) The two flags are belt-and-braces: devrig accepts them and can equally derive both
paths itself, which is what it does on every later start.&lt;/p&gt;

&lt;p&gt;Why give up that responsibility? Because &lt;strong&gt;the shape of the launcher is product knowledge, and it changes.&lt;/strong&gt;
Adding a pathing JAR to work around Windows’ “input line is too long” limit changed what the launcher must
contain. Had the install script owned it, every existing machine would carry a stale launcher written by
whichever installer version happened to run there, and fixing it would mean asking users to re-run an
installer. Because the &lt;em&gt;binary&lt;/em&gt; owns it, the fix shipped with the binary and applied itself on the next
start.&lt;/p&gt;

&lt;p&gt;What the installer must &lt;em&gt;not&lt;/em&gt; do is a written, locked document —
&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/docs/install-scripts-contract.md&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;docs/install-scripts-contract.md&lt;/code&gt;&lt;/a&gt;, titled &lt;em&gt;“install devrig, register PATH — nothing else”&lt;/em&gt;.
Two prohibitions: never auto-register &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt; with an AI Agent, because that edits Claude’s / Codex’s /
Gemini’s own config files, state outside &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;~/.mcp-steroid&lt;/code&gt; that belongs to the user; and never auto-install
the MCP Steroid plugin into an IDE. Both stay explicit — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig install &amp;lt;agent&amp;gt;&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig install plugin&lt;/code&gt;.
Instead the flow &lt;strong&gt;promotes&lt;/strong&gt; them: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig install devrig&lt;/code&gt; ends by listing the exact commands you may run
next, and the scripts print no next-steps block of their own, so guidance cannot drift as the command set
changes. Writing that down as a contract rather than leaving it as taste turns “should the installer wire up
my agent?” into a closed question: an installer that silently rewires your tools turns a “download this CLI”
decision into a “modify my agents” decision you never made.&lt;/p&gt;

&lt;h2 id=&quot;the-launcher-and-self-healing-on-every-start&quot;&gt;The launcher, and self-healing on every start&lt;/h2&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;~/.mcp-steroid/bin/devrig&lt;/code&gt; on my machine, in full:&lt;/p&gt;

&lt;div class=&quot;language-sh highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c&quot;&gt;#!/bin/sh&lt;/span&gt;
&lt;span class=&quot;c&quot;&gt;# devrig launcher — managed by the devrig binary. Writes nothing to stdout (MCP stdio channel).&lt;/span&gt;
&lt;span class=&quot;c&quot;&gt;# Pins the JDK devrig runs under via DEVRIG_JAVA_HOME (its supported runtime), then hands off to&lt;/span&gt;
&lt;span class=&quot;c&quot;&gt;# the install-tree devrig launcher.&lt;/span&gt;
&lt;span class=&quot;nv&quot;&gt;DEVRIG_JAVA_HOME&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;/Users/jonnyzzz/.mcp-steroid/binaries/jdk-macos-arm64-0.101-614107ed76e9/amazon-corretto-25.jdk/Contents/Home&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;export &lt;/span&gt;DEVRIG_JAVA_HOME
&lt;span class=&quot;nb&quot;&gt;exec&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;/Users/jonnyzzz/.mcp-steroid/binaries/devrig-macos-arm64-0.101-a7c8cf0d16e2/devrig-0.101-40690055/bin/devrig&quot;&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$@&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Six lines, three of them comments — the entire user-facing surface of the installation. The JDK pin answers
“which Java does it run under?”: not the user’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PATH&lt;/code&gt;, not their &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;JAVA_HOME&lt;/code&gt;, but the runtime it shipped
with. On &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main&lt;/code&gt;, after 0.101, the header gained two machine-parseable lines —
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;# devrig launcher version:&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;# devrig launcher source:&lt;/code&gt; — for a reason we get to in a moment.&lt;/p&gt;

&lt;p&gt;The Windows peer is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;~/.mcp-steroid/bin/devrig.cmd&lt;/code&gt;, same shape:&lt;/p&gt;

&lt;div class=&quot;language-bat highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;@echo &lt;span class=&quot;na&quot;&gt;off&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;set&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;DEVRIG_JAVA_HOME=&amp;lt;jdk home&amp;gt;&quot;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;call&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&amp;lt;install tree&amp;gt;\bin\devrig.bat&quot;&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;%&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Deliberately &lt;strong&gt;no PowerShell at launch&lt;/strong&gt; — a unit test asserts the generated launcher does not contain the
string. The first generation used a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig.bat&lt;/code&gt; that trampolined into a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig.ps1&lt;/code&gt;, and paying
PowerShell startup on every agent call is the mistake that taught us not to. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DEVRIG_JAVA_HOME&lt;/code&gt; works
because the build patches Gradle’s own start-script templates in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;startScripts.doLast&lt;/code&gt;, injecting a
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;JAVA_HOME&lt;/code&gt; override ahead of Gradle’s “Determine the Java command” marker, under a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;require(...)&lt;/code&gt; that
fails the build if Gradle ever moves that marker.&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/npx-kt/src/main/kotlin/com/jonnyzzz/mcpSteroid/devrig/BinLauncher.kt&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BinLauncher.ensureBinLauncher()&lt;/code&gt;&lt;/a&gt; runs on &lt;strong&gt;every&lt;/strong&gt; devrig start and makes sure, first, that the launcher
exists and points at devrig’s &lt;em&gt;own&lt;/em&gt; current install tree and the JDK it is &lt;em&gt;currently&lt;/em&gt; running under — so
it self-heals if it was deleted, or went stale because an upgrade landed a new tree; and second, that the
launcher is reachable on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PATH&lt;/code&gt;: on POSIX by symlinking it into the first &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PATH&lt;/code&gt; directory that is both
writable and under &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;$HOME&lt;/code&gt; (pure Java, no subprocess), on Windows by registering the bin directory in the
HKCU user &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PATH&lt;/code&gt;, gated by a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;bin/.user-path-registered&lt;/code&gt; marker file. Windows needs a PowerShell helper for
exactly one reason: pure Java cannot &lt;em&gt;persist&lt;/em&gt; a user &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PATH&lt;/code&gt; entry, only check membership.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And here is the part that “rewrite on every start” gets wrong if you stop there.&lt;/strong&gt; Two versions coexist
by design, so the moment an &lt;em&gt;older&lt;/em&gt; devrig starts — an agent still holding a stale registration, a session
that outlived an upgrade — it would happily rewrite the launcher backwards, and the newest install would
lose. &lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/issues/373&quot;&gt;#373&lt;/a&gt; is the guard: the launcher carries its writer’s version in that header line, and
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;shouldKeepNewerLauncher&lt;/code&gt; refuses to overwrite a launcher stamped strictly newer than the running build
(SNAPSHOT counts as newest). A kept-newer launcher still gets its &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;+x&lt;/code&gt; bit repaired. Self-healing without
that check is not self-healing, it is a race — and it took shipping side-by-side installs to see it.&lt;/p&gt;

&lt;p&gt;The rest is other people’s edge cases:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;writeIfChanged&lt;/code&gt; compares against the intended content, normalizing CRLF/LF and a trailing newline, and
returns early when they match — but still repairs a lost &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;+x&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;A real write stages to a sibling &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;name&amp;gt;.new&amp;lt;pid&amp;gt;&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;chmod +x&lt;/code&gt; &lt;em&gt;before&lt;/em&gt; the move, then moves atomically.
If that is blocked — Windows holding the launcher open — it renames the original to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;name&amp;gt;.old&amp;lt;pid&amp;gt;&lt;/code&gt;,
which NTFS permits on an open file, moves again, and deletes the old one; five attempts, 10 ms apart.
Crash leftovers are never swept: a stray &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.old1234&lt;/code&gt; is cheaper than a sweeper that could delete a live
launcher.&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DEVRIG_BIN_NO_AUTO_REGISTER&lt;/code&gt; is an undocumented opt-out, and self-registration defaults to &lt;em&gt;off&lt;/em&gt; for
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SNAPSHOT&lt;/code&gt; builds so a local dev build never clobbers a real launcher; an explicit &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig install&lt;/code&gt;
forces it regardless. The whole thing is best-effort — failing to heal the launcher must never take down
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig mcp&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whatever you typed to launch it, you end up pointing at the one stable launcher. That is the invariant, and
it is why agent registrations point at &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;~/.mcp-steroid/bin/devrig&lt;/code&gt; and survive every upgrade without being
rewritten.&lt;/p&gt;

&lt;h2 id=&quot;update-the-updater-that-knows-nothing&quot;&gt;Update: the updater that knows nothing&lt;/h2&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:website-gen&lt;/code&gt; publishes a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;version.json&lt;/code&gt; next to the scripts. It is not a manifest:&lt;/p&gt;

&lt;div class=&quot;language-json highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;version-base&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;0.101&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;One field — what version is promoted right now. &lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/npx-kt/src/main/kotlin/com/jonnyzzz/mcpSteroid/devrig/DevrigUpdateChecker.kt&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DevrigUpdateChecker&lt;/code&gt;&lt;/a&gt; fetches it with a cache-busting query
and 10-second timeouts, and when a newer version exists it writes to stderr &lt;em&gt;and&lt;/em&gt; pushes an MCP
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;notifications/message&lt;/code&gt; so the agent sees it too. Any failure returns &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;null&lt;/code&gt; and is swallowed at debug
level: an update check must never be able to break a run. The same endpoint serves the in-IDE plugin’s
checker, which adds an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;?intellij-version=&amp;lt;build&amp;gt;&lt;/code&gt; query so the answer can be build-aware.&lt;/p&gt;

&lt;p&gt;Comparing versions deserved its own type. The first version compared strings by prefix, which is how we got
&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/issues/360&quot;&gt;#360&lt;/a&gt;: our release tags and our CI tags were not mutually orderable, so either could look like
an update to the other. &lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/mcp-core/src/main/kotlin/com/jonnyzzz/mcpSteroid/util/text/DevrigVersion.kt&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DevrigVersion&lt;/code&gt;&lt;/a&gt; now implements &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Comparable&lt;/code&gt; with two product rules — a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SNAPSHOT&lt;/code&gt;
build sorts above anything promoted, and build metadata after the first &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-&lt;/code&gt; carries no precedence, so
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0.86.0-a1b2c3d&lt;/code&gt; does not rank below &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0.86.0&lt;/code&gt; — and the decision is a plain
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;isUpdateAvailable(current, promoted) = promoted &amp;gt; current&lt;/code&gt;, which also means a &lt;em&gt;downgrade&lt;/em&gt; is not an
update.&lt;/p&gt;

&lt;h3 id=&quot;and-then-the-upgrade-is-run-the-install-script-again&quot;&gt;And then the upgrade is: run the install script again&lt;/h3&gt;

&lt;p&gt;Everything above is what makes that sentence sufficient —&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;the script is regenerated per release, so it always carries the &lt;em&gt;current&lt;/em&gt; URLs and hashes;&lt;/li&gt;
  &lt;li&gt;installs are content-addressed, so a new version lands beside the old one instead of over it;&lt;/li&gt;
  &lt;li&gt;the binary owns the launcher, so the new binary repoints it on its first start;&lt;/li&gt;
  &lt;li&gt;agent registrations point at the stable launcher path, so nothing needs re-registering;&lt;/li&gt;
  &lt;li&gt;the script is idempotent, so running it when you are already current prints &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;already installed&lt;/code&gt; and
downloads nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For 0.101 — the release you get if you run the one-liner today — that is the whole story: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt; tells
you, you re-run the line. And then we shipped the obvious next step, and the shape of it is the reason this
post exists.&lt;/p&gt;

&lt;h3 id=&quot;the-updater-that-cannot-age&quot;&gt;The updater that cannot age&lt;/h3&gt;

&lt;p&gt;&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/npx-kt/src/main/kotlin/com/jonnyzzz/mcpSteroid/devrig/AutoUpdater.kt&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AutoUpdater&lt;/code&gt;&lt;/a&gt; is merged on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main&lt;/code&gt; and not in 0.101 — install today and you get the
notify-only behaviour above; the next release turns this on. It does genuinely self-update, and here is the
interesting part of it:&lt;/p&gt;

&lt;div class=&quot;language-kotlin highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;val&lt;/span&gt; &lt;span class=&quot;py&quot;&gt;scriptUrl&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;isWin&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;https://devrig.dev/install.ps1&quot;&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;https://devrig.dev/install.sh&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;That is the entire update algorithm: download today’s install script, run it, supervise it. There is no
archive handling in the binary, no URL construction, no hash table, no unpack logic, no launcher
rewriting — &lt;strong&gt;none of the artifact knowledge that goes stale.&lt;/strong&gt; The parts that rot fastest live in a file
fetched fresh from the website seconds before it runs.&lt;/p&gt;

&lt;p&gt;The title oversells slightly, so let me undersell it: the binary still hardcodes the two script URLs, the
OS branch, the process-spawning and timeout behaviour, the marker protocol, and the version semantics. A
real aging surface — just a small, slow-moving one, and none of it is the part that changes when a vendor
moves a URL or we switch an archive format.&lt;/p&gt;

&lt;p&gt;Which answers the objection from the top of this post. “Put the updater inside the binary” fails because
&lt;em&gt;your update logic ages with the oldest installed copy.&lt;/em&gt; The escape is not to refuse to self-update, it is
to make the in-binary part contain no logic to age. A machine that sat on 0.99 for a year does not upgrade
using 0.99’s ideas about archive formats or vendor URLs; it upgrades using today’s, because all 0.99 knows
how to do is fetch and exec.&lt;/p&gt;

&lt;p&gt;What is &lt;em&gt;around&lt;/em&gt; that one line is the part I underestimated:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Scheduling — and only where it is safe.&lt;/strong&gt; The active updater runs &lt;em&gt;only&lt;/em&gt; in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig mcp&lt;/code&gt; sessions: it
ticks, then sleeps &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Random.nextLong(180, 481).minutes&lt;/code&gt; — 3 to 8 hours, jittered so a fleet does not
stampede the CDN — and repeats forever. A short one-shot command keeps the passive notice and never
spawns an installer. Nobody’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig --version&lt;/code&gt; should turn into a 200 MB download.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Multi-process coordination&lt;/strong&gt;, because on this machine six agents routinely each hold a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig mcp&lt;/code&gt;.
&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/devrig-common/src/main/kotlin/com/jonnyzzz/mcpSteroid/devrig/UpdateCoordination.kt&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UpdateCoordination&lt;/code&gt;&lt;/a&gt; writes an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;update-&amp;lt;pid&amp;gt;-version-&amp;lt;v&amp;gt;&lt;/code&gt; marker under &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;~/.mcp-steroid/update/&lt;/code&gt;, then
&lt;strong&gt;re-checks after announcing&lt;/strong&gt; and yields if a lower pid is also in progress. Lowest pid wins; losers
exit silently. An &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;updated-&amp;lt;v&amp;gt;&lt;/code&gt; marker means “already installed here” and turns the tick into a restart
notice, once per process.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Supervision.&lt;/strong&gt; The installer is spawned detached with a one-hour timeout and its own per-pid log file.
On Windows it prefers &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;powershell.exe&lt;/code&gt; by absolute System32 path, falling back to whatever &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;powershell&lt;/code&gt;
or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pwsh&lt;/code&gt; is on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PATH&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;No retry cap, deliberately.&lt;/strong&gt; Too many transient root causes exist, and the goal is to keep users
current, so every failure retries on the next 3–8 hour tick, forever. Diagnosis lives in stderr and the
log files rather than in a state machine.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Never downgrade&lt;/strong&gt;, and the marker GC is bounded below &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;min(current, promoted)&lt;/code&gt; so a session running
&lt;em&gt;newer&lt;/em&gt; than the promoted version — what a rollback looks like — cannot delete records older sessions
still need.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Three ways off.&lt;/strong&gt; SNAPSHOT builds skip the whole thing, and either &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DEVRIG_NO_AUTO_UPDATE&lt;/code&gt; or the
launcher-write opt-out &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DEVRIG_BIN_NO_AUTO_REGISTER&lt;/code&gt; disables it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two things I would still flag. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;version.json&lt;/code&gt; is &lt;strong&gt;unsigned&lt;/strong&gt;, and it just stopped being advisory: that one
field now decides whether a script is downloaded and executed. And nothing pins a &lt;em&gt;session&lt;/em&gt; to a version —
the update repoints the shared launcher, so the next thing an agent launches is the new binary, mid-task.
“The fleet finishes on the version it started with” is a property we describe and do not yet enforce.&lt;/p&gt;

&lt;h2 id=&quot;distribution-and-the-release-ordering-trap&quot;&gt;Distribution, and the release-ordering trap&lt;/h2&gt;

&lt;p&gt;The scripts and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;version.json&lt;/code&gt; are static files on GitHub Pages at &lt;a href=&quot;https://devrig.dev/&quot;&gt;devrig.dev&lt;/a&gt; — one
canonical host, with the older &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mcp-steroid.jonnyzzz.com&lt;/code&gt; 307-redirecting to it rather than serving a
second copy, and the code (both update checkers, both install scripts) pointing at the canonical
name. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:website-gen&lt;/code&gt;’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;generateWebsite&lt;/code&gt; task depends on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:installer-gen:generateInstaller&lt;/code&gt; and writes
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;version.json&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;updatePlugins.xml&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;install.sh&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;install.ps1&lt;/code&gt; into &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;website/build/generated-static&lt;/code&gt;,
which Hugo folds into the site root. Nothing generated is committed. The binaries live on the GitHub
Release; the JDKs are never mirrored — users download them straight from the vendor.&lt;/p&gt;

&lt;p&gt;The non-obvious component here is a &lt;em&gt;branch&lt;/em&gt;. The site does not deploy from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main&lt;/code&gt;:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Deploy from the long-lived &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;website&lt;/code&gt; branch, NOT &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main&lt;/code&gt;. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;website&lt;/code&gt; tracks &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main&lt;/code&gt; but can deliberately
lag it so website changes that depend on an UNRELEASED devrig binary — e.g. a new &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;install.sh&lt;/code&gt; /
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;install.ps1&lt;/code&gt; contract — stay off the live site until a matching GitHub release exists.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The install script’s content depends on a GitHub release that does not exist until you publish it, and the
release notes point at an install script that must not go live early. Deploy from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main&lt;/code&gt; and there is a
window — minutes or days — where &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig.dev/install.sh&lt;/code&gt; references a release tag that 404s, and every new
user in that window gets a broken install with no explanation. A separate deploy branch, fast-forwarded
&lt;em&gt;after&lt;/em&gt; the release is published, closes it — as long as nobody advances the branch by hand out of order,
which is convention plus a release-readiness check, not a permission. The Pages workflow also rebuilds on
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;release: published&lt;/code&gt;, because generating &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;updatePlugins.xml&lt;/code&gt; requires querying the release for its asset
URL.&lt;/p&gt;

&lt;p&gt;The ordering constraint is worth stating as a sequence, because getting it wrong is how you serve a 404:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;Build the artifacts and &lt;strong&gt;publish the GitHub release&lt;/strong&gt; for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;v&amp;lt;version&amp;gt;&lt;/code&gt;. Nothing about the website has
moved yet, so the live site still describes the previous release, correctly.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Generate&lt;/strong&gt; — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:installer-gen:generateInstaller&lt;/code&gt; resolves the devrig zip &lt;em&gt;from that release&lt;/em&gt;, which is
why the release has to exist first, and resolves the JDKs live from the vendors.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Fast-forward &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;website&lt;/code&gt; to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main&lt;/code&gt;.&lt;/strong&gt; This is the deploy: Hugo folds in the generated &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;install.sh&lt;/code&gt;,
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;install.ps1&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;version.json&lt;/code&gt;, and the one-liner starts pointing at the new release in the same push
that starts announcing it.&lt;/li&gt;
  &lt;li&gt;Only now does a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt; on someone’s laptop see a new &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;version-base&lt;/code&gt; and tell its user.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In between steps 1 and 3, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main&lt;/code&gt; can carry an install script for a release that does not exist yet, and no
user is exposed to it.&lt;/p&gt;

&lt;h2 id=&quot;signing-implemented-once-designed-twice-not-shipped-yet&quot;&gt;Signing: implemented once, designed twice, not shipped yet&lt;/h2&gt;

&lt;p&gt;The current chain of trust, hop by hop, with no softening:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;You paste a URL.&lt;/strong&gt; HTTPS to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig.dev&lt;/code&gt; (GitHub Pages, behind a CDN). &lt;em&gt;Unauthenticated beyond
TLS.&lt;/em&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The script runs.&lt;/strong&gt; Its contents are whatever that host served. &lt;strong&gt;Not signed.&lt;/strong&gt; Ask for
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;install.sh.asc&lt;/code&gt; today and you get a 404.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Artifacts download.&lt;/strong&gt; Each is checked against a SHA-256 baked into the script at generation time.
&lt;em&gt;Strong — but only as strong as hop 2, since whoever can change the script can change the hashes.&lt;/em&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The JDK hashes were earned.&lt;/strong&gt; At generation time the vendor JDK archives were OpenPGP-verified
against fingerprints pinned in source, fail-fast, on every resolve including cache hits. &lt;em&gt;This part is
real — with one honest asterisk: it protects the release engineer, not the user. None of that evidence
travels to the machine, so a user cannot re-check it.&lt;/em&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The devrig zip itself has no signature at all.&lt;/strong&gt; Its only credential is a hash written by the same
unsigned script. The runtime we did not build gets stronger provenance than the binary we did, which
is a funny thing to discover about your own pipeline.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;version.json&lt;/code&gt; is fetched on every run, and it is not signed.&lt;/strong&gt; Until recently the worst a tampered one
could do was fake an update &lt;em&gt;notice&lt;/em&gt;. Now it decides whether a script is downloaded and executed. That
one unsigned field is the weakest thing in this post, and it got weaker the day auto-update shipped.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the gap is hop 2, the classic &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;curl | sh&lt;/code&gt; circularity: verifying a script before executing it requires
something already trusted on the machine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;We solved this before, in the project’s own prehistory.&lt;/strong&gt; The Go generation fetched
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https://devrig.dev/download/latest.json&lt;/code&gt; with a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.sig&lt;/code&gt; and verified it natively against &lt;strong&gt;two embedded SSH
public keys&lt;/strong&gt; — one ed25519, one RSA-4096 — using &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;golang.org/x/crypto/ssh&lt;/code&gt;, no &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ssh-keygen&lt;/code&gt; subprocess;
the release side had &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ssh-sign.sh&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sync-release.sh&lt;/code&gt;. Its bootstrap script also re-verified the
binary’s SHA-512 on &lt;em&gt;every&lt;/em&gt; run, not just after download.&lt;/p&gt;

&lt;p&gt;The current design, written up as v7 of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;docs/devrig-deployment-spec.md&lt;/code&gt; and marked &lt;em&gt;“design ready for
implementation”&lt;/em&gt;, resolves the circularity rather than ignoring it: a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;version.properties&lt;/code&gt; manifest, a
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;version.properties.signatures&lt;/code&gt; file holding &lt;strong&gt;two ed25519 SSH signatures&lt;/strong&gt;, and an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;allowed_signers&lt;/code&gt; file
in OpenSSH format pinning both public keys — &lt;strong&gt;two keys, two operators, two machines&lt;/strong&gt;, both of which must
verify, so one compromised laptop is not enough. &lt;strong&gt;Verification is Java-only&lt;/strong&gt;: the shell wrapper &lt;em&gt;fetches
and stores&lt;/em&gt; the signatures and never checks them, and the installed binary verifies on upgrade. That is the
key move — the initial install is trust-on-first-use over HTTPS, and every subsequent update is
verified by code already installed and already trusted, so you stop trying to bootstrap trust from a pipe.
The manifest also carries a SHA for each wrapper script, so an upgrade can verify the &lt;em&gt;launchers&lt;/em&gt; too, and
a weekly CI job &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HEAD&lt;/code&gt;s every URL in the manifest and spot-checks the SHAs, so a dead vendor URL fails a
build rather than a user’s install.&lt;/p&gt;

&lt;p&gt;None of that ships today. The honest one-line summary is &lt;em&gt;HTTPS, plus generation-time vendor PGP, plus
install-time SHA-256 — with the bootstrap hop unauthenticated, the promoted-version field unsigned, and a
worked-out design for closing both.&lt;/em&gt; If you are evaluating this pattern for your own tool, that is the
sentence to weigh.&lt;/p&gt;

&lt;h2 id=&quot;testing-scripts-and-generators&quot;&gt;Testing scripts and generators&lt;/h2&gt;

&lt;p&gt;Shell scripts and code generators are both famously untested, usually with the same excuse: “you’d need a
real machine”. You need a container, and a seam.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The seam&lt;/strong&gt; is &lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/installer-gen/src/main/kotlin/com/jonnyzzz/mcpSteroid/installer/InstallerGenerator.kt&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;writeInstallerScripts(outDir, table, devrig, version)&lt;/code&gt;&lt;/a&gt; — network-free, wrapping a
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;renderInstallerScripts&lt;/code&gt; that touches neither network nor disk. A test hands it a synthetic five-platform
table pointing at a local HTTP server and gets a real installer out. Every lane below rests on it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hermetic unit tests&lt;/strong&gt; (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;./gradlew :installer-gen:test&lt;/code&gt;) — no network at all; the URL-routing fakes
hard-error on anything they were not given:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Test&lt;/th&gt;
      &lt;th&gt;What it pins down&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/installer-gen/src/test/kotlin/com/jonnyzzz/mcpSteroid/installer/InstallerGeneratorTest.kt&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;InstallerGeneratorTest&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
      &lt;td&gt;24 tests: placeholders all resolved, exactly-five-platform validation, ASCII-only, unsafe version strings, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;resolveDevrig&lt;/code&gt; version-mismatch&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/installer-gen/src/test/kotlin/com/jonnyzzz/mcpSteroid/installer/JdkArtifactsTest.kt&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;JdkArtifactsTest&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
      &lt;td&gt;Vendor parsing, the shallowest-&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;bin/java&lt;/code&gt; scan with a nested &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;jre&lt;/code&gt; decoy, and &lt;em&gt;“expected 4 Corretto + 1 Azul”&lt;/em&gt; as a hard assertion&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/installer-gen/src/test/kotlin/com/jonnyzzz/mcpSteroid/installer/PgpVerifierTest.kt&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PgpVerifierTest&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
      &lt;td&gt;Tampered data, key-not-in-keyring, wrong pinned fingerprint, fingerprint matching that ignores case and spaces — against keys generated in-test&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/installer-gen/src/test/kotlin/com/jonnyzzz/mcpSteroid/installer/CacheTest.kt&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CacheTest&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
      &lt;td&gt;Hash-mismatch rejection, &lt;strong&gt;re-verification on a cache hit with a corrupted cache file&lt;/strong&gt;, and “must not download when caching can’t be validated” for the missing-ETag case&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Two are worth stealing as a technique. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;$ProgressPreference&lt;/code&gt; test does not assert the line is
&lt;em&gt;present&lt;/em&gt;: it asserts it appears &lt;strong&gt;exactly once, at an index before&lt;/strong&gt; both &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Invoke-WebRequest -Uri&lt;/code&gt; and
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Expand-Archive -Path&lt;/code&gt;, and is &lt;em&gt;absent&lt;/em&gt; from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;install.sh&lt;/code&gt;. The #273 test requires any &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RuntimeInformation&lt;/code&gt;
access to sit inside a &lt;em&gt;preceding&lt;/em&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;try {&lt;/code&gt;. Both encode the property that mattered, not the diff that fixed
it. Fingerprints are injectable into &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;resolveAllJdks&lt;/code&gt; for the same reason — so the PGP tests can pin a
generated key.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docker integration lanes&lt;/strong&gt; (&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/installer-gen/src/installerIntegrationTest/kotlin/com/jonnyzzz/mcpSteroid/installer/tests/InstallerBootstrapTest.kt&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;InstallerBootstrapTest&lt;/code&gt;&lt;/a&gt; and its PowerShell peer, in a
separate &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;installerIntegrationTest&lt;/code&gt; source set gated so a plain &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;./gradlew check&lt;/code&gt; compiles but never runs
them):&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;em&gt;“end-to-end on ubuntu (glibc)”&lt;/em&gt; — a fake devrig zip and fake JDK tarball served by an &lt;strong&gt;nginx side-car
started first&lt;/strong&gt;, so the test learns its bridge IP and bakes those URLs plus the fixtures’ real SHA-256s
through the seam. In a digest-pinned Ubuntu image it then checks both downloads, SHA verified, trees at
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig-linux-x64-0.0.0-test-&amp;lt;sha12&amp;gt;&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;bin/java&lt;/code&gt; executable, the delegation carrying the exact
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--install-script=&lt;/code&gt; / &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--jdk-home=&lt;/code&gt; values, and &lt;strong&gt;no agent registration&lt;/strong&gt; — then a second run asserting
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;already installed:&lt;/code&gt; and zero downloads.&lt;/li&gt;
  &lt;li&gt;&lt;em&gt;“refuses musl (alpine)”&lt;/em&gt; — the real script in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;alpine:3.21&lt;/code&gt;: non-zero exit, the message explains musl,
and the output &lt;strong&gt;never contains “downloading”&lt;/strong&gt;, which proves the ordering rather than the wording.&lt;/li&gt;
  &lt;li&gt;&lt;em&gt;the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pwsh&lt;/code&gt; lane&lt;/em&gt; — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mcr.microsoft.com/powershell:lts-7.4-ubuntu-22.04&lt;/code&gt;, because Windows-native Docker is
not something this CI provisions. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DEVRIG_OS&lt;/code&gt; / &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DEVRIG_CPU&lt;/code&gt; stay unset so the real auto-detect branch
runs, and the template &lt;em&gt;anticipates&lt;/em&gt; it: a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;bin/java&lt;/code&gt; stub is accepted where &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;bin/java.exe&lt;/code&gt; would be.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The best line in the suite is a constant: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;INSTALLER_HOME_DIR = &quot;/home/tester one&quot;&lt;/code&gt;. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HOME&lt;/code&gt; with a space in
it. Every quoting bug in a shell installer dies there, permanently, for free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The native Windows lane.&lt;/strong&gt; A &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pwsh&lt;/code&gt;-on-Linux test cannot catch a PowerShell 5.1 / .NET Framework bug,
which is exactly what &lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/issues/273&quot;&gt;#273&lt;/a&gt; was. So &lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/test-integration-agent-launch/src/test/kotlin/com/jonnyzzz/mcpSteroid/agentlaunch/InstallerPs1ExecutionTest.kt&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;InstallerPs1ExecutionTest&lt;/code&gt;&lt;/a&gt; runs the installer for real on
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;windows-latest&lt;/code&gt; under both &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;powershell.exe&lt;/code&gt; (5.1) and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pwsh&lt;/code&gt; (7.x), serving fixtures from an in-process
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;com.sun.net.httpserver.HttpServer&lt;/code&gt; (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Invoke-WebRequest&lt;/code&gt; rejects &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;file://&lt;/code&gt;), and asserts by &lt;em&gt;negative&lt;/em&gt;: the
output must not contain &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&quot;The property &apos;OSArchitecture&apos; cannot be found&quot;&lt;/code&gt;. &lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/test-integration-agent-launch/src/test/kotlin/com/jonnyzzz/mcpSteroid/agentlaunch/InstallerScriptTest.kt&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;InstallerScriptTest&lt;/code&gt;&lt;/a&gt; adds a
cheap syntax gate — the PowerShell AST parser under 5.1, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sh -n&lt;/code&gt; on Linux. Both lanes run on every branch
push, with TeamCity carrying the same suites internally.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;resolveAllJdks&lt;/code&gt; leans on two vendor APIs that can change under you, so it is covered three ways:
hermetically over synthetic archives; by the fail-fast PGP check on every resolve, so a vendor serving
something unexpected fails the &lt;em&gt;build&lt;/em&gt; and not a user’s install; and by &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;generateInstaller&lt;/code&gt; running for
real in the release pipeline. Tests that intentionally hit public vendor feeds are tagged live-network and
&lt;strong&gt;excluded from the default test task&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So: the scripts’ &lt;em&gt;logic&lt;/em&gt; is tested hermetically, their &lt;em&gt;behaviour&lt;/em&gt; in real shells in real containers, their
&lt;em&gt;content&lt;/em&gt; at generation time, and the one thing you cannot fake — Windows’ own PowerShell — gets a dedicated
runner.&lt;/p&gt;

&lt;h2 id=&quot;where-this-was-built-and-tested&quot;&gt;Where this was built and tested&lt;/h2&gt;

&lt;p&gt;Everything above lives in &lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid&quot;&gt;jonnyzzz/mcp-steroid&lt;/a&gt;. The vendor endpoints, regexes and API field
names this post deliberately does not transcribe are all in these files:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;The JDK model&lt;/strong&gt; — &lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/installer-gen/src/main/kotlin/com/jonnyzzz/mcpSteroid/installer/JdkModel.kt&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;JdkModel.kt&lt;/code&gt;&lt;/a&gt;, &lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/installer-gen/src/main/kotlin/com/jonnyzzz/mcpSteroid/installer/CorrettoJdk.kt&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CorrettoJdk.kt&lt;/code&gt;&lt;/a&gt;, &lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/installer-gen/src/main/kotlin/com/jonnyzzz/mcpSteroid/installer/AzulJdk.kt&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AzulJdk.kt&lt;/code&gt;&lt;/a&gt;,
&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/installer-gen/src/main/kotlin/com/jonnyzzz/mcpSteroid/installer/PgpVerifier.kt&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PgpVerifier.kt&lt;/code&gt;&lt;/a&gt;, &lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/installer-gen/src/main/kotlin/com/jonnyzzz/mcpSteroid/installer/Cache.kt&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Cache.kt&lt;/code&gt;&lt;/a&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The generator&lt;/strong&gt; — &lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/installer-gen/src/main/kotlin/com/jonnyzzz/mcpSteroid/installer/InstallerGenerator.kt&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;InstallerGenerator.kt&lt;/code&gt;&lt;/a&gt; and the two templates,
&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/installer-gen/src/main/resources/templates/install.sh.tmpl&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;install.sh.tmpl&lt;/code&gt;&lt;/a&gt; / &lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/installer-gen/src/main/resources/templates/install.ps1.tmpl&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;install.ps1.tmpl&lt;/code&gt;&lt;/a&gt;. The templates are the installer; the
generator is 300 lines of validation around them.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The CLI side&lt;/strong&gt; — &lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/npx-kt/src/main/kotlin/com/jonnyzzz/mcpSteroid/devrig/BinLauncher.kt&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BinLauncher.kt&lt;/code&gt;&lt;/a&gt;, &lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/npx-kt/src/main/kotlin/com/jonnyzzz/mcpSteroid/devrig/InstallCommand.kt&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;InstallCommand.kt&lt;/code&gt;&lt;/a&gt;,
&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/npx-kt/src/main/kotlin/com/jonnyzzz/mcpSteroid/devrig/DevrigUpdateChecker.kt&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DevrigUpdateChecker.kt&lt;/code&gt;&lt;/a&gt;, &lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/npx-kt/src/main/kotlin/com/jonnyzzz/mcpSteroid/devrig/AutoUpdater.kt&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AutoUpdater.kt&lt;/code&gt;&lt;/a&gt;. In &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig-common&lt;/code&gt;:
&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/devrig-common/src/main/kotlin/com/jonnyzzz/mcpSteroid/devrig/HomePaths.kt&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HomePaths.kt&lt;/code&gt;&lt;/a&gt; (the layout, pinned by tests and deliberately not configurable) and
&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/devrig-common/src/main/kotlin/com/jonnyzzz/mcpSteroid/devrig/UpdateCoordination.kt&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UpdateCoordination.kt&lt;/code&gt;&lt;/a&gt; (the marker protocol).&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The contracts&lt;/strong&gt; — &lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/docs/install-scripts-contract.md&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;docs/install-scripts-contract.md&lt;/code&gt;&lt;/a&gt; (locked) and
&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/docs/updates-check/devrig-auto-update.md&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;docs/updates-check/devrig-auto-update.md&lt;/code&gt;&lt;/a&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The tests&lt;/strong&gt; — &lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/installer-gen/src/installerIntegrationTest/kotlin/com/jonnyzzz/mcpSteroid/installer/tests/InstallerBootstrapTest.kt&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;InstallerBootstrapTest.kt&lt;/code&gt;&lt;/a&gt; is the one to read. The Windows lanes live
in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;test-integration-agent-launch&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The design doc&lt;/strong&gt; — &lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/docs/devrig-deployment-spec.md&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;docs/devrig-deployment-spec.md&lt;/code&gt;&lt;/a&gt;, v7, including the signing and GC work
that is designed but unshipped. Its iteration history runs v1 → v7.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The prehistory&lt;/strong&gt; — &lt;a href=&quot;https://github.com/jonnyzzz/old-devrig-private&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;jonnyzzz/old-devrig-private&lt;/code&gt;&lt;/a&gt;, the Go generation: a per-project
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.devrig&lt;/code&gt; home, SHA-512 content addressing, re-verification on every run, and a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;latest.json&lt;/code&gt; update feed
signed under two embedded SSH keys we have not yet re-adopted.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Almost none of the sharp edges above were found by design review. The Windows work exists because people
ran &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;irm | iex&lt;/code&gt; and got an error message instead of a tool, then told us. The two open items on the JDK
model — folding the resolved URL into the cache key, tightening Azul’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;latest&lt;/code&gt; selection — came out of a
quorum review of that PR, not from the author. Distribution code is exactly the kind of thing where a
second and third reader pay for themselves, because the failure modes live on machines you do not own. One
more warning from the old generation: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;IsUpdateAvailable()&lt;/code&gt; there returned &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;info.Version == thisVersion&lt;/code&gt;,
the inverse of what its own comment described, so it announced an update precisely when you were current.
Update paths need tests more than they need cleverness.&lt;/p&gt;

&lt;h2 id=&quot;what-is-not-solved-yet&quot;&gt;What is not solved yet&lt;/h2&gt;

&lt;p&gt;The list I would want to read if I were evaluating this for my own project.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;version.json&lt;/code&gt; is unsigned, and it now triggers code execution.&lt;/strong&gt; Top of the list. Everything else here
is inconvenience; this one is a trust gap that auto-update promoted from theoretical to live.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The bootstrap hop is unauthenticated&lt;/strong&gt;, as laid out above. Design exists; implementation does not.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;No session pinning.&lt;/strong&gt; An update repoints the shared launcher under a running fleet.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;No garbage collection, and no uninstall.&lt;/strong&gt; Old trees accumulate forever; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig gc&lt;/code&gt; does not exist.
And between the launcher, a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PATH&lt;/code&gt; symlink, an HKCU &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PATH&lt;/code&gt; entry, marker files, agent registrations and a
few hundred MB per version, there is no supported way to remove any of it.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;No rollback command, and no health-checked activation&lt;/strong&gt;, despite the design assuming rollbacks happen —
the auto-updater’s GC bound explicitly handles “a session running newer than the promoted version,
post-rollback”. A release that downloads and unpacks cleanly but fails to &lt;em&gt;start&lt;/em&gt; still becomes the
launcher’s target, and rolling back means hand-editing two paths in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;~/.mcp-steroid/bin/devrig&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The signing design stops at the happy path.&lt;/strong&gt; Two keys and an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;allowed_signers&lt;/code&gt; file is the easy half.
Rotation, revocation, recovery after a compromise, anti-replay for a signed-but-stale manifest, and a
migration story for clients installed &lt;em&gt;before&lt;/em&gt; a verifier exists are all unwritten. So is macOS
notarization and Windows code-signing for the artifacts themselves.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;No proxy or custom-CA story.&lt;/strong&gt; The one that bothers us most: the person we keep describing — corporate
Windows box, no admin rights — is exactly the person behind a TLS-inspecting proxy. Neither script is
documented as honouring &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HTTPS_PROXY&lt;/code&gt; or an enterprise root CA. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;curl&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Invoke-WebRequest&lt;/code&gt; pick some
of that up from the environment by accident; “by accident” is not a support answer.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;No download retry or resume.&lt;/strong&gt; A transfer that dies at 80 % is a failed install and a re-run from zero,
on a 200 MB JDK. The auto-updater’s answer is “try again in 3–8 hours”, which is fine for it and no help
to a human watching a progress line.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Test gaps we know about.&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;requireShellSafe&lt;/code&gt; — showcased above as security-critical — has no test of
its own. Nothing exercises the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;wget&lt;/code&gt; fallback. And &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;windows-latest&lt;/code&gt; is Windows Server 2025 with a modern .NET,
so the machine that actually broke in &lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/issues/273&quot;&gt;#273&lt;/a&gt; — base Windows 10 with .NET Framework 4.6.x — is
still not in CI.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Vendor drift is only caught at release time.&lt;/strong&gt; The weekly “HEAD every URL and re-check the SHAs” job is
in the design, not in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.github/workflows&lt;/code&gt;, so a vendor pulling a build is discovered by the next release
or by a user. And because generation resolves live and the output is not committed, there is no retained
record proving which coordinates a given release actually served — awkward for a post that opened by
promising you could tell a security team exactly what got executed.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;One user per machine.&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;~/.mcp-steroid&lt;/code&gt; is per-&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;$HOME&lt;/code&gt; and explicitly not configurable — the
plugin↔devrig marker contract pins the location. A shared CI box gets one installation.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Two vendors, five platforms.&lt;/strong&gt; No Temurin, no GraalVM, no Intel macOS, no musl. All deliberate, and all
a real “sorry” to real people.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;is-this-an-open-source-project-on-its-own&quot;&gt;Is this an open-source project on its own?&lt;/h2&gt;

&lt;p&gt;I think so, and I keep coming back to it because the &lt;em&gt;reusable&lt;/em&gt; part is genuinely small. Strip out
everything MCP Steroid–specific and what is left is:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;A JDK data model resolved live from vendor sources and PGP-verified against pinned fingerprints
(~three files plus a cache).&lt;/li&gt;
  &lt;li&gt;A generator that renders two script templates from that model, with generation-time validation (one
file plus the templates).&lt;/li&gt;
  &lt;li&gt;A convention: content-addressed side-by-side installs under a fixed home, with the &lt;em&gt;binary&lt;/em&gt; owning
the launcher, and “update” defined as “run the current install script”.&lt;/li&gt;
  &lt;li&gt;A test harness: a pure render seam, an nginx side-car, three container lanes plus one native Windows
runner, and a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HOME&lt;/code&gt; with a space in it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is a plausible &lt;em&gt;toolkit&lt;/em&gt; — call it &lt;em&gt;ship-your-jvm-cli&lt;/em&gt;. Not a library yet: the binary-side half
(launcher registration, update supervision, version policy, multi-process coordination) is still welded to
this product, and a library has to offer those as APIs rather than as a description. And what makes the
generator half work is mostly &lt;strong&gt;conventions and negative constraints&lt;/strong&gt; rather than code — the rules:
resolve at build time, verify vendor signatures at generation time, never let the script own the launcher,
make update mean install, and put everything the script prints on stderr.&lt;/p&gt;

&lt;p&gt;Becoming a real project would mean pluggable artifact coordinates rather than “devrig on
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;jonnyzzz/mcp-steroid&lt;/code&gt; releases”; a configurable home, which conflicts with the marker contract that made
it fixed in the first place — a design argument, not a refactor; Temurin and GraalVM alongside Corretto and
Zulu; and a finished signing story.&lt;/p&gt;

&lt;p&gt;If you have shipped a JVM CLI to strangers, I would like to compare notes — particularly on two things.
First, &lt;strong&gt;signing a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;curl | sh&lt;/code&gt; bootstrap&lt;/strong&gt;: our &lt;em&gt;designed&lt;/em&gt; answer is trust-on-first-use plus two-key
ed25519 verification for every update thereafter, and since it is designed rather than shipped, this is a
genuine question and not a humblebrag — has anyone done better without shipping a package manager?
Second, &lt;strong&gt;retention&lt;/strong&gt;: how do you decide an old install tree is safe to delete, when the whole point of
content addressing was to never track what points at it?&lt;/p&gt;

&lt;p&gt;Open an issue on &lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid&quot;&gt;jonnyzzz/mcp-steroid&lt;/a&gt;, or reply on &lt;a href=&quot;https://www.linkedin.com/in/jonnyzzz/&quot;&gt;LinkedIn&lt;/a&gt; or
&lt;a href=&quot;https://twitter.com/jonnyzzz&quot;&gt;Twitter&lt;/a&gt; — and if &lt;em&gt;ship-your-jvm-cli&lt;/em&gt; is something you would actually use rather than nod at,
say so, because that is the only signal that would make us extract it&lt;/p&gt;</content>

  
  
  
  
  

    <author>
      <name>Eugene Petrenko</name>
    </author>

  
    <category term="devrig" />
  
    <category term="mcp-steroid" />
  
    <category term="cli" />
  
    <category term="jvm" />
  
    <category term="kotlin" />
  
    <category term="distribution" />
  
    <category term="open-source" />
  
    <category term="ai-agents" />
  
    <category term="ai-coding" />
  
    <summary type="html">A JVM command-line tool is easy to write and miserable to ship. You need five platforms, a JDK the user does not have, an update path that never overwrites a running install, and a trust story for a `curl | sh` one-liner. Here is the architecture we ended up with in `devrig`: generate the install script from a model whose JDK coordinates are signature-verified, make installs content-addressed and side-by-side, let the binary own its own launcher, and make &quot;update&quot; mean nothing more than running a fresh install script</summary>
  
  </entry>
  
  <entry>
    <title type="html">Personal Assistant without OpenClaw or Hermes: Just One Prompt</title>
    <link href="https://jonnyzzz.com/blog/2026/07/19/personal-assistant-agent/" rel="alternate" type="text/html" title="Personal Assistant without OpenClaw or Hermes: Just One Prompt" />
    <published>2026-07-19T00:00:00+00:00</published>
    <updated>2026-07-19T00:00:00+00:00</updated>
    <id>/blog/2026/07/19/personal-assistant-agent</id>
    <content type="html" xml:base="https://jonnyzzz.com/blog/2026/07/19/personal-assistant-agent/">&lt;p&gt;I wanted a personal assistant for quite some time. I can message from my phone. Something absolutely
simple and what I can understand and fine-tune to my needs. Not a chatbot — an assistant: 
“watch that Slack DM thread and ping me when they reply”, “write down our AWS account conventions”,
“build me a tool that inspects my iTerm2 tabs”. Requests arrive on Slack and Telegram at random times,
some finish in a minute, some take forty, and we want the acknowledgment to land now and the result when
it is ready — without message two waiting behind message one.&lt;/p&gt;

&lt;p&gt;The naive approach — one AI Agent in one chat loop that does everything itself — falls apart on exactly that
schedule. The Agent picks up a long task, its context fills with the work, the instructions dilute, and the
next message queues behind a forty-minute errand. Worse: when the session eventually compacts or restarts,
half-done work simply evaporates, and nobody tells you.&lt;/p&gt;

&lt;p&gt;So the rule at the top of our operating contract is a role split, not a feature: &lt;strong&gt;“You assist and manage —
you never do the work yourself.”&lt;/strong&gt; The interactive session routes, tracks, follows up, and reports. Every
actual task runs in a fresh, disposable, headless sub-agent process. The whole thing was scaffolded,
smoke-tested, run through its first real tasks, hit its first production incident, and shipped its first
self-authored fix in a single afternoon — 2026-07-17, 13:29 to about 15:50. Now it’s running on the second computer.&lt;/p&gt;

&lt;p&gt;And here is the part I want to be upfront about, because the title promises it: the founding spec was &lt;strong&gt;one
message pasted into an interactive &lt;a href=&quot;https://code.claude.com/docs/en/headless&quot;&gt;Claude Code&lt;/a&gt; session&lt;/strong&gt;. Not a product install, not a config
wizard — a prompt. Lightly redacted, the worst typos cleaned, the rest left as I typed it the first time:&lt;/p&gt;

&lt;details&gt;
  &lt;summary&gt;&lt;strong&gt;THE PROMPT&lt;/strong&gt; — the message that started the whole system&lt;/summary&gt;

  &lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;you are my personal assistant, your goal is to setup and prepare the /workflows for that.
Do the following steps to set this up:
  - create a repository under gh jonnyzzz jonnyzzz-personal, private one
  - configure a workflows to regularly monitor and handle all the requests via the Slack&apos;s me-only
    private channel, it will be our communication protocol
  - configure and setup telegram communication channel, create the scripts to poll for messages
    and store all messages on the disk and handle them, use uv python, once done, ask me
    for credentials, 
  - only accept messages from my user accounts
  - credentials are never committed to the repository, only stay locally.

So the flow is the following -- you poll and process these two input channels. For each
request -- you MUST start the new sub-agent process, use run-agent.sh for that -- once the
process is completed -- process and send followup to the channel from which you receive
the input.

You should create the self-improvement loop -- start every 3 hours new sub-agent which
3x quorum processes and analyzes everything what was done and self improves. Running agentic
loops is necessary -- your task to manage that flow.

Commit and push all the changes to your flows to this new gh repo, update the repo regularly
and re-read from it too.

I expect you to script the most of the tasks to simplify the processes, effectiveness, and
outcomes. Setup hooks in this repository for yourself to always remember your goals, you are
assisting, managing, and never doing.
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;  &lt;/div&gt;

&lt;/details&gt;

&lt;p&gt;Everything below — the wrapper scripts, the hooks, the state files, the quorum, the iTerm2 supervisor — grew
from these lines plus the follow-up conversation. Even this post draft did. The sub-agent mechanics trace back to the
&lt;a href=&quot;https://run-agent.sh&quot;&gt;run-agent.sh&lt;/a&gt; approach the prompt names. I am not pretending this replaces OpenClaw or Hermes;
the point is narrower and, I think, more useful: &lt;em&gt;this class of assistant now costs one prompt and one agent
session&lt;/em&gt;, and at that price you should build one before you adopt one.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Discipline for an AI Agent should live in the architecture — process boundaries, files on disk, git — not in
a paragraph of prompt the model is supposed to remember forty minutes into a session.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This post is the build log, and then a comparison — as fair as I can make it — against the two big names in
the personal-agent space right now: &lt;a href=&quot;https://github.com/openclaw/openclaw&quot;&gt;OpenClaw&lt;/a&gt; and &lt;a href=&quot;https://github.com/NousResearch/hermes-agent&quot;&gt;Hermes Agent&lt;/a&gt;.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;The system started from &lt;strong&gt;one only prompt&lt;/strong&gt; above and evolved to a really powerful and supportive buddy,
that learns from what you do, and how it works. Below, is that it thinks of itself.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;With this post I share how my AI Agents evolved into a personal assistant, given the right context,
and the best tools. This appers in-line with 
&lt;a href=&quot;/blog/2026/03/24/agentic-experience-and-tools/&quot;&gt;my goals and program: Agentic AI Experience &amp;amp; Tools&lt;/a&gt;.
Find
me on &lt;a href=&quot;https://www.linkedin.com/in/jonnyzzz/&quot;&gt;LinkedIn&lt;/a&gt; or &lt;a href=&quot;https://x.com/jonnyzzz&quot;&gt;Twitter&lt;/a&gt;, and if an assistant like this would fit your own workflow,
get in touch — I am happy to help you shape one.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;one-rule-at-the-top-enforced-structurally&quot;&gt;One Rule at the Top, Enforced Structurally&lt;/h2&gt;

&lt;p&gt;The orchestrator is an interactive &lt;a href=&quot;https://code.claude.com/docs/en/headless&quot;&gt;Claude Code&lt;/a&gt; session with a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLAUDE.md&lt;/code&gt; contract that is
auto-loaded every session. Line 5 is the prime directive quoted above, and it is enforced three ways, because
hoping the model remembers is not a mechanism:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Process boundaries.&lt;/strong&gt; Every request becomes a fresh &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;claude -p&lt;/code&gt; &lt;a href=&quot;https://code.claude.com/docs/en/headless&quot;&gt;headless run&lt;/a&gt; via a wrapper
script &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;run-agent.sh&lt;/code&gt; — one request, one process, no shared context. The orchestrator is forbidden from editing even its
own flow files (scripts, prompts, hooks, docs) inline; flow changes also go through a sub-agent.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Hooks.&lt;/strong&gt; Claude Code &lt;a href=&quot;https://code.claude.com/docs/en/hooks&quot;&gt;hooks&lt;/a&gt; re-print the directive at every session start and inject a one-line
reminder before &lt;em&gt;every single prompt&lt;/em&gt;: “You assist and manage, never do.” The session-start hook also
appends live state pulled from the state files with &lt;a href=&quot;https://jqlang.org/&quot;&gt;jq&lt;/a&gt; — active watches, agents still running — so a
fresh session wakes up already knowing “agent X is in flight, watch Y is armed”.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;An inverted frame for workers.&lt;/strong&gt; The sub-agent prompt frame has to explicitly flip the rule: “you ARE the
worker here, so do the task yourself — the never-do-inline rule applies to the orchestrator, not you.”
Sub-agents run in the same directory and &lt;a href=&quot;https://code.claude.com/docs/en/memory&quot;&gt;inherit the same &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLAUDE.md&lt;/code&gt;&lt;/a&gt;; without that line the
never-do rule would recurse forever. The best line in the whole repo&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is exactly one carve-out, and it exists for an architectural reason we will get to: requests that are
purely channel communication — send or read a Slack or Telegram message on my behalf — are handled by the
orchestrator directly. Slack is reachable only from the orchestrator session, so there is no worker to
delegate a Slack send to, and Telegram sends are code-restricted to my own chat either way. Even the carve-out
leaves a trail: every direct reply is recorded through the state script, so the audit surface stays complete.&lt;/p&gt;

&lt;h2 id=&quot;two-channels-in-one-owner&quot;&gt;Two Channels In, One Owner&lt;/h2&gt;

&lt;p&gt;Slack side: a private channel, reached through the session’s Slack &lt;a href=&quot;https://modelcontextprotocol.io/&quot;&gt;MCP&lt;/a&gt; tools, where only messages from my
user id count as requests; new-message detection is a plain timestamp watermark in a state file.&lt;/p&gt;

&lt;p&gt;Telegram side: a dedicated bot with a hard owner allowlist — enforced &lt;em&gt;inside the CLI code&lt;/em&gt;, not in the prompt. The
poller surfaces only allowlisted senders as work; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;send&lt;/code&gt; refuses non-allowlisted chat ids outright; strangers
land in a log on stderr and are never replied to. Even bootstrapping is stranger-proof: with an empty
allowlist, the poller logs every unknown sender and warns on stderr, a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;seen&lt;/code&gt; command aggregates them into
candidate ids, the flow posts those ids to Slack for me to confirm, and only then does the allowlist get
filled — the bot never replies to an unconfirmed id, not even once.&lt;/p&gt;

&lt;p&gt;The Telegram CLI is a single-file Python script with &lt;a href=&quot;https://peps.python.org/pep-0723/&quot;&gt;PEP-723&lt;/a&gt; inline metadata, run via
&lt;a href=&quot;https://docs.astral.sh/uv/guides/scripts/&quot;&gt;uv&lt;/a&gt;, talking to the Bot API with &lt;a href=&quot;https://www.python-httpx.org/&quot;&gt;httpx&lt;/a&gt;. Its signature feature came from a direct
directive at 14:47 on day one: &lt;em&gt;polling CLIs must run-and-wait, exiting after a 15-second idle bucket.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Any other chats: Just the same way as we implemented Slack and Telegram – are one prompt away from
the availability.&lt;/p&gt;

&lt;h2 id=&quot;bucket-mode-near-realtime-from-a-blocking-poll&quot;&gt;Bucket Mode: Near-Realtime From a Blocking Poll&lt;/h2&gt;

&lt;p&gt;There is no daemon, no webhook, no queue broker anywhere in this system. The “runtime” is one interactive
session running a repeating inbox cycle. Slack ingress is the boring half — an MCP channel read against the
timestamp watermark, once per tick. The Telegram side is where the design lives: the event source is a
blocking CLI with carefully chosen exit semantics:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;poll --first-wait 240 --idle 15 --max-wait 280

wait up to 240s for the FIRST message       # nothing arrived: quiet exit 0, tick ends
on arrival, print it immediately            # unbuffered JSON line per message, as it lands
then switch to idle mode: exit after 15s    # a burst of messages becomes ONE bucket of work
never run past 280s total                   # hard cap, the poll can never block forever
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Each underlying &lt;a href=&quot;https://core.telegram.org/bots/api#getupdates&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;getUpdates&lt;/code&gt;&lt;/a&gt; long-poll timeout is clamped to the remaining budget with a hard
~50-second cap, and a sub-second tail is slept out rather than re-polled — a deliberate courtesy, per the
comment in the CLI itself, instead of hammering the API with near-zero-timeout calls. The result: each loop
tick holds a roughly four-minute listen, so response latency is near-instant for the cost of one cheap
blocking call. All updates — authorized or not — are appended to a jsonl transcript before the offset
advances, so nothing is consumed invisibly.&lt;/p&gt;

&lt;p&gt;I like this trade a lot. OpenClaw and Hermes both answer “how do messages get in” with a resident gateway
process. We answer it with a &lt;em&gt;budget&lt;/em&gt;: latency comes from long-polling math, not infrastructure.&lt;/p&gt;

&lt;h2 id=&quot;one-request-one-process&quot;&gt;One Request, One Process&lt;/h2&gt;

&lt;p&gt;Dispatch is a small shell wrapper around &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;claude -p&lt;/code&gt;, &lt;a href=&quot;https://run-agent.sh&quot;&gt;https://run-agent.sh&lt;/a&gt;.
For every request it creates a run directory with three
artifacts — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;prompt.md&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;result.json&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;stderr.log&lt;/code&gt; — appends a start record &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;{id, channel, started, dir,
pid, status:&quot;running&quot;}&lt;/code&gt; to an append-only &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;agents.jsonl&lt;/code&gt;, runs the sub-agent with JSON output, then appends a
done record with the exit code. Every agent lifecycle event is an append-only record; every run leaves a full
paper trail. A discipline rule sits on top: never hand-append JSON to state files — always go through the
state script.&lt;/p&gt;

&lt;p&gt;The hardening matters more than the happy path: the wrapper traps EXIT, TERM, and INT, and writes an
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;exit:143, &quot;aborted: dispatcher killed&quot;&lt;/code&gt; done-record if it dies before the normal one is written — guarded by
a flag so it never double-writes. An id can never stick in “running” forever just because the wrapper was
killed — the trap covers every catchable death, and the reaper we will meet below closes the one it cannot, a
straight &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;kill -9&lt;/code&gt;. That trap was not designed up front. It was shipped in response to an incident.&lt;/p&gt;

&lt;h2 id=&quot;the-first-incident-and-the-fix-two-hours-later&quot;&gt;The First Incident, and the Fix, Two Hours Later&lt;/h2&gt;

&lt;p&gt;At 13:45 on day one — four minutes after the first real task landed — the first concurrent dispatch of two
sub-agents went wrong. The dispatcher combined a shell &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;amp;&lt;/code&gt; with the tool-level background dispatch it should
have used, and one wrapper died mid-run. The work was recovered and the lessons were logged.&lt;/p&gt;

&lt;p&gt;At 15:50 the same day, the system’s first self-improvement cycle shipped the systemic fix in one commit: the
pid tracking and abort trap above, a state script for scripted bookkeeping, a deliver-first inbox flow with an
explicit failure path and a reaper, and codified delegation rules. The rule that came out of it landed as
literally step 7 of the day-one inbox prompt: dispatch with the tool’s background mode — &lt;em&gt;“NEVER a shell
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;amp;&lt;/code&gt;“&lt;/em&gt;. (It sits at step 9 today — the loop has since quorum-grown two more steps above it.)&lt;/p&gt;

&lt;p&gt;Incident → three-agent post-mortem → quorum-approved fix → committed process change, all machine-executed on
day one. Fix shipped same afternoon as the incident. That arc, more than any feature, is why I trust the
design.&lt;/p&gt;

&lt;h2 id=&quot;deliver-first-fail-loudly-reap-the-dead&quot;&gt;Deliver First, Fail Loudly, Reap the Dead&lt;/h2&gt;

&lt;p&gt;The inbox cycle runs every ~5 minutes and its ordering is opinionated:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;1. pull the repo                # re-read your own instructions before acting
2. deliver finished work FIRST  # before polling for anything new
   success: read result.json, reply short, outcome first
   failure: send a notice + the tail of stderr.log — never silence
3. reap: entries &quot;running&quot; &amp;gt;30 min AND whose dispatcher pid fails kill -0
   → close as exit:143, note &quot;reaped: dispatcher/agent dead&quot;, report each on its channel
4. re-check watches             # e.g. &quot;tell me when that thread gets a reply&quot;; expire with a notice after 24h
5. poll Telegram (bucket), 
6. read Slack
7. dispatch each new request    # ack &quot;On it — task &amp;lt;id&amp;gt;&quot;, background, never shell &amp;amp;
8. catch late finishers, 
9. push flow changes
10. nothing new → end quietly, no channel noise
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Step 1 means a changed instruction file takes effect &lt;em&gt;mid-cycle&lt;/em&gt;. Step 2 means a finished task is reported
before new work is taken. The failure path means a crashed sub-agent produces a notice with its stderr tail
instead of vanishing. The reaper uses &lt;a href=&quot;https://man7.org/linux/man-pages/man1/kill.1.html&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;kill -0&lt;/code&gt;&lt;/a&gt; as a liveness check so a genuinely long-running agent
is left alone, while a dead dispatcher’s orphaned record gets closed and reported. Between the abort trap, the
failure notice, and the reaper, there is no path where a dispatched request just disappears&lt;/p&gt;

&lt;p&gt;The listing above is already a historical document, by the way: by the end of day two the loop had
quorum-grown to twelve steps — an early re-arm and a self-improvement catch-up — plus a standing rule that
the cycle must never end in a terminal state. The ten steps you see are day one’s version; the upgrades came
from the process described next.&lt;/p&gt;

&lt;p&gt;One pre-dispatch window remains, and it belongs on the record: the Telegram offset advances at poll time, so a
message consumed moments before a session death lives only in the jsonl transcript, which nothing re-reads as
work. Slack does not share the hole — its watermark is written back only after processing. A future quorum
cycle owes us a fix.&lt;/p&gt;

&lt;h2 id=&quot;a-quorum-that-rewrites-its-own-process&quot;&gt;A Quorum That Rewrites Its Own Process&lt;/h2&gt;

&lt;p&gt;“Self-improving agent” is usually a euphemism for “the model edits its own prompt, and we hope”. Ours is a
pipeline with an evidence phase, independence, a threshold, and verification.&lt;/p&gt;

&lt;p&gt;Every 3 hours: gather read-only evidence — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;git log --stat&lt;/code&gt; since the last cycle, both state jsonl files, the
newest agent run directories, the previous cycle report. Then spawn &lt;strong&gt;three independent analysis sub-agents in
parallel over the same evidence&lt;/strong&gt; — in-session Agent-tool tasks this time, not headless dispatches — each with
a distinct lens:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;reliability&lt;/strong&gt; — what failed, hung, was retried, or silently dropped;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;effectiveness&lt;/strong&gt; — routing speed, reply usefulness, loop closure;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;simplicity&lt;/strong&gt; — what to script, simplify, or delete; where the orchestrator did work inline.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each returns proposals as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;{file, change, rationale}&lt;/code&gt;. The decision rule, as first shipped: accept only if at
least 2 of 3 lenses independently support the same file and intent. Rejected proposals are written to a
rejection log with reasons — the disagreement is an artifact, not a discard. Accepted changes are applied by
exactly one applier sub-agent, dispatched through the same headless wrapper as any other task (the
orchestrator never edits flows
inline, even here), then mechanically verified — &lt;a href=&quot;https://man7.org/linux/man-pages/man1/bash.1.html&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;bash -n&lt;/code&gt;&lt;/a&gt; on every changed shell script, executables
stay executable, the Telegram CLI still authenticates, the settings file still parses with &lt;a href=&quot;https://jqlang.org/&quot;&gt;jq&lt;/a&gt; — then
pushed, with a cycle report to Slack.&lt;/p&gt;

&lt;p&gt;Cycle #1’s real numbers: 18 proposals, 4 bundles accepted, 8 rejected and logged, one commit. Yes, 4 + 8 ≠ 18 —
an accepted “bundle” groups several overlapping proposals into one coherent change, which is exactly what a
2-of-3 agreement on file and intent produces. That commit was the incident fix.&lt;/p&gt;

&lt;p&gt;Then &lt;strong&gt;the quorum rewrote its own constitution&lt;/strong&gt;. The strict overlap rule — accept only what two lenses
independently author — turned out to be a filter so fine it soon produced a cycle with zero accepts, so at
22:14 on day one a cycle replaced it, through its own machinery: three analysts still propose, the proposals
are deduped, and three &lt;em&gt;independent voters&lt;/em&gt; now approve or reject each one, accept at 2-of-3 votes. One cycle
later the scheduler got the same treatment: after one overnight run analyzed a window where nothing had
happened, the gate became “3 hours elapsed AND new activity since the last cycle”, empty windows skipped and
logged. Seven cycles ran in the first two days, and the process that runs them is itself the thing they keep
amending&lt;/p&gt;

&lt;h2 id=&quot;the-repo-is-the-source-of-truth&quot;&gt;The Repo Is the Source of Truth&lt;/h2&gt;

&lt;p&gt;The closing rule of the contract: &lt;em&gt;“If the flows in this repo and your memory disagree, the repo wins —
re-read it.”&lt;/em&gt; Behavior is data on disk, not weights-in-context. Prompts are re-read every cycle and the repo
is re-pulled before acting, so a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;git push&lt;/code&gt; is a live behavior deployment — including from another machine.
The sync script is deliberately forgiving (&lt;a href=&quot;https://git-scm.com/docs/git-pull&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pull --rebase --autostash&lt;/code&gt;&lt;/a&gt;, continue offline, retry
push next cycle) because the loop must never wedge on git.&lt;/p&gt;

&lt;p&gt;My favorite small artifact of this discipline: our AWS notes document that a local config file literally
spells a profile name &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Sanbox&lt;/code&gt; — typo and all — instruct agents to use the typo as-is, mark fixing it as a
pending &lt;em&gt;manual&lt;/em&gt; action for me, and forbid agents from editing that file themselves. Reality documented over
reality “fixed” out from under the human.&lt;/p&gt;

&lt;p&gt;Secrets follow the same locality principle. Credentials live only in a gitignored directory with tight file
modes; state and logs are gitignored too — only flow logic is committed. A pre-commit hook blocks staged
secrets three ways: anything under the secrets directory (only its &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.example&lt;/code&gt; templates and README are
exempt), any &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.env&lt;/code&gt; file, and any &lt;em&gt;added diff line&lt;/em&gt; matching credential shapes.
A third layer rides in every sub-agent frame — a standing “never commit or print tokens”
line — because defense in depth assumes any single layer will eventually miss.&lt;/p&gt;

&lt;p&gt;The sharpest boundary is capability asymmetry: the Slack connection exists &lt;em&gt;only&lt;/em&gt; in the orchestrator session
via MCP — there is deliberately no Slack CLI or token in the repo, so a compromised or confused sub-agent
cannot reach Slack tokens at all. Telegram is softer, and I want to be precise about how much softer: the CLI’s send
path is code-restricted to my own chat, so &lt;em&gt;through our tooling&lt;/em&gt; the worst case is a message to me. But the
raw bot token does live in the local secrets directory, sub-agents are permitted to read credentials, and a
rogue or confused one could call the Bot API directly, bypassing the CLI entirely. The allowlist is a
guarantee about the tool, not a bound on the token. That asymmetry is also why channel sends are the one thing
the orchestrator does directly.&lt;/p&gt;

&lt;h2 id=&quot;the-assistant-that-babysits-other-ai-agents&quot;&gt;The Assistant That Babysits Other AI Agents&lt;/h2&gt;

&lt;p&gt;“Build me a tool that inspects my iTerm2 tabs” was on the wish list in the opening paragraph, and it grew into my
favorite capability of the whole system. This machine usually runs eight to eleven &lt;a href=&quot;https://iterm2.com/&quot;&gt;iTerm2&lt;/a&gt; consoles with
other AI Agents inside — &lt;a href=&quot;https://code.claude.com/docs/en/headless&quot;&gt;Claude Code&lt;/a&gt; sessions, &lt;a href=&quot;https://github.com/openai/codex&quot;&gt;Codex CLI&lt;/a&gt; sessions — each grinding
through its own long task. Somebody has to notice when one of them stalls on an approval prompt or runs its
context to 100%. Now the assistant does.&lt;/p&gt;

&lt;p&gt;The read path is one &lt;a href=&quot;https://developer.apple.com/library/archive/documentation/AppleScript/Conceptual/AppleScriptLangGuide/introduction/ASLR_intro.html&quot;&gt;AppleScript&lt;/a&gt; pass, strictly read-only. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;status&lt;/code&gt; subcommand walks every
window, tab, and session without activating or focusing anything, and prints for each: a header (session id,
name, tty, window/tab position, plus iTerm2’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;processing&lt;/code&gt; / &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;at-prompt&lt;/code&gt; flags when exposed), the last twelve
non-blank visible lines, and a first-pass status guess — WORKING, WAITING, IDLE/DONE, or BLOCKED, with a
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[100% context]&lt;/code&gt; flag when that shows on screen. The classifier is grep over screen tails, nothing smarter:
spinner glyphs mean WORKING, “waiting for” means WAITING, a shell prompt means IDLE/DONE, a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[y/n]&lt;/code&gt; or a
“Goal blocked” means BLOCKED. BLOCKED is checked first because — the comment in the script says it plainly — a
missed approval prompt is the costliest miss. The sub-agent running the survey is briefed to treat the guesses
as first-pass only, re-read each tail, and report most-actionable-first. “What are my terminals doing” is now a
routine Telegram request — the reply ledger shows three fleet summaries inside one day.&lt;/p&gt;

&lt;p&gt;Watching is safe; writing into another agent’s console is not. So the write path got the opposite treatment: it
exists, and it is deliberately hard to use. The hard rule in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLAUDE.md&lt;/code&gt; says console writes go ONLY through the
guarded &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;send&lt;/code&gt; subcommand, never raw osascript, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;send&lt;/code&gt; refuses in layers:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;it resolves the target by session id AND demands the expected tty on the command line — if the session’s
actual tty differs, it refuses: writing into the wrong console is the nightmare scenario;&lt;/li&gt;
  &lt;li&gt;it demands &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--expect&lt;/code&gt;, a literal substring that must be on the target screen &lt;em&gt;right now&lt;/em&gt; — a &lt;a href=&quot;https://en.wikipedia.org/wiki/Time-of-check_to_time-of-use&quot;&gt;TOCTOU&lt;/a&gt;
guard: if the prompt scrolled away or changed between survey and send, it refuses;&lt;/li&gt;
  &lt;li&gt;keystrokes come from a safe vocabulary — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;y&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;n&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;enter&lt;/code&gt;, a single digit; arbitrary text is an explicit
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--raw&lt;/code&gt; opt-in;&lt;/li&gt;
  &lt;li&gt;every write lands in an audit log with the exact bytes sent and content hashes of the screen before and after.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On top of the tool, the approval flow adds policy: approve ONCE with the least-privilege key, never “always
allow” or “don’t ask again”, one approval per request, abort untouched if the screen shows anything but the
expected prompt&lt;/p&gt;

&lt;p&gt;The base inspector was one of day one’s first two dispatches; the survey and the guarded writer came out of
quorum cycle #4 on day two — the guardrails themselves were proposed, 2-of-3 voted, and shipped by the same
three-lens process as every other flow change.&lt;/p&gt;

&lt;p&gt;And on day two the thing earned its keep. The afternoon survey flagged window 3: a Codex agent double-stuck,
asking approval to kill its own hung Gradle test worker and showing “Goal blocked” right above. The status
report &lt;em&gt;flagged&lt;/em&gt; it for me instead of acting on its own; the unstuck ran only after I said go. The audit log
then records three writes in sixty-six seconds: a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;y&lt;/code&gt; against the expected text “confirmed stuck Gradle test
worker” — approve the kill, once; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/goal resume&lt;/code&gt; plus a newline against “Goal blocked”; and the discovery of
the day — in the Codex TUI, Enter does not submit while the agent is mid-turn. The typed command just sat there
on screen. So the third audited keystroke is a single Tab, which is how Codex queues input for when the current
turn ends. Nobody documents these things. That Tab went in through the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--raw&lt;/code&gt; escape hatch, under audit — and
by the next quorum cycle it had graduated to a first-class key in the safe vocabulary, next to a new “Agent TUI
quirks” note in the repo recording which console queues on Tab and which submits on Enter. Your assistant
learns these things one guarded keystroke at a time, and then it writes them down.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;An AI Agent supervising other AI Agents is only as trustworthy as the narrowest write path you give it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Let me be precise about what this is: best-effort supervision of peer agents, not a control plane. The consoles
expose no API — the assistant reads the same screen text I would, and the classifications are regex guesses a
sub-agent double-checks before reporting. What makes me comfortable anyway is the thesis this post keeps
repeating: every intervention is id-and-tty-verified, precondition-checked, least-privilege, and audited. The
discipline lives in the architecture, not in the model’s good intentions.&lt;/p&gt;

&lt;p&gt;The last quorum cycle of day two made this a standing service: the assistant now keeps a notify-only watch
on the other consoles and pings me when one of them stalls — paired with a hard rule in the contract that
automated watches never write to a console. Helping the agents in the other terminals is the job; pressing
their keys stays mine.&lt;/p&gt;

&lt;h2 id=&quot;the-field-openclaw-and-hermes&quot;&gt;The Field: OpenClaw and Hermes&lt;/h2&gt;

&lt;p&gt;Now the comparison — because the two projects everyone will name in reply occupy the same niche with nearly
opposite choices.&lt;/p&gt;

&lt;p&gt;First, identities, honestly hedged. &lt;strong&gt;OpenClaw&lt;/strong&gt; here means the self-hosted personal-agent runtime at
&lt;a href=&quot;https://github.com/openclaw/openclaw&quot;&gt;github.com/openclaw/openclaw&lt;/a&gt; — released November 2025 as Warelay and renamed through
CLAWDIS/Clawdbot/Moltbot to OpenClaw, &lt;a href=&quot;https://en.wikipedia.org/wiki/OpenClaw&quot;&gt;per Wikipedia&lt;/a&gt;. &lt;a href=&quot;https://api.github.com/repos/openclaw/openclaw&quot;&gt;The GitHub API&lt;/a&gt; gave it
383,244 stars on 2026-07-17, while Wikipedia’s March 2026 snapshot said ~247k — treat any star count as a
photo of a moving train. Not to be confused with &lt;a href=&quot;https://github.com/pjasicek/OpenClaw&quot;&gt;pjasicek/OpenClaw&lt;/a&gt;, a C++ remake of the 1997
game Captain Claw, no relevance to AI Agents. &lt;strong&gt;Hermes&lt;/strong&gt; here means Nous Research’s
&lt;a href=&quot;https://github.com/NousResearch/hermes-agent&quot;&gt;Hermes Agent&lt;/a&gt; (216,301 stars by the &lt;a href=&quot;https://api.github.com/repos/NousResearch/hermes-agent&quot;&gt;same API call&lt;/a&gt; the same day) — not the
&lt;a href=&quot;https://nousresearch.com/hermes3/&quot;&gt;Hermes LLM weight series&lt;/a&gt; from the same lab, and not &lt;a href=&quot;https://github.com/facebook/hermes&quot;&gt;Meta’s Hermes JS engine&lt;/a&gt;.
&lt;a href=&quot;https://www.turingpost.com/p/hermes&quot;&gt;Turing Post reported&lt;/a&gt; Hermes overtaking OpenClaw on OpenRouter daily tokens in June 2026 — 224B
against 186B per day — though I have not verified OpenRouter’s underlying numbers independently. Both projects
move fast — several defaults below are as-of-mid-July-2026 and worth re-checking against their docs.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Axis&lt;/th&gt;
      &lt;th&gt;jonnyzzz-personal&lt;/th&gt;
      &lt;th&gt;OpenClaw&lt;/th&gt;
      &lt;th&gt;Hermes Agent&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Runtime&lt;/td&gt;
      &lt;td&gt;interactive session + loop&lt;/td&gt;
      &lt;td&gt;Gateway daemon&lt;/td&gt;
      &lt;td&gt;gateway service&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Ingress&lt;/td&gt;
      &lt;td&gt;MCP read + bucket poll&lt;/td&gt;
      &lt;td&gt;persistent channel sockets&lt;/td&gt;
      &lt;td&gt;push + long-poll adapters&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Channels&lt;/td&gt;
      &lt;td&gt;2, one owner&lt;/td&gt;
      &lt;td&gt;20+&lt;/td&gt;
      &lt;td&gt;20+&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Session model&lt;/td&gt;
      &lt;td&gt;fresh process per request&lt;/td&gt;
      &lt;td&gt;DMs share main session&lt;/td&gt;
      &lt;td&gt;persistent per session key&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Memory&lt;/td&gt;
      &lt;td&gt;git repo + jsonl&lt;/td&gt;
      &lt;td&gt;Markdown + daily notes&lt;/td&gt;
      &lt;td&gt;MEMORY.md + FTS5 SQLite&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Self-improvement&lt;/td&gt;
      &lt;td&gt;3-lens quorum, 3-hourly&lt;/td&gt;
      &lt;td&gt;Skill Workshop, human-gated&lt;/td&gt;
      &lt;td&gt;per-turn background review&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Isolation&lt;/td&gt;
      &lt;td&gt;capability asymmetry&lt;/td&gt;
      &lt;td&gt;sandbox off by default&lt;/td&gt;
      &lt;td&gt;six exec backends&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Model coupling&lt;/td&gt;
      &lt;td&gt;Claude Code only&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://docs.openclaw.ai/&quot;&gt;model-agnostic&lt;/a&gt;&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://hermes-agent.nousresearch.com/docs/&quot;&gt;model-agnostic&lt;/a&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Behavior deploy&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;git push&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;workspace + skill file edits&lt;/td&gt;
      &lt;td&gt;file writes + skills&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;strong&gt;Ingress: a daemon versus a budget.&lt;/strong&gt; OpenClaw’s &lt;a href=&quot;https://docs.openclaw.ai/&quot;&gt;Gateway&lt;/a&gt; is “the single source of truth for
sessions, routing, and channel connections” — one always-on process holding persistent connections to
&lt;a href=&quot;https://github.com/openclaw/openclaw&quot;&gt;twenty-plus platforms&lt;/a&gt;, exposing a &lt;a href=&quot;https://docs.openclaw.ai/concepts/architecture&quot;&gt;WebSocket API on a localhost port&lt;/a&gt;. Hermes
runs the same shape — one resident &lt;a href=&quot;https://hermes-agent.nousresearch.com/docs/user-guide/messaging/&quot;&gt;gateway&lt;/a&gt; process with &lt;a href=&quot;https://hermes-agent.nousresearch.com/docs/user-guide/messaging/&quot;&gt;20+ adapters&lt;/a&gt;. Neither is
quite push end to end, to be fair: in both projects the Telegram channel &lt;a href=&quot;https://docs.openclaw.ai/channels/telegram&quot;&gt;defaults&lt;/a&gt;
&lt;a href=&quot;https://hermes-agent.nousresearch.com/docs/user-guide/messaging/telegram&quot;&gt;to long polling&lt;/a&gt; with webhooks as the opt-in mode. But the shape holds — a resident daemon owns
the connections. That buys instant delivery and breadth we simply do not have. It also buys daemon problems: OpenClaw’s
docs note events are &lt;a href=&quot;https://docs.openclaw.ai/concepts/architecture&quot;&gt;not replayed after a gap&lt;/a&gt; and pending sub-agent announcements are
&lt;a href=&quot;https://docs.openclaw.ai/tools/subagents&quot;&gt;lost on Gateway restart&lt;/a&gt;. Our blocking bucket poll delivers a burst as one unit of work,
resumes after any restart (the watermark and offset are files), and costs no infrastructure while idle. Two
channels are enough for one owner.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sessions and trust granularity.&lt;/strong&gt; This is the deepest philosophical split. In OpenClaw, by default all DMs
collapse into the agent’s &lt;em&gt;main session&lt;/em&gt; — the docs themselves say &lt;a href=&quot;https://docs.openclaw.ai/concepts/multi-agent&quot;&gt;“true isolation requires one agent per
person”&lt;/a&gt; — and &lt;a href=&quot;https://docs.openclaw.ai/concepts/agent-loop&quot;&gt;runs are serialized per session lane&lt;/a&gt;, which as I read it means
a shared session can head-of-line block. Hermes resolves a session key and &lt;a href=&quot;https://hermes-agent.nousresearch.com/docs/developer-guide/architecture&quot;&gt;carries persistent conversation
history&lt;/a&gt; across turns — genuine continuity, which our stateless workers lack. We chose the
opposite: one request, one process, no carried context, and the orchestrator’s jsonl files as the only shared
truth. Continuity lives in files we can read; isolation is the default, not the upgrade.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Self-improvement governance.&lt;/strong&gt; All three systems admit the same thing — an agent will spot improvements to
itself — and gate it differently. OpenClaw’s &lt;a href=&quot;https://docs.openclaw.ai/tools/skills&quot;&gt;Skill Workshop&lt;/a&gt; is a human-review proposal
queue: the agent drafts, a human approves (the viral “self-improving” framing overstates the default — though
nothing I read in the docs blocks an agent with write access from editing skill files directly); behavior
there deploys as edits to the agent’s &lt;a href=&quot;https://docs.openclaw.ai/concepts/memory&quot;&gt;workspace files&lt;/a&gt; and skills. Hermes runs a
&lt;a href=&quot;https://hermes-agent.nousresearch.com/docs/user-guide/features/memory&quot;&gt;background review after each turn&lt;/a&gt; — optionally on a cheaper sidecar model — that writes memory
and skills autonomously; a &lt;a href=&quot;https://hermes-agent.nousresearch.com/docs/user-guide/features/skills&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;write_approval&lt;/code&gt; flag&lt;/a&gt; can stage writes for human consent, but it
ships off by default, so out of the box the agent writes freely. Ours is machine-gated rather than
human-gated: three independent lenses over shared evidence, a 2-of-3 quorum on file+intent, a rejection log,
one applier, and mechanical verification before push. Slower than Hermes, more autonomous than OpenClaw — and
every change lands as a reviewable, revertable git diff. Hermes’s self-writes are mostly markdown too —
MEMORY.md, SKILL.md, though a skill can carry helper scripts — and staged writes
&lt;a href=&quot;https://hermes-agent.nousresearch.com/docs/user-guide/features/skills&quot;&gt;can be diff-reviewed before landing&lt;/a&gt;; what I found no mention of in its docs is version
history after they land — a file on disk, but no &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;git log&lt;/code&gt; behind it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Memory and source of truth.&lt;/strong&gt; OpenClaw is refreshingly blunt: &lt;a href=&quot;https://docs.openclaw.ai/concepts/memory&quot;&gt;“the model only remembers what gets saved to
disk”&lt;/a&gt; — curated &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MEMORY.md&lt;/code&gt;, daily notes, optional vector search. Hermes goes further:
&lt;a href=&quot;https://hermes-agent.nousresearch.com/docs/user-guide/features/memory&quot;&gt;bounded, agent-curated core memory&lt;/a&gt; (over-limit writes error out, forcing consolidation) plus
FTS5 full-text search over &lt;em&gt;every&lt;/em&gt; past session in SQLite. Both beat us at recall — we cannot search old
conversations at all, only grep the repo and the jsonl trail. What we have instead is that our entire behavior
surface is version-controlled: no hidden state in a database, every behavior change has a hash, an author
process, and a revert path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Isolation defaults.&lt;/strong&gt; OpenClaw ships with &lt;a href=&quot;https://docs.openclaw.ai/gateway/sandboxing&quot;&gt;sandboxing off&lt;/a&gt; — tools execute on the host
unless you opt into Docker with hardening flags — and that ecosystem’s attack surface is not hypothetical:
&lt;a href=&quot;https://blogs.cisco.com/ai/personal-ai-agents-like-openclaw-are-a-security-nightmare&quot;&gt;Cisco’s AI threat researchers&lt;/a&gt; documented a malicious community skill, its ranking inflated to #1 on
the &lt;a href=&quot;https://docs.openclaw.ai/tools/skills&quot;&gt;public skills registry&lt;/a&gt; (molthub then, ClawHub today), silently exfiltrating data via
curl, prompt injection included, and &lt;a href=&quot;https://en.wikipedia.org/wiki/OpenClaw&quot;&gt;Wikipedia catalogs the wider fallout&lt;/a&gt;. Hermes offers
&lt;a href=&quot;https://hermes-agent.nousresearch.com/docs/&quot;&gt;six execution backends&lt;/a&gt; from local to Docker to serverless; the same docs describe command
approval and allowlists on top. Our answer is smaller but structural: sub-agents are fresh processes with full
run artifacts, the Slack connection simply does not exist outside the orchestrator, and the Telegram CLI
refuses every chat id but mine — a tool-level check, not a hard bound, as the secrets section admits. And to
be honest on the same axis we just judged OpenClaw on: our sub-agents run headless with permission prompts
bypassed by default (&lt;a href=&quot;https://code.claude.com/docs/en/permission-modes&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--permission-mode bypassPermissions&lt;/code&gt;&lt;/a&gt;, overridable per run to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;acceptEdits&lt;/code&gt;),
executing on the host. Unattended runs need it, but it means our isolation comes from process freshness and
capability absence, not from a sandbox — we do not ship one either.&lt;/p&gt;

&lt;p&gt;To be fair about what this comparison is: OpenClaw and Hermes are &lt;em&gt;products&lt;/em&gt; — installable, multi-channel,
multi-user-adjacent, with skill registries and communities. Ours is one owner’s operations repo that happens
to embody the opposite bets. If you want WhatsApp, twenty channels, and a heartbeat that &lt;a href=&quot;https://docs.openclaw.ai/gateway/heartbeat&quot;&gt;thinks every 30
minutes by default&lt;/a&gt; — an hour under OAuth-style auth — run theirs. If you want to know exactly
why your assistant did what it did last Tuesday, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;git log&lt;/code&gt; is a hard act to beat.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The compliance argument.&lt;/strong&gt; There is one more consequence of the size gap, and for anyone working inside a
company it may be the decisive one. Everything we &lt;em&gt;added&lt;/em&gt; on top of Claude Code is the prompt folded at the
top of this post, roughly one thousand lines of generated scripts, and a git history where every behavior
change is a commit. A corporate security or compliance review can actually &lt;em&gt;read&lt;/em&gt; that — one sitting, line by
line. The review still inherits the vendor layer underneath, to be clear — Claude Code itself, the Slack MCP
server, and the unsandboxed &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;bypassPermissions&lt;/code&gt; execution admitted above — but an AI Coding tool is a thing
your company has likely already assessed or is assessing anyway; the &lt;em&gt;marginal&lt;/em&gt; surface this assistant adds
is one prompt and a thousand lines. Now put OpenClaw or Hermes through the same review: a full runtime,
twenty-plus channel adapters, a community skills registry with a
&lt;a href=&quot;https://blogs.cisco.com/ai/personal-ai-agents-like-openclaw-are-a-security-nightmare&quot;&gt;demonstrated malicious-skill incident&lt;/a&gt;, and a release cadence that outruns any approval process. I am
not saying they cannot pass review — I am saying the prompt-sized system is &lt;em&gt;reviewable&lt;/em&gt; in a way a platform
is not. And building it yourself carries a bonus no product ships: you watched every piece of it land as a
diff.&lt;/p&gt;

&lt;h2 id=&quot;what-id-steal-and-what-stays&quot;&gt;What I’d Steal, and What Stays&lt;/h2&gt;

&lt;p&gt;From Hermes I would steal searchable session history — FTS5 over past conversations is the feature I miss most,
and jsonl grep is a poor substitute. From OpenClaw, the heartbeat contract: an explicit act-or-stay-silent
protocol for proactive ticks is cleaner than our watches mechanism. Some of our own edges are unsolved too:
the orchestrator is a single interactive session on a single machine, seven quorum cycles in two days are
still a small sample to generalize from, and parts of the day-one code probably contain subtle bugs we have
not hit yet.&lt;/p&gt;

&lt;p&gt;What stays, without hesitation: assist/manage/never-do as enforced architecture, deliver-first ordering with a
loud failure path, and the repo as the only source of truth. The constraint made the design better — the same
sentence closed a section of &lt;a href=&quot;/blog/2026/07/16/a-real-macos-desktop-for-a-linux-agent/&quot;&gt;the Tart desktop
post&lt;/a&gt; three days ago, and it earned its
repeat&lt;/p&gt;

&lt;h2 id=&quot;show-me-yours&quot;&gt;Show Me Yours&lt;/h2&gt;

&lt;p&gt;If you run OpenClaw or Hermes daily, I want to hear where my comparison is unfair — both projects move fast,
and some defaults above will already be stale by the time you read this. And if you have built your own
orchestrator on Claude Code — especially if it survived its own first incident — show me the post-mortem. Find
me on &lt;a href=&quot;https://www.linkedin.com/in/jonnyzzz/&quot;&gt;LinkedIn&lt;/a&gt; or &lt;a href=&quot;https://x.com/jonnyzzz&quot;&gt;Twitter&lt;/a&gt;, and if an assistant like this would fit your own workflow,
get in touch — I am happy to help you shape one. Or skip the middleman: unfold the prompt at the top, swap in
your own repo name, and paste it into a fresh agent session. That is the whole install — the rest is a
conversation.&lt;/p&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;em&gt;Related reading: how we audit what agents do to git in
&lt;a href=&quot;/blog/2026/06/26/inside-the-git-hooks-for-ai-agents/&quot;&gt;Inside the Git Hooks for AI Agents&lt;/a&gt;, and
how we gave a Linux Agent eyes and hands on macOS in
&lt;a href=&quot;/blog/2026/07/16/a-real-macos-desktop-for-a-linux-agent/&quot;&gt;A Real macOS Desktop for a Linux Agent&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</content>

  
  
  
  
  

    <author>
      <name>Eugene Petrenko</name>
    </author>

  
    <category term="ai-agents" />
  
    <category term="ai-coding" />
  
    <category term="infrastructure" />
  
    <category term="workflow" />
  
    <category term="self-improvement" />
  
    <category term="hermes-agent" />
  
    <category term="openclaw" />
  
    <summary type="html">In one afternoon — one pasted prompt, included below — we turned an interactive Claude Code session into a personal-assistant orchestrator: channels in, a fresh headless sub-agent per request, an inbox loop that delivers finished work before taking new work, and a three-lens quorum that reviews and ships its own process fixes. By day two it was also babysitting the machine&apos;s other AI Agent consoles through a guarded keystroke path. No daemon, no webhooks, no database — jsonl files, blocking CLIs, and git as the deployment mechanism — and a hands-on comparison against OpenClaw and Hermes.</summary>
  
  </entry>
  
  <entry>
    <title type="html">A Real macOS Desktop for a Linux Agent: Driving Tart VMs over SSH with Agent Skills</title>
    <link href="https://jonnyzzz.com/blog/2026/07/16/a-real-macos-desktop-for-a-linux-agent/" rel="alternate" type="text/html" title="A Real macOS Desktop for a Linux Agent: Driving Tart VMs over SSH with Agent Skills" />
    <published>2026-07-16T00:00:00+00:00</published>
    <updated>2026-07-16T00:00:00+00:00</updated>
    <id>/blog/2026/07/16/a-real-macos-desktop-for-a-linux-agent</id>
    <content type="html" xml:base="https://jonnyzzz.com/blog/2026/07/16/a-real-macos-desktop-for-a-linux-agent/">&lt;p&gt;&lt;a href=&quot;/images/posts/2026-07-16-tart-macos-desktop.png&quot;&gt;&lt;img src=&quot;/images/posts/2026-07-16-tart-macos-desktop.png&quot; alt=&quot;A real macOS desktop — Finder, Dock, wallpaper — captured over SSH from a
headless Tart VM&quot; /&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The AI Agent that took this screenshot has never touched a Mac. It is running on Linux, it reached an Apple
Silicon Mac over SSH, booted a &lt;a href=&quot;https://tart.run/&quot;&gt;Tart&lt;/a&gt; VM there, and captured a real macOS Aqua session — Finder, Dock,
wallpaper and all.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In the &lt;a href=&quot;/blog/2026/07/05/intellij-in-docker-integration-tests/&quot;&gt;previous post&lt;/a&gt; we booted a full IntelliJ IDEA inside a Docker container — Xvfb for the
display, fluxbox to keep the windows still, ffmpeg recording, live video streamed into a browser tab
(&lt;a href=&quot;https://youtu.be/rA-pBJR9wh0&quot;&gt;watch it on YouTube&lt;/a&gt;). I mentioned that Docker limits us to Linux, and
that “solutions like tart to run virtual macOS” were left uncovered. That was the honest gap: if the thing
you need your Agent to test is &lt;em&gt;macOS itself&lt;/em&gt; — Gatekeeper, the TCC consent prompts, Aqua window focus — a
Linux container will never show it to you.&lt;/p&gt;

&lt;p&gt;I experimented with capturing a demo video with an AI Agent on macOS. We processed all the experience, and
condensed it into this post — the macOS half of the story. This time the deliverable is not a Kotlin test
harness — it is a set of &lt;a href=&quot;https://github.com/jonnyzzz/tart-skills&quot;&gt;Agent Skills&lt;/a&gt;: plain-markdown instructions plus a small &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;bin/tart-remote&lt;/code&gt;
orchestrator that any AI Agent on Linux can pick up to &lt;strong&gt;boot, see, and drive a real macOS desktop over
SSH&lt;/strong&gt;. It is open source in &lt;a href=&quot;https://github.com/jonnyzzz/tart-skills&quot;&gt;jonnyzzz/tart-skills&lt;/a&gt;.&lt;/p&gt;

&lt;h2 id=&quot;the-problem-a-macos-gui-and-no-mac-for-the-agent&quot;&gt;The problem: a macOS GUI, and no Mac for the Agent&lt;/h2&gt;

&lt;p&gt;&lt;a href=&quot;https://tart.run/&quot;&gt;Tart&lt;/a&gt; runs macOS and Linux virtual machines on Apple Silicon using Apple’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Virtualization.framework&lt;/code&gt;
(&lt;a href=&quot;https://tart.run/&quot;&gt;tart.run&lt;/a&gt;). It is excellent — but it only runs on an Apple Silicon Mac.
In my setup, most of the agentic payload is running on Linux, and I decided to explore the approach
of connecting a Linux machine to a Mac VM, over SSH. The Agent side is not picky — we tested from Linux,
and the same commands work from a Docker container or from another Mac. The one hard requirement is the
VM host: that is always an Apple Silicon Mac. With this post I want to show what is possible.&lt;/p&gt;

&lt;p&gt;There is a second problem, softer but just as real. The lessons from the Docker post lived &lt;em&gt;inside&lt;/em&gt; one
project’s harness. This time we wanted the knowledge to travel — between Claude Code, Codex, Gemini, and
whatever comes next. Markdown skills plus a thin CLI move between Agents in a way a test module never will.&lt;/p&gt;

&lt;h2 id=&quot;the-two-ssh-hops&quot;&gt;The two SSH hops&lt;/h2&gt;

&lt;p&gt;The whole design is one clean seam. The Agent never talks to Tart directly; it runs &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ssh mac &apos;tart-remote …&apos;&lt;/code&gt;,
and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tart-remote&lt;/code&gt; owns everything Mac- and VM-specific.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;graph LR
    Agent[&quot;Linux AI Agent&amp;lt;br/&amp;gt;(the skills)&quot;]
    Mac[&quot;Mac host, Apple Silicon&amp;lt;br/&amp;gt;bin/tart-remote + tart&amp;lt;br/&amp;gt;boots + supervises the VM&quot;]
    VM[&quot;macOS GUI VM&amp;lt;br/&amp;gt;Aqua session:&amp;lt;br/&amp;gt;IntelliJ, Finder…&quot;]

    Agent --&amp;gt;|ssh| Mac
    Mac --&amp;gt;|&quot;ssh mux&amp;lt;br/&amp;gt;screencapture / cliclick&quot;| VM
    Mac -.-&amp;gt;|&quot;stdout: PNG / MP4 / IP&quot;| Agent

    style Agent fill:#e1f5ff
    style Mac fill:#fff4e6
    style VM fill:#f3e5f5
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;A Tart VM is a long-lived foreground process — a naive &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ssh mac &apos;tart run …&apos;&lt;/code&gt; boots a VM that dies the moment
the SSH command returns. So &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;vm-up&lt;/code&gt; boots it &lt;em&gt;detached&lt;/em&gt; (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;nohup … &amp;lt;/dev/null &amp;amp; disown&lt;/code&gt;) and the VM keeps its
GUI session alive after the Agent disconnects. The inner hop is multiplexed with SSH &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ControlMaster&lt;/code&gt; /
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ControlPersist&lt;/code&gt;, which — besides being faster — dodges macOS sshd’s “Too many authentication failures”
lockout that a busy Agent otherwise trips within seconds.&lt;/p&gt;

&lt;p&gt;Six skills sit on top of that orchestrator:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Skill&lt;/th&gt;
      &lt;th&gt;What the Agent uses it for&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tart-remote-setup&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;The Linux→Mac hop: install &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tart-remote&lt;/code&gt;, verify Tart is there&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tart-vm-manage&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Boot (detached) / provision / status / stop / delete the VM&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tart-vm-screenshot&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Capture the screen as a PNG — the Agent’s eyes&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tart-vm-intellij&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Launch IntelliJ IDEA and drive it with click / type&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tart-vm-video&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Record the GUI as video&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tart-vm-cache&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Share one IDE copy across every VM — faster, less disk&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;A full run reads the way you’d hope (abbreviated — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;$MAC&lt;/code&gt; is your Mac, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;$V&lt;/code&gt; a task-unique
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TART_VM=…&lt;/code&gt;, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cache-setup&lt;/code&gt; runs once first — let it finish before &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;provision&lt;/code&gt;, or the VM falls back to
downloading its own IntelliJ; the &lt;a href=&quot;https://github.com/jonnyzzz/tart-skills&quot;&gt;repo&lt;/a&gt; has the exact quick start):&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;ssh &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$MAC&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$V&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt; ~/bin/tart-remote vm-up&quot;&lt;/span&gt;         &lt;span class=&quot;c&quot;&gt;# boot a macOS VM, detached&lt;/span&gt;
ssh &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$MAC&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$V&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt; ~/bin/tart-remote provision&quot;&lt;/span&gt;     &lt;span class=&quot;c&quot;&gt;# cliclick, ffmpeg, IntelliJ, TCC grant&lt;/span&gt;
ssh &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$MAC&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$V&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt; ~/bin/tart-remote screenshot -&quot;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; desktop.png
ssh &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$MAC&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$V&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt; ~/bin/tart-remote start-ide&quot;&lt;/span&gt;     &lt;span class=&quot;c&quot;&gt;# launch IntelliJ into the GUI session&lt;/span&gt;
ssh &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$MAC&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$V&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt; ~/bin/tart-remote click 800 450&quot;&lt;/span&gt; &lt;span class=&quot;c&quot;&gt;# drive it&lt;/span&gt;
ssh &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$MAC&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$V&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt; ~/bin/tart-remote record 15 -&quot;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; clip.mov
ssh &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$MAC&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$V&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt; ~/bin/tart-remote vm-gc&quot;&lt;/span&gt;         &lt;span class=&quot;c&quot;&gt;# clean up after yourself&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;By default &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;vm-up&lt;/code&gt; boots the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;macos-tahoe-base&lt;/code&gt; image (macOS 26) — set &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TART_IMAGE&lt;/code&gt; to reuse an image you
already have pulled, the first pull is tens of gigabytes.&lt;/p&gt;

&lt;p&gt;Two design rules carried the whole thing. &lt;strong&gt;stdout is data, stderr is logs&lt;/strong&gt; — so &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;screenshot -&lt;/code&gt; and
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;record N -&lt;/code&gt; stream raw bytes you redirect straight into a file, while every log line goes to stderr. And
state is read straight from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tart list&lt;/code&gt; / &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tart ip&lt;/code&gt;, so there is no bookkeeping to get out of sync.&lt;/p&gt;

&lt;h2 id=&quot;the-gotchas-learned-the-hard-way&quot;&gt;The gotchas, learned the hard way&lt;/h2&gt;

&lt;p&gt;Most of this project was distilled from a prior video-production effort that drove Tart VMs the hard way —
hand-typed &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ssh&lt;/code&gt; commands, VMs that died the moment a terminal closed, and a run nobody could replay. A few
lessons cost a real debugging session in &lt;a href=&quot;https://devrig.dev&quot;&gt;devrig&lt;/a&gt; each, and they are the interesting part.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The keychain that blocks your VM.&lt;/strong&gt; On a headless macOS 15+ host, booting a &lt;em&gt;macOS&lt;/em&gt; guest failed with a
security error, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Code=-9&lt;/code&gt;, “Failed to create new HostKey”. Nothing in that message says the word “keychain”,
and yet that is the cause: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Virtualization.framework&lt;/code&gt; needs the host’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;login.keychain&lt;/code&gt; to be &lt;em&gt;unlocked&lt;/em&gt;, and
a headless SSH session leaves it locked (&lt;a href=&quot;https://tart.run/faq/&quot;&gt;tart FAQ&lt;/a&gt;, &lt;a href=&quot;https://github.com/openai/tart/issues/1146&quot;&gt;openai/tart#1146&lt;/a&gt;). A Linux
guest boots fine; only macOS trips it. The fix is one line in &lt;a href=&quot;https://github.com/jonnyzzz/tart-skills/blob/main/bin/tart-remote&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;vm-up&lt;/code&gt;&lt;/a&gt; — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;security unlock-keychain&lt;/code&gt; —
gated behind a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TART_KEYCHAIN_PW&lt;/code&gt; you only set on a trusted host.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Agent grants its own screen-recording consent.&lt;/strong&gt; Without a Screen Recording grant in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TCC.db&lt;/code&gt;,
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;screencapture&lt;/code&gt; over SSH returns a black frame — and on macOS 15+ the consent dialog that would fix it can
never appear in a headless session. Which client needs the grant has drifted across macOS versions:
historically &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/usr/libexec/sshd-keygen-wrapper&lt;/code&gt;, and — since Sequoia picked up &lt;a href=&quot;https://www.openssh.com/releasenotes.html&quot;&gt;OpenSSH 9.8’s sshd
split&lt;/a&gt; — sometimes &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;com.apple.sshd-session&lt;/code&gt;. Recent &lt;a href=&quot;https://github.com/cirruslabs/macos-image-templates&quot;&gt;Cirrus Labs base images&lt;/a&gt;
pre-bake the wrapper grant — SIP is off there, which is what makes &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TCC.db&lt;/code&gt; writable at all, not what
disables the checks — so a fresh VM often captures fine before any provisioning; &lt;a href=&quot;https://github.com/jonnyzzz/tart-skills/blob/main/provision/provision.sh&quot;&gt;provisioning&lt;/a&gt;
writes the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;com.apple.sshd-session&lt;/code&gt; grant on top to cover both. Then macOS 15+ &lt;em&gt;still&lt;/em&gt; periodically pops a
“requesting to bypass the system private window picker” dialog. Captures keep working, but the box sits in
the frame — so the Agent
notices it in its own screenshot and clicks it away. An Agent approving a
macOS privacy prompt about the Agent is my favorite moment in the whole project&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A stranded ⌘ that opened the logout dialog.&lt;/strong&gt; &lt;a href=&quot;https://github.com/BlueM/cliclick&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cliclick&lt;/code&gt;&lt;/a&gt;’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;kp:&lt;/code&gt; accepts only named keys — no letters — so
the natural “⌘A to clear the field” as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;kd:cmd kp:a ku:cmd&lt;/code&gt; errors on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;a&lt;/code&gt; &lt;em&gt;after&lt;/em&gt; pressing Cmd down and never
releases it. Cmd stays held, the next keystrokes become shortcuts, and one of our test runs cheerfully opened
the macOS “quit everything and log out” dialog. &lt;a href=&quot;https://github.com/jonnyzzz/tart-skills/blob/main/bin/tart-remote&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;bin/tart-remote&lt;/code&gt;&lt;/a&gt; now releases &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cmd,ctrl,alt,shift&lt;/code&gt; after
every &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;key&lt;/code&gt; call, even when &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cliclick&lt;/code&gt; returns an error, so a typo can’t strand a modifier. For a letter with
a modifier you use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;t:&lt;/code&gt; inside the combo — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;kd:cmd t:a ku:cmd&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No Homebrew required.&lt;/strong&gt; The remote Mac we validated against had no Xcode Command Line Tools and no
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sshpass&lt;/code&gt;. So Tart installs brew-less from its release tarball into &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;~/bin&lt;/code&gt;, and the inner hop authenticates
through OpenSSH’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SSH_ASKPASS&lt;/code&gt; (&lt;a href=&quot;https://man.openbsd.org/ssh#SSH_ASKPASS&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SSH_ASKPASS_REQUIRE=force&lt;/code&gt;&lt;/a&gt;, which needs no tty) when &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sshpass&lt;/code&gt; is absent.
The only hard dependencies on the Mac are &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tart&lt;/code&gt; and a stock &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ssh&lt;/code&gt;. The constraint made the design better.&lt;/p&gt;

&lt;h2 id=&quot;one-ide-shared-across-every-vm&quot;&gt;One IDE, shared across every VM&lt;/h2&gt;

&lt;p&gt;Downloading a full IntelliJ into every VM is slow and wastes tens of gigabytes. Instead, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cache-setup&lt;/code&gt;
downloads IntelliJ IDEA Community Edition &lt;em&gt;once&lt;/em&gt; into a folder on the Mac host, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;vm-up&lt;/code&gt; mounts that folder
&lt;strong&gt;read-only&lt;/strong&gt; into every VM, and the IDE runs straight from the mount — no per-VM download, no per-VM copy.
We watched a fresh VM, with nothing in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/Applications&lt;/code&gt;, launch IntelliJ straight off the shared read-only
mount. For managing IDE versions we point at &lt;a href=&quot;https://devrig.dev&quot;&gt;devrig&lt;/a&gt;, the little CLI that downloads and starts
JetBrains IDE backends for an Agent.&lt;/p&gt;

&lt;p&gt;Because the Mac is shared between many AI Agent tasks at once, the skills bake in etiquette: a unique VM name per
task, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tart-remote ls&lt;/code&gt; to see the neighbors, and an always-clean-up-with-&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;vm-gc&lt;/code&gt; rule even when the task
failed. Two Agents each booted their own VM concurrently while we tested, and neither stepped on the other.&lt;/p&gt;

&lt;h2 id=&quot;what-we-actually-get&quot;&gt;What we actually get&lt;/h2&gt;

&lt;p&gt;&lt;a href=&quot;/images/posts/2026-07-16-tart-intellij-launched.png&quot;&gt;&lt;img src=&quot;/images/posts/2026-07-16-tart-intellij-launched.png&quot; alt=&quot;IntelliJ IDEA Welcome screen inside the VM — first-run dialogs cleared by the
Agent itself, driven over SSH from another machine&quot; /&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Validated end-to-end, over both SSH hops, against a remote Apple Silicon Mac with &lt;em&gt;no Homebrew on it&lt;/em&gt;:
clone → keychain-unlocked detached boot → &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;provision&lt;/code&gt; → a real desktop PNG → IntelliJ launched — and the
Agent walked the first-run dialogs itself: the screen-recording consent, the User Agreement checkbox plus
Continue, Data Sharing, the local-network prompt — all the way to the Welcome screen → a valid 1600×900
QuickTime video. The screenshots in this post are those captures, streamed straight through both hops to
the Agent.&lt;/p&gt;

&lt;p&gt;There are edges we have not solved yet. Sharing devrig’s downloads across VMs is not wired up — devrig wants
a writable home, and the shared cache is read-only. The periodic private-window-picker dialog still appears and
still needs a click. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TART_KEYCHAIN_PW&lt;/code&gt; puts a password on a command line, so it is for trusted hosts only.
And mind the coordinates: the guest display is HiDPI, so screenshots carry 2× the pixels
of the logical resolution your clicks use — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;provision&lt;/code&gt; pins the mode with displayplacer (a macOS 26 guest
ignores the VM display setting otherwise), but the divide-by-two stays yours.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;The same thesis as the Docker post, one level up: an AI Agent needs eyes and hands on &lt;em&gt;every&lt;/em&gt; operating
system its users run — not just the one that happens to fit in a container&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2 id=&quot;try-it-point-it-at-a-mac&quot;&gt;Try it, point it at a Mac&lt;/h2&gt;

&lt;p&gt;Clone &lt;a href=&quot;https://github.com/jonnyzzz/tart-skills&quot;&gt;jonnyzzz/tart-skills&lt;/a&gt;, turn on Remote Login on any Apple Silicon Mac you can reach, and let your
Agent take its first macOS screenshot. If you built the Docker variant from the last post, this is the macOS
half of the same idea. Then show me what your Agent drove — tell me on &lt;a href=&quot;https://www.linkedin.com/in/jonnyzzz/&quot;&gt;LinkedIn&lt;/a&gt; or
&lt;a href=&quot;https://twitter.com/jonnyzzz&quot;&gt;Twitter&lt;/a&gt;, or open an issue on the repo.&lt;/p&gt;</content>

  
  
  
  
  

    <author>
      <name>Eugene Petrenko</name>
    </author>

  
    <category term="ai-agents" />
  
    <category term="ai-coding" />
  
    <category term="tart" />
  
    <category term="macos" />
  
    <category term="testing" />
  
    <category term="intellij" />
  
    <category term="agent-skills" />
  
    <summary type="html">Last time we put a full IntelliJ IDEA inside a Docker container. But Docker only gives you Linux, and some GUIs are macOS — Aqua, Gatekeeper, TCC prompts and all. So we taught a Linux AI Agent to boot, see, and drive a real macOS desktop inside a Tart VM over SSH, and packaged the whole thing as reusable Agent Skills you can point at any Apple Silicon Mac.</summary>
  
  </entry>
  
  <entry>
    <title type="html">A Real IntelliJ IDEA Inside Docker: Integration Tests with X Server, Window Manager, and Live Video</title>
    <link href="https://jonnyzzz.com/blog/2026/07/05/intellij-in-docker-integration-tests/" rel="alternate" type="text/html" title="A Real IntelliJ IDEA Inside Docker: Integration Tests with X Server, Window Manager, and Live Video" />
    <published>2026-07-05T00:00:00+00:00</published>
    <updated>2026-07-05T00:00:00+00:00</updated>
    <id>/blog/2026/07/05/intellij-in-docker-integration-tests</id>
    <content type="html" xml:base="https://jonnyzzz.com/blog/2026/07/05/intellij-in-docker-integration-tests/">&lt;p&gt;&lt;a href=&quot;https://youtu.be/rA-pBJR9wh0&quot;&gt;&lt;img src=&quot;/images/posts/2026-07-05-docker-ide-demo-play.jpg&quot; alt=&quot;A full IntelliJ IDEA running in Docker, its live screen streamed into a browser tab while an AI 
Agent drives the debugger&quot; /&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The demo: an integration test boots IntelliJ IDEA inside a Docker container, streams its live screen into
the browser tab on the right, and a Claude agent drives the IDE debugger — all of it the setup described
below. &lt;a href=&quot;https://youtu.be/rA-pBJR9wh0&quot;&gt;Watch it on YouTube&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The question I keep asking myself while writing my IntelliJ plugins – is there a way to write an
integration test that proves the plugin actually works inside the IDE. You need tests 10x more thanks
to AI Agents and agentic loops, and since I integrate deeply with the IDE, I need that even more. The integration tests are
also the source of the Agentic traces I use to self-improve &lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid&quot;&gt;MCP Steroid&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;In this post, I share insights to the setup of full integration tests with IntelliJ-based IDEs, 
running with GUI, inside Docker container.&lt;/p&gt;

&lt;p&gt;It started about 8 years ago, when we first introduced performance tests into the IntelliJ platform —
such tests are now visible under the &lt;a href=&quot;https://github.com/JetBrains/intellij-community/tree/master/tools/intellij.tools.ide.starter&quot;&gt;IntelliJ Community&lt;/a&gt;. We’ve been using these tests to
stabilize &lt;a href=&quot;https://jonnyzzz.com/talks/2021-02-26-ij20/&quot;&gt;IntelliJ Shared Indexes&lt;/a&gt;. This time, I provide the fully reusable way to
run integration tests, now in Docker and with full GUI and tools.&lt;/p&gt;

&lt;p&gt;We walk through the essentials: the Docker image, the X server setup, how we keep
IntelliJ manageable in there, and how the video reaches your browser. Enough details that you (better your AI Agent) can
re-implement the approach for your own GUI-under-test — it is not IntelliJ-specific at all. All the code is
open source in the &lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/tree/main/test-integration&quot;&gt;test-integration&lt;/a&gt; module, and the tech powers the
&lt;a href=&quot;https://mcp-steroid.jonnyzzz.com&quot;&gt;demo videos&lt;/a&gt; we are preparing for the project.&lt;/p&gt;

&lt;p&gt;This continues the MCP Steroid series — the &lt;a href=&quot;/blog/2026/01/04/mcp-steroids-intellij/&quot;&gt;original post&lt;/a&gt; explains why AI Agents get the full
IntelliJ runtime, and the &lt;a href=&quot;/blog/2026/06/02/devrig-mcp-steroid-level-3/&quot;&gt;devrig post&lt;/a&gt; covers the autonomous IDE lifecycle. Today is about the
test infrastructure underneath.&lt;/p&gt;

&lt;h2 id=&quot;why-not-headless-why-not-mocks&quot;&gt;Why not headless, why not mocks&lt;/h2&gt;

&lt;p&gt;First of all, you can find the &lt;a href=&quot;https://plugins.jetbrains.com/docs/intellij/testing-plugins.html&quot;&gt;testing framework in the IntelliJ SDK&lt;/a&gt;, that is great and enough for most
of the cases. Here we discuss more high-level integration tests, which are as close to the end-user IDE
as possible, with modal dialogs, and production files layout, production threading, deadlocks, and more.&lt;/p&gt;

&lt;p&gt;The interesting bugs live in the seams: a modal dialog that blocks the whole
IDE, the trust prompt that appears only on first open of a project, indexing that makes every PSI call
return incomplete results or fail. MCP Steroid server executes Kotlin code inside the live IDE process —
such the integration test has to
prove that works while the IDE is doing all its usual IDE things.&lt;/p&gt;

&lt;p&gt;Headless IntelliJ exists, but it is a different product in practice. No window subsystem means no dialogs, no
focus, no screenshots — and screenshots are an MCP Steroid feature (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;steroid_take_screenshot&lt;/code&gt;). If the agent
can see the IDE in production, the test environment must render actual pixels, and the IDE behaviour
should be tested appropriately. The headless IDE — the same
flavor the test framework uses — runs a different threading model that is much more convenient for testing.
For the reference, &lt;a href=&quot;https://www.jetbrains.com/help/idea/remote-development-overview.html&quot;&gt;Remote Development&lt;/a&gt; functions slightly differently, we are not covering it.
I want to be certain my plugin and my features are behaving in the GUI version of the IDE, just like the end-users do.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;requirement&lt;/strong&gt; list settled to: real IDE, real display, reproducible on any machine with Docker, and
observable — when a 15-minute test goes sideways, “read 40MB of idea.log” is a bad debugging story. We wanted
to just look at the screen, and I confirm, an AI Agent can do that too!&lt;/p&gt;

&lt;p&gt;In the MCP Steroid we combine approaches – there are fast and lightweight tests with the 
&lt;a href=&quot;https://plugins.jetbrains.com/docs/intellij/welcome.html&quot;&gt;IntelliJ Platform SDK&lt;/a&gt;,
and there are heavy docker-based integration tests to cover the most heavy end-to-end flows. We are discussing
the second below.&lt;/p&gt;

&lt;h2 id=&quot;why-docker&quot;&gt;Why Docker?&lt;/h2&gt;

&lt;p&gt;Since I want to stay as close as possible to end-user scenarios – a clean machine is &lt;strong&gt;required&lt;/strong&gt;.
We run MCP Steroid together with AI Agents such as Claude Code, Codex, and Gemini. I do not want my local
machine setup to interfere with the actual integration test. That is where Docker comes in – every time,
we run in the same scripted, clean environment.&lt;/p&gt;

&lt;p&gt;Docker comes with the price – I can only integration-test my project with Linux. There are
solutions like &lt;a href=&quot;https://tart.run/&quot;&gt;tart&lt;/a&gt; to run virtual macOS and other solutions on Windows, which I
do not cover so far.&lt;/p&gt;

&lt;h2 id=&quot;the-image-heavy-base-thin-ide-layer&quot;&gt;The image: heavy base, thin IDE layer&lt;/h2&gt;

&lt;p&gt;The Docker setup is two layers. A heavy &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ide-base&lt;/code&gt; image holds everything that changes rarely and caches
well; a thin per-IDE layer adds an IDE distribution on top. The base starts from a small Ubuntu image,
installs the X stack, and ends by dropping to a non-root user that does nothing (&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/test-integration/src/test/docker/ide-base/Dockerfile&quot;&gt;full Dockerfile&lt;/a&gt;):&lt;/p&gt;

&lt;div class=&quot;language-dockerfile highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt;&lt;span class=&quot;s&quot;&gt; ubuntu:24.04&lt;/span&gt;

&lt;span class=&quot;c&quot;&gt;# X server, window manager, capture tools, fonts, and the JetBrains Runtime&apos;s native X deps&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;RUN &lt;/span&gt;apt-get update &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\
&lt;/span&gt;    apt-get &lt;span class=&quot;nb&quot;&gt;install&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-y&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--no-install-recommends&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\
&lt;/span&gt;        xvfb &lt;span class=&quot;se&quot;&gt;\
&lt;/span&gt;        x11-utils &lt;span class=&quot;se&quot;&gt;\
&lt;/span&gt;        fluxbox &lt;span class=&quot;se&quot;&gt;\
&lt;/span&gt;        fonts-dejavu &lt;span class=&quot;se&quot;&gt;\
&lt;/span&gt;        fonts-jetbrains-mono &lt;span class=&quot;se&quot;&gt;\
&lt;/span&gt;        ffmpeg &lt;span class=&quot;se&quot;&gt;\
&lt;/span&gt;        scrot &lt;span class=&quot;se&quot;&gt;\
&lt;/span&gt;        xdotool &lt;span class=&quot;se&quot;&gt;\
&lt;/span&gt;        xclip &lt;span class=&quot;se&quot;&gt;\
&lt;/span&gt;        xterm &lt;span class=&quot;se&quot;&gt;\
&lt;/span&gt;        libxi6 libxrender1 libxtst6 libxext6 libx11-6 &lt;span class=&quot;se&quot;&gt;\
&lt;/span&gt;        libfreetype6 libfontconfig1 &lt;span class=&quot;se&quot;&gt;\
&lt;/span&gt;        curl git unzip rsync procps ca-certificates &lt;span class=&quot;se&quot;&gt;\
&lt;/span&gt;        &lt;span class=&quot;c&quot;&gt;# … a few more utilities trimmed for the post&lt;/span&gt;
    &amp;amp;&amp;amp; rm -rf /var/lib/apt/lists/*

# … plus six Temurin JDKs (8, 11, 17, 21, 24, 25) and Node.js 20, and the AI Agent CLIs
&lt;span class=&quot;c&quot;&gt;# (Claude Code, Codex, Gemini) behind a daily cache-bust ARG so they stay fresh&lt;/span&gt;

&lt;span class=&quot;c&quot;&gt;# a non-root user that owns the dotfiles and caches. ubuntu:24.04 already ships a&lt;/span&gt;
&lt;span class=&quot;c&quot;&gt;# default user at uid 1000, so free the slot before claiming it for `agent`.&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;RUN &lt;/span&gt;userdel &lt;span class=&quot;nt&quot;&gt;-r&lt;/span&gt; ubuntu 2&amp;gt;/dev/null &lt;span class=&quot;o&quot;&gt;||&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;true&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; useradd &lt;span class=&quot;nt&quot;&gt;-m&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-u&lt;/span&gt; 1000 &lt;span class=&quot;nt&quot;&gt;-s&lt;/span&gt; /bin/bash agent

&lt;span class=&quot;k&quot;&gt;USER&lt;/span&gt;&lt;span class=&quot;s&quot;&gt; agent&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;WORKDIR&lt;/span&gt;&lt;span class=&quot;s&quot;&gt; /home/agent&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;CMD&lt;/span&gt;&lt;span class=&quot;s&quot;&gt; [&quot;sleep&quot;, &quot;infinity&quot;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Every package earns its place:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;xvfb&lt;/code&gt; — the virtual X server, our display&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fluxbox&lt;/code&gt; — a tiny window manager; without one, IntelliJ windows have no position, no focus, no stacking&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;x11-utils&lt;/code&gt; — gives us &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;xdpyinfo&lt;/code&gt; for the display readiness probe&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;xdotool&lt;/code&gt; — window geometry and raw input injection from scripts&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;scrot&lt;/code&gt; + &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ffmpeg&lt;/code&gt; — screenshots and screen recording&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fonts-jetbrains-mono&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fonts-dejavu&lt;/code&gt; — an IDE without fonts renders as squares, not much to look at&lt;/li&gt;
  &lt;li&gt;the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;libx*&lt;/code&gt; set — the JetBrains Runtime is a JVM with AWT; these are its native X client dependencies&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The six JDKs let the IDE detect system JDKs and let tests exercise multi-version SDK selection; the
daily cache-bust keeps the fast-moving agent CLIs fresh while the expensive apt layer rebuilds rarely.
The same way you may need node, python, and other dev dependencies.&lt;/p&gt;

&lt;p&gt;The last three lines matter more than they look. The container’s main process is literally &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sleep infinity&lt;/code&gt; —
the container is an empty, running room. Xvfb, the window manager, ffmpeg, the IDE itself — every real
process is started later, via &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;docker exec&lt;/code&gt;, from the test harness. This is the single most useful design
decision in the whole setup: the harness owns the exact startup order, sees every process’s output
separately, can restart any piece, and cleanup is LIFO through one stack of dispose actions. A &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CMD&lt;/code&gt; that
tries to boot everything in a shell script gives you none of that.&lt;/p&gt;

&lt;p&gt;The IDE layer is almost embarrassingly small (&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/test-integration/src/test/docker/ide-agent/Dockerfile&quot;&gt;full Dockerfile&lt;/a&gt;), and potentially
cheap enough to run via &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;docker exec&lt;/code&gt; or &lt;a href=&quot;https://devrig.dev&quot;&gt;devrig&lt;/a&gt;:&lt;/p&gt;

&lt;div class=&quot;language-dockerfile highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;ARG&lt;/span&gt;&lt;span class=&quot;s&quot;&gt; BASE_IMAGE=mcp-steroid-ide-base-test&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt;&lt;span class=&quot;s&quot;&gt; ${BASE_IMAGE}&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;USER&lt;/span&gt;&lt;span class=&quot;s&quot;&gt; root&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;COPY&lt;/span&gt;&lt;span class=&quot;s&quot;&gt; ide.tar.gz /tmp/ide.tar.gz&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;RUN &lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;mkdir&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-p&lt;/span&gt; /opt/idea &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;chmod &lt;/span&gt;a+rwx /opt &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\
&lt;/span&gt;    &lt;span class=&quot;nb&quot;&gt;tar&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-xzf&lt;/span&gt; /tmp/ide.tar.gz &lt;span class=&quot;nt&quot;&gt;-C&lt;/span&gt; /opt/idea &lt;span class=&quot;nt&quot;&gt;--strip-components&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;1 &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\
&lt;/span&gt;    &lt;span class=&quot;nb&quot;&gt;rm&lt;/span&gt; /tmp/ide.tar.gz
&lt;span class=&quot;k&quot;&gt;USER&lt;/span&gt;&lt;span class=&quot;s&quot;&gt; agent&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;ENV&lt;/span&gt;&lt;span class=&quot;s&quot;&gt; PATH=&quot;/opt/idea/bin:/usr/local/bin:/usr/bin:$PATH&quot;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;CMD&lt;/span&gt;&lt;span class=&quot;s&quot;&gt; [&quot;sleep&quot;, &quot;infinity&quot;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ide.tar.gz&lt;/code&gt; is downloaded by the build on the host — the same resolver that powers
&lt;a href=&quot;/blog/2026/06/02/devrig-mcp-steroid-level-3/&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig backend download&lt;/code&gt;&lt;/a&gt; — and hard-linked into a hash-keyed build context, so Docker’s
BuildKit reuses the ~1.5 GB layer between runs instead of re-snapshotting it. PyCharm, GoLand, WebStorm,
Android Studio, and CLion get identical thin layers, only the archive and launcher name differ; Rider additionally
installs the .NET SDK.&lt;/p&gt;

&lt;h2 id=&quot;starting-the-container&quot;&gt;Starting the container&lt;/h2&gt;

&lt;p&gt;The harness launches the container with plain &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;docker run&lt;/code&gt; — no Testcontainers library right now.
I would recommend the &lt;a href=&quot;https://testcontainers.com/&quot;&gt;Testcontainers&lt;/a&gt; library, since everything we do is similar and
should be supported there as well. We are considering moving to it later in the project too.&lt;/p&gt;

&lt;p&gt;Here is the run command:&lt;/p&gt;

&lt;div class=&quot;language-sh highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;docker run &lt;span class=&quot;nt&quot;&gt;-d&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--rm&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--init&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--add-host&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;host.docker.internal:host-gateway &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;-v&lt;/span&gt; &amp;lt;run-dir&amp;gt;:/mcp-run-dir:rw &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;-v&lt;/span&gt; &amp;lt;repo-cache&amp;gt;:/repo-cache:ro &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;-v&lt;/span&gt; &amp;lt;m2-cache&amp;gt;:/home/agent/.m2:rw &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;-v&lt;/span&gt; &amp;lt;gradle-cache&amp;gt;:/home/agent/.gradle:rw &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;-p&lt;/span&gt; 6754 &lt;span class=&quot;nt&quot;&gt;-p&lt;/span&gt; 8765 &lt;span class=&quot;nt&quot;&gt;-p&lt;/span&gt; 5005 &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &amp;lt;image-id&amp;gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The container command comes from the image’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CMD&lt;/code&gt; — that &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sleep infinity&lt;/code&gt; again. The Maven and Gradle cache
mounts persist dependency downloads between runs. Each &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-p&lt;/code&gt; publishes the container port to a random free
host port; the harness reads the mapping back and prints it, so parallel test runs never fight over ports.&lt;/p&gt;

&lt;p&gt;Three published ports carry everything interesting:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Port&lt;/th&gt;
      &lt;th&gt;Purpose&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;6754&lt;/td&gt;
      &lt;td&gt;MCP Steroid HTTP server inside the IDE — the test’s API&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;8765&lt;/td&gt;
      &lt;td&gt;video streaming dashboard — the part for humans&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;5005&lt;/td&gt;
      &lt;td&gt;JDWP — attach a debugger to the containerized IDE JVM&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;That last one is worth a pause: the IDE inside the container runs with
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-agentlib:jdwp=...,server=y,suspend=n&lt;/code&gt;, so when a test misbehaves you attach IntelliJ-on-the-host to
IntelliJ-in-the-container and step through the plugin code live. Debugging an IDE with an IDE — it never
stops being funny.&lt;/p&gt;

&lt;p&gt;Two small flags carry weight. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--init&lt;/code&gt; gives us a proper PID 1 that reaps zombie processes — with dozens of
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;docker exec&lt;/code&gt; sessions spawning Xvfb, ffmpeg, rsync loops and an entire JVM, orphans are guaranteed.
And we do &lt;em&gt;not&lt;/em&gt; pass &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--user&lt;/code&gt;: the image bakes a non-root &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;agent&lt;/code&gt; user (uid 1000 by default) that owns its dotfiles and
caches; overriding the uid from outside breaks fluxbox and Maven in creative ways. The host run directory is
made world-writable instead. Not elegant, works everywhere.&lt;/p&gt;

&lt;h2 id=&quot;the-x-server-xvfb-a-readiness-probe-and-fluxbox&quot;&gt;The X server: Xvfb, a readiness probe, and fluxbox&lt;/h2&gt;

&lt;p&gt;Everything display-related lives in a small driver before anything else runs. The startup is two commands and one wait loop
(&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/test-integration/src/main/kotlin/com/jonnyzzz/mcpSteroid/integration/infra/xcvb.kt&quot;&gt;the display driver&lt;/a&gt;):&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c&quot;&gt;## Start the server&lt;/span&gt;
Xvfb :99 &lt;span class=&quot;nt&quot;&gt;-screen&lt;/span&gt; 0 3840x2160x24 &lt;span class=&quot;nt&quot;&gt;-ac&lt;/span&gt;

&lt;span class=&quot;c&quot;&gt;## Wait for it in the other session; poll until xdpyinfo succeeds&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;for &lt;/span&gt;i &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;si&quot;&gt;$(&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;seq &lt;/span&gt;1 150&lt;span class=&quot;si&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;do 
   &lt;/span&gt;xdpyinfo &lt;span class=&quot;nt&quot;&gt;-display&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;$DISPLAY&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1 &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;exit &lt;/span&gt;0&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;sleep &lt;/span&gt;0.1&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; 
&lt;span class=&quot;k&quot;&gt;done&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;exit &lt;/span&gt;1
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Xvfb :99 -screen 0 3840x2160x24 -ac&lt;/code&gt; — a 4K virtual screen, 24-bit color, access control off (we are alone
inside the container). The readiness probe matters: Xvfb takes a moment to create its socket, and an IDE
launched a hair too early dies with a cryptic &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Can&apos;t connect to X11 display&lt;/code&gt;. Polling &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;xdpyinfo&lt;/code&gt; until it
succeeds costs at most 15 seconds and removed a whole class of flaky starts. From here on, the harness stamps
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DISPLAY=:99&lt;/code&gt; onto every &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;docker exec&lt;/code&gt; it issues, and that is the entire “installation” of the display&lt;/p&gt;

&lt;p&gt;Then the window manager. You might think you can skip it — the X server renders fine without one. You can
not. Without a WM, windows get no focus management, popups draw at 0,0, and IntelliJ’s own window activation
logic quietly breaks. fluxbox is small, and is fully configured by writing three tiny
dotfiles before launch (&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/test-integration/src/main/kotlin/com/jonnyzzz/mcpSteroid/integration/infra/xcvb-windows.kt&quot;&gt;the window-manager setup&lt;/a&gt;):&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c&quot;&gt;## optional wallpaper — point it at your own image, or drop this line&lt;/span&gt;
&lt;span class=&quot;nb&quot;&gt;printf&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;background: fullscreen\nbackground.pixmap: /usr/share/images/mcp-steroid-wallpaper.jpg\n&apos;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; /home/agent/.fluxbox/overlay
&lt;span class=&quot;nb&quot;&gt;printf&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;session.screen0.toolbar.visible: false\n&apos;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; /home/agent/.fluxbox/init
&lt;span class=&quot;nb&quot;&gt;printf&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;
    [app] (name=jetbrains-idea)
      [Decorations]  {NONE}
    [end]
    [app] (name=xterm)
      [Decorations]  {NONE}
    [end]
&apos;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; /home/agent/.fluxbox/apps

&lt;span class=&quot;c&quot;&gt;## Start it&lt;/span&gt;
fluxbox
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;After that, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;xdotool windowsize&lt;/code&gt; / &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;windowmove&lt;/code&gt; pins the layout: the IDE takes the left two thirds of the 4K
screen, and an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;xterm&lt;/code&gt; on the right third tails the test’s own status output. When you watch the video
stream, that xterm is the running commentary of what the harness is doing to the IDE at that moment.&lt;/p&gt;

&lt;h2 id=&quot;managing-intellij-inside-the-container&quot;&gt;Managing IntelliJ inside the container&lt;/h2&gt;

&lt;p&gt;Getting IntelliJ to &lt;em&gt;launch&lt;/em&gt; in Docker is easy. Getting it to reach a working editor with zero human clicks
is where the real knowledge accumulated, one failure at a time. The launch itself is one line — the launcher
script from the unpacked distribution, pointed at the project:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c&quot;&gt;## make sure DISPLAY variable is set&lt;/span&gt;
&lt;span class=&quot;nv&quot;&gt;DISPLAY&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;:99 /opt/idea/bin/idea /home/agent/project-home
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;That one line only survives because the IDE is fully prepared on disk before it runs. JDK pinning happens in
the factory as the project is deployed (&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/test-integration/src/main/kotlin/com/jonnyzzz/mcpSteroid/integration/infra/intelliJ-factory.kt&quot;&gt;intelliJ-factory.kt&lt;/a&gt;); the rest is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;startIde()&lt;/code&gt;
(&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/test-integration/src/main/kotlin/com/jonnyzzz/mcpSteroid/integration/infra/intelliJ.kt&quot;&gt;intelliJ.kt&lt;/a&gt;), in this order:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Pin the project and Gradle JDK&lt;/strong&gt; in the project’s own &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.idea/misc.xml&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.idea/gradle.xml&lt;/code&gt;, so
import picks the right JVM.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Create the folders&lt;/strong&gt; — config, system, logs, plugins, and the project dir, so the IDE never races to
create them.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Write the consent files&lt;/strong&gt; — the config-dir files and the JVM userPrefs that pre-accept EULA and
privacy policy (more on these below).&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Write &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;trusted-paths.xml&lt;/code&gt;&lt;/strong&gt; — mark the project path (and, for tests, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/&lt;/code&gt;) as trusted, so the Trust
Project dialog never appears.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Write the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.vmoptions&lt;/code&gt;&lt;/strong&gt; — the full set of JVM flags (below).&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Write &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;jdk.table.xml&lt;/code&gt;&lt;/strong&gt; — pre-populate the JDK table for IntelliJ IDEA-family IDEs, so project-open
Gradle sync resolves the JDK instead of stalling ~8 minutes on an unknown SDK.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Two of these carry most of the weight: the VM options and the consent files. Let’s look at each.&lt;/p&gt;

&lt;h3 id=&quot;the-vm-options&quot;&gt;The VM options&lt;/h3&gt;

&lt;p&gt;Everything that keeps that launcher alive unattended is in the generated &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.vmoptions&lt;/code&gt; file. Its location is
special — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;idea-home&amp;gt;.vmoptions&lt;/code&gt;, so for us &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/opt/idea.vmoptions&lt;/code&gt;. Here is the full set we generate:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;-Xmx6g
-Xms1g

## Debugger is enabled, should we need it — it listens inside the Docker container
-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005

## Redirect the IDE folders to explicit paths — enough to sandbox the IDE, even without Docker
-Didea.config.path=/mcp-run-dir/intellij/ide-config
-Didea.system.path=/home/agent/ide-system
-Didea.log.path=/mcp-run-dir/intellij/ide-log
-Didea.plugins.path=/home/agent/ide-plugins

## MCP Steroid plugin — bind on all interfaces so Docker can map the port out
-Dmcp.steroid.server.host=0.0.0.0
-Dmcp.steroid.server.port=6754
-Dmcp.steroid.dialog.killer.enabled=true
-Dmcp.steroid.updates.enabled=false
-Dmcp.steroid.analytics.enabled=false
-Dmcp.steroid.idea.description.enabled=false
-Dmcp.steroid.storage.path=/mcp-run-dir/intellij/mcp-steroid

## Suppress the AI promo window (it fetches a remote URL on first run and, in Docker,
## times out after 480s — an 8-minute startup deadlock). Also cut the network timeout.
-Dllm.show.ai.promotion.window.on.start=false
-Didea.connection.timeout=3000

## License server for commercial IDEs (Rider, etc.)
-DJETBRAINS_LICENSE_SERVER=https://your-intellij-linense-server-or-ide-services

## Skip EULA, consent dialogs, trust prompts, and onboarding
-Didea.trust.disabled=true
-Djb.consents.confirmation.enabled=false
-Djb.privacy.policy.text=&amp;lt;!--999.999--&amp;gt;
-Djb.privacy.policy.ai.assistant.text=&amp;lt;!--999.999--&amp;gt;
-Dmarketplace.eula.reviewed.and.accepted=true
-Dwriterside.eula.reviewed.and.accepted=true
-Didea.initially.ask.config=never
-Dide.newUsersOnboarding=false
-Dnosplash=true

## Belt-and-suspenders vs the SDK-download consent modal that blocks JPS builds
-Dunknown.sdk=false
-Dunknown.sdk.auto=false
-Dunknown.sdk.modal.jps=false

## Never talk to the outside world about updates or telemetry
-Didea.suppress.statistics.report=true
-Didea.local.statistics.without.report=true
-Dfeature.usage.event.log.send.on.ide.close=false
-Dide.enable.notification.trace.data.sharing=false
-Didea.updates.url=http://127.0.0.1
-Dide.no.platform.update=true
-Dide.browser.disabled=true
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Each of those trust/consent/onboarding flags is necessary to avoid a modal dialog on the first start.
The good news – it works now, and it is a solvable grind.
My favorite: the AI promo window that appears on a
fresh IDE once made startup deadlock for a full eight minutes inside Docker before we found
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;llm.show.ai.promotion.window.on.start=false&lt;/code&gt;. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;unknown.sdk&lt;/code&gt; trio is a close second — a background build
task deciding it wants to download a JDK, and asking for consent with a modal, on a machine with no human.
A dialog nobody can click is a distributed systems problem now. And because every IDE release can add a new
first-run popup, the plugin ships an active dialog killer as the last line of defense.&lt;/p&gt;

&lt;p&gt;Config, logs, and system dirs are split deliberately: config and logs live on the bind-mounted run
directory, so after a failed CI run you get the full &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;idea.log&lt;/code&gt; and the exact IDE config as artifacts. The
system dir (caches, indexes) stays on the container filesystem, where it is fast and disposable. The MCP
Steroid plugin ZIP is unpacked into the plugins dir before launch, and a floating license server is
configured via &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-DJETBRAINS_LICENSE_SERVER&lt;/code&gt; so commercial IDEs like Rider skip the license dialog too —
ours points at an internal JetBrains server, you would bring your own here.&lt;/p&gt;

&lt;h3 id=&quot;the-consent-files&quot;&gt;The consent files&lt;/h3&gt;

&lt;p&gt;VM options cover most of the first-run popups, but not all. A few consents live in files the IDE reads
before any option is applied, so we write those ourselves before launch. IntelliJ records EULA and
privacy-policy acceptance both in the config dir and in the JVM user preferences under the home directory;
we pre-write them with a version far in the future (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;999.999&lt;/code&gt;) so the IDE always thinks the latest terms
are already accepted:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# inside the IDE config dir
options/other.xml
early-access-registry.txt
options/AIOnboardingPromoWindowAdvisor.xml

# inside the agent home (JVM userPrefs + consent options)
.java/.userPrefs/jetbrains/prefs.xml
.java/.userPrefs/jetbrains/privacy_policy/prefs.xml
.config/JetBrains/consentOptions/accepted
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The user-prefs files carry the future version — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;accepted_version=999.999&lt;/code&gt;,
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;privacy_policy_accepted_version=999.999&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;eua_accepted_version=999.999&lt;/code&gt;, and so on — while the config-dir
files switch off onboarding and the AI-promo window, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;consentOptions/accepted&lt;/code&gt; records the
usage-statistics choice. The exact list (there are a couple more, including an encoded Java-prefs path) is in
&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/intellij-downloader/src/buildsrc-shared/kotlin/com/jonnyzzz/mcpSteroid/ideDownloader/IdeStartupConfig.kt&quot;&gt;IdeStartupConfig.kt&lt;/a&gt;. It is not glamorous, but it is the difference between a clean start
and an IDE waiting on a dialog nobody will ever click.&lt;/p&gt;

&lt;p&gt;With all of that written to disk, we finally run the launcher — and now we wait.&lt;/p&gt;

&lt;h2 id=&quot;waiting-for-intellij-to-be-ready&quot;&gt;Waiting for IntelliJ to be ready&lt;/h2&gt;

&lt;p&gt;Since MCP Steroid is already a tool to access IDE internals, we use the tool itself
to monitor IDE readiness, for that we pass the following steps:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;poll the MCP Steroid port until it answers, then wait for the IDE window&lt;/li&gt;
  &lt;li&gt;register the JDKs in the IDE (essential for IntelliJ IDEA-based tools)&lt;/li&gt;
  &lt;li&gt;set the project SDK&lt;/li&gt;
  &lt;li&gt;trigger the Maven/Gradle import and wait for it to actually finish&lt;/li&gt;
  &lt;li&gt;wait for indexing and background progress to settle&lt;/li&gt;
  &lt;li&gt;resolve any remaining unknown SDKs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first step is a plain poll — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;curl&lt;/code&gt; the MCP port until it answers, while failing fast if
the IDE process dies or the log shows a startup failure (&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/test-integration/src/main/kotlin/com/jonnyzzz/mcpSteroid/integration/infra/mcp-steroid.kt&quot;&gt;mcp-steroid.kt&lt;/a&gt;). Every step after that is a Kotlin snippet
we send through the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;steroid_execute_code&lt;/code&gt; tool, which runs it inside the live IDE. Setting the project
SDK, for example, is just the IntelliJ API (&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/test-integration/src/main/kotlin/com/jonnyzzz/mcpSteroid/integration/infra/mcp-steroid-jdks.kt&quot;&gt;mcp-steroid-jdks.kt&lt;/a&gt;):&lt;/p&gt;

&lt;div class=&quot;language-kotlin highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;// runs inside the live IDE via steroid_execute_code&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;val&lt;/span&gt; &lt;span class=&quot;py&quot;&gt;sdk&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;ProjectJdkTable&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;getInstance&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;getSdksOfType&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nc&quot;&gt;JavaSdk&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;getInstance&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;())&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;first&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;it&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;name&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;21&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;||&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;it&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;name&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;temurin-21&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;||&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;it&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;name&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;corretto-21&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;nf&quot;&gt;edtWriteAction&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;JavaSdkUtil&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;applyJdkToProject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;project&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;sdk&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Triggering the import and, crucially, &lt;em&gt;waiting for it to finish&lt;/em&gt; is the same idea — subscribe to
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ProjectDataImportListener&lt;/code&gt; (Gradle) or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MavenImportListener&lt;/code&gt; (Maven), fire the refresh, and await
the finished event instead of a blind &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sleep&lt;/code&gt;. The last step waits for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Observation.awaitConfiguration&lt;/code&gt;
to drain, then for the IDE to reach &lt;em&gt;smart mode&lt;/em&gt; with zero running background progress indicators, held for
several quiet rounds — indexing arrives in waves as libraries and their sources download, so a one-shot
check returns too early. It also fails fast if progress freezes, so a stuck import does not hang to the
timeout. The import and settle logic is in &lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/test-integration/src/main/kotlin/com/jonnyzzz/mcpSteroid/integration/infra/mcp-steroid-import.kt&quot;&gt;mcp-steroid-import.kt&lt;/a&gt;.&lt;/p&gt;

&lt;h2 id=&quot;the-end-user-aidev-experience-in-the-mcp-steroid-project&quot;&gt;The end-user AI/Dev experience in the MCP Steroid project&lt;/h2&gt;

&lt;blockquote&gt;
  &lt;p&gt;An integration test must be designed to start from the IDE as a usual test.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I’ve seen many integration tests that were hard to start locally and only ever ran on CI. That makes
them unique, and maintained only by their authors. With Agentic AI and coding agents we can’t afford
that price – each test run must be as cheap and as fast as possible, because our AI Agents will call
such tests many times.&lt;/p&gt;

&lt;p&gt;From an AI Agent’s (or a developer’s) point of view, the whole machinery above collapses into one call:&lt;/p&gt;

&lt;div class=&quot;language-kotlin highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nd&quot;&gt;@Test&lt;/span&gt;
&lt;span class=&quot;nd&quot;&gt;@Timeout&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;value&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;15&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;unit&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;TimeUnit&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nc&quot;&gt;MINUTES&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;fun&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;`container&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;starts&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;and&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;IDE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;becomes&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;ready`&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;runWithCloseableStack&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;lifetime&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;-&amp;gt;&lt;/span&gt;
    &lt;span class=&quot;nc&quot;&gt;IntelliJContainer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;create&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;lifetime&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;IntelliJContainerOpts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;consoleTitle&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;ide-container&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;IntelliJContainer.create&lt;/code&gt; (&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/test-integration/src/main/kotlin/com/jonnyzzz/mcpSteroid/integration/infra/intelliJ-factory.kt&quot;&gt;intelliJ-factory.kt&lt;/a&gt;) builds the image, starts the
container, brings up Xvfb → fluxbox → video → screenshots → xterm, deploys the plugin and the test project,
launches the IDE, and blocks in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;waitForMcpReady()&lt;/code&gt;. That gets the IDE and the MCP server up; a test that
needs a fully imported project calls &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;waitForProjectReady()&lt;/code&gt; next — the JDK/import/indexing steps from above.
The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;lifetime&lt;/code&gt; stack unwinds all of it in reverse order — ffmpeg gets its SIGINT before
the container goes away, so the video file always has a valid trailer. After that, tests speak plain MCP over
HTTP: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;steroid_execute_code&lt;/code&gt; runs Kotlin inside the live IDE, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;steroid_list_windows&lt;/code&gt; reports which dialogs
are showing and whether indexing is in progress, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;steroid_take_screenshot&lt;/code&gt; returns what the agent sees.&lt;/p&gt;

&lt;h2 id=&quot;sending-the-live-video-to-a-browser-tab&quot;&gt;Sending the live video to a browser tab&lt;/h2&gt;

&lt;p&gt;Now the fun part. Running an integration test with GUI version of an IDE. Are you curious to see what’s inside?&lt;/p&gt;

&lt;p&gt;Screenshots came first — a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;scrot&lt;/code&gt; loop drops one PNG per second into the mounted volume,
and that alone made failures debuggable. But flipping through PNG files is archaeology. During long agent
runs we wanted to &lt;em&gt;watch&lt;/em&gt;, live, like over a colleague’s shoulder.&lt;/p&gt;

&lt;p&gt;The obvious answer is VNC, and we deliberately skipped it. VNC means a VNC server in the container, a client
on every developer machine, one more port, one more password. Our real requirement was much weaker:
one-directional, view-only, zero-install. Something a browser tab can show.&lt;/p&gt;

&lt;p&gt;So the pipeline is: &lt;strong&gt;ffmpeg grabs the X display into a growing fragmented MP4 file, and a tiny Node.js
server streams that file into a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;video&amp;gt;&lt;/code&gt; tag as it grows.&lt;/strong&gt; No VNC, no WebRTC, no websockets — HTTP chunked
transfer, invented last century, works great.&lt;/p&gt;

&lt;p&gt;The capture side (&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/test-integration/src/main/kotlin/com/jonnyzzz/mcpSteroid/integration/infra/xcvb-video.kt&quot;&gt;the capture + streaming driver&lt;/a&gt;) — 24 fps, and a keyframe every second
(&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-g 24&lt;/code&gt;), which is load-bearing, as we will see:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;ffmpeg &lt;span class=&quot;nt&quot;&gt;-nostdin&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-y&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;-f&lt;/span&gt; x11grab &lt;span class=&quot;nt&quot;&gt;-video_size&lt;/span&gt; 3840x2160 &lt;span class=&quot;nt&quot;&gt;-framerate&lt;/span&gt; 24 &lt;span class=&quot;nt&quot;&gt;-i&lt;/span&gt; :99 &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;-c&lt;/span&gt;:v libx264 &lt;span class=&quot;nt&quot;&gt;-preset&lt;/span&gt; ultrafast &lt;span class=&quot;nt&quot;&gt;-tune&lt;/span&gt; zerolatency &lt;span class=&quot;nt&quot;&gt;-crf&lt;/span&gt; 28 &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;-pix_fmt&lt;/span&gt; yuv420p &lt;span class=&quot;nt&quot;&gt;-r&lt;/span&gt; 24 &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;-g&lt;/span&gt; 24 &lt;span class=&quot;nt&quot;&gt;-keyint_min&lt;/span&gt; 24 &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;-x264-params&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;keyint&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;24:min-keyint&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;24:scenecut&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;0:rc-lookahead&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;0 &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;-movflags&lt;/span&gt; frag_keyframe+empty_moov+default_base_moof &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;-frag_duration&lt;/span&gt; 1000000 &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;-flush_packets&lt;/span&gt; 1 &lt;span class=&quot;nt&quot;&gt;-fflags&lt;/span&gt; +flush_packets &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    /tmp/video/recording.mp4
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Three details here cost us real debugging days, so read them as a checklist:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-movflags frag_keyframe+empty_moov&lt;/code&gt;&lt;/strong&gt; turns the output into a &lt;em&gt;fragmented&lt;/em&gt; MP4. A normal MP4 writes
its index (the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;moov&lt;/code&gt; atom) at the end, when encoding finishes — useless for a file you want to play
&lt;em&gt;while&lt;/em&gt; it is being written. Fragmented MP4 is self-describing from the first fragment.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The keyframe interval must be forced down.&lt;/strong&gt; x264’s default is one keyframe per ~250 frames, and each
fragment starts on a keyframe — so the browser would sit on a black screen 20–50 seconds waiting for the
first fragment to complete. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;keyint=24&lt;/code&gt; (one second) makes playback start almost immediately. We set both
the generic &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-g&lt;/code&gt; option and x264’s own &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;keyint&lt;/code&gt; params, so the encoder and the muxer agree on one-second
fragments.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;ffmpeg writes to a container-local path, not the mounted volume.&lt;/strong&gt; Docker Desktop’s virtiofs does not
flush file data to the host while the writing process keeps the file open — the host sees a zero-byte
file for the entire recording. So ffmpeg writes to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/tmp&lt;/code&gt; inside the container, and a one-second
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;rsync --inplace&lt;/code&gt; loop mirrors the growing file to the mounted volume. Closed files (like the scrot PNGs)
flush fine; it is specifically the always-open file that bites. The streaming server below reads the
same container-local file — the rsync copy exists only so the host keeps the recording as an artifact.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The serving side is a few hundred lines of Node.js baked into the image, more than half of it the dashboard
page (&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/test-integration/src/test/docker/ide-base/video-server.js&quot;&gt;video-server.js&lt;/a&gt;). The tail-follow core is under a hundred lines: keep the HTTP
response open, remember a byte position, and every ~100 ms send whatever the file grew by (slightly
simplified here):&lt;/p&gt;

&lt;div class=&quot;language-javascript highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;// GET /video.mp4 — stream the growing file with chunked transfer encoding&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;serveVideo&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;res&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;res&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;writeHead&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;200&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;Content-Type&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;video/mp4&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;Connection&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;keep-alive&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;position&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;scheduleNext&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;ms&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;setTimeout&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;sendChunk&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;ms&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

    &lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;sendChunk&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;if &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;res&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;destroyed&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
        &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;stat&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;getVideoStat&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;if &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;stat&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;scheduleNext&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;100&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

        &lt;span class=&quot;k&quot;&gt;if &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;stat&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;size&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;position&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;stream&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;fs&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;createReadStream&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;VIDEO_FILE&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;start&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;position&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;end&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;stat&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;size&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
            &lt;span class=&quot;nx&quot;&gt;stream&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;on&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;chunk&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;if &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;res&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;destroyed&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;res&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;write&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;chunk&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
            &lt;span class=&quot;nx&quot;&gt;stream&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;on&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;end&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;position&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;stat&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;size&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;scheduleNext&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;75&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;nf&quot;&gt;scheduleNext&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;100&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;nf&quot;&gt;sendChunk&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GET /&lt;/code&gt; serves a small dashboard page — a full-viewport &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;video autoplay muted playsinline&amp;gt;&lt;/code&gt; element pointed
at &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/video.mp4&lt;/code&gt;, the run ID in the header, and a status dot polling &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GET /status&lt;/code&gt; every 300 ms. When the
harness shuts the server down, the page notices — repeated &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/status&lt;/code&gt; failures flip the dot to “stopped” and
the tab closes itself. On the host side, the harness waits for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/status&lt;/code&gt; to answer and runs
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;open http://localhost:&amp;lt;mapped-8765&amp;gt;/&lt;/code&gt; — on macOS the tab appears by itself the moment the stream is alive.
On other platforms you follow the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VIDEO_DASHBOARD=&lt;/code&gt; line from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;session-info.txt&lt;/code&gt; in the run directory,
it is printed to the test output too:&lt;/p&gt;

&lt;div class=&quot;language-kotlin highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nf&quot;&gt;waitFor&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;35_000L&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;Video streaming server ready&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nf&quot;&gt;curl&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;$dashboardUrl/status&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;httpCode&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;200&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;isMacOs&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;ProcessBuilder&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;open&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;dashboardUrl&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;start&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;One browser caveat we learned the embarrassing way while recording the demo: &lt;strong&gt;Safari refused to play the
growing fragmented MP4&lt;/strong&gt; — it waited for the first frame forever, Chrome handled the same stream fine. If
you build this, test in Chrome first and save yourself an hour of blaming the encoder.&lt;/p&gt;

&lt;p&gt;The latency of the whole chain — X11 frame to browser pixel — comes to a couple of seconds in our runs. For
“is the agent stuck on a dialog or actually indexing”, that is more than enough.&lt;/p&gt;

&lt;h2 id=&quot;the-same-pipeline-on-camera&quot;&gt;The same pipeline, on camera&lt;/h2&gt;

&lt;p&gt;The &lt;a href=&quot;https://youtu.be/rA-pBJR9wh0&quot;&gt;demo video&lt;/a&gt; at the top of this post is this exact infrastructure, filmed. It runs the
pipeline one level deeper: a macOS VM (via &lt;a href=&quot;https://tart.run&quot;&gt;Tart&lt;/a&gt;) runs IntelliJ, which runs the
integration test from the gutter, which boots the Docker container with the &lt;em&gt;inner&lt;/em&gt; IDE, and Chrome pops up
with the live dashboard streaming that inner IDE while a Claude agent drives its debugger. An IDE in a
container inside a VM, watched from a browser tab — and every layer is exactly the code above.&lt;/p&gt;

&lt;p&gt;Producing a &lt;em&gt;readable&lt;/em&gt; video needed only three small overrides on top of the stock pipeline: the inner
display drops from 4K to 1280×720 (so IDE fonts map ~1:1 onto video pixels), CRF goes from 28 to 20, and the
x264 preset from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ultrafast&lt;/code&gt; to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;veryfast&lt;/code&gt;. The demo checkout carries a small local patch that reads these
from environment variables — candidates to land on main as proper knobs, not there yet. Upstream CI still
runs the defaults: same code path exercised daily, slightly nicer pixels for the camera.&lt;/p&gt;

&lt;h2 id=&quot;re-implementing-this-for-your-own-gui-tests&quot;&gt;Re-implementing this for your own GUI tests&lt;/h2&gt;

&lt;p&gt;Nothing above is IntelliJ-specific. The recipe, condensed:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Base image:&lt;/strong&gt; Debian/Ubuntu + &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;xvfb&lt;/code&gt;, a small WM (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fluxbox&lt;/code&gt;), &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;x11-utils&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;xdotool&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;scrot&lt;/code&gt;,
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ffmpeg&lt;/code&gt;, fonts, and your app’s native GUI libraries. End it with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CMD [&quot;sleep&quot;, &quot;infinity&quot;]&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Own the startup order from the outside.&lt;/strong&gt; Start Xvfb via &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;docker exec&lt;/code&gt;, poll &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;xdpyinfo&lt;/code&gt; until the
display answers, then the WM, then capture, then your application. Register cleanups in reverse.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Disarm every first-run dialog&lt;/strong&gt; your application can show. Hunt them down one by one; each is a hang in
CI. Budget real time for this — it took us the longest.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Health-check with fail-fast branches:&lt;/strong&gt; process alive, log clean, endpoint answering. Never poll for
success alone.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Screenshots first&lt;/strong&gt; (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;scrot&lt;/code&gt; in a loop — trivial and already saves you), &lt;strong&gt;video second&lt;/strong&gt;: ffmpeg
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;x11grab&lt;/code&gt; → fragmented MP4 with 1-second keyframes → tail-follow HTTP server → &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;video&amp;gt;&lt;/code&gt; tag. Write to a
container-local path and rsync to the volume if you are on Docker Desktop.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Publish three ports:&lt;/strong&gt; your app’s API, the video dashboard, and a debugger.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We drive all of this from Kotlin, but it is nothing a shell script can’t do. The whole sequence, wired
together — the two background loops (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;scrot&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;rsync&lt;/code&gt;) are the ones the sections above only described:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nb&quot;&gt;set&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-euo&lt;/span&gt; pipefail
&lt;span class=&quot;nv&quot;&gt;cid&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;$(&lt;/span&gt;docker run &lt;span class=&quot;nt&quot;&gt;-d&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--rm&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--init&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-p&lt;/span&gt; 8765 my-gui-image&lt;span class=&quot;si&quot;&gt;)&lt;/span&gt;   &lt;span class=&quot;c&quot;&gt;# the &quot;sleep infinity&quot; room&lt;/span&gt;
x&lt;span class=&quot;o&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;{&lt;/span&gt; docker &lt;span class=&quot;nb&quot;&gt;exec&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-e&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;DISPLAY&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;:99 &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$cid&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$@&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;}&lt;/span&gt;          &lt;span class=&quot;c&quot;&gt;# every command carries DISPLAY&lt;/span&gt;
x &lt;span class=&quot;nb&quot;&gt;mkdir&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-p&lt;/span&gt; /tmp/video /tmp/shots

x Xvfb :99 &lt;span class=&quot;nt&quot;&gt;-screen&lt;/span&gt; 0 1280x720x24 &lt;span class=&quot;nt&quot;&gt;-ac&lt;/span&gt; &amp;amp;                   &lt;span class=&quot;c&quot;&gt;# 1. virtual display&lt;/span&gt;
x bash &lt;span class=&quot;nt&quot;&gt;-c&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;for i in $(seq 1 150); do xdpyinfo -display :99 &amp;gt;/dev/null 2&amp;gt;&amp;amp;1 &amp;amp;&amp;amp; exit 0; sleep 0.1; done; exit 1&apos;&lt;/span&gt;
x fluxbox &amp;amp;                                              &lt;span class=&quot;c&quot;&gt;# 2. window manager&lt;/span&gt;
x xclock &amp;amp;                                               &lt;span class=&quot;c&quot;&gt;# 3. your GUI app (stand-in here)&lt;/span&gt;
x bash &lt;span class=&quot;nt&quot;&gt;-c&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;while :; do scrot /tmp/shots/$(date +%s).png; sleep 1; done&apos;&lt;/span&gt; &amp;amp;   &lt;span class=&quot;c&quot;&gt;# screenshots&lt;/span&gt;
x ffmpeg &lt;span class=&quot;nt&quot;&gt;-f&lt;/span&gt; x11grab &lt;span class=&quot;nt&quot;&gt;-video_size&lt;/span&gt; 1280x720 &lt;span class=&quot;nt&quot;&gt;-framerate&lt;/span&gt; 24 &lt;span class=&quot;nt&quot;&gt;-i&lt;/span&gt; :99 &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;-c&lt;/span&gt;:v libx264 &lt;span class=&quot;nt&quot;&gt;-preset&lt;/span&gt; ultrafast &lt;span class=&quot;nt&quot;&gt;-tune&lt;/span&gt; zerolatency &lt;span class=&quot;nt&quot;&gt;-crf&lt;/span&gt; 28 &lt;span class=&quot;nt&quot;&gt;-pix_fmt&lt;/span&gt; yuv420p &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;-g&lt;/span&gt; 24 &lt;span class=&quot;nt&quot;&gt;-keyint_min&lt;/span&gt; 24 &lt;span class=&quot;nt&quot;&gt;-x264-params&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;keyint&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;24:min-keyint&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;24:scenecut&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;0:rc-lookahead&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;0 &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;-movflags&lt;/span&gt; frag_keyframe+empty_moov+default_base_moof &lt;span class=&quot;nt&quot;&gt;-frag_duration&lt;/span&gt; 1000000 &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;-flush_packets&lt;/span&gt; 1 &lt;span class=&quot;nt&quot;&gt;-fflags&lt;/span&gt; +flush_packets /tmp/video/recording.mp4 &amp;amp;   &lt;span class=&quot;c&quot;&gt;# 4. capture&lt;/span&gt;
x bash &lt;span class=&quot;nt&quot;&gt;-c&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;while :; do rsync --inplace /tmp/video/recording.mp4 /mcp-run-dir/; sleep 1; done&apos;&lt;/span&gt; &amp;amp;
x node /usr/local/bin/video-server.js /tmp/video/recording.mp4 8765 &amp;amp;   &lt;span class=&quot;c&quot;&gt;# 5. stream&lt;/span&gt;
open &lt;span class=&quot;s2&quot;&gt;&quot;http://localhost:&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;$(&lt;/span&gt;docker port &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$cid&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt; 8765/tcp | &lt;span class=&quot;nb&quot;&gt;cut&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-d&lt;/span&gt;: &lt;span class=&quot;nt&quot;&gt;-f2&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;/&quot;&lt;/span&gt;        &lt;span class=&quot;c&quot;&gt;# 6. watch&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The parts I would warn you about, honestly: the vmoptions dialog-hunting never truly ends — every IDE
release can add a new first-run popup, and we find out when CI hangs. The 4K Xvfb display makes ffmpeg eat a
steady chunk of CPU; on weak CI workers you may want 1080p. And the whole &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;docker exec&lt;/code&gt; orchestration is more
code than a docker-compose file — the flexibility is worth it for us, but it is a real trade-off.&lt;/p&gt;

&lt;h2 id=&quot;try-it-steal-it-improve-it&quot;&gt;Try it, steal it, improve it&lt;/h2&gt;

&lt;p&gt;Everything in this post is in the open &lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid&quot;&gt;jonnyzzz/mcp-steroid&lt;/a&gt; repository — the
&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/test-integration/src/test/docker/ide-base/Dockerfile&quot;&gt;base&lt;/a&gt; and &lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/test-integration/src/test/docker/ide-agent/Dockerfile&quot;&gt;agent&lt;/a&gt; Dockerfiles, the &lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/test-integration/src/main/kotlin/com/jonnyzzz/mcpSteroid/integration/infra/xcvb.kt&quot;&gt;X server driver&lt;/a&gt;, the
&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/test-integration/src/main/kotlin/com/jonnyzzz/mcpSteroid/integration/infra/xcvb-video.kt&quot;&gt;video pipeline&lt;/a&gt;, and the &lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/test-integration/src/test/docker/ide-base/video-server.js&quot;&gt;video-server.js&lt;/a&gt; you can lift wholesale into your
own project. To see it live:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;./gradlew :test-integration:test &lt;span class=&quot;nt&quot;&gt;--tests&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;*IntelliJContainerTest.container starts and IDE becomes ready&apos;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;On macOS a browser tab opens by itself on a live IDE booting inside a container; elsewhere, follow the
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VIDEO_DASHBOARD=&lt;/code&gt; URL from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;session-info.txt&lt;/code&gt; in the run directory.&lt;/p&gt;

&lt;p&gt;If you build a variant of this for your own GUI testing — a different app, a different streaming trick, or
you got WebRTC working where we settled for chunked HTTP — I genuinely want to hear about it. Tell me on
&lt;a href=&quot;https://www.linkedin.com/in/jonnyzzz/&quot;&gt;LinkedIn&lt;/a&gt; or &lt;a href=&quot;https://twitter.com/jonnyzzz&quot;&gt;Twitter&lt;/a&gt;, or open an issue on the repo. And if the demo video is what
brought you here: that is this same pipeline, just pointed at a camera.&lt;/p&gt;</content>

  
  
  
  
  

    <author>
      <name>Eugene Petrenko</name>
    </author>

  
    <category term="mcp" />
  
    <category term="mcp-steroid" />
  
    <category term="ai-agents" />
  
    <category term="ai-coding" />
  
    <category term="intellij" />
  
    <category term="docker" />
  
    <category term="testing" />
  
    <category term="devrig" />
  
    <summary type="html">Evals that boot a full IntelliJ IDEA — pixels, dialogs, indexing and all — inside a Docker container. Xvfb provides the display, fluxbox keeps the windows in place, ffmpeg records everything, and a tiny Node.js server streams the live video straight into a browser tab on your machine. Here is the whole recipe, so you can build your own.</summary>
  
  </entry>
  
  <entry>
    <title type="html">Inside the Git Hooks: Tagging Every AI Agent Commit (Part 3)</title>
    <link href="https://jonnyzzz.com/blog/2026/06/26/inside-the-git-hooks-for-ai-agents/" rel="alternate" type="text/html" title="Inside the Git Hooks: Tagging Every AI Agent Commit (Part 3)" />
    <published>2026-06-26T00:00:00+00:00</published>
    <updated>2026-06-26T00:00:00+00:00</updated>
    <id>/blog/2026/06/26/inside-the-git-hooks-for-ai-agents</id>
    <content type="html" xml:base="https://jonnyzzz.com/blog/2026/06/26/inside-the-git-hooks-for-ai-agents/">&lt;p&gt;This is the third part of the series. &lt;a href=&quot;/blog/2026/06/20/auditing-git-for-ai-agents/&quot;&gt;Part 1&lt;/a&gt;
laid out the approach — keep the keys away from the agent, a git proxy as the hard boundary, git hooks as
the lighter alternative. &lt;a href=&quot;/blog/2026/06/22/inside-the-git-proxy-for-ai-agents/&quot;&gt;Part 2&lt;/a&gt; was the
build of the proxy. This one is the build of the &lt;strong&gt;hooks&lt;/strong&gt;: the in-container layer you reach for when you
own the image but not the network path.&lt;/p&gt;

&lt;p&gt;The job is the same two questions — &lt;em&gt;which commits did the agent produce, and did it touch only what we
allowed?&lt;/em&gt; — answered from inside the sandbox instead of on the wire. A reminder from Part 1: this is
&lt;strong&gt;best-effort observability, not a security boundary&lt;/strong&gt;. A sufficiently determined agent in its own sandbox
can defeat any of it. We build it anyway, because for the overwhelmingly common case where the agent is
just doing its work, it gives you clean provenance for almost nothing — and the proxy is still there as the
floor.&lt;/p&gt;

&lt;p&gt;The plan: stamp a session-id &lt;a href=&quot;https://git-scm.com/docs/git-interpret-trailers&quot;&gt;trailer&lt;/a&gt; on every commit as
it is written, keep that tag alive across amend/rebase/squash, and on push capture a restorable copy of
what is going out. Four hooks do it — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;prepare-commit-msg&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;commit-msg&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;post-rewrite&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pre-push&lt;/code&gt; — wired
in through &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;core.hooksPath&lt;/code&gt;. Let me walk the load-bearing parts.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;wiring-hijack-every-hook-without-breaking-the-repos-own&quot;&gt;Wiring: Hijack Every Hook Without Breaking the Repo’s Own&lt;/h2&gt;

&lt;p&gt;Part 1 covered the install: bake the hooks into the image root-owned and read-only, and point
&lt;a href=&quot;https://git-scm.com/docs/git-config&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;core.hooksPath&lt;/code&gt;&lt;/a&gt; at them in the system config so &lt;em&gt;every&lt;/em&gt; repository
the agent touches — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/workspace&lt;/code&gt; and any clone it makes later — runs them. But &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;core.hooksPath&lt;/code&gt; is greedy:
it &lt;strong&gt;replaces&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;$GIT_DIR/hooks&lt;/code&gt; entirely. The moment you set it, a project’s own &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.git/hooks/pre-commit&lt;/code&gt;
stops firing. That is a regression you do not want to ship to developers who rely on their own hooks.&lt;/p&gt;

&lt;p&gt;So the first thing each of our hooks does is run the repository’s own hook of the same name, honor its exit
code, and only then do our work:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;run_local_hook(name, args, stdin):
    # resolve the MAIN repo&apos;s .git even from a linked worktree, and as an absolute path
    git_dir = git rev-parse --path-format=absolute --git-common-dir
    local   = &quot;$git_dir/hooks/$name&quot;
    if is_executable(local):
        exec local with (args, stdin)   # its exit code becomes ours — honor a rejection
    # else: nothing to chain, carry on
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Two traps hide in those four lines. First, &lt;strong&gt;recursion&lt;/strong&gt;: if you resolve the hook path through
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;core.hooksPath&lt;/code&gt; you point straight back at yourself and loop forever — so resolve the &lt;em&gt;literal&lt;/em&gt;
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;$GIT_DIR/hooks&lt;/code&gt;, never the configured path. Second, &lt;strong&gt;worktrees&lt;/strong&gt;: a linked worktree has its own private
gitdir, but the conventional hooks live in the main repo’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.git&lt;/code&gt;. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--git-common-dir&lt;/code&gt; resolves to the main
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.git&lt;/code&gt; from anywhere, so a hook running inside a worktree still finds the project’s real hooks.&lt;/p&gt;

&lt;p&gt;For hooks that receive data on stdin (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pre-push&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;post-rewrite&lt;/code&gt;), there is a third trap: you need that
stdin &lt;em&gt;twice&lt;/em&gt; — once for your own logic, once to replay to the local hook — and command substitution
silently strips trailing newlines and mangles blank lines. So buffer stdin to a temp file and replay the
&lt;strong&gt;exact bytes&lt;/strong&gt;:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;stdin_buf = mktemp(); read all of stdin into stdin_buf
... parse stdin_buf for our own work ...
run_local_hook(name, args, stdin=stdin_buf)   # byte-for-byte, not via $(...)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;One more constraint shaped the whole library: it runs on the macOS dev host &lt;em&gt;and&lt;/em&gt; the Linux agent image,
so it is written for &lt;strong&gt;bash 3.2&lt;/strong&gt; — no associative arrays, no namerefs. Portability is a feature when your
hooks have to behave identically in two very different places&lt;/p&gt;

&lt;h2 id=&quot;tagging-one-trailer-re-collected-every-time&quot;&gt;Tagging: One Trailer, Re-Collected Every Time&lt;/h2&gt;

&lt;p&gt;The tag itself is a git &lt;em&gt;trailer&lt;/em&gt; — a structured &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Key: value&lt;/code&gt; line at the foot of the commit message, the
same machinery as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Signed-off-by&lt;/code&gt;:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Agent-Session: 2026-06-20T11:04:18Z-7f3a9c
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The naive approach — append that line to the message — falls apart the first time someone squashes three
commits or rebases a branch. Trailers get duplicated, buried mid-message, or dropped. The robust approach
is to treat the trailer block as something you &lt;strong&gt;recompute&lt;/strong&gt; on every message, not something you append
once:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;apply_tag(msgfile, sid):
    existing = all &quot;Agent-Session:&quot; values already in the message
    from_urls = session ids parsed out of any orchestrator URLs pasted in the body
    all = dedup(existing + from_urls + [sid])        # order-preserving, drop blanks
    body = message with the raw Agent-Session lines AND those URLs stripped out
    # re-emit ONE trailing trailer block, deduped, via the real tool
    git interpret-trailers --if-exists addIfDifferent --if-missing add \
        (one --trailer &quot;Agent-Session: &amp;lt;v&amp;gt;&quot; per v in all) &amp;lt;&amp;lt;&amp;lt; body  &amp;gt; msgfile
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Three things matter here. We let &lt;a href=&quot;https://git-scm.com/docs/git-interpret-trailers&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;git interpret-trailers&lt;/code&gt;&lt;/a&gt;
place the block — it knows where the trailer section ends and how to keep it well-formed, which hand-rolled
string-appending never gets right. We &lt;strong&gt;re-collect&lt;/strong&gt; existing values before re-emitting, so a squash of
commits from different sessions &lt;em&gt;preserves every distinct&lt;/em&gt; session id and &lt;em&gt;dedups identical&lt;/em&gt; ones — the
audit trail survives history rewriting. And we also harvest ids out of any session URL the agent pasted
into the message body, so a commit references its origin even when the trailer was never added by hand.&lt;/p&gt;

&lt;p&gt;This runs in &lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;prepare-commit-msg&lt;/code&gt;&lt;/strong&gt;, the primary tagging point. It fires for commit, amend, merge,
cherry-pick and every rebase reword/squash/fixup path — and, as Part 1 noted, it is the one commit-time
hook that &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;git commit --no-verify&lt;/code&gt; does &lt;strong&gt;not&lt;/strong&gt; suppress, which is exactly why the tag lives there.&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# prepare-commit-msg
run_local_hook(&quot;prepare-commit-msg&quot;, argv, stdin)  || exit with its code
apply_tag(msgfile, resolve_sid())   || warn &quot;tagging failed (continuing)&quot;
exit 0
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;commit-msg&lt;/code&gt;&lt;/strong&gt; is a thin backstop: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;prepare-commit-msg&lt;/code&gt; already covers every path, so this one only acts
if the final message somehow has no tag — a guard against a future regression, never the normal route. It
never blocks the commit&lt;/p&gt;

&lt;p&gt;Where does the session id come from? From a fixed file the environment writes once per session — covered in
&lt;a href=&quot;/blog/2026/06/20/auditing-git-for-ai-agents/&quot;&gt;Part 1&lt;/a&gt;. The resolver reads the first line of
that file and sanitizes it into a trailer-safe token: keep the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:&lt;/code&gt; so an ISO-8601 timestamp survives, drop
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/&lt;/code&gt; so it can never break a path you later build from the id. No file, or a blank one, means “skip
tagging” — the hooks never block on a missing id.&lt;/p&gt;

&lt;h2 id=&quot;provenance-across-rewrites&quot;&gt;Provenance Across Rewrites&lt;/h2&gt;

&lt;p&gt;When the agent amends or rebases, commit hashes change, and a tag-by-hash audit trail would lose the
thread. &lt;a href=&quot;https://git-scm.com/docs/githooks&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;post-rewrite&lt;/code&gt;&lt;/a&gt; closes that gap: git hands it
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;old-sha&amp;gt; &amp;lt;new-sha&amp;gt;&lt;/code&gt; pairs on stdin (space-separated — &lt;em&gt;not&lt;/em&gt; tab, a detail that has bitten many a hook),
and we append them to a per-session provenance log before delegating to the repo’s own hook:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# post-rewrite  (stdin: &quot;&amp;lt;old&amp;gt; &amp;lt;new&amp;gt;&quot; per line)
buffer stdin
for &quot;&amp;lt;old&amp;gt; &amp;lt;new&amp;gt;&quot; in stdin:
    append &quot;&amp;lt;timestamp&amp;gt;  &amp;lt;mode&amp;gt;  &amp;lt;old&amp;gt;  &amp;lt;new&amp;gt;&quot;  to  $ARTIFACTS/_provenance/&amp;lt;sid&amp;gt;.log
run_local_hook(&quot;post-rewrite&quot;, argv, stdin=buffer)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Now an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;old → new&lt;/code&gt; chain is recoverable even after the local history has been reshaped, and that log later
travels with the push capture&lt;/p&gt;

&lt;h2 id=&quot;enforcement-on-push-re-tag-or-bail-out&quot;&gt;Enforcement on Push: Re-Tag, or Bail Out&lt;/h2&gt;

&lt;p&gt;The commit hooks are easy to bypass — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--no-verify&lt;/code&gt;, a tool that writes commits directly, an agent in a
hurry. &lt;a href=&quot;https://git-scm.com/docs/githooks&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pre-push&lt;/code&gt;&lt;/a&gt; is the net that catches untagged commits before they
leave the box. For each pushed branch it works out which commits are actually new, and if any of them lack
the tag, it rewrites them and &lt;strong&gt;aborts the push&lt;/strong&gt; so the corrected commits go out on the retry — what you
might call “amend on push”.&lt;/p&gt;

&lt;p&gt;The first subtlety is &lt;strong&gt;bounding the range&lt;/strong&gt;. git hands &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pre-push&lt;/code&gt; a line per ref —
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;local-ref&amp;gt; &amp;lt;local-sha&amp;gt; &amp;lt;remote-ref&amp;gt; &amp;lt;remote-sha&amp;gt;&lt;/code&gt; — and the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;remote-sha&lt;/code&gt; is the authoritative “what the
server already has” boundary. Except when it is all-zero, which means a &lt;em&gt;brand-new&lt;/em&gt; branch: there is no
remote tip, and if you naively take “everything reachable from local-sha” you will try to re-tag and
capture the entire history of the repo. So for a new branch, fall back to the merge-base against the
remote’s existing tracking tips; if even that cannot be found, do not guess — skip:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;push_base(local_sha, remote_sha, remote):
    if remote_sha is not all-zero:  return remote_sha          # normal update
    tips = object names of refs/remotes/&amp;lt;remote&amp;gt;/*
    if no tips:  return &quot;&quot;                                     # cannot bound — caller must skip
    return git merge-base local_sha &amp;lt;tips...&amp;gt;                  # new branch: nearest known ancestor
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;(A small landmine worth flagging: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;git merge-base --remotes&lt;/code&gt; is &lt;strong&gt;not&lt;/strong&gt; a thing. You have to enumerate the
tracking-tip object names and pass them explicitly.)&lt;/p&gt;

&lt;p&gt;With a base in hand, the enforcement pass is straightforward — only rewrite real &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;refs/heads/*&lt;/code&gt; updates
(skip deletions, and skip tags, since rewriting a tag ref would clobber it):&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# pre-push enforcement
need_retry = 0
for &quot;&amp;lt;localref&amp;gt; &amp;lt;localsha&amp;gt; _ &amp;lt;remotesha&amp;gt;&quot; in stdin:
    skip if deletion, or localref not under refs/heads/
    base = push_base(localsha, remotesha, remote)   ; skip if empty
    if any commit in base..localsha has no Agent-Session trailer and we have a sid:
        retag_range(base, localref, localsha, sid)   # checkout-free, see below
        need_retry = 1
if need_retry: exit 1   # &quot;re-tagged commits; please re-run your push&quot;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Checking a commit for the trailer is a one-liner thanks to git’s own formatter —
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;git show -s --format=&apos;%(trailers:key=Agent-Session,valueonly)&apos;&lt;/code&gt; prints the value or nothing.&lt;/p&gt;

&lt;h3 id=&quot;re-tagging-without-a-checkout&quot;&gt;Re-Tagging Without a Checkout&lt;/h3&gt;

&lt;p&gt;Rewriting commits inside a hook is where most implementations reach for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;git rebase&lt;/code&gt; — and inherit all of
rebase’s hazards: it checks out, it fights a dirty working tree, it can drop empty commits, and a failure
leaves rebase state lying around. We avoid all of it with &lt;a href=&quot;https://git-scm.com/docs/git-commit-tree&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;git
commit-tree&lt;/code&gt;&lt;/a&gt;, which builds a commit object from a tree with no
working-tree involvement at all:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;retag_range(boundary, tipref, tip, sid):
    map = {}                                  # old sha -&amp;gt; new sha
    for c in  git rev-list --reverse --topo-order  boundary..tip:     # parents before children
        tree     = c^{tree}
        newparents = [ map[p] or p  for p in parents(c) ]             # remap to rewritten parents
        msg      = apply_tag(message_of(c), sid)
        newc     = commit-tree tree (-p each newparent) -F msg \
                       with c&apos;s original author/committer name+email+DATE preserved
        map[c]   = newc
    git update-ref tipref  map[tip]           # move the branch to the rewritten tip
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Two details make it correct. &lt;strong&gt;Topological order, parents first&lt;/strong&gt;, with a parent-remap table: when you
rewrite a commit its children must be re-pointed at the &lt;em&gt;new&lt;/em&gt; parent, or you fork history. And you must
&lt;strong&gt;preserve the original author and committer identity and dates&lt;/strong&gt; (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GIT_AUTHOR_DATE&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GIT_COMMITTER_DATE&lt;/code&gt;,
and the name/email pairs) — otherwise the “re-tag” silently rewrites timestamps and authorship, which is
both wrong and alarming in a blame view. Because nothing is ever checked out, a dirty tree is fine and an
empty commit is fine; there is no rebase state to clean up if a step fails. This is the same
“rewrite the whole pushed suffix in topological order” idea Part 2 mentioned for the proxy — here is the
actual mechanism.&lt;/p&gt;

&lt;p&gt;This only makes sense for a real branch ref. A delete, a detached-HEAD push, or a raw-SHA push has no
branch to move, so the hook skips the rewrite there rather than do something surprising.&lt;/p&gt;

&lt;h2 id=&quot;capture-on-push-a-restorable-copy-never-blocking&quot;&gt;Capture on Push: a Restorable Copy, Never Blocking&lt;/h2&gt;

&lt;p&gt;After enforcement, a second pass captures what is going out — and unlike enforcement, capture &lt;strong&gt;never&lt;/strong&gt;
blocks a push. If any of it fails, the developer’s push still succeeds; the failure is logged and that is
all. Each piece is bounded to the push range (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;base..local-sha&lt;/code&gt;), and if the range is empty or cannot be
bounded, the whole capture is skipped rather than writing a degenerate artifact or dumping full history.&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# pre-push capture (per pushed ref, in a subshell so a failure is contained)
base  = push_base(...)            ; skip if empty
range = base..localsha
skip if  git rev-list --count range  == 0          # nothing new

git bundle create   bundle.bundle  localref --not base                 # restorable clone source
git rev-list --objects range | git pack-objects --no-thin --stdout  &amp;gt; pack.pack
git format-patch -o patches/  range                                    # human-readable, per commit
git rev-list --merges range  &amp;gt; merges.txt                              # format-patch omits merges
copy the provenance log + write metadata.json
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;A few choices worth calling out:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;The non-thin pack.&lt;/strong&gt; Here is the contrast with Part 2 that I like. The proxy could not pass a flag to
the agent’s git — it had to &lt;em&gt;advertise&lt;/em&gt; the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;no-thin&lt;/code&gt; capability and let the client comply. In a hook,
&lt;strong&gt;you are the client&lt;/strong&gt;, so you just say &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;git pack-objects --no-thin&lt;/code&gt; and get a self-contained pack you can
re-index anywhere. Same goal, opposite side of the wire.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Stream with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--stdout&lt;/code&gt;.&lt;/strong&gt; Letting &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pack-objects&lt;/code&gt; write its own file drops it in the repo and then
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;rename()&lt;/code&gt;s it into place — which fails with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;EXDEV&lt;/code&gt; when your artifacts directory is on a different mount
than the repo (it usually is). Streaming to stdout and redirecting sidesteps the cross-mount rename
entirely.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;A bundle &lt;em&gt;and&lt;/em&gt; patches &lt;em&gt;and&lt;/em&gt; a pack.&lt;/strong&gt; They serve different masters: the
&lt;a href=&quot;https://git-scm.com/docs/git-bundle&quot;&gt;bundle&lt;/a&gt; is a one-file restorable clone source, the patches are
what a human actually reads in review, and the pack is the raw objects. Cheap to produce, and you will be
glad of all three the day you need to reconstruct a branch the agent deleted.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;metadata.json&lt;/code&gt; built with a JSON tool&lt;/strong&gt;, not string interpolation — branch names and URLs contain
characters that turn hand-built JSON into a parsing bug. Let the tool escape them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything lands under one auto-uploaded artifacts directory, and capture directories are named flat (slash
in a branch name becomes a dash) so a branch like &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;feature/x&lt;/code&gt; does not blow up the path.&lt;/p&gt;

&lt;h2 id=&quot;the-limits-again--briefly&quot;&gt;The Limits, Again — Briefly&lt;/h2&gt;

&lt;p&gt;Everything in &lt;a href=&quot;/blog/2026/06/20/auditing-git-for-ai-agents/&quot;&gt;Part 1’s “hooks can be broken”&lt;/a&gt;
still holds and I will not repeat it in full: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;core.hooksPath&lt;/code&gt; can be overridden from a per-user config or
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-c&lt;/code&gt; on the command line, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;git push --no-verify&lt;/code&gt; skips &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pre-push&lt;/code&gt;, and an agent with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sudo&lt;/code&gt; can simply
delete the hook directory. None of that is a defect — it is the definition of an in-sandbox layer. The
hooks make the honest, common case observable and cheap; when you need a &lt;em&gt;guarantee&lt;/em&gt;, that is the proxy’s
job, because the proxy is the one control the agent cannot reach.&lt;/p&gt;

&lt;p&gt;There is also a softer failure mode the hooks lean into deliberately: they &lt;strong&gt;warn rather than block&lt;/strong&gt;.
Every hook writes its warnings both to stderr and to a log in the artifacts tree, so a tagging or capture
hiccup is visible after the fact without ever having stopped the developer’s commit or push. For
observability, a tool that breaks the thing it observes is worse than no tool.&lt;/p&gt;

&lt;h2 id=&quot;where-this-sits-in-the-series&quot;&gt;Where This Sits in the Series&lt;/h2&gt;

&lt;p&gt;Two builds, one goal. The &lt;a href=&quot;/blog/2026/06/22/inside-the-git-proxy-for-ai-agents/&quot;&gt;proxy&lt;/a&gt; is the
wall outside the sandbox that the agent cannot route around; these hooks are the diligent assistant inside
it. Run the proxy when you control the network, run the hooks when you control the image, and run both when
you can — the hooks enrich the common case while the proxy guarantees the floor.&lt;/p&gt;

&lt;p&gt;And both feed the same thing: a complete, attributed record of what every agent did to the code. That
record is not just an audit trail — it is the raw material for measuring a fleet of agents and making the
next run better, which is the larger system I am building now and will write about separately.&lt;/p&gt;

&lt;p&gt;If you implement a version of this — a smarter tagging scheme, a cheaper capture, a hook trick I missed — I
would love to hear it on &lt;a href=&quot;https://www.linkedin.com/in/jonnyzzz/&quot;&gt;LinkedIn&lt;/a&gt; or
&lt;a href=&quot;https://twitter.com/jonnyzzz&quot;&gt;Twitter&lt;/a&gt;. And if your team is wiring AI Agents into real infrastructure and
wants a hand on the boundary and the audit trail, that is exactly the kind of problem I enjoy — reach out
and let’s scope it together.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;git-documentation-used-in-this-post&quot;&gt;Git Documentation Used in This Post&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://git-scm.com/docs/githooks&quot;&gt;githooks&lt;/a&gt; — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;prepare-commit-msg&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;commit-msg&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;post-rewrite&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pre-push&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://git-scm.com/docs/git-config&quot;&gt;git-config&lt;/a&gt; — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;core.hooksPath&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://git-scm.com/docs/git-interpret-trailers&quot;&gt;git-interpret-trailers&lt;/a&gt; — recomputing the trailer block&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://git-scm.com/docs/git-commit-tree&quot;&gt;git-commit-tree&lt;/a&gt; — checkout-free commit rewriting&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://git-scm.com/docs/git-rev-list&quot;&gt;git-rev-list&lt;/a&gt; and &lt;a href=&quot;https://git-scm.com/docs/git-rev-parse&quot;&gt;git-rev-parse&lt;/a&gt; — ranges, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--git-common-dir&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://git-scm.com/docs/git-bundle&quot;&gt;git-bundle&lt;/a&gt;, &lt;a href=&quot;https://git-scm.com/docs/git-pack-objects&quot;&gt;git-pack-objects&lt;/a&gt;, &lt;a href=&quot;https://git-scm.com/docs/git-format-patch&quot;&gt;git-format-patch&lt;/a&gt; — the capture artifacts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Related reading on this blog:
&lt;a href=&quot;/blog/2026/06/20/auditing-git-for-ai-agents/&quot;&gt;What Did the Agent Just Push? (Part 1)&lt;/a&gt;,
&lt;a href=&quot;/blog/2026/06/22/inside-the-git-proxy-for-ai-agents/&quot;&gt;Inside the Git Proxy (Part 2)&lt;/a&gt;,
&lt;a href=&quot;/blog/2026/02/02/git-fork-pattern/&quot;&gt;Git Fork Pattern&lt;/a&gt;, and
&lt;a href=&quot;/blog/2019/04/09/git-replication/&quot;&gt;Efficient Git Replication&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</content>

  
  
  
  
  

    <author>
      <name>Eugene Petrenko</name>
    </author>

  
    <category term="ai-agents" />
  
    <category term="ai-coding" />
  
    <category term="git" />
  
    <category term="infrastructure" />
  
    <category term="workflow" />
  
    <summary type="html">Part 3 — the build for the lighter solution. How a set of git hooks stamps a session id on every commit an AI Agent makes, survives squash and rebase, and captures each push — all best-effort, all inside the sandbox.</summary>
  
  </entry>
  
  <entry>
    <title type="html">Inside the Git Proxy: Capturing What an AI Agent Pushed (Part 2)</title>
    <link href="https://jonnyzzz.com/blog/2026/06/22/inside-the-git-proxy-for-ai-agents/" rel="alternate" type="text/html" title="Inside the Git Proxy: Capturing What an AI Agent Pushed (Part 2)" />
    <published>2026-06-22T00:00:00+00:00</published>
    <updated>2026-06-22T00:00:00+00:00</updated>
    <id>/blog/2026/06/22/inside-the-git-proxy-for-ai-agents</id>
    <content type="html" xml:base="https://jonnyzzz.com/blog/2026/06/22/inside-the-git-proxy-for-ai-agents/">&lt;p&gt;This is the second half of
&lt;a href=&quot;/blog/2026/06/20/auditing-git-for-ai-agents/&quot;&gt;What Did the Agent Just Push?&lt;/a&gt;. Part 1 made
the case for a &lt;strong&gt;git proxy&lt;/strong&gt; as the hard boundary, set up the one rule that makes it work — the agent never
holds the real keys — and showed the &lt;em&gt;enforcement&lt;/em&gt; half: rejecting any head outside the allow-list before a
byte reaches the server. Enforcement bounds the blast radius, and it is the part you can ship first.&lt;/p&gt;

&lt;p&gt;This part is the &lt;em&gt;capture&lt;/em&gt; half: how the proxy actually gets the traffic, reads what the agent pushed, and
keeps it recoverable. It is the deep end — protocol gymnastics, packfile internals, and the side-effects of
sitting in the middle of git’s wire protocol.&lt;/p&gt;

&lt;p&gt;Recall the shape of a push from Part 1: a ref-advertisement &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GET /info/refs?service=git-receive-pack&lt;/code&gt;, then
a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;POST /git-receive-pack&lt;/code&gt; carrying pkt-line commands followed by a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PACK&lt;/code&gt;. The proxy already parsed those
commands and located the pack — everything below works on that pack.&lt;/p&gt;

&lt;hr /&gt;

&lt;h3 id=&quot;make-https-the-only-way-in&quot;&gt;Make HTTPS the Only Way In&lt;/h3&gt;

&lt;p&gt;From my standpoint, the SSH is the best transport one should use with Git. On the other hand, 
Git supports multiple transports, and the tricks we are covering here are similar for all of them.&lt;/p&gt;

&lt;p&gt;In our case, we decided to use HTTPS with token authentication, that requires much less additional 
setup than SSH. We are not using &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;git://&lt;/code&gt; protocol in production, as you may assume.
An agent that can open an SSH connection to the server, but we specify no access keys.
The git proxy server URL is explained to the agent in prompt files. Agent has no token to
access the original Git server via HTTPS.&lt;/p&gt;

&lt;p&gt;For HTTPS you have two ways in. Either the agent’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https_proxy&lt;/code&gt; points at you and you &lt;strong&gt;MITM&lt;/strong&gt; the TLS
tunnel with your own CA installed in the sandbox’s trust store, or you publish a dedicated hostname the
agent’s remotes point at directly. If you MITM, guard one thing: a client opens the tunnel with
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CONNECT host:443&lt;/code&gt; and then sends an inner &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Host:&lt;/code&gt; header, and the two must match — reject a mismatch with
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;421 Misdirected Request&lt;/code&gt;, or an agent can &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CONNECT&lt;/code&gt; an allowed host and then ask for a different one,
slipping past your per-host policy.&lt;/p&gt;

&lt;p&gt;Once the bytes are yours, &lt;strong&gt;classify by an exact whitelist, not a prefix match.&lt;/strong&gt; Git’s HTTP surface is
small and each endpoint has a fixed method, query shape and content type:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Request&lt;/th&gt;
      &lt;th&gt;Operation&lt;/th&gt;
      &lt;th&gt;Notes&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GET …/info/refs?service=git-upload-pack&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;read&lt;/td&gt;
      &lt;td&gt;smart fetch/clone discovery&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GET …/info/refs?service=git-receive-pack&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;write&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;push discovery — classified early&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;POST …/git-upload-pack&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;read&lt;/td&gt;
      &lt;td&gt;smart fetch&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;POST …/git-receive-pack&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;write&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;smart push&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GET …/info/refs&lt;/code&gt; (no query), &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;…/HEAD&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;…/objects/…&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;read&lt;/td&gt;
      &lt;td&gt;dumb protocol, read-only by design&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;anything else&lt;/td&gt;
      &lt;td&gt;deny&lt;/td&gt;
      &lt;td&gt;reject reserved git paths with 400&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Two things fall out of this. A push is a &lt;em&gt;write&lt;/em&gt; the moment you see its discovery &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GET&lt;/code&gt;, not just at the
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;POST&lt;/code&gt; — so the discovery request is the natural place to influence the push (we use it below). And under
this whitelist the dumb protocol is read-only: the one write path you allow is smart &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;receive-pack&lt;/code&gt;, which
is the one place branch policy bites. (Git also has a legacy WebDAV-style HTTP push, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;git-http-push&lt;/code&gt;, that
writes &lt;em&gt;outside&lt;/em&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;receive-pack&lt;/code&gt; — so deny &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DAV&lt;/code&gt; methods and those paths too, or a write could sneak in
behind your back). One endpoint deserves a hard no: the dumb &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;objects/info/alternates&lt;/code&gt; (and
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;http-alternates&lt;/code&gt;) can point the client at object stores &lt;em&gt;outside&lt;/em&gt; your proxy, so reject it — otherwise an
allow-listed clone can quietly source objects from somewhere you don’t audit.&lt;/p&gt;

&lt;h3 id=&quot;capture-log-every-commit-that-was-pushed&quot;&gt;Capture: log every commit that was pushed&lt;/h3&gt;

&lt;p&gt;The proxy already knows &lt;em&gt;which refs&lt;/em&gt; moved — that was enforcement, in Part 1. Provenance needs the
&lt;em&gt;commits&lt;/em&gt;. We want to read the pack and log each commit’s hash, author and subject — ideally store the pack
too, for offline recovery.&lt;/p&gt;

&lt;p&gt;There is one obstacle. By default, git is allowed to send a &lt;strong&gt;thin pack&lt;/strong&gt;: a pack whose objects may be
deltas against base objects that are &lt;em&gt;not in the pack&lt;/em&gt;, because the receiver is assumed to already have
them (&lt;a href=&quot;https://git-scm.com/docs/gitformat-pack&quot;&gt;gitformat-pack&lt;/a&gt;). A thin pack is efficient on the wire and
miserable to parse in isolation — you don’t have the bases. (It is possible to request missing
objects via the HTTP dumb protocol from the main Git server, I would not go this way).&lt;/p&gt;

&lt;p&gt;The fix is a capability the protocol already defines. The server can advertise &lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;no-thin&lt;/code&gt;&lt;/strong&gt;, and per the
spec &lt;em&gt;“A client MUST NOT send a thin pack if the server advertises the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;no-thin&lt;/code&gt; capability”&lt;/em&gt;
(&lt;a href=&quot;https://git-scm.com/docs/gitprotocol-capabilities&quot;&gt;gitprotocol-capabilities&lt;/a&gt;). So the proxy rewrites the
ref advertisement on its way back to the agent, injecting &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;no-thin&lt;/code&gt; into the capability list. The agent’s
git then sends a &lt;strong&gt;self-contained&lt;/strong&gt; pack the proxy can parse on its own.&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;on GET /info/refs?service=git-receive-pack:
    strip_header(request, &quot;Accept-Encoding&quot;)        # advert comes back uncompressed so we can rewrite it
    advert = fetch_upstream_advertisement()
    advert = inject_capability(advert, &quot;no-thin&quot;)   # rewrites the first ref line&apos;s pkt-line and its
                                                    #   4-byte length prefix; mind the pkt-line limit
                                                    #   (65520 total / 65516 of payload)
    return advert
    # if no-thin won&apos;t fit, you have two choices: fail CLOSED (reject the push) if you need a hard audit
    # guarantee, or fall back to storing the raw thin pack and resolving it later against the upstream
    # objects — but do not pretend you walked it; a thin pack is not self-contained
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Two honest caveats here, because the details bite:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;no-thin&lt;/code&gt; does not turn off delta compression.&lt;/strong&gt; It only guarantees that every delta’s base is
&lt;em&gt;inside the same pack&lt;/em&gt;. Git will still delta-encode a commit against another commit in the pack. So your
pack walker must resolve in-pack commit deltas — but only the commit objects, which are few and small.
Trees and blobs you can inflate-and-discard. Memory stays bounded by the commit set, not by the pack
size. It means we can unpack the PACK file and read the commits, trees and the file BLOBs without
looking for additional objects in the parent repository.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Self-contained packs are bigger&lt;/strong&gt; than thin ones, and the gap is not subtle. In one test, injecting
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;no-thin&lt;/code&gt; flipped a real push from a 490-byte thin pack (a ref-delta against a base the server already
had) to a 5,697-byte self-contained one — and it overrode the client’s hardcoded &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--thin&lt;/code&gt; with zero
cooperation from the agent. You can reproduce the effect yourself: push one small commit and compare the
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;POST&lt;/code&gt; body size with and without &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;no-thin&lt;/code&gt; in the advertisement (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GIT_TRACE_PACKET=1&lt;/code&gt; makes the framing
visible). For a handful of commits that is a fine trade; for a 2 GB monorepo migration it is not. Know
which one you are running.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;BLOGs are not DIFFS&lt;/strong&gt;. Git stores the whole file content in BLOGs, which are in the commit. BLOBs
do not show what was changed, it’s the *actual content of the changed file**. Git packs BLOBs with
delta compression on the PACK file. Should you want to find the actual change – you still need to access
the parent commit objects -&amp;gt; all tree objects they reference -&amp;gt; and the BLOBs of the previous revisions. 
There can be multiple base objects in the merge-commits (aka commits with multiple parents).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Capture should happen &lt;em&gt;after&lt;/em&gt; the server accepts the push, gated on the upstream result. The
receive-pack response carries a &lt;a href=&quot;https://git-scm.com/docs/gitprotocol-pack&quot;&gt;report-status&lt;/a&gt;: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;unpack ok&lt;/code&gt;
plus a per-ref &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ok &amp;lt;ref&amp;gt;&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ng &amp;lt;ref&amp;gt; &amp;lt;reason&amp;gt;&lt;/code&gt;. A push can &lt;em&gt;partially&lt;/em&gt; succeed — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;unpack ok&lt;/code&gt; and then
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ng&lt;/code&gt; on one ref — so don’t log a commit as “pushed” until the server says &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ok&lt;/code&gt; for the ref that carries it.&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;capture(response, pack):                           # runs only after the server accepted the push
    status = parse_report_status(response)         # truth is in the response body, NOT the HTTP code:
    if status.unpack != &quot;ok&quot;: return               #   &quot;unpack ok&quot; can still be followed by &quot;ng &amp;lt;ref&amp;gt; ...&quot;
    accepted = [r for r in status.refs if r.result == &quot;ok&quot;]   # log ONLY the refs the server actually took
    if not accepted: return                        #   (a 200 can carry per-ref &quot;ng&quot; — never trust the 200)
    store(pack, key = f&quot;{session_id}/{new_head}.pack&quot;)        # spool the pack to storage; no second copy
    for commit in walk_commits(pack, reachable_from=accepted):   # bases are in-pack thanks to no-thin, so
        log(session_id, commit.hash, commit.author, commit.subject)  # resolve commit deltas; drop blobs
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Run the capture off to the side — a detached, best-effort task. If logging fails, the developer’s push
must still succeed. Observability that breaks the thing it observes is worse than no observability&lt;/p&gt;

&lt;h3 id=&quot;a-pack-is-snapshots-not-diffs&quot;&gt;A Pack Is Snapshots, Not Diffs&lt;/h3&gt;

&lt;p&gt;There is a deeper limit worth stating plainly, because it surprises people and it decides what you can
actually recover. &lt;strong&gt;Git stores snapshots, not diffs.&lt;/strong&gt; A blob is the &lt;em&gt;whole&lt;/em&gt; file, never a change to it;
the deltas you see &lt;em&gt;inside&lt;/em&gt; a packfile (thin or not) are a storage-and-transport trick — one object encoded
against another to save bytes — and not the semantic change the agent made. When you resolve the pack you
get back whole files: the complete new version of everything that was touched, never a tidy “here is what
changed”. So the pack, by itself, tells you the new state, not the edit.&lt;/p&gt;

&lt;p&gt;To learn &lt;em&gt;what changed&lt;/em&gt; you have to diff a commit against its parent — and that is where the pack runs out.
For commits &lt;em&gt;inside&lt;/em&gt; the pushed range you are fine: a commit and its parent are both new, both in the pack,
so you can diff them from the pack alone. The gap is the &lt;strong&gt;boundary&lt;/strong&gt; — the first new commit’s parent is
the old head, and the old head’s tree and the &lt;em&gt;old&lt;/em&gt; versions of the changed files are precisely the objects
the pack omits, because the server already had them. A non-thin pack is self-contained for its &lt;em&gt;new&lt;/em&gt;
objects, but it is silent about the past. (A thin pack looks tempting here, since its wire bytes encode the
changed blob as a delta against the parent’s version — but that base blob still is not in the pack, so you
cannot apply it without fetching the parent anyway). And you cannot cheat by faking an empty advertisement
to make the client upload all of history: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;receive-pack&lt;/code&gt; does a compare-and-swap on each command’s old
object id and rejects a mismatch, and rewriting that old id back would break a signed push.&lt;/p&gt;

&lt;p&gt;This has a blunt consequence, and it is the real reason the next section exists. If the same file was
touched across several commits, you can reconstruct the chain of new snapshots from the pack — but turning
that into a readable history of &lt;em&gt;changes&lt;/em&gt; needs the parent at every boundary. Lose those parent objects to
garbage collection and the diff is gone for good, even though you faithfully archived every pushed pack. So
capturing the agent’s pushes is necessary but not sufficient: you also have to keep the &lt;strong&gt;parents&lt;/strong&gt;
reachable. That is why you back up &lt;em&gt;heads&lt;/em&gt; in git, not just archive packs — pin the heads so the old
commits, trees and blobs survive long enough to tell you what actually changed.&lt;/p&gt;

&lt;h3 id=&quot;keep-the-objects-alive-backup-marker-refs&quot;&gt;Keep the objects alive: backup marker refs&lt;/h3&gt;

&lt;p&gt;There is a subtle way to lose the very data you just captured. The agent pushes to a branch, you record
it, the agent (or a cleanup job) deletes that branch, and now those objects are unreferenced on the server
and eligible for garbage collection. Your &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.pack&lt;/code&gt; in object storage survives, but the &lt;em&gt;server&lt;/em&gt; no longer
has them.&lt;/p&gt;

&lt;p&gt;The cheap fix is to pin each captured head with a &lt;strong&gt;backup marker ref&lt;/strong&gt; — a second, ref-only push that
points a marker like &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;refs/heads/_captures/session-&amp;lt;id&amp;gt;/&amp;lt;branch&amp;gt;-&amp;lt;shortsha&amp;gt;&lt;/code&gt; at the head the agent just
pushed. Because the head’s objects are &lt;em&gt;already&lt;/em&gt; on the server, this push transfers &lt;strong&gt;no objects at all&lt;/strong&gt;:
it sends the canonical empty pack (a 32-byte pack with zero objects for SHA-1 repositories). It is fast,
and idempotent — on a name collision you resolve the existing marker and confirm it points at exactly
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;new_head&lt;/code&gt; before treating it as success (the short SHA in the name is a convenience, not a proof of
identity) — and it keeps the commits reachable and GC-safe until your retention sweep removes them.&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;after a successful capture of &amp;lt;new_head&amp;gt; on &amp;lt;branch&amp;gt;:
    marker = f&quot;refs/heads/_captures/session-{session_id}/{branch}-{short(new_head)}&quot;
    # a create command is &quot;&amp;lt;zero-id&amp;gt; &amp;lt;new_head&amp;gt; &amp;lt;marker&amp;gt;&quot;; the objects are already upstream, so this
    # push carries the empty pack. It uses the proxy&apos;s OWN credential and skips the allow-list — the
    # proxy writes these markers, the agent never does
    receive_pack_push(zero_id, new_head, marker)
    # on a collision, resolve the existing marker and verify it == new_head, then treat as success
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Fire it as a separate, isolated request — &lt;em&gt;not&lt;/em&gt; a second command piggybacked into the agent’s own push.
That distinction is load-bearing. If you inject your marker command into the client’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;receive-pack&lt;/code&gt; body
and the client used &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;git push --atomic&lt;/code&gt;, then a failure of &lt;em&gt;your&lt;/em&gt; command (a name collision, a slow host)
atomically fails the &lt;em&gt;agent’s&lt;/em&gt; real push. You would have turned your insurance policy into the thing that
drops the developer’s work. A standalone ref push, sequenced after the agent’s push reports &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ok&lt;/code&gt;, cannot
couple to it, and it leaves the agent’s signed certificate untouched. Give it its own generous timeout
(real git hosts can be slow) and never loop it back through the forward proxy. It is independent and
best-effort — if it fails, you log it and move on.&lt;/p&gt;

&lt;p&gt;One honest limitation, though, and it is worth saying out loud: &lt;strong&gt;signed pushes can take the marker away
from you entirely.&lt;/strong&gt; If the upstream &lt;em&gt;requires&lt;/em&gt; a valid push certificate on the capture namespace, you are
blocked both ways — you cannot add the marker to the agent’s own push (that alters its signed command list
and breaks the signature), and a separate marker push would itself need a certificate, which the proxy
cannot produce without a signing identity of its own. So under mandatory signed-push you have two options:
give the proxy its own service key that the host trusts for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;refs/heads/_captures/*&lt;/code&gt; and let it sign its
marker pushes — a &lt;em&gt;different&lt;/em&gt; signature than the agent’s, made by the proxy as itself — or drop the
server-side marker altogether and lean on the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.pack&lt;/code&gt; you already captured into your own storage, which
needs no ref on the host at all. The marker is insurance; when the host won’t let you buy it, your own
captured pack is the policy that still pays out. You can workaround that by adding the secondary Git
repository, that is synchronized with the main repository, it will be used to keep all the captured
packs and markers, and without certificates.&lt;/p&gt;

&lt;p&gt;Two more things make the markers pay off. First, they are exactly what saves you from a &lt;strong&gt;force-push or a
delete&lt;/strong&gt;: the marker pins &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;new_head&lt;/code&gt;, so even if the agent later force-pushes its branch backward or
deletes it, the objects stay reachable through the marker and survive &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;git gc --prune=now&lt;/code&gt;. Drop the
marker in your retention sweep and they become collectable again. Second, the marker is what makes
&lt;strong&gt;recovery cheap&lt;/strong&gt;: a warm repository that already has &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;old_head&lt;/code&gt; fetches only the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;old_head..new_head&lt;/code&gt;
range, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;git log old..new&lt;/code&gt; plus &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;git show&lt;/code&gt; reconstructs exactly the commits and patches that were
pushed — force-push included, since &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;A..B&lt;/code&gt; means “reachable from B but not A”. And this is where the
snapshot/diff problem from earlier gets solved almost for free: because &lt;em&gt;every&lt;/em&gt; push gets its own marker,
the marker you wrote for the &lt;strong&gt;previous&lt;/strong&gt; push pins what is now the &lt;strong&gt;parent&lt;/strong&gt; of this one. The chain of
markers keeps each boundary’s old side reachable, so the parent objects you need to compute a real diff are
still there — you backed up the heads as you went, exactly as that section argued. One caveat from the real
world: a custom ref namespace like &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;refs/captures/*&lt;/code&gt; is not portable — some hosts honor only &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;refs/heads/*&lt;/code&gt;
and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;refs/tags/*&lt;/code&gt; and silently reject the rest, so a lightweight &lt;strong&gt;tag&lt;/strong&gt; is often the most portable pin.
Tags auto-follow into clones, though, so remember to clean them up after recovery&lt;/p&gt;

&lt;h3 id=&quot;side-effects-to-watch&quot;&gt;Side-Effects to Watch&lt;/h3&gt;

&lt;p&gt;A proxy that parses pushes is a man-in-the-middle by design, and a few of its side-effects bite in
production rather than in the demo:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;You have to buffer the command prefix before you can forward.&lt;/strong&gt; The ref commands sit in front of the
pack, so policy can’t run until you have read and held &lt;em&gt;that&lt;/em&gt; much. You do not have to hold the whole
pack in memory, though — once policy passes, stream or tee the pack to disk or object storage as it flows
upstream. Either way, apply a size cap: an unbounded push, accidental or malicious, is a memory- and
disk-exhaustion vector. This is buffering you are &lt;em&gt;adding&lt;/em&gt; to the path; bound it deliberately.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Object ids are not always 40 hex characters.&lt;/strong&gt; Most parsers quietly assume SHA-1. A repository using
the SHA-256 object format has 64-character ids, a different empty-pack size, and will be rejected as
malformed by SHA-1-only code. Either handle both hash lengths or document the limitation loudly — a
silent “malformed push” on a SHA-256 repo is a miserable thing to debug
(&lt;a href=&quot;https://git-scm.com/docs/gitformat-pack&quot;&gt;gitformat-pack&lt;/a&gt;).&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Signed pushes sign the heads, not the pack — so read them, never edit them.&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;git push --signed&lt;/code&gt;
wraps the ref-update command list in a &lt;em&gt;push certificate&lt;/em&gt; and GPG-signs it. The signature covers the
affected heads — each &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;old-id new-id refname&lt;/code&gt; update — plus the pusher identity, the destination URL and
a server-issued nonce. It does &lt;strong&gt;not&lt;/strong&gt; cover the pack: each &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;new-id&lt;/code&gt; is a SHA, so signing the heads
already certifies the objects by hash (&lt;a href=&quot;https://git-scm.com/docs/gitprotocol-pack&quot;&gt;gitprotocol-pack&lt;/a&gt;).
The tempting conclusion is “then a proxy can never handle a signed push.” Not quite. The proxy never
needs to &lt;em&gt;touch&lt;/em&gt; that list — it reads it and decides: forward the certificate byte-for-byte when every
head is allowed, reject the whole push when one is not. Enforcement, capture and markers all survive,
because none of them edit the signed bytes. What signed push removes are the edit-the-list shortcuts —
remap an old id, inject an extra ref, strip a disallowed one — each of which stops the signature
verifying. Two consequences are worth pinning down:
    &lt;ul&gt;
      &lt;li&gt;&lt;strong&gt;Filtering is all-or-nothing.&lt;/strong&gt; You cannot quietly drop one disallowed ref from a multi-ref push;
you forward it whole or reject it whole. Reject is the safe default, so the boundary still holds — you
just lose the ability to &lt;em&gt;partially&lt;/em&gt; allow a signed push.&lt;/li&gt;
      &lt;li&gt;&lt;strong&gt;Relay the nonce untouched.&lt;/strong&gt; The nonce lives in the advertisement; when you inject &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;no-thin&lt;/code&gt; there,
keep the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;push-cert&lt;/code&gt; nonce intact, or the client’s certificate will not verify upstream.&lt;/li&gt;
    &lt;/ul&gt;

    &lt;p&gt;This is also the deeper reason capture and markers stay strictly out of band: they must never appear in
the signed command list, or they would invalidate the very push they are trying to record.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;HTTP success is not git success.&lt;/strong&gt; Worth repeating because it is the easiest one to get wrong: a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;200&lt;/code&gt;
from the server’s git backend does not mean the objects were stored. The truth is in the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;report-status&lt;/code&gt;
body. Gate every capture on it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are reasons not to build the proxy. They are the difference between one that works on your
laptop and one you can leave running in front of a fleet.&lt;/p&gt;

&lt;h2 id=&quot;where-this-leaves-you&quot;&gt;Where This Leaves You&lt;/h2&gt;

&lt;p&gt;That is the whole proxy: intercept HTTPS, force a parseable pack, gate capture on the server’s real answer,
pin the heads so nothing you captured gets collected, and respect the protocol’s sharp edges — signed,
atomic, partial-success. None of it is exotic; it is mostly careful reading of the Git wire-protocol docs
linked below. The payoff is the thing Part 1 was after: you know exactly what every agent pushed, you can
recover it, and only the heads you allowed ever change upstream&lt;/p&gt;

&lt;p&gt;One more reason to learn these internals now rather than wait: the substrate is being rebuilt. The
agent-first forges and next-generation version-control systems landing in 2026 — Cursor’s
&lt;a href=&quot;https://explainx.ai/blog/cursor-origin-git-hosting-github-alternative-ai-agents-2026&quot;&gt;Origin&lt;/a&gt; and Epic’s
&lt;a href=&quot;https://github.com/EpicGames/lore&quot;&gt;Lore&lt;/a&gt;, both of which I touch on in
&lt;a href=&quot;/blog/2026/06/20/auditing-git-for-ai-agents/&quot;&gt;Part 1&lt;/a&gt; — still move objects, still pack and
delta them, still have to tell you what an agent changed. The transport and the storage may shift under
you, but pkt-lines, packs, thin-vs-self-contained, and “snapshots, not diffs” are the vocabulary you carry
across all of it.&lt;/p&gt;

&lt;p&gt;If you came straight here for the mechanics, the &lt;em&gt;why&lt;/em&gt; — credential isolation, the lighter git-hooks
alternative, and how to choose between them — is in
&lt;a href=&quot;/blog/2026/06/20/auditing-git-for-ai-agents/&quot;&gt;Part 1&lt;/a&gt;. And the build of that lighter
alternative — the in-container hooks that tag every commit and capture each push — is
&lt;a href=&quot;/blog/2026/06/26/inside-the-git-hooks-for-ai-agents/&quot;&gt;Part 3&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;And if your team is building this for real and wants a second pair of hands on the proxy or the capture
pipeline, this is exactly the kind of problem I enjoy. I am happy to &lt;strong&gt;consult and help you build it&lt;/strong&gt; —
reach out on &lt;a href=&quot;https://www.linkedin.com/in/jonnyzzz/&quot;&gt;LinkedIn&lt;/a&gt; and let’s scope it together.&lt;/p&gt;

&lt;p&gt;Given all the information – my implementation we log all the pushes intercepted to a log file. We
assume the Git server will keep all objects (in our case it does) and run pinning job outside of
the main flow.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;git-documentation-used-in-this-post&quot;&gt;Git Documentation Used in This Post&lt;/h2&gt;

&lt;p&gt;The proxy is mostly careful reading of the public Git wire-protocol and format docs:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://git-scm.com/docs/gitprotocol-http&quot;&gt;gitprotocol-http&lt;/a&gt; — the smart HTTP transport&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://git-scm.com/docs/gitprotocol-common&quot;&gt;gitprotocol-common&lt;/a&gt; — pkt-line framing and limits&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://git-scm.com/docs/gitprotocol-capabilities&quot;&gt;gitprotocol-capabilities&lt;/a&gt; — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;no-thin&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;report-status&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://git-scm.com/docs/gitprotocol-pack&quot;&gt;gitprotocol-pack&lt;/a&gt; — the receive-pack exchange, status report, push certificate&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://git-scm.com/docs/gitformat-pack&quot;&gt;gitformat-pack&lt;/a&gt; — pack and thin-pack format, object hashing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the deeper design notes, the Git project keeps a set of technical documents under
&lt;a href=&quot;https://github.com/git/git/tree/master/Documentation/technical&quot;&gt;Documentation/technical&lt;/a&gt; in the source
tree — the protocol and format docs themselves were promoted to the top-level &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gitprotocol-*&lt;/code&gt; and
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gitformat-pack&lt;/code&gt; man pages linked above.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Related reading on this blog:
&lt;a href=&quot;/blog/2026/06/20/auditing-git-for-ai-agents/&quot;&gt;What Did the Agent Just Push? (Part 1)&lt;/a&gt;,
&lt;a href=&quot;/blog/2026/06/26/inside-the-git-hooks-for-ai-agents/&quot;&gt;Inside the Git Hooks (Part 3)&lt;/a&gt;,
&lt;a href=&quot;/blog/2026/02/02/git-fork-pattern/&quot;&gt;Git Fork Pattern&lt;/a&gt;, and
&lt;a href=&quot;/blog/2019/04/09/git-replication/&quot;&gt;Efficient Git Replication&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</content>

  
  
  
  
  

    <author>
      <name>Eugene Petrenko</name>
    </author>

  
    <category term="ai-agents" />
  
    <category term="ai-coding" />
  
    <category term="git" />
  
    <category term="infrastructure" />
  
    <category term="workflow" />
  
    <summary type="html">Part 2 — how the git proxy intercepts HTTPS, forces a parseable pack, logs every commit, pins the objects with marker refs, and survives the sharp edges of a signed, atomic protocol.</summary>
  
  </entry>
  
  <entry>
    <title type="html">What Did the Agent Just Push? Auditing Git for AI Agents</title>
    <link href="https://jonnyzzz.com/blog/2026/06/20/auditing-git-for-ai-agents/" rel="alternate" type="text/html" title="What Did the Agent Just Push? Auditing Git for AI Agents" />
    <published>2026-06-20T00:00:00+00:00</published>
    <updated>2026-06-20T00:00:00+00:00</updated>
    <id>/blog/2026/06/20/auditing-git-for-ai-agents</id>
    <content type="html" xml:base="https://jonnyzzz.com/blog/2026/06/20/auditing-git-for-ai-agents/">&lt;p&gt;You hand a repository to an autonomous AI Agent and let it work. It clones, branches, commits, pushes,
maybe opens a pull request. A few minutes later it tells you it is done. Two questions stay on the table,
and neither has a comfortable answer:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Which commits did it actually produce?&lt;/strong&gt; Not the summary it wrote for you — the real objects that
landed on the server.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Are you sure it only touched the branches you allowed?&lt;/strong&gt; One confused agent force-pushing &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main&lt;/code&gt; is
a bad afternoon.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I have been running fleets of agents on the same codebases for a while now (see
&lt;a href=&quot;/blog/2026/01/30/orchestrating-ai-fleets/&quot;&gt;Orchestrating AI Fleets&lt;/a&gt; and the
&lt;a href=&quot;/blog/2026/02/02/git-fork-pattern/&quot;&gt;Git Fork Pattern&lt;/a&gt;), and the more agents you run, the
more these two questions turn from curiosity into operational risk. This post is about answering them at
the layer where the truth lives: &lt;strong&gt;git itself&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This post is the approach: why this is a git problem, the one rule that makes everything else possible, and
the two mechanisms — a git proxy and git hooks — at a level you can reason about and act on. The deepest
wire-protocol mechanics of the proxy are involved enough that they get their own follow-up; here we cover
the shape and the decisions, with enough pseudo-code that your AI Agent can start implementing it in your
stack.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;why-this-is-a-git-problem-not-a-policy-document&quot;&gt;Why This Is a Git Problem, Not a Policy Document&lt;/h2&gt;

&lt;p&gt;The naive answer is “just tell the agent which branches it may touch.” That is a policy. Policies are
suggestions to a process that has a shell, a network, and a strong urge to finish its task. Agents are
smart — give one a constraint phrased as a request and it may find a creative way around it, not out of
bad intent but because working around obstacles is literally what we trained it to do.&lt;/p&gt;

&lt;p&gt;So we do not rely on asking nicely. We rely on two properties:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Blast radius&lt;/strong&gt; — only the heads we selected can change on the real server, enforced somewhere the
agent cannot route around.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Provenance&lt;/strong&gt; — every commit the agent created is captured and attributed, so we can recover, review,
and audit it after the fact. This will be a great source for iterative learning later.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There are two ways to get there. The first is a &lt;strong&gt;git proxy&lt;/strong&gt; that sits outside the agent’s sandbox — the
hard boundary. The second is &lt;strong&gt;git hooks&lt;/strong&gt; baked into the sandbox image — a lighter, best-effort layer for
when you own the image but not the network path. We use the proxy as the real wall and treat hooks as
extra telemetry. Here is why.&lt;/p&gt;

&lt;h2 id=&quot;the-one-rule-underneath-everything-dont-give-agents-the-real-keys&quot;&gt;The One Rule Underneath Everything: Don’t Give Agents the Real Keys&lt;/h2&gt;

&lt;p&gt;Before any proxy or hook, there is a decision that makes the rest possible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The agent never holds the credentials to the real git server.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Inside the sandbox, the agent gets a short-lived, scoped token that authenticates it to &lt;em&gt;our&lt;/em&gt; endpoint —
not to GitHub or Space, not to your internal git host. We inject that token the boring way: a git credential helper
or a URL rewrite, never the real key on disk. The real push credential lives in the proxy, which the agent
cannot read. When a push is allowed, the proxy authenticates upstream with its own service credential.&lt;/p&gt;

&lt;p&gt;This is the property that survives a clever agent. A sandbox is a permission boundary, and inside its own
sandbox an agent may have enough rights to do almost anything — read its environment, rewrite files,
disable the tooling we installed. If the real key were anywhere in that environment, a determined agent
could find it and talk to the Git server directly, and every control we built would be decoration. Because the
key is &lt;em&gt;not&lt;/em&gt; there, the worst an escaped agent can reach is the proxy — and the proxy enforces policy and
holds the key. The blast radius is bounded by where the credential lives, not by how well-behaved the
agent is&lt;/p&gt;

&lt;p&gt;I want to note – the pattern how we are going to limit the agent push/pull operations is not standard,
and potentially is hard-to-impossible to implement out of the box with GitHub, Space or any third-party Git host.
Agentic AI opens yet another direction for us – software is now very cheap to build, given you know how.&lt;/p&gt;

&lt;p&gt;Below we explain enough ideas to implement it. Git supports multiple protocols, including HTTP, SSH, &lt;del&gt;and Git over TCP&lt;/del&gt;.
We decided to use HTTP below, but the same technique can be applied to other protocols as well, mainly to SSH.&lt;/p&gt;

&lt;h2 id=&quot;solution-1-the-git-proxy--the-hard-boundary&quot;&gt;Solution 1: The Git Proxy — the Hard Boundary&lt;/h2&gt;

&lt;p&gt;A git push over HTTP is a surprisingly inspectable conversation. If you put a proxy on the path between the
agent and the server, you can read the whole exchange, decide whether to allow it, and capture everything
that flows through — without the agent being able to tell the difference, and without it being able to go
around you, because it has nowhere else to go&lt;/p&gt;

&lt;h3 id=&quot;what-a-push-actually-looks-like-on-the-wire&quot;&gt;What a push actually looks like on the wire&lt;/h3&gt;

&lt;p&gt;Git’s “smart HTTP” protocol does a push in two HTTP requests
(&lt;a href=&quot;https://git-scm.com/docs/gitprotocol-http&quot;&gt;gitprotocol-http&lt;/a&gt;):&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Ref advertisement&lt;/strong&gt; — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GET /info/refs?service=git-receive-pack&lt;/code&gt;. After a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;# service=git-receive-pack&lt;/code&gt;
header line and a flush, the server lists every ref and its current object id, and on the &lt;em&gt;first&lt;/em&gt; ref
line it appends a NUL byte followed by the
&lt;a href=&quot;https://git-scm.com/docs/gitprotocol-capabilities&quot;&gt;capabilities&lt;/a&gt; it supports.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The push itself&lt;/strong&gt; — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;POST /git-receive-pack&lt;/code&gt;. The body is a sequence of commands (“update
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;refs/heads/x&lt;/code&gt; from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;old&amp;gt;&lt;/code&gt; to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;new&amp;gt;&lt;/code&gt;”) followed by a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PACK&lt;/code&gt; — the binary blob of objects being
transferred.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The advertisement, the command list and the status report are all framed in &lt;strong&gt;pkt-lines&lt;/strong&gt;: a 4-byte hex
length prefix in front of each chunk, with a special &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0000&lt;/code&gt; “flush” packet as a separator
(&lt;a href=&quot;https://git-scm.com/docs/gitprotocol-common&quot;&gt;gitprotocol-common&lt;/a&gt;). The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PACK&lt;/code&gt; payload itself is raw
bytes that follow the command section. That framing is the whole reason a proxy can parse the stream
cheaply — you always know where the next record starts.&lt;/p&gt;

&lt;h3 id=&quot;enforcement-reject-the-heads-you-didnt-allow&quot;&gt;Enforcement: reject the heads you didn’t allow&lt;/h3&gt;

&lt;p&gt;This is the easy and most valuable half. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ref-update&lt;/code&gt; commands sit in plain pkt-lines at the front of the
POST body, &lt;em&gt;before&lt;/em&gt; the pack. Parse them, compare each target ref against your allow-list, and refuse the
ones that don’t match — before a single byte reaches the real server.&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;on POST /git-receive-pack(body):
    # read pkt-lines until the 0000 flush that ends the
    # command list; the pack begins right after it.
    # --signed wraps the commands in a push-cert ...
    # push-cert-end block (then push-options): parse the
    # commands from inside it and forward the cert
    # byte-for-byte — the signature covers it
    commands, pack = split_at_pack(body)

    for (old, new, ref) in parse_commands(commands):

        # e.g. only refs/heads/agent/* may change
        if not allowed(ref):                      
            return reject(ref, &quot;ref not in allow-list&quot;)

        if is_delete(new) and not allow_deletes(ref):
            return reject(ref, &quot;deletes not permitted&quot;)

    # only now, with our real credential
    response = forward_upstream(body)

    # the audit trail — the capture step, in a follow-up
    capture(response, pack)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Because the proxy holds the credential and the agent does not, this is a wall, not a request. An agent
that dislikes the wall cannot push around it — its token only opens the proxy.&lt;/p&gt;

&lt;p&gt;That is the enforcement half — the part that bounds the blast radius, and the part you can ship first. The
other half is &lt;em&gt;capture&lt;/em&gt;: getting the traffic to the proxy in the first place, reading the pushed pack,
logging every commit, and keeping the objects alive for recovery. It turns out to be a meaty topic on its
own — MITM and endpoint classification, thin packs and the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;no-thin&lt;/code&gt; trick, why a pack gives you whole
files and not diffs, backup marker refs, and the side-effects of sitting in the middle of a signed, atomic
protocol — so I’ll give the build its own follow-up post. If
you only ever do one thing, do enforcement; if you want the audit trail too, that follow-up is where it
gets built.&lt;/p&gt;

&lt;h2 id=&quot;solution-2-git-hooks--the-lighter-in-container-alternative&quot;&gt;Solution 2: Git Hooks — the Lighter, In-Container Alternative&lt;/h2&gt;

&lt;p&gt;The proxy needs you to control the network path. Sometimes you don’t — you control the &lt;em&gt;image&lt;/em&gt; the agent
runs in, but the egress is whatever it is. In that case &lt;a href=&quot;https://git-scm.com/docs/githooks&quot;&gt;git hooks&lt;/a&gt; get
you most of the provenance, with none of the wire-protocol work.&lt;/p&gt;

&lt;p&gt;The idea: stamp every commit with a session id as it is created, and capture artifacts on push.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;prepare-commit-msg&lt;/code&gt;&lt;/strong&gt; is the primary tagging point. It fires for commit, amend, merge, cherry-pick and
the rebase reword/squash/fixup paths — and, crucially, it is &lt;strong&gt;not&lt;/strong&gt; suppressed by &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;git commit
--no-verify&lt;/code&gt; (unlike &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pre-commit&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;commit-msg&lt;/code&gt;, which are). That is exactly why it is the &lt;em&gt;primary&lt;/em&gt;
tag: the one commit-time hook the agent can’t wave away. Have it append
a &lt;a href=&quot;https://git-scm.com/docs/git-interpret-trailers&quot;&gt;trailer&lt;/a&gt; like &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Agent-Session: &amp;lt;id&amp;gt;&lt;/code&gt; to the message. A
trailer is a structured &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Key: value&lt;/code&gt; line at the end of the message, the same machinery as
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Signed-off-by&lt;/code&gt;, so it survives rebases and you can grep for it later. Re-collect existing values and
re-emit one block, so a squash &lt;em&gt;preserves distinct&lt;/em&gt; sessions and &lt;em&gt;dedups identical&lt;/em&gt; ones.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;commit-msg&lt;/code&gt;&lt;/strong&gt; is a thin validator — a second chance to confirm the trailer is present. Like the
others, it only observes and never blocks the commit.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;post-rewrite&lt;/code&gt;&lt;/strong&gt; records old→new commit mapping after amend/rebase, so provenance follows the rewrite.
(Mind the detail: its stdin is &lt;em&gt;space&lt;/em&gt;-separated, not tab.)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pre-push&lt;/code&gt;&lt;/strong&gt; is the enforcement-and-capture net. If it finds an untagged commit, it rewrites the whole
pushed suffix in topological order with a checkout-free
&lt;a href=&quot;https://git-scm.com/docs/git-commit-tree&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;git commit-tree&lt;/code&gt;&lt;/a&gt; — no rebase, so no dirty-tree or
empty-commit hazards — re-parenting each descendant onto its rewritten ancestor (and keeping every parent
of a merge with repeated &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-p&lt;/code&gt;), then moves the branch and &lt;strong&gt;aborts the push&lt;/strong&gt; so the corrected commits go
out on the retry. That auto-rewrite only makes sense when the pushed &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;local-ref&amp;gt;&lt;/code&gt; is a real
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;refs/heads/*&lt;/code&gt; branch; for a delete, a detached-HEAD push or a raw-SHA push there is no branch to move, so
abort with instructions instead. On a clean push it captures — a
&lt;a href=&quot;https://git-scm.com/docs/git-bundle&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;git bundle&lt;/code&gt;&lt;/a&gt; (validate it with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;git bundle verify&lt;/code&gt;), per-commit
patches, and a small &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;metadata.json&lt;/code&gt;. Be honest about what this is, though: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pre-push&lt;/code&gt; fires &lt;em&gt;before&lt;/em&gt; the
remote answers, so these are &lt;strong&gt;attempted-push artifacts&lt;/strong&gt; — they record what the agent tried to send, not
what the server finally accepted. For “what actually landed”, you need the proxy or a server-side hook.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To make the hooks apply to &lt;em&gt;every&lt;/em&gt; repository — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/workspace&lt;/code&gt; and any clone the agent makes later — wire
them through &lt;a href=&quot;https://git-scm.com/docs/git-config&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;core.hooksPath&lt;/code&gt;&lt;/a&gt; rather than copying files into each
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.git/hooks&lt;/code&gt;. Bake the hooks into the image root-owned and read-only, and set &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;core.hooksPath&lt;/code&gt; in the
root-owned &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/etc/gitconfig&lt;/code&gt;.&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# Dockerfile (sketch)
COPY --chmod=555 --chown=root:root githooks/ /opt/githooks/
RUN git config --system core.hooksPath /opt/githooks
# resolve one canonical Agent-Session id in the entrypoint and write it to a fixed, well-known FILE
# (say /.session_id) that the hooks read — NOT an env var. A file is harder for the agent to unset or
# forge than the environment, and it survives sub-shells and `git` wrappers. Pre-create it owner-writable
# in the image (the entrypoint runs non-root); a missing or blank file just means &quot;skip tagging&quot; — the
# hooks still never block.
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Why a file and not the obvious environment variable: the agent shares that environment. It can &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;unset&lt;/code&gt; the
variable, override it, or spawn &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;git&lt;/code&gt; with a scrubbed env — the same class of bypass we just saw with
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GIT_CONFIG_*&lt;/code&gt;. A fixed path the entrypoint writes once, before the agent runs, is a smaller target. It is
still inside the sandbox, so still defeat-able by a determined agent — but it removes the easy footgun, and
that is the right bar for a best-effort layer.&lt;/p&gt;

&lt;h3 id=&quot;hooks-can-be-broken-by-the-agent-or-an-ide--and-thats-expected&quot;&gt;Hooks Can Be Broken by the Agent or an IDE — and That’s Expected&lt;/h3&gt;

&lt;p&gt;Here is the part most write-ups skip, and the reason hooks are the &lt;em&gt;second&lt;/em&gt; solution, not the first.&lt;/p&gt;

&lt;p&gt;A hook runs &lt;em&gt;inside the agent’s sandbox&lt;/em&gt;, which means it lives in a place where the agent often has enough
permissions to defeat it. The honest list of holes:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Config precedence runs the wrong way for you.&lt;/strong&gt; It is tempting to think &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/etc/gitconfig&lt;/code&gt; is
authoritative because it is “system”. It is the opposite — system config is the &lt;em&gt;lowest&lt;/em&gt; precedence, and
a per-user &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;~/.gitconfig&lt;/code&gt; overrides it (&lt;a href=&quot;https://git-scm.com/docs/git-config&quot;&gt;git-config&lt;/a&gt;, SCOPES). So an
agent that can write its own global config can point &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;core.hooksPath&lt;/code&gt; somewhere empty. Root-owned
read-only files raise the bar, but precedence is not on your side.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;And precedence is only the easy bypass.&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;git -c core.hooksPath=…&lt;/code&gt; on the command line, a repo-local
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.git/config&lt;/code&gt;, or the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GIT_CONFIG_COUNT&lt;/code&gt; / &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GIT_CONFIG_NOSYSTEM&lt;/code&gt; environment variables all override the
system file too. A &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;git&lt;/code&gt; wrapper that scrubs &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GIT_CONFIG_*&lt;/code&gt; and refuses &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-c core.hooksPath=&lt;/code&gt; would close
these, but now you are maintaining a wrapper — and you are still inside the sandbox.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;git push --no-verify&lt;/code&gt; skips &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pre-push&lt;/code&gt;&lt;/strong&gt; entirely, unless you also wrap the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;git&lt;/code&gt; binary.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;A sufficiently privileged agent can just remove the hooks.&lt;/strong&gt; In a dedicated sandbox the agent may well
have &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sudo&lt;/code&gt; or own the filesystem. If it decides to delete &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/opt/githooks&lt;/code&gt;, it can.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We do not treat any of this as a defect. &lt;strong&gt;Hooks are best-effort observability, not a security boundary.&lt;/strong&gt;
They give you clean provenance for the overwhelmingly common case where the agent is doing ordinary work,
and they cost almost nothing. The moment you need a guarantee rather than a strong default, you are back to
the proxy — because the proxy is the one control the agent cannot reach, and the credential isolation
behind it is the one fact the agent cannot wish away.&lt;/p&gt;

&lt;p&gt;That is the whole reason for the ordering. Proxy first, hooks as a helpful layer on top.&lt;/p&gt;

&lt;h2 id=&quot;the-two-solutions-side-by-side&quot;&gt;The Two Solutions, Side by Side&lt;/h2&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Property&lt;/th&gt;
      &lt;th&gt;Git Proxy (Solution 1)&lt;/th&gt;
      &lt;th&gt;Git Hooks (Solution 2)&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Where it runs&lt;/td&gt;
      &lt;td&gt;Outside the sandbox, on the path&lt;/td&gt;
      &lt;td&gt;Inside the sandbox image&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Can the agent bypass it?&lt;/td&gt;
      &lt;td&gt;No — it holds the keys, agent can’t&lt;/td&gt;
      &lt;td&gt;Yes — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--no-verify&lt;/code&gt;, sudo, config&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Enforces blast radius&lt;/td&gt;
      &lt;td&gt;Yes, before bytes reach the server&lt;/td&gt;
      &lt;td&gt;Best-effort (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pre-push&lt;/code&gt; only)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Captures every commit&lt;/td&gt;
      &lt;td&gt;Yes (raw pack if &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;no-thin&lt;/code&gt; skipped)&lt;/td&gt;
      &lt;td&gt;Attempts only, until a hook is off&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Needs control of the network&lt;/td&gt;
      &lt;td&gt;Yes&lt;/td&gt;
      &lt;td&gt;No&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Needs control of the image&lt;/td&gt;
      &lt;td&gt;No&lt;/td&gt;
      &lt;td&gt;Yes&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Right mental model&lt;/td&gt;
      &lt;td&gt;A wall&lt;/td&gt;
      &lt;td&gt;A diligent assistant&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Access possible outside of the session&lt;/td&gt;
      &lt;td&gt;Controlled by the proxy&lt;/td&gt;
      &lt;td&gt;Controlled by the Git Hosting&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;If you can run only one, run the proxy. If you can run both, the hooks make the common case richer while
the proxy guarantees the floor.&lt;/p&gt;

&lt;p&gt;The long answer actually is to design and use the combination of both. For example, I believe that we
still need to keep track of all changes that were created by the AI Agent; it will help in the future us
better understand what happened, how quality and other project metrics were affected. It will definitely
help us to improve the agent’s performance and the project’s quality. This information will be necessary
to measure the impact of the agent on the project.&lt;/p&gt;

&lt;p&gt;We are missing &lt;strong&gt;Solution 3&lt;/strong&gt;. The pipeline to mark, backup, and analyze all the AI-made changes at scale. I’m
building it now.&lt;/p&gt;

&lt;h2 id=&quot;meanwhile-git-itself-is-being-rebuilt-for-agents&quot;&gt;Meanwhile, Git Itself Is Being Rebuilt for Agents&lt;/h2&gt;

&lt;p&gt;It is worth zooming out, because the ground under all of this is moving. In June 2026 two projects landed
that take the same problem somewhere very different. &lt;strong&gt;Cursor Origin&lt;/strong&gt;
(&lt;a href=&quot;https://explainx.ai/blog/cursor-origin-git-hosting-github-alternative-ai-agents-2026&quot;&gt;overview&lt;/a&gt;) is a git
&lt;em&gt;forge&lt;/em&gt; built for agents rather than humans — its whole premise is that when dozens of background agents
clone, branch and push against one repository at once, git’s human-scale assumptions become the bottleneck.
And &lt;a href=&quot;https://github.com/EpicGames/lore&quot;&gt;&lt;strong&gt;Lore&lt;/strong&gt;&lt;/a&gt;, Epic Games’ new open-source version control system,
rebuilds the &lt;em&gt;substrate&lt;/em&gt; — content-addressed, local-first, and equally at home with code and the
multi-gigabyte binaries that games and film drag around.&lt;/p&gt;

&lt;p&gt;Two different layers, one signal: version control is being rethought for a world where most commits are not
typed by a person. That does not retire anything in this post. Most teams still run plain git against
GitHub, GitLab or an internal host today — and even an agent-first forge has to answer the same two
questions this whole write-up is about: &lt;em&gt;which commits did the agent produce, and did it touch only what we
allowed?&lt;/em&gt; The boundary and the audit trail travel with you whatever the substrate; the proxy and the hooks
are how you get them on the git you already have.&lt;/p&gt;

&lt;h2 id=&quot;conclusion-ai-automation-is-not-just-running-the-jobs&quot;&gt;Conclusion: AI Automation Is Not Just Running the Jobs&lt;/h2&gt;

&lt;p&gt;It is easy to think the hard part of agent automation is getting the agent to &lt;em&gt;do&lt;/em&gt; the work. It is not.
The agent does the work happily. The hard part is everything around the work:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Bound the blast radius.&lt;/strong&gt; Assume the agent is capable and occasionally wrong, keep the real
credentials out of its reach, and put enforcement somewhere it cannot route around. A boundary the
agent can edit is a preference, not a boundary.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Collect the data.&lt;/strong&gt; Every commit, every push, attributed to a session and stored. Not because you
expect disaster, but because that log is the raw material for the &lt;em&gt;next&lt;/em&gt; iteration — which prompts
produce which diffs, where agents go off the rails, what a good run looks like versus a bad one. You
cannot improve a system you cannot see, and an agent that pushes into a black box teaches you nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That second point is the one our team keeps coming back to. The capture is not just an audit trail; it is a
feedback loop. The same data that lets you recover a lost branch lets you measure your fleet and tune it —
the kind of self-improvement loop I wrote about in
&lt;a href=&quot;/blog/2026/04/28/ai-agent-roadmap-research/&quot;&gt;the AI Agent roadmap research&lt;/a&gt; and the
&lt;a href=&quot;/blog/2026/02/06/run-agent-multi-agent-orchestration/&quot;&gt;multi-agent orchestration&lt;/a&gt; work. Give
agents room to run, take away the keys to the things they shouldn’t touch, and write down everything they
do. Then read it back and make the next run better.&lt;/p&gt;

&lt;p&gt;The mechanics of the proxy — how it intercepts the traffic, forces a parseable pack, logs every commit, and
pins the objects so nothing gets garbage-collected — are involved enough to deserve their own follow-up
post: &lt;a href=&quot;/blog/2026/06/22/inside-the-git-proxy-for-ai-agents/&quot;&gt;Inside the Git Proxy: Capturing What an AI Agent Pushed (Part 2)&lt;/a&gt;.
And the lighter, in-container alternative — the git hooks that stamp a session id on every commit and
capture each push from inside the sandbox — gets its own build in
&lt;a href=&quot;/blog/2026/06/26/inside-the-git-hooks-for-ai-agents/&quot;&gt;Inside the Git Hooks: Tagging Every AI Agent Commit (Part 3)&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you have built something similar — a different boundary, a smarter capture, a hook trick I missed — I
would love to hear it. Find me on &lt;a href=&quot;https://www.linkedin.com/in/jonnyzzz/&quot;&gt;LinkedIn&lt;/a&gt; or
&lt;a href=&quot;https://twitter.com/jonnyzzz&quot;&gt;Twitter&lt;/a&gt;, and tell me what your agents tried to get away with.&lt;/p&gt;

&lt;p&gt;And if your team is wiring AI Agents into real infrastructure and wants a second pair of hands on the
isolation and audit layer — the proxy, the credential boundary, the capture pipeline — this is exactly the
kind of problem I enjoy. I am happy to &lt;strong&gt;consult and help you build it&lt;/strong&gt;: from a quick architecture review
to designing the whole thing with your team. Reach out on
&lt;a href=&quot;https://www.linkedin.com/in/jonnyzzz/&quot;&gt;LinkedIn&lt;/a&gt; and let’s scope it together.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;git-documentation-used-in-this-post&quot;&gt;Git Documentation Used in This Post&lt;/h2&gt;

&lt;p&gt;Everything above leans on the public Git documentation rather than any one implementation:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://git-scm.com/docs/gitprotocol-http&quot;&gt;gitprotocol-http&lt;/a&gt; — the smart HTTP transport&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://git-scm.com/docs/gitprotocol-common&quot;&gt;gitprotocol-common&lt;/a&gt; — pkt-line framing&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://git-scm.com/docs/gitprotocol-capabilities&quot;&gt;gitprotocol-capabilities&lt;/a&gt; — capabilities on the advertisement&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://git-scm.com/docs/githooks&quot;&gt;githooks&lt;/a&gt; and &lt;a href=&quot;https://git-scm.com/docs/git-config&quot;&gt;git-config&lt;/a&gt; — hooks and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;core.hooksPath&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://git-scm.com/docs/git-interpret-trailers&quot;&gt;git-interpret-trailers&lt;/a&gt;, &lt;a href=&quot;https://git-scm.com/docs/git-bundle&quot;&gt;git-bundle&lt;/a&gt;, &lt;a href=&quot;https://git-scm.com/docs/git-commit-tree&quot;&gt;git-commit-tree&lt;/a&gt;, &lt;a href=&quot;https://git-scm.com/docs/git-index-pack&quot;&gt;git-index-pack&lt;/a&gt; — the hook toolbox&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The wire-protocol and packfile internals — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;report-status&lt;/code&gt;, the pack format, push certificates — are
covered in the follow-up post.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Related reading on this blog:
&lt;a href=&quot;/blog/2026/06/22/inside-the-git-proxy-for-ai-agents/&quot;&gt;Inside the Git Proxy (Part 2)&lt;/a&gt;,
&lt;a href=&quot;/blog/2026/06/26/inside-the-git-hooks-for-ai-agents/&quot;&gt;Inside the Git Hooks (Part 3)&lt;/a&gt;,
&lt;a href=&quot;/blog/2026/02/02/git-fork-pattern/&quot;&gt;Git Fork Pattern&lt;/a&gt;,
&lt;a href=&quot;/blog/2019/04/09/git-replication/&quot;&gt;Efficient Git Replication&lt;/a&gt;, and
&lt;a href=&quot;/blog/2026/01/16/code-review-bottleneck/&quot;&gt;the code review bottleneck&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</content>

  
  
  
  
  

    <author>
      <name>Eugene Petrenko</name>
    </author>

  
    <category term="ai-agents" />
  
    <category term="ai-coding" />
  
    <category term="git" />
  
    <category term="infrastructure" />
  
    <category term="workflow" />
  
    <summary type="html">When you hand a repo to an autonomous AI Agent, you need to bound what it can touch and capture what it did. Part 1 — the approach — keep the keys away from the agent, a git proxy as the hard boundary, and git hooks as the lighter alternative.</summary>
  
  </entry>
  
  <entry>
    <title type="html">devrig: MCP Steroid Goes Level 3 — Autonomous IDE Setup for AI Agents</title>
    <link href="https://jonnyzzz.com/blog/2026/06/02/devrig-mcp-steroid-level-3/" rel="alternate" type="text/html" title="devrig: MCP Steroid Goes Level 3 — Autonomous IDE Setup for AI Agents" />
    <published>2026-06-02T00:00:00+00:00</published>
    <updated>2026-06-02T00:00:00+00:00</updated>
    <id>/blog/2026/06/02/devrig-mcp-steroid-level-3</id>
    <content type="html" xml:base="https://jonnyzzz.com/blog/2026/06/02/devrig-mcp-steroid-level-3/">&lt;p&gt;If you have ever wired an AI Agent to an IntelliJ-family IDE through MCP, you know the routine:
start the IDE, install the plugin, write down the port number, paste an HTTP URL into your agent’s
config, restart the agent, hope you guessed which project the agent is supposed to see. Two IDEs
open? Pick one. PyCharm &lt;em&gt;and&lt;/em&gt; IntelliJ both running? Hope the port-number lottery favors you.&lt;/p&gt;

&lt;p&gt;The new &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt; CLI in &lt;a href=&quot;https://mcp-steroid.jonnyzzz.com/releases/0.100/&quot;&gt;MCP Steroid 0.100&lt;/a&gt; kills that ritual. One stdio command —
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig mcp&lt;/code&gt; — and the agent gets a deterministic view of every IDE, every project, every window,
no matter how many are open. If the right IDE is not running, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt; downloads it and starts it.
And the agent’s MCP config? &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig install claude&lt;/code&gt; writes it for you — and from then on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig mcp&lt;/code&gt;
connects to every running IDE that has the MCP Steroid plugin installed.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/posts/2026-06-02-devrig-bridge.svg&quot; alt=&quot;One devrig bridge connects your AI Agent to all running IDEs at once — and can start more&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This is what I call Level 3 of MCP Steroid:&lt;/strong&gt; the agent is no longer the customer of a manually
configured environment. The environment configures itself for the agent.&lt;/p&gt;

&lt;p&gt;The headline number: the e2e test in &lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/test-integration/src/test/kotlin/com/jonnyzzz/mcpSteroid/integration/tests/DevrigManagedBackendAgentE2ETest.kt&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DevrigManagedBackendAgentE2ETest.kt&lt;/code&gt;&lt;/a&gt; provisions an IntelliJ
Community via &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt;, opens &lt;a href=&quot;https://github.com/keycloak/keycloak&quot;&gt;Keycloak&lt;/a&gt;, and asks Claude to find PSI usages of
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;org.keycloak.models.UserModel&lt;/code&gt; over its &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ReferencesSearch&lt;/code&gt;. The verified count is &lt;strong&gt;4,633&lt;/strong&gt;, in
one prompt.&lt;/p&gt;

&lt;p&gt;I want to show what it does, why it matters, where the trade-offs are, and — most importantly —
ask what &lt;em&gt;you&lt;/em&gt; would have it do next.&lt;/p&gt;

&lt;h2 id=&quot;the-three-levels&quot;&gt;The three levels&lt;/h2&gt;

&lt;p&gt;I have been organizing the work in my head as three levels of autonomy. They line up roughly with
Swarmia’s &lt;a href=&quot;https://www.swarmia.com/blog/five-levels-ai-agent-autonomy/&quot;&gt;five levels of coding-agent autonomy&lt;/a&gt;, but applied to the &lt;strong&gt;agent ⇄ IDE&lt;/strong&gt;
boundary:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Level 1 — Manual.&lt;/strong&gt; The user starts the IDE, installs the plugin, copies the port into the
agent’s MCP config. Every step is human. This is where most teams still live.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Level 2 — Auto-discovery.&lt;/strong&gt; The agent’s MCP client finds whichever IDE happens to be running,
reads its open projects, routes calls correctly. The user still has to launch the IDE.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Level 3 — Fully autonomous lifecycle.&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig install&lt;/code&gt; writes the agent’s MCP config.
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig backend download/start&lt;/code&gt; brings the IDE into existence. Open project, route calls, stop
cleanly. The human is out of the loop.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt; is the first thing in the MCP Steroid family that delivers Level 3 end-to-end.&lt;/p&gt;

&lt;h2 id=&quot;a-quick-map-of-where-we-were&quot;&gt;A quick map of where we were&lt;/h2&gt;

&lt;p&gt;The &lt;a href=&quot;/blog/2026/01/04/mcp-steroids-intellij/&quot;&gt;original MCP Steroid post&lt;/a&gt; argued that AI Agents need the &lt;strong&gt;full&lt;/strong&gt; IntelliJ runtime,
not a curated list of tool functions. The MCP standard defines two transports:
&lt;a href=&quot;https://modelcontextprotocol.io/specification/2025-03-26/basic/transports&quot;&gt;stdio and Streamable HTTP&lt;/a&gt;. JetBrains’ own bundled MCP Server picks HTTP — it
listens on a port and the agent dials in. Stdio still dominates IDE use cases though (~67 % of
deployments in 2026 per a &lt;a href=&quot;https://www.digitalapplied.com/blog/mcp-adoption-statistics-2026-model-context-protocol&quot;&gt;recent adoption survey&lt;/a&gt;), and for good reason: it maps 1:1
to “launch a subprocess; talk to it over pipes”. No ports, no DNS, no firewall, no “which IDE
answered?”.&lt;/p&gt;

&lt;p&gt;For one user, one machine, one IDE, the HTTP model is fine. For a real agentic-coding workstation
it is a small disaster:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Real-world setup&lt;/th&gt;
      &lt;th&gt;What breaks&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;IntelliJ + PyCharm + Rider open simultaneously&lt;/td&gt;
      &lt;td&gt;Each grabs a different port; agent picks one&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Two windows of the same product&lt;/td&gt;
      &lt;td&gt;Same port, two projects, ambiguous routing&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Project on a remote dev machine&lt;/td&gt;
      &lt;td&gt;Manual SSH tunnel, manual port-forward&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;First-time setup on a clean laptop&lt;/td&gt;
      &lt;td&gt;Install IDE → install plugin → restart → …&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Background batch job needing an IDE&lt;/td&gt;
      &lt;td&gt;“Please leave the IDE open” — for hours&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;This is the swamp &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt; is built to drain. The product website calls it
“&lt;a href=&quot;https://mcp-steroid.jonnyzzz.com/docs/devrig/&quot;&gt;one bridge, every IDE&lt;/a&gt;” — one &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig mcp&lt;/code&gt; process connects your AI Agent to
&lt;strong&gt;all&lt;/strong&gt; the IntelliJ-family IDEs running on your machine at once, across projects, and can
download and start more on demand.&lt;/p&gt;

&lt;h2 id=&quot;what-devrig-actually-is&quot;&gt;What &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt; actually is&lt;/h2&gt;

&lt;p&gt;Architecturally, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt; is the smallest possible thing the agent talks to: a Kotlin-based
command-line program that speaks MCP over stdio on its front and routes everything else to
whatever IntelliJ-family IDE makes sense. The same binary doubles as an admin CLI you can run
by hand.&lt;/p&gt;

&lt;p&gt;One scope note, because it is a common question: today &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt; is a suite that bridges the agent
to IDEs running the &lt;strong&gt;MCP Steroid plugin&lt;/strong&gt; — that is what gives the agent the full IntelliJ
runtime. It is &lt;strong&gt;not&lt;/strong&gt; a front-end for the &lt;strong&gt;IntelliJ-native MCP Server&lt;/strong&gt; (JetBrains’ own bundled
one). &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt; already &lt;em&gt;detects&lt;/em&gt; those IDEs so it can offer to fix them, but it does not route the
agent through them yet. Speaking to the IntelliJ-native MCP Server directly — so an agent reaches a
stock IDE with no plugin installed — is planned for a future release.&lt;/p&gt;

&lt;p&gt;The current subcommand surface, straight from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig --help&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/posts/2026-06-02-devrig-help.png&quot; alt=&quot;devrig --help in iTerm2 with JetBrains Mono&quot; /&gt;&lt;/p&gt;

&lt;p&gt;The agent only ever sees &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig mcp&lt;/code&gt;. Everything else is for humans or CLI integrations, or for swarm scripts
(&lt;a href=&quot;/blog/2026/03/17/run-agent-v2-release/&quot;&gt;run-agent.sh&lt;/a&gt; and friends) orchestrating fleets. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt; ships without a bundled
runtime and is compiled for &lt;strong&gt;Java 25&lt;/strong&gt;: provide a JDK 25 on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PATH&lt;/code&gt;, via &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DEVRIG_JAVA_HOME&lt;/code&gt;.
Amazon Corretto 25 works; so does any other JDK 25.&lt;/p&gt;

&lt;p&gt;We are going to automatically fetch the required runtime JDK in the next versions.&lt;/p&gt;

&lt;h2 id=&quot;devrig-install--the-new-entry-point&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig install&lt;/code&gt; — the new entry point&lt;/h2&gt;

&lt;p&gt;For most users, the very first command they run is the one that wires &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt; into their coding
agent. It landed in 0.100 to close &lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/issues/64&quot;&gt;issue #64&lt;/a&gt;:&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/posts/2026-06-02-devrig-install.png&quot; alt=&quot;devrig install claude in iTerm2 with JetBrains Mono&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Three flavors, all delegating to the agent’s own CLI under the hood so the resulting config is
exactly the one the agent itself expects:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig install claude&lt;/code&gt; → &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;claude mcp add --scope user mcp-steroid -- devrig mcp&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig install codex&lt;/code&gt; → &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;codex mcp add mcp-steroid -- devrig mcp&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig install gemini&lt;/code&gt; → &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini mcp add --type stdio --scope user --trust mcp-steroid devrig mcp&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These three syntaxes were the &lt;em&gt;single biggest source of setup failures&lt;/em&gt; while scripting the demo.
Three CLIs, two of which have the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--&lt;/code&gt; argv-forwarding gotcha,
one with a different flag spelling each release. The bug-of-the-week was forgetting the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mcp&lt;/code&gt;
subcommand and registering &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt; with nothing after it — at which point the agent tried to run
plain &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt;, got the help banner on stdout, and reported “MCP server initialize failed” with no
explanation. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig install&lt;/code&gt; subcommand makes that class of failure unrepresentable. (The
subcommand was originally spelled &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mpc&lt;/code&gt;; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mcp&lt;/code&gt; is now the canonical name and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mpc&lt;/code&gt; stays as a hidden
alias so older registrations keep working.) Re-running &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig install&lt;/code&gt; is a safe, idempotent upsert:
it finds every prior devrig/&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mcp-steroid&lt;/code&gt; entry and consolidates them into one.&lt;/p&gt;

&lt;p&gt;It also records &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;JAVA_HOME&lt;/code&gt; explicitly so the registered MCP command is reproducible — the JDK
the user actually has on their host is the one the agent will use, not whatever the user’s shell
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;JAVA_HOME&lt;/code&gt; resolves to next month.&lt;/p&gt;

&lt;h2 id=&quot;discovery-the-unglamorous-backbone&quot;&gt;Discovery, the unglamorous backbone&lt;/h2&gt;

&lt;p&gt;Pop a few JetBrains IDEs open and run &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig backend&lt;/code&gt;. On my workstation right now, with one real
IntelliJ open and one previously-downloaded managed Community sitting on disk:&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/posts/2026-06-02-devrig-backend.png&quot; alt=&quot;devrig backend discovery in iTerm2 with JetBrains Mono&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Two paths feed that list:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Marker files&lt;/strong&gt; — every IDE with MCP Steroid installed writes a schema-versioned JSON file at
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;~/.mcp-steroid/markers/&amp;lt;pid&amp;gt;.mcp-steroid&lt;/code&gt; carrying its pid, MCP URL, IDE identity, plugin
version, bearer token, and a per-JVM &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;bootHash&lt;/code&gt;. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt; reads them and subscribes to each
IDE’s NDJSON projects-stream endpoint for a live snapshot of open projects.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Active port scan&lt;/strong&gt; of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;127.0.0.1:63342..63361&lt;/code&gt; (the &lt;a href=&quot;https://www.jetbrains.com/help/idea/settings-tools-built-in-server.html&quot;&gt;IntelliJ built-in Netty server&lt;/a&gt;,
which picks the first free port from 63342) and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:64342..64361&lt;/code&gt; (JetBrains’ own bundled
&lt;a href=&quot;https://www.jetbrains.com/help/idea/mcp-server.html&quot;&gt;MCP server&lt;/a&gt;). This catches IDEs running &lt;em&gt;without&lt;/em&gt; MCP Steroid so we can offer to
fix them.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig backend&lt;/code&gt; lists &lt;em&gt;managed&lt;/em&gt; backends too — IDEs &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt; itself downloaded under
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;~/.mcp-steroid/backends/&lt;/code&gt; — even when they are not currently running. That’s the second row in
the screenshot. Discovery, install state, and lifecycle state share one consolidated view, and
every row carries the &lt;strong&gt;plugin version&lt;/strong&gt; so an out-of-date MCP Steroid is visible at a glance.&lt;/p&gt;

&lt;p&gt;Names are not the same string the IDE uses internally. The &lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/docs/devrig-naming.md&quot;&gt;devrig naming spec&lt;/a&gt;
defines an &lt;em&gt;exposed&lt;/em&gt; form: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;slug(originalName)-projectHash&lt;/code&gt;, where &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;projectHash&lt;/code&gt; is a
SHA-256-derived &lt;strong&gt;base62&lt;/strong&gt; string (8 chars) over canonical inputs. Base62 was a deliberate switch
from base64url — the earlier scheme produced exposed names like &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mcp-steroid-gumou---&lt;/code&gt; whenever
the encoder spilled trailing &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-&lt;/code&gt; / &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;_&lt;/code&gt; characters. Identifier-safe alphabet, always 8 chars,
stable across IDE restarts.&lt;/p&gt;

&lt;h2 id=&quot;routing-across-many-ides-many-projects&quot;&gt;Routing across many IDEs, many projects&lt;/h2&gt;

&lt;p&gt;When two IDEs both have a project called &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;myapp&lt;/code&gt; open, what does the agent see? Names alone are
ambiguous, and the agent will not be impressed by “tried to open &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;myapp&lt;/code&gt;, got the wrong one”.&lt;/p&gt;

&lt;p&gt;Same scheme as above — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;slug&amp;gt;-&amp;lt;projectHash&amp;gt;&lt;/code&gt; — applied per project per IDE pid. Two &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;myapp&lt;/code&gt;s
become two distinct exposed names. Windows route the same way (used by screenshots and
window-targeted actions).&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/posts/2026-06-02-devrig-project.png&quot; alt=&quot;devrig project listing in iTerm2 with JetBrains Mono&quot; /&gt;&lt;/p&gt;

&lt;p&gt;There is one subtlety worth knowing, and it is where the first follow-up to 0.100 landed. In 0.100, when
the agent called &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;steroid_open_project&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt; &lt;em&gt;guessed&lt;/em&gt; the target IDE — any running &lt;em&gt;managed&lt;/em&gt;
backend first (the agent’s own sandbox), the newest discovered IDE otherwise. That guess is fine
until two IDEs could each plausibly answer. So &lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/pull/87&quot;&gt;issue #87&lt;/a&gt; made the choice explicit:
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;steroid_list_projects&lt;/code&gt; now self-describes a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;backends[]&lt;/code&gt; list, each entry carrying an opaque,
routable &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;backend_name&lt;/code&gt;, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;steroid_open_project&lt;/code&gt; now &lt;strong&gt;requires&lt;/strong&gt; that &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;backend_name&lt;/code&gt; on the
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt; surface to say &lt;em&gt;exactly&lt;/em&gt; which IDE opens the project. It is a &lt;strong&gt;devrig-only&lt;/strong&gt; parameter — a
direct one-IDE connection does not expose it, and ignores it if sent — and an unknown value comes
back as a self-correcting error listing the routable ones. The guidance
the agent gets: prefer the backend that already has the same repository — or a sibling git worktree
of it — open. One catch worth repeating in the prompt: those ids are &lt;strong&gt;not&lt;/strong&gt; stable across IDE
restarts, so the rule is &lt;em&gt;re-read &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;steroid_list_projects&lt;/code&gt;, don’t cache &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;backend_name&lt;/code&gt;&lt;/em&gt;.&lt;/p&gt;

&lt;h2 id=&quot;auto-downloading-and-auto-starting-a-managed-ide&quot;&gt;Auto-downloading and auto-starting a managed IDE&lt;/h2&gt;

&lt;p&gt;The biggest jump from Level 2 to Level 3. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig backend download&lt;/code&gt; (no id) lists what is
available, &lt;em&gt;and&lt;/em&gt; declares which IDEs are compatible with the plugin version that ships in this
build of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/posts/2026-06-02-devrig-backend-download.png&quot; alt=&quot;devrig backend download list in iTerm2 with JetBrains Mono&quot; /&gt;&lt;/p&gt;

&lt;p&gt;A few interesting things here:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Community editions&lt;/strong&gt; come from the real GitHub releases of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;idea-community&lt;/code&gt; / &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pycharm-community&lt;/code&gt;,
not from the marketing-version JetBrains feed — the JetBrains products API doesn’t surface true
IC/PC release artifacts in a stable enough way. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt; resolves them directly from the
open-source repos.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Android Studio&lt;/strong&gt; comes from the &lt;a href=&quot;https://developer.android.com/studio&quot;&gt;Quail canary feed&lt;/a&gt; — there is no
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;data.services.jetbrains.com&lt;/code&gt; row for it, but Quail publishes a canonical SHA-256 per archive.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Plugin compatibility is gated at download time.&lt;/strong&gt; Every row carries the IDE’s build baseline
(e.g. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;261&lt;/code&gt;). The bundled MCP Steroid plugin’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;since-build&lt;/code&gt; / &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;until-build&lt;/code&gt; from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;plugin.xml&lt;/code&gt;
is read at startup, and IDEs whose baseline falls outside the supported range are marked
&lt;em&gt;incompatible&lt;/em&gt; — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig backend download&lt;/code&gt; refuses to fetch them rather than letting the agent
land in an IDE that silently won’t load the plugin. Primary baseline today: &lt;strong&gt;2026.1 (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;261&lt;/code&gt;)&lt;/strong&gt;,
with &lt;strong&gt;2026.2 EAP (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;262&lt;/code&gt;)&lt;/strong&gt; as secondary verification. &lt;strong&gt;2025.3 (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;253&lt;/code&gt;) is dropped.&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Archive caching is hash-qualified.&lt;/strong&gt; Community and Ultimate archives that happen to share a
filename cannot collide; both unpacked trees carry a SHA-derived prefix.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;$ devrig backend download idea-ultimate
… resolving IDE 2026.1.2 from data.services.jetbrains.com …
… downloading idea-ultimate-2026.1.2 (≈ 1.2 GB) …
id:       idea-ultimate-2026.1.2
state:    installed
install:  /Users/jonnyzzz/.mcp-steroid/backends/idea-ultimate-2026.1.2
cache:    /Users/jonnyzzz/.mcp-steroid/caches/idea-ultimate-2026.1.2

$ devrig backend start idea-ultimate
pid:    67890
log:    /Users/jonnyzzz/.mcp-steroid/caches/idea-ultimate-2026.1.2/logs/managed.log
config: /Users/jonnyzzz/.mcp-steroid/caches/idea-ultimate-2026.1.2/config
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The started IDE is &lt;strong&gt;headless from the user’s perspective&lt;/strong&gt; — it can stay in the background as
long as the agent needs it, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig backend stop&lt;/code&gt; reaps it cleanly. A global lock serialises
download / start / stop so two concurrent agents can’t trash the same on-disk install. Managed
backends emit anonymous PostHog beacons (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig_session_initialized&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig_exec_code&lt;/code&gt;,
start / stop, errors) so I can tell whether agents are actually using the lifecycle or working
around it.&lt;/p&gt;

&lt;h2 id=&quot;the-whole-flow-end-to-end&quot;&gt;The whole flow, end to end&lt;/h2&gt;

&lt;p&gt;The sequence above is exactly what the end-to-end test does, and it is the cleanest demonstration of
Level 3 I have. In one prompt, with no MCP config and no IDE running up front: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig install claude&lt;/code&gt;, then
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig backend download idea-community&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig backend start idea-community&lt;/code&gt;, and then Claude
opens &lt;strong&gt;Keycloak&lt;/strong&gt; and runs
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;JavaPsiFacade.findClass(&quot;org.keycloak.models.UserModel&quot;) → ReferencesSearch.search(psiClass).findAll().size&lt;/code&gt;.
The return value is real:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;4,633 PSI usages of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;org.keycloak.models.UserModel&lt;/code&gt; — in one prompt.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It comes straight from the e2e test (&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/test-integration/src/test/kotlin/com/jonnyzzz/mcpSteroid/integration/tests/DevrigManagedBackendAgentE2ETest.kt&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DevrigManagedBackendAgentE2ETest.kt&lt;/code&gt;&lt;/a&gt;). One honesty note: the IDE
archive and Keycloak’s Maven indices are pre-warmed before the run, so the wall-clock stays short —
but that only changes the &lt;em&gt;timing&lt;/em&gt;. The agent’s actual work — provision the IDE, open the project,
run the PSI search — is byte-identical whether the caches are warm or cold.&lt;/p&gt;

&lt;h2 id=&quot;what-0100-changed-beyond-devrig&quot;&gt;What 0.100 changed beyond &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt;&lt;/h2&gt;

&lt;p&gt;The &lt;a href=&quot;https://mcp-steroid.jonnyzzz.com/releases/0.100/&quot;&gt;0.100 release notes&lt;/a&gt; (&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/releases/tag/v0.100&quot;&gt;GitHub release &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;v0.100&lt;/code&gt;&lt;/a&gt;) cover the full
delta, but the agent-facing items worth naming alongside &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Typed &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;modal&lt;/code&gt; mode for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;steroid_execute_code&lt;/code&gt;.&lt;/strong&gt; The earlier mix of dialog-killer and
allow-modal booleans is replaced with one explicit parameter: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;smart_non_modal&lt;/code&gt; (default — close
stray dialogs, verify non-modal, commit + refresh, wait for smart mode), &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;non_modal&lt;/code&gt; (assert
only, no mid-run policy), or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;unleashed&lt;/code&gt; (intentional modal-dialog workflows).&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The tool surface narrowed from 10 → 8.&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;steroid_action_discovery&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;steroid_apply_patch&lt;/code&gt;
are gone; the same workflows live as recipes (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mcp-steroid://ide/action-discovery&lt;/code&gt;,
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mcp-steroid://ide/apply-patch&lt;/code&gt; with the in-script &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;applyPatch { }&lt;/code&gt; DSL) reached only through
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;steroid_fetch_resource&lt;/code&gt;. The IDE-conditional context the resource carries is the reason for
not advertising recipes via the generic MCP &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;resources/list&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;steroid_input&lt;/code&gt; switched to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;window_id&lt;/code&gt;&lt;/strong&gt; (from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;steroid_list_windows&lt;/code&gt; /
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;steroid_take_screenshot&lt;/code&gt;). Breaking change to that tool’s input — worth flagging if you have
scripts pinning the old &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;screenshot_execution_id&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The devrig↔plugin wire contract is pinned&lt;/strong&gt;, additive-only, with contract tests covering the
full &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;steroid_execute_code&lt;/code&gt; parameter set (&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/issues/23&quot;&gt;issue #23&lt;/a&gt;).&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Token-efficient tabular output.&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;printCsv&lt;/code&gt; / &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;printToon&lt;/code&gt; script-context helpers let
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;steroid_execute_code&lt;/code&gt; return compact tables without token bloat (&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/issues/34&quot;&gt;#34&lt;/a&gt; / &lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/issues/35&quot;&gt;#35&lt;/a&gt;).&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Human review mode is gone in full&lt;/strong&gt; — the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;review.*&lt;/code&gt; registry keys, the
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mcp.steroid.review.mode&lt;/code&gt; system property, the storage helpers, the docs and website pages.
The product is focused on agent-driven IDE execution rather than a human-gated review step.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;pros--what-changes-for-the-agent&quot;&gt;Pros — what changes for the agent&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;One transport, one server.&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig mcp&lt;/code&gt; is the only thing the agent’s MCP config has to
know. No ports, no URLs, no per-machine drift. Stdio is the dominant IDE transport for a reason.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The install is a single command.&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig install claude|codex|gemini&lt;/code&gt; writes the agent
config the way the agent itself expects. The three-CLI argv-forwarding tax is paid once, in
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt;, not on every user’s machine.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Multi-IDE, multi-window, multi-project — out of the box.&lt;/strong&gt; Hash-suffixed routing makes
“which one?” disambiguation explicit; base62 hashes are identifier-safe.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Compatibility gating.&lt;/strong&gt; If the bundled plugin can’t load in a given IDE build, that IDE is
flagged at &lt;em&gt;download time&lt;/em&gt;. Fewer silent dead-ends.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;First run is one command, not a setup checklist.&lt;/strong&gt; No “go install the IDE first” step in the
agent’s bring-up guide — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig backend download&lt;/code&gt; &lt;em&gt;is&lt;/em&gt; the install step (a real download the first
time, cached after).&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Background mode is normal.&lt;/strong&gt; Spinning up an IDE for a 4-hour batch job stops being weird; it
is just &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;start&lt;/code&gt; + &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;stop&lt;/code&gt;. The Docker test matrix exercises that path daily.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Inspectable.&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig backend --json&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig project --json&lt;/code&gt; give swarm scripts a
deterministic snapshot to make decisions on. Managed backends report their lifecycle via
PostHog beacons.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;cons--and-i-want-you-to-read-these-honestly&quot;&gt;Cons — and I want you to read these honestly&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;JDK 25 is a hard requirement.&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt; doesn’t bundle a JVM in 0.100; the host must provide
Java 25. A bundled-runtime story is on the roadmap (the deployment spec exists), not in the box
yet.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Port-range assumption.&lt;/strong&gt; The active scanner pings exactly &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;63342..63361&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;64342..64361&lt;/code&gt;.
Anything outside those ranges is invisible. Right default in 2026; still a default.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Managed-IDE operations are one-at-a-time.&lt;/strong&gt; A global lock prevents concurrent download / start
/ stop. Two agents on the same machine launching different IDEs in parallel will serialise.
Probably the right call; worth knowing.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Detection lag.&lt;/strong&gt; A crashed IDE stays in the routing table for ~2 seconds until the next poll.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The trust dialog can still block a fully-autonomous open.&lt;/strong&gt; On 2026.1.2 the project-trust
auto-grant regressed (&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/issues/65&quot;&gt;issue #65&lt;/a&gt;): a freshly opened project can stall on the Trust
dialog — the one human gate that quietly defeats the “human out of the loop” pitch until someone
clicks it. Embarrassing for a Level 3 story, and it belongs near the top of the fix list.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Multi-IDE labeling is still settling.&lt;/strong&gt; With several IDEs open, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;steroid_list_projects&lt;/code&gt; /
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;steroid_list_windows&lt;/code&gt; could mislabel which backend a project belongs to (&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/issues/89&quot;&gt;issue #89&lt;/a&gt;).
The explicit &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;backend_name&lt;/code&gt; model above points the right way, but making the labels themselves
reliable across many IDEs is the actual fix, and that part is still in flight.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Plugin install on user-launched IDEs still has a human seam.&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig backend provision&lt;/code&gt;
prints instructions — it does not yet drop the plugin file into the user’s main IDE config
automatically. Closing that seam is on the list.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Single-user assumption.&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;~/.mcp-steroid/&lt;/code&gt; is per user. Multi-tenant CI servers will need a
story; today they get one home directory.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;macOS / Linux first.&lt;/strong&gt; Windows is supported (the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig install&lt;/code&gt; PowerShell path is real),
but most production hours so far live on macOS arm64.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;2025.3 is dropped.&lt;/strong&gt; If you are on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;253&lt;/code&gt;, the plugin will refuse to load and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt; will
refuse to download the matching managed backend. Upgrade to 2026.1.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;open-questions--what-should-it-do-next&quot;&gt;Open questions — what should it do next?&lt;/h2&gt;

&lt;p&gt;This is the part I really wrote this post for. 0.100 is shipped, a fast follow-up added explicit
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;backend_name&lt;/code&gt; routing, and the foundation works. Now I want to choose what to build next based on
what &lt;em&gt;you&lt;/em&gt; actually need.&lt;/p&gt;

&lt;p&gt;A few directions on the whiteboard — please tell me which ones matter, and what I am missing:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Remote IDEs as a first-class concept.&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt; already routes between local IDEs. Same
abstraction wants to extend to a JetBrains Gateway / SSH host: tell &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt; “use the IDE on
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dev-box-7&lt;/code&gt;” and it sets up the channel. Niche, or the next obvious step?&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Fully autonomous plugin install into a user-launched IDE.&lt;/strong&gt; Drop the plugin file in the
right directory, restart the IDE, verify the marker appeared, report back. Worth it, or do
most users want a manual confirmation step?&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Bundled-JRE devrig.&lt;/strong&gt; Ship a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;~/.mcp-steroid/binaries/&amp;lt;os&amp;gt;-&amp;lt;cpu&amp;gt;-&amp;lt;sha&amp;gt;/&lt;/code&gt; cache with a small
wrapper, so the JDK-25 prerequisite stops being a prerequisite. The deployment spec is written.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Fleet mode.&lt;/strong&gt; One &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt; server, N parallel agents, one user — each agent gets its own
managed IDE window without trampling the others. The lock today serialises; fleet mode would
need a different concurrency story.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig doctor&lt;/code&gt;.&lt;/strong&gt; Self-diagnostic command: “your plugin is out of date in IDE 2, port 63344
is taken by a different process, your JDK is missing on Linux/arm64, your &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;claude&lt;/code&gt; MCP entry
points at a stale path.”&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Sandboxed IDE profiles.&lt;/strong&gt; Spawn an IDE with a throwaway config dir for one task, then
delete. Reproducible runs without polluting the user’s main IDE setup.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;CI-friendly mode.&lt;/strong&gt; Headless start optimised for GitHub Actions / TeamCity where the
“human in the loop” assumption no longer holds.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Plugin auto-update.&lt;/strong&gt; When &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt; itself updates and ships a newer plugin, push it into
every managed IDE on the machine.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Auth and policy.&lt;/strong&gt; The per-IDE bearer token is already on the marker file. The next question
is who is allowed to do what — does that matter to teams, or is it overkill outside enterprise?&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;More agent CLIs.&lt;/strong&gt; Cursor, Aider, Continue, IntelliJ AI Assistant chat — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig install&lt;/code&gt;
today knows three. Adding a fourth is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mcpAddStdioArgs&lt;/code&gt; plus an enum entry. Which one would
actually unblock you?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Reply on &lt;a href=&quot;https://www.linkedin.com/in/jonnyzzz/&quot;&gt;LinkedIn&lt;/a&gt;, &lt;a href=&quot;https://twitter.com/jonnyzzz&quot;&gt;Twitter&lt;/a&gt;, or open an issue on
&lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid&quot;&gt;jonnyzzz/mcp-steroid&lt;/a&gt; — and the more concrete the workflow you describe, the more
directly I can shape this around it. Issue &lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/issues/64&quot;&gt;#64&lt;/a&gt; tracked the auto-install ask before
it landed; the same template works for anything on the list above.&lt;/p&gt;

&lt;h2 id=&quot;where-this-is-going-and-where-it-is&quot;&gt;Where this is going, and where it is&lt;/h2&gt;

&lt;p&gt;I have been writing about &lt;a href=&quot;/blog/2026/03/24/agentic-experience-and-tools/&quot;&gt;Agentic Experience and Tools&lt;/a&gt; as the discipline of building
products with the AI Agent as the &lt;em&gt;user&lt;/em&gt;, not as a feature bolted onto a human-facing UI. MCP
Steroid is that bet, and it has a deliberate arc:&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/posts/2026-06-02-mcp-steroid-arc.svg&quot; alt=&quot;MCP Steroid&apos;s arc — the in-IDE plugin, then devrig the bridge, then remote IDE fleets&quot; /&gt;&lt;/p&gt;

&lt;p&gt;The plugin came first: hand the agent the &lt;strong&gt;full&lt;/strong&gt; IntelliJ runtime instead of a curated tool menu.
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt; is the next move — wrap that capability in one stdio bridge so the agent reaches &lt;em&gt;every&lt;/em&gt;
MCP Steroid–enabled IDE on the machine, and can bring a managed one into existence on its own. The
step after that is remote: the same protocol pointed at IDEs on other hosts, and eventually fleets
of them, which is probably where the commercial product starts to make sense.&lt;/p&gt;

&lt;p&gt;So where are we, honestly? Level 3 has worked end to end — the &lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid/blob/main/test-integration/src/test/kotlin/com/jonnyzzz/mcpSteroid/integration/tests/DevrigManagedBackendAgentE2ETest.kt&quot;&gt;Keycloak run&lt;/a&gt; is not a mock,
and the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;backend_name&lt;/code&gt; routing that landed right after 0.100 makes “which IDE?” an explicit,
answerable question. The foundation is real. It is also still an independent experiment with sharp
edges: the 2026.1.2 trust-dialog regression can still stall an autonomous open, the JDK-25
prerequisite is unbundled, plugin promotion into a user’s own IDE still has a manual step, and
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;~/.mcp-steroid/&lt;/code&gt; assumes a single user. Remote and fleet mode are designed, not shipped.&lt;/p&gt;

&lt;p&gt;The honest summary: the agent can now stop asking a human for help just to &lt;em&gt;get started&lt;/em&gt; against a
real IDE — and that, I think, is where this kind of tooling gets interesting. What it should reach
for next I genuinely do not know yet; that is what the open questions above are for. Tell me which
one is yours.&lt;/p&gt;</content>

  
  
  
  
  

    <author>
      <name>Eugene Petrenko</name>
    </author>

  
    <category term="mcp" />
  
    <category term="mcp-steroid" />
  
    <category term="ai-agents" />
  
    <category term="ai-coding" />
  
    <category term="agentic-experience" />
  
    <category term="intellij" />
  
    <category term="cli" />
  
    <category term="devrig" />
  
    <summary type="html">The new `devrig` CLI in MCP Steroid 0.100 turns the IDE bring-up ritual into one stdio command. `devrig install ` writes the MCP config; `devrig backend download/start` brings an IntelliJ-family IDE into existence and gives the agent an IDE of its own. In the e2e test, Claude provisions an IDE and finds 4,633 PSI usages of one class in Keycloak — in one prompt.</summary>
  
  </entry>
  
  <entry>
    <title type="html">From HTTP MCP to stdio: Lessons Connecting an AI Agent to a Desktop IDE</title>
    <link href="https://jonnyzzz.com/blog/2026/05/20/http-mcp-to-stdio-lessons/" rel="alternate" type="text/html" title="From HTTP MCP to stdio: Lessons Connecting an AI Agent to a Desktop IDE" />
    <published>2026-05-20T00:00:00+00:00</published>
    <updated>2026-05-20T00:00:00+00:00</updated>
    <id>/blog/2026/05/20/http-mcp-to-stdio-lessons</id>
    <content type="html" xml:base="https://jonnyzzz.com/blog/2026/05/20/http-mcp-to-stdio-lessons/">&lt;p&gt;If you want to connect an AI Agent to a locally running desktop application — in my case the
IntelliJ family — there is one obvious idea. Run an MCP server &lt;em&gt;inside&lt;/em&gt; the application. Bind it to a
port. Point the agent at that port as an HTTP MCP server. Done.&lt;/p&gt;

&lt;p&gt;It is the obvious idea because it is the correct first idea. I built exactly that, shipped it
as part of my independent experiment, and
used it every day. It kept breaking in real workstation setups — including my own. This is the
story of why, and why the answer turned out to be the least glamorous transport in the spec:
&lt;strong&gt;stdio&lt;/strong&gt;, behind a small CLI that does the routing.&lt;/p&gt;

&lt;h2 id=&quot;the-obvious-approach-an-http-mcp-server-inside-the-ide&quot;&gt;The obvious approach: an HTTP MCP server inside the IDE&lt;/h2&gt;

&lt;p&gt;The MCP standard defines two transports: &lt;a href=&quot;https://modelcontextprotocol.io/specification/2025-03-26/basic/transports&quot;&gt;stdio and Streamable HTTP&lt;/a&gt; (the older
HTTP+SSE transport was deprecated in the 2025-03-26 spec). For a desktop app the HTTP route looks
natural — the IDE is already a long-running statefull process, so let it open a socket and answer MCP calls.&lt;/p&gt;

&lt;p&gt;So I implemented the HTTP layer and reached for the &lt;a href=&quot;https://github.com/modelcontextprotocol/kotlin-sdk&quot;&gt;Ktor MCP integration in the Kotlin SDK&lt;/a&gt;.
And immediately hit the first snag (state January 2026): at the time, that integration only spoke the &lt;strong&gt;SSE&lt;/strong&gt; transport,
not the newer &lt;strong&gt;Streamable HTTP&lt;/strong&gt; the latest spec wanted. I was stuck on a spec-version mismatch
before I had connected a single agent. (The Kotlin SDK has since grown &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mcpStreamableHttp()&lt;/code&gt; helpers;
back then it had not.)&lt;/p&gt;

&lt;p&gt;The thing that saved me here was not clever code. It was &lt;strong&gt;integration tests for the MCP layer&lt;/strong&gt;.
It was using Docker image, where an agent (claude, codex, gemini) is started and connected
to the MCP server running in the test. It asks an agent to look at the MCP tools and call them.&lt;/p&gt;

&lt;p&gt;Every time I or an AI Agent re-implemented the transport, a test would go red and tell me I had broken the contract.
I re-did that layer more than once, and the test suite caught a regression every single time. If you
take one portable lesson from this whole post, take that one: an agent-facing protocol is exactly the
kind of surface that deserves real integration tests, because the consumer – an LLM – will not file a
bug report unless you ask it explicitly (which I often do)&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Good integration-test coverage of the MCP layer paid for itself many times over.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2 id=&quot;where-http-against-a-desktop-app-falls-apart&quot;&gt;Where HTTP-against-a-desktop-app falls apart&lt;/h2&gt;

&lt;p&gt;The naive model assumes one user, one machine, one IDE. That is not what a real agentic-coding
workstation looks like. Here is the pile of complications, roughly in the order they bit me.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A dynamic port at startup, with no limit on instances.&lt;/strong&gt; Nothing stops you from running several
instances of the same IDE at once — distinct processes, each launched with its own config directory.
A &lt;em&gt;fixed&lt;/em&gt; port is therefore impossible — the first IDE would grab it and every other instance would
have to fail to bind. So each IDE has to pick its port dynamically: start
from a configurable base and increment until one is free. This is, not coincidentally, exactly how
the &lt;a href=&quot;https://www.jetbrains.com/help/idea/settings-tools-built-in-server.html&quot;&gt;IntelliJ built-in web server&lt;/a&gt; behaves (it starts from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;63342&lt;/code&gt;). It works. But it
turns the port into a moving target.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Several IDEs at once, by design.&lt;/strong&gt; IntelliJ is not a single app — JetBrains ships a &lt;a href=&quot;https://www.jetbrains.com/products/&quot;&gt;whole family
of IDEs&lt;/a&gt; (IDEA, PyCharm, GoLand, WebStorm, Rider, and more). One can have
two or three of them open at once; power users run more than one
instance — distinct processes with their own config directories — for different projects. Some use
EAP versions in a row with release builds. Each such IntelliJ-based IDE
process is its own MCP server on its own base-plus-increment port. (Several project &lt;em&gt;windows&lt;/em&gt; in one
instance are a different axis — one port, several projects.) Now I have a &lt;em&gt;set&lt;/em&gt; of endpoints to
discover and route between, not one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Start-order dependence.&lt;/strong&gt; Because the ports are assigned first-come-first-served from the base, the
port a given IDE gets depends on the order you launched everything in. To make the agent talk to a
&lt;em&gt;specific&lt;/em&gt; IDE, you have to start that IDE first so it grabs the predictable port. That is a terrible
thing to ask a human to remember, and an impossible thing to ask an agent to reason about. That is
not manageable to maintain each time the port changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Agents can’t disambiguate identically-named MCP servers.&lt;/strong&gt; This was the quiet killer. You cannot
simply register five “mcp-steroid” servers with an agent and expect it to choose wisely — to the
agent they look like the same server, and it does not understand that they are different IDEs. In
practice, I ended up registering exactly one server, on the first port in the range — and then back to
the start-order ritual to make sure the right IDE owned that port.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nested IDEs.&lt;/strong&gt; Sometimes an IDE is launched &lt;em&gt;from&lt;/em&gt; another IDE. Now the set of processes the MCP
server must cover is not even stable for the duration of a session.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Agent up, IDE down.&lt;/strong&gt; The one that hurt the most in daily use. The agent runs for hours; the IDE
does not. I shut an IDE down to update it, or it crashed, or I just forgot to open it — and the agent
was instantly dead in the water, frequently unable to reconnect even after the IDE came back. An HTTP
client dialing a port that no longer answers has nowhere to go. The MCP Steroid implementation
specifically handles unknown sessions to keep an agent working after an IDE is restarted.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Real-world setup&lt;/th&gt;
      &lt;th&gt;What HTTP-in-the-IDE breaks&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Two instances of the same IDE&lt;/td&gt;
      &lt;td&gt;Can’t share a port; base+increment; which one answers?&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;IDEA + PyCharm + GoLand open at once&lt;/td&gt;
      &lt;td&gt;Several servers, several ports, agent picks one&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;“Use &lt;em&gt;this&lt;/em&gt; IDE” intent&lt;/td&gt;
      &lt;td&gt;Depends on launch order — start it first or lose&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Agent registered with N identical servers&lt;/td&gt;
      &lt;td&gt;Agent can’t disambiguate; only one ends up usable&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;IDE shut down to update while the agent runs&lt;/td&gt;
      &lt;td&gt;Agent dead, often can’t reconnect&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;None of these is fatal on its own. I worked around every one of them and kept using the tool. But the
workarounds stack into a fragile pile, and the pile is the point: HTTP-against-a-desktop-app is
fighting the environment instead of fitting it.&lt;/p&gt;

&lt;h2 id=&quot;the-pivot-the-app-doesnt-care-about-the-transport&quot;&gt;The pivot: the app doesn’t care about the transport&lt;/h2&gt;

&lt;p&gt;Here is the schema that unlocked everything.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;The IDE side does not care whether the bytes arrive over HTTP or stdio. MCP is MCP. The transport
is a detail at the edge.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Instead of forcing the &lt;em&gt;application&lt;/em&gt; to host the network endpoint, move all the routing and
connection-handling to a small program the agent launches directly — and let &lt;em&gt;that&lt;/em&gt; talk to the IDEs
however it likes.&lt;/p&gt;

&lt;p&gt;That program is a CLI. The agent speaks MCP to it over &lt;strong&gt;stdio&lt;/strong&gt;: launch a subprocess, talk over
pipes. No agent-facing ports, no DNS, no firewall, no “which IDE answered?”. And because the CLI owns
the routing,
it can do all the things the IDE-hosted server could not: discover every running IDE, pick the right
one, start one if none exists, and survive an IDE restarting underneath it.&lt;/p&gt;

&lt;p&gt;In MCP Steroid this CLI is &lt;strong&gt;devrig&lt;/strong&gt;. The agent’s entire configuration becomes one stdio command;
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt; is the coordinator that connects to all your IDEs, regardless of how many are running. The
mechanics of what &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt; discovers, downloads, and routes — and the screenshots — are the subject of
a follow-up, the &lt;a href=&quot;/blog/2026/06/02/devrig-mcp-steroid-level-3/&quot;&gt;Level 3 post&lt;/a&gt;; this post is about &lt;em&gt;why&lt;/em&gt; it has to exist.&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;        Coding Agent
            │  MCP over stdio  (one subprocess, no ports)
            ▼
        devrig  ── coordinator / router ──┐
            │            │            │   │
            ▼            ▼            ▼   ▼
        IDEA #1      PyCharm       GoLand   (start one on demand if none)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;why-stdio--a-coordinator-is-actually-more-powerful&quot;&gt;Why stdio + a coordinator is actually more powerful&lt;/h2&gt;

&lt;p&gt;Moving to stdio is not just “the same thing without ports.” It changes what is &lt;em&gt;possible&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sessionless routing, because the state lives on disk.&lt;/strong&gt; MCP Steroid is a &lt;em&gt;stateful&lt;/em&gt; MCP — its
commands change the IDE’s state (files, refactorings, run configs, etc.). That sounds like it would
&lt;em&gt;need&lt;/em&gt; sticky sessions, but it is the opposite: because the durable state is in the process,
there is no MCP session to pin. Any command can be routed to &lt;strong&gt;any&lt;/strong&gt; backend capable of
executing it. That is what makes multi-IDE-per-project realistic — GoLand handling the Go module,
an IntelliJ handling the Java service, both serving the same checkout. The one catch I am still
sharpening: the project key has to encode the &lt;em&gt;IDE type&lt;/em&gt;, or the agent can’t reliably tell the
GoLand-&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;myapp&lt;/code&gt; from the IDEA-&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;myapp&lt;/code&gt; and route to the right one. The data is there; making it obvious
to the agent is the open challenge.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Restart resilience.&lt;/strong&gt; The stdio coordinator can let connections come and go. The “agent up, IDE
down” dead end stops being a dead end — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt; can reconnect, or start an IDE, instead of leaving the
agent staring at a closed socket. It can also provision the IDE configurations if neccessary for the project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Provision on demand.&lt;/strong&gt; Once the agent talks to a coordinator instead of a fixed socket, “no IDE is
running” stops being a dead end — the coordinator can bring one into existence for the task rather
than asking a human to “please leave the IDE open for the next four hours.” How that will work once
devrig ships it is the story for the follow-up &lt;a href=&quot;/blog/2026/06/02/devrig-mcp-steroid-level-3/&quot;&gt;Level 3 post&lt;/a&gt;; the point here is that the
architecture is what makes it possible at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A path to remote.&lt;/strong&gt; Once the agent talks to a coordinator instead of a fixed local socket, the
particular local IDE stops being the center of the architecture. The same protocol extends to remote
IDEs — and eventually to fleets of them. That is the part I am most interested in commercially, and
the reason &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt; itself is the thing worth promoting, not any single IDE behind it.&lt;/p&gt;

&lt;h2 id=&quot;routing-to-a-project-project_name-and-discovery&quot;&gt;Routing to a project: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;project_name&lt;/code&gt; and discovery&lt;/h2&gt;

&lt;p&gt;A coordinator only helps if the agent can say &lt;em&gt;which&lt;/em&gt; project it means without knowing anything about
ports, process ids, or how many IDEs are open. So the most important thing the coordinator does is
turn “all the IDEs on this machine” into one flat, addressable list of projects. Two halves make that
work: &lt;strong&gt;discovery&lt;/strong&gt; and a single addressing key, &lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;project_name&lt;/code&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Discovery&lt;/strong&gt; is the coordinator’s live picture of the world. It keeps an up-to-date view of the
IDEs running on the machine and the projects each one currently has open, as one map — every open
project, across every running IDE, in a single table. When an IDE opens or closes a project, the map
updates; the agent never has to be told.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;project_name&lt;/code&gt;&lt;/strong&gt; is the only thing the agent has to care about. The coordinator exposes each
discovered project under a stable name and hands the agent that list. Every project-targeting tool
takes exactly one project argument — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;project_name&lt;/code&gt; — and nothing else about &lt;em&gt;where&lt;/em&gt; the project
lives. The agent reads the list, picks a name, and calls the tool.&lt;/p&gt;

&lt;p&gt;The routing itself is then almost boring, which is the point. On each call the coordinator looks the
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;project_name&lt;/code&gt; up in its discovery map, finds the IDE that actually has that project open, and
forwards the call there — translating the exposed name back to whatever that IDE calls the project
internally. If two IDEs happen to have a project with the same name open, the coordinator makes the
exposed &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;project_name&lt;/code&gt; unique, so the agent can still aim at exactly one. The agent addresses a name;
the coordinator does the plumbing.&lt;/p&gt;

&lt;p&gt;This is the concrete shape of the sessionless routing above: the key is the &lt;em&gt;project&lt;/em&gt;, not a session,
so any call for a given &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;project_name&lt;/code&gt; simply lands wherever that project lives. That’s the whole
trick behind “one agent, many IDEs” — the agent holds a list of names, and discovery keeps that list
honest.&lt;/p&gt;

&lt;h2 id=&quot;mcp-cli-or-skill-there-is-no-single-answer&quot;&gt;MCP, CLI, or skill? There is no single answer&lt;/h2&gt;

&lt;p&gt;One more thing the journey clarified, and I want to leave it as an open question rather than a verdict.
There is a real design-space discussion hiding under all of this: should a capability be exposed as an
&lt;strong&gt;MCP&lt;/strong&gt; tool, a &lt;strong&gt;CLI&lt;/strong&gt;, or an agent &lt;strong&gt;skill&lt;/strong&gt; — or some combination, skill+CLI, skill+MCP?&lt;/p&gt;

&lt;p&gt;These are at least three distinct axes in a multidimensional feature space, not a ranked list. Some
combinations are clearly more powerful for specific scenarios, and I do not believe there is one
correct point. The honest design move is to assume you will want several of them and factor your
architecture so that adding another surface is cheap. Choosing stdio + a CLI coordinator was partly a
bet on exactly that flexibility: the CLI is a substrate the MCP, the skills, and the human-facing
commands can all sit on top of.&lt;/p&gt;

&lt;p&gt;Give &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt; a try. And let me know what MCP+CLI+Skills equation looks like for you.&lt;/p&gt;

&lt;h2 id=&quot;what-i-would-tell-my-earlier-self&quot;&gt;What I would tell my earlier self&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Start with the obvious transport, but treat it as a hypothesis.&lt;/strong&gt; HTTP-in-the-IDE was the right
thing to try first and the right thing to abandon once the environment voted against it.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Match the transport to the environment, not to the protocol’s defaults.&lt;/strong&gt; A desktop app full of
swappable, restartable, multi-instance processes wants a subprocess-and-pipes model, not a
server-and-port one.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Push routing to the edge you control.&lt;/strong&gt; The IDE can’t own discovery sanely; a CLI the agent
launches can.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Stateful-on-disk means sessionless.&lt;/strong&gt; That inversion is what makes many-backends routing simple.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Test the agent-facing layer like a public API.&lt;/strong&gt; Your most demanding consumer can’t describe what
you broke.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is experimental, in-progress work — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt; is the go-to direction now, but I am still learning
where it bends. It has rough edges I am not hiding: a runtime prerequisite you have to satisfy by
hand, single-user assumptions, and lifecycle operations that serialize. Rather than restate the exact
version numbers here (they will move release to release), the prerequisites, the trade-offs I am
least sure about, and an explicit list of open questions are what the follow-up &lt;a href=&quot;/blog/2026/06/02/devrig-mcp-steroid-level-3/&quot;&gt;Level 3 post&lt;/a&gt;
will cover — head there when you want to actually run it.&lt;/p&gt;

&lt;p&gt;If you have wired an agent to a desktop app and hit a different wall than these — or solved one of
them a better way — I want to hear it. Reply on &lt;a href=&quot;https://www.linkedin.com/in/jonnyzzz/&quot;&gt;LinkedIn&lt;/a&gt; or &lt;a href=&quot;https://twitter.com/jonnyzzz&quot;&gt;Twitter&lt;/a&gt;, or open an
issue on &lt;a href=&quot;https://github.com/jonnyzzz/mcp-steroid&quot;&gt;jonnyzzz/mcp-steroid&lt;/a&gt;. The more concrete the workstation you describe, the more
directly it shapes what &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devrig&lt;/code&gt; does next.&lt;/p&gt;</content>

  
  
  
  
  

    <author>
      <name>Eugene Petrenko</name>
    </author>

  
    <category term="mcp" />
  
    <category term="mcp-steroid" />
  
    <category term="devrig" />
  
    <category term="ai-agents" />
  
    <category term="ai-coding" />
  
    <category term="intellij" />
  
    <category term="architecture" />
  
    <category term="cli" />
  
    <summary type="html">The obvious way to connect an AI Agent to a running IntelliJ is an HTTP MCP server inside the IDE. I built it, shipped it, and used it daily — and it kept breaking on real workstations: dynamic ports, start-order lottery, multiple IDEs, and the agent-up/IDE-down dead end. Here is why I moved the whole thing to stdio and a CLI coordinator called devrig, and what the journey taught me.</summary>
  
  </entry>
  
  <entry>
    <title type="html">130 Release-Roadmap Issues, 818 Agent Runs: A Mid-Iteration Reality Check</title>
    <link href="https://jonnyzzz.com/blog/2026/04/28/ai-agent-roadmap-research/" rel="alternate" type="text/html" title="130 Release-Roadmap Issues, 818 Agent Runs: A Mid-Iteration Reality Check" />
    <published>2026-04-28T00:00:00+00:00</published>
    <updated>2026-04-28T00:00:00+00:00</updated>
    <id>/blog/2026/04/28/ai-agent-roadmap-research</id>
    <content type="html" xml:base="https://jonnyzzz.com/blog/2026/04/28/ai-agent-roadmap-research/">&lt;p&gt;Every product team running toward a release has the same artifact: a roadmap. Not the whole
backlog — the &lt;strong&gt;umbrella epic for the next release&lt;/strong&gt;, with a hundred-plus sub-issues hanging off
it. Most are stubs: a title, a description, maybe some comments. No implementation plan. No
code-level analysis. No effort estimate. No dependency map.&lt;/p&gt;

&lt;p&gt;A senior engineer researching one such issue typically spends 2–4 hours: reading the codebase,
understanding the architecture, scoping the change, writing it up as an actionable spec. For a
release roadmap of 130+ issues against a multi-million-line codebase, that’s weeks of analyst
work before anyone writes a single line of code.&lt;/p&gt;

&lt;p&gt;We ran an AI agent swarm instead — &lt;strong&gt;three different agent CLIs, Claude, Codex, and Gemini&lt;/strong&gt;,
orchestrated together. Four calendar days. 818 agent runs. ~$576 total cost. Every roadmap issue
researched. Then, &lt;strong&gt;while the release was still in flight&lt;/strong&gt;, we ran a second pass to check the
research against what the team was actually doing — explicitly &lt;em&gt;not&lt;/em&gt; a “did it ship” scorecard,
because nothing was expected to be done yet. Here’s how it works, what it produced, and the
honest verdict.&lt;/p&gt;

&lt;h2 id=&quot;its-a-release-roadmap-and-there-is-a-process&quot;&gt;It’s a release roadmap, and there is a process&lt;/h2&gt;

&lt;p&gt;This was not “point a swarm at a backlog and see what happens.” It was a release roadmap, and the
swarm followed a defined process, orchestrated by a root agent that does as little work as
possible and delegates everything:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Collect.&lt;/strong&gt; Pull every unresolved sub-issue of the release umbrella epic from the issue
tracker (through a small tracker CLI we built). Pick the product-requirements (PRD-prefixed)
issues and the engineering issues, and reconstruct the &lt;strong&gt;tree of linked sub-issues&lt;/strong&gt; for each root — the
dependency graph matters as much as the issues themselves.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Research.&lt;/strong&gt; Run each issue through the multi-round pipeline below.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Review by quorum.&lt;/strong&gt; When a root issue is done, or whenever a decision is unclear, spin up
&lt;strong&gt;three review agents&lt;/strong&gt; and take the quorum verdict — not one model’s opinion.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Loop.&lt;/strong&gt; Keep going in a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/loop&lt;/code&gt; until the roadmap is covered, applying the same principle
recursively: the orchestrator delegates, the sub-agents do the work.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The whole thing runs on &lt;a href=&quot;https://run-agent.sh&quot;&gt;run-agent.sh&lt;/a&gt; and the &lt;a href=&quot;/blog/2026/01/05/rlm-multi-agent-orchestration/&quot;&gt;RLM methodology&lt;/a&gt; — one task
per agent, fresh context, markdown files carrying state between them.&lt;/p&gt;

&lt;h2 id=&quot;the-pipeline-r1--r2--r3&quot;&gt;The Pipeline: R1 → R2 → R3&lt;/h2&gt;

&lt;p&gt;Each issue goes through three rounds of agent work. Two issues run in parallel at all times, with
the shell orchestration managing dispatch, detecting completion, skipping already-researched
issues, and retrying agents that time out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Round 1 (Claude)&lt;/strong&gt; reads the issue text, explores the codebase using &lt;a href=&quot;https://mcp-steroid.jonnyzzz.com&quot;&gt;MCP Steroid&lt;/a&gt;,
and produces a first-draft research document: problem framing, relevant source files with class
and method references, and 2–3 solution options. Each option includes a complexity rating
(Low / Med / High), effort estimate (S / M / L / XL), key changes, test strategy, and a trade-offs
table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Round 2 (Codex)&lt;/strong&gt; validates every file reference from Round 1 against the actual codebase. It
finds exact line numbers, checks git history for recent changes to the relevant files, and flags
edge cases Round 1 missed. Every file reference gets a validation status: confirmed, renamed, or
not found. This is the mechanical correctness pass.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Round 3 (Claude)&lt;/strong&gt; synthesises Round 1 + Round 2, resolves conflicts, and writes the final
recommendation: a concrete implementation plan with validated code references, a test strategy,
and follow-up work.&lt;/p&gt;

&lt;p&gt;Claude for R1/R3 and Codex for R2 reflects what each model does well in practice. R2 is largely
mechanical — check that this file exists, find this class, confirm this method — and Codex handles
that efficiently. R1 and R3 require reasoning across options and writing a coherent
recommendation, where Claude produces better outputs. Gemini was in the swarm too, mostly in the
review quorum (more on its small share below).&lt;/p&gt;

&lt;h2 id=&quot;the-tools&quot;&gt;The Tools&lt;/h2&gt;

&lt;p&gt;Three things make this pipeline work at scale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://run-agent.sh&quot;&gt;run-agent.sh&lt;/a&gt;&lt;/strong&gt; is the orchestration script that launches Claude, Codex, and
Gemini in isolated sub-processes. Each run gets its own
timestamped directory: exact prompt, full stdout/stderr capture, PID tracking, exit codes. Full
traceability for every agent execution. The shell orchestration loops over TASK files, detects
completion by checking output files, skips already-researched issues, and retries timeouts. See
the &lt;a href=&quot;/blog/2026/02/06/run-agent-multi-agent-orchestration/&quot;&gt;full description here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://mcp-steroid.jonnyzzz.com&quot;&gt;MCP Steroid&lt;/a&gt;&lt;/strong&gt; gives agents programmatic access
to the full codebase: PSI navigation, class resolution, call graph tracing, symbol search. Without
it, agents produce vague references like “look in the parser module” or “check the rendering
pipeline”. With it, they produce specific class names, method signatures, and
line ranges — navigating the actual PSI tree and resolving inheritance chains. This is the
difference between a summary and a useful research document.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TASK* files&lt;/strong&gt; are structured task definitions written once and consumed by many agents (they
carry no extension — the orchestration matches &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TASK&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TASK-2&lt;/code&gt;, and friends by name). The shell
script loops over them and dispatches agent runs accordingly. The format is simple and consistent
enough that agents parse it without extra tooling. Here are the actual task files that drove the
run (generalized — internal identifiers removed):&lt;/p&gt;

&lt;details&gt;
  &lt;summary&gt;&lt;strong&gt;TASK&lt;/strong&gt; — collect the roadmap and orchestrate&lt;/summary&gt;

  &lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Your main goal is to collect all the relevant issues from the release-roadmap umbrella epic
in our issue tracker (query: all unresolved sub-tasks of the release epic).

Use our small issue-tracker CLI to read the tracker. If it lacks a feature or fails, start
run-agent.sh with a task to fix it. Equip that agent with THE_PROMPT_v5.md from
https://run-agent.sh to research, fix, review, deploy, and build the fix to the CLI, so you
can move on. Repeat that process as many times as you need.

Pick all tasks with the PRD* prefix and the engineering tasks for the product without the
prefix. For each task create a &amp;lt;task-id&amp;gt;.md document with discussion. Each such task has a
tree of linked tasks, which you need to include in the document. I want the graph of linked
sub-tasks for each root task.

Do that work in parallel using https://jonnyzzz.com/RLM.md. Our main goal is descriptive
write-ups for all selected tasks, and the related/linked issue tree.

Once a selected root issue is done, start 3 agents with run-agent.sh to review the work and
do the fixes.

Repeat that work in a /loop until the work is complete. You are the root agent; your main
goal is to orchestrate the research, do as little actual work as possible, and delegate
everything to run-agent.sh, as suggested in RLM.md and THE_PROMPT_v5.md. Apply it recursively.

When not clear, start 3x run-agents and make the quorum decisions.
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;  &lt;/div&gt;

&lt;/details&gt;

&lt;details&gt;
  &lt;summary&gt;&lt;strong&gt;TASK-2&lt;/strong&gt; — the research group of five&lt;/summary&gt;

  &lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Now create a research group of 5 agents running on the target codebase, each via
run-agent.sh. Our goal is to research each task as deeply as possible and provide 3 potential
ways of solving it. Use MCP Steroid to work on the assignment. Since there are far too many
tasks, prepare all the prompts in the prompts/ folder and a run script that calls all agents
on all tasks. For each task, 3 runs: claude -&amp;gt; codex -&amp;gt; claude. Each run includes the same
run-agent.sh and THE_PROMPT_v5.md, and refers to MCP Steroid&apos;s AGENTS.md as the usage guide.
Write all the outcomes to the issue-id.md file respectively. Make each agent use run-agent.sh
to research different parts of the monorepo. Your goal is to delegate and manage the process;
set up a /loop so we keep running and don&apos;t stop. Work one issue at a time. For issues with
linked issues, prefer the leaf issues first; create a dedicated file like
issue-id-leaf-another-issue-id.md and place details there. Include a high-level overview in
the initial issue.
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;  &lt;/div&gt;

&lt;/details&gt;

&lt;details&gt;
  &lt;summary&gt;&lt;strong&gt;TASK-3&lt;/strong&gt; — close the gap on the stub issues&lt;/summary&gt;

  &lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;## Goal
Research all remaining .md files in this directory that have no proposed solutions yet
(53 standalone stubs with only tracker metadata, plus 2 leaf files missing solutions).

For each issue:
1. Check tracker status via our issue-tracker CLI — if already resolved/closed, append
   &quot;Status: Resolved&quot; and skip deep research.
2. Check for sufficient information — if the description is primarily marketing/strategy
   language with no concrete technical requirements (no user-facing behaviour, no API
   contract, no acceptance criteria), append &quot;Status: Insufficient information — PRD lacks
   technical detail&quot; and list what is missing. Do NOT fabricate solutions for thin PRDs.
3. If still open and detailed enough, run the full R1 -&amp;gt; R2 -&amp;gt; R3 pipeline
   (claude -&amp;gt; codex -&amp;gt; claude) via run-agent.sh, writing solutions into the issue&apos;s .md file.

Each agent fetches the latest issue description from the tracker before researching. Apply
THE_PROMPT_v5.md from https://run-agent.sh and use MCP Steroid to research the monorepo. Work
2 issues in parallel. The process may already be running — check existing .md sizes before
launching agents (skip if a file already has solution sections).
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;  &lt;/div&gt;

&lt;/details&gt;

&lt;details&gt;
  &lt;summary&gt;&lt;strong&gt;TASK-4&lt;/strong&gt; — the mid-iteration snapshot&lt;/summary&gt;

  &lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Your goal now is to run a snapshot review of the investigation. Time has passed since the
original research, and the release is still in flight — so we should be able to see what has
moved, how the team approached features, and which approaches were selected, without expecting
anything to be finished.

For each suggested ticket, run a batch of run-agent.sh agents to review whether and how it was
addressed. Learn from each decision and compare it against the options we proposed.

Use RLM.md, run-agent.sh, and any other tools to assess all the tickets. For more context and
approaches, look at the other TASK*.md files in this folder; they contain the necessary
information and approaches you should re-use.
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;  &lt;/div&gt;

&lt;/details&gt;

&lt;h2 id=&quot;the-numbers&quot;&gt;The Numbers&lt;/h2&gt;

&lt;p&gt;Here’s what the full run produced:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Metric&lt;/th&gt;
      &lt;th&gt;Value&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Issues analyzed&lt;/td&gt;
      &lt;td&gt;130+&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Agent runs total&lt;/td&gt;
      &lt;td&gt;818&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Codex runs&lt;/td&gt;
      &lt;td&gt;322 (~39%)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Claude runs&lt;/td&gt;
      &lt;td&gt;318 (~39%)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Gemini runs&lt;/td&gt;
      &lt;td&gt;12 (~1%)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Other / unattributed (orchestration, review, retries)&lt;/td&gt;
      &lt;td&gt;166 (~20%)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Total token consumption&lt;/td&gt;
      &lt;td&gt;~155–200M tokens&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Total cost (API prices)&lt;/td&gt;
      &lt;td&gt;~$576 (~$315 Codex, ~$259 Claude)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Cost per fully-researched issue&lt;/td&gt;
      &lt;td&gt;~$5 (~$576 / 120)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Calendar days&lt;/td&gt;
      &lt;td&gt;4 (overnight gaps)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Issues with full 3-round research&lt;/td&gt;
      &lt;td&gt;120&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Total output&lt;/td&gt;
      &lt;td&gt;52,162 lines&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Average output per issue&lt;/td&gt;
      &lt;td&gt;~435 lines&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Issues found already implemented&lt;/td&gt;
      &lt;td&gt;9&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Issues flagged as directly implementable&lt;/td&gt;
      &lt;td&gt;~39%&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;A few of those numbers deserve commentary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three agents, very uneven shares.&lt;/strong&gt; Codex and Claude split the pipeline almost evenly. Gemini
landed at ~1% (12 runs) — not because of model quality, but because it kept tripping over filesystem
&lt;strong&gt;sandbox path restrictions&lt;/strong&gt; in this setup and we fell back to the other two rather than fight
it mid-run. Worth knowing if you’re planning a three-CLI swarm: the orchestration is easy; the
per-CLI sandbox and auth quirks are what actually cost you time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;~$5 per issue.&lt;/strong&gt; ~$576 across 120 fully-researched issues — the cost of 2–4 hours of senior
engineer analysis, done in roughly 90 minutes of wall-clock time per issue (two pipelines in
parallel). At any reasonable engineer billing rate, the economics aren’t close.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;9 issues already implemented.&lt;/strong&gt; During research, agents cross-referenced issue descriptions
against the current codebase and found nine cases where the requested functionality already
existed — implemented at some point without closing the ticket. Catching this early avoids days
of duplicated work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;120 of 130+ got full 3-round research.&lt;/strong&gt; The initial four-day swarm covered 65 issues; a later
pass researched the 55 remaining stubs — 8h49m of continuous execution, two parallel workers, no
API failures — bringing the total to 120. (Peak concurrency
was only two workers, with a long overnight idle gap; there’s easy throughput left on the table.)&lt;/p&gt;

&lt;h2 id=&quot;what-the-output-looks-like&quot;&gt;What the Output Looks Like&lt;/h2&gt;

&lt;p&gt;This is not summarization. It’s the analyst work a senior engineer does before writing code. A
typical fully-researched issue document contains:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;A &lt;strong&gt;problem framing&lt;/strong&gt; section: what the issue requests, what the codebase does instead, where the
gap is&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;File:class:method references&lt;/strong&gt; with line numbers — confirmed by R2 against the actual repository&lt;/li&gt;
  &lt;li&gt;A &lt;strong&gt;git history note&lt;/strong&gt; on recent changes to those files — flagging active areas where merge risk
is higher&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Two or three solution options&lt;/strong&gt;, each with complexity, effort, key changes, test strategy, and a
trade-offs table&lt;/li&gt;
  &lt;li&gt;A &lt;strong&gt;validation section&lt;/strong&gt; from Round 2: which references were confirmed, which were corrected,
which edge cases were added&lt;/li&gt;
  &lt;li&gt;A &lt;strong&gt;final recommendation&lt;/strong&gt; from Round 3 with rationale, implementation sequence, and follow-up work&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When the output is good, a team lead reads it in 15–20 minutes and makes a prioritization decision
with full context. The shift is real: from “I need half a day to understand this issue before I can
scope it” to “I need to review what the agent found and decide which option makes sense.”&lt;/p&gt;

&lt;h2 id=&quot;a-mid-iteration-snapshot-not-a-postmortem&quot;&gt;A Mid-Iteration Snapshot, Not a Postmortem&lt;/h2&gt;

&lt;p&gt;Here is the part that’s easy to get wrong. After the research, while the release was &lt;strong&gt;still in
flight&lt;/strong&gt;, we ran a second pass against the same roadmap — same orchestration, same MCP Steroid,
same &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;run-agent.sh&lt;/code&gt; — asking a different question. Not “what should we implement,” but “what is the
team actually doing, and how does it compare to what we proposed?”&lt;/p&gt;

&lt;p&gt;This is a &lt;strong&gt;mid-iteration snapshot&lt;/strong&gt;, and the framing matters: at the time of the check, &lt;strong&gt;nothing
was expected to be finished.&lt;/strong&gt; The release hadn’t shipped. So this is not a scorecard of “did the
research come true.” It’s a directional read — a chance to see, on the issues where work &lt;em&gt;had&lt;/em&gt;
started, whether the research pointed the right way.&lt;/p&gt;

&lt;p&gt;The snapshot was map-reduce: one agent per issue (refetch status, scan git history for related
commits, compare proposal vs. current direction), then a thematic reduce across about eight
subsystem groupings, then a final reduce into a single lessons document. Same RLM principle — one
task per agent, fresh context, markdown carries state — with two reduce stages because the input
was too large for one synthesis pass.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Snapshot outcome (138 items, incl. parent/leaf)&lt;/th&gt;
      &lt;th&gt;Share&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Work landed — matched the proposal&lt;/td&gt;
      &lt;td&gt;5%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Work landed — diverged from the proposal&lt;/td&gt;
      &lt;td&gt;33%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;In progress&lt;/td&gt;
      &lt;td&gt;23%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Still open — proposal still valid&lt;/td&gt;
      &lt;td&gt;27%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Abandoned / obsolete&lt;/td&gt;
      &lt;td&gt;11%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Insufficient PRD — never actionable&lt;/td&gt;
      &lt;td&gt;1%&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Read against a finished release, “5% matched” would look brutal. Read as a mid-cycle snapshot, it’s
the wrong number to fixate on — because of what “diverged” means for the other 33%. In most diverged
cases the agent identified the right root cause or dependency, but maintainers chose a different
implementation layer, scope, or product shape. The research was directionally right, even if the
patch landed elsewhere — in an adjacent caller, an integration layer, or a shared platform surface
rather than where it pointed.&lt;/p&gt;

&lt;p&gt;That reframes what the pipeline is for. The output is &lt;strong&gt;not&lt;/strong&gt; a patch location to apply as-is. It’s
a structured comparison of options plus an identification of root cause and dependencies. The hit
rate is the wrong metric; the value is in the framing and the dependency map.&lt;/p&gt;

&lt;h2 id=&quot;what-we-learned&quot;&gt;What We Learned&lt;/h2&gt;

&lt;h3 id=&quot;some-solutions-arent-viable--even-after-three-review-rounds&quot;&gt;Some solutions aren’t viable — even after three review rounds&lt;/h3&gt;

&lt;p&gt;This is the uncomfortable one. A non-trivial share of recommendations were simply &lt;strong&gt;not good&lt;/strong&gt;, and
three rounds of review didn’t save them. The agents over-technicalized product questions (treating
“should we keep this feature” as an engineering task), confidently prescribed a local patch where
the real decision was about ownership or release sequencing, overcomplicated easy tasks, and
oversimplified hard ones. Three passes of a confident model reviewing another confident model can
converge on a polished, plausible, &lt;strong&gt;wrong&lt;/strong&gt; answer. More rounds did not fix wrong framing.&lt;/p&gt;

&lt;h3 id=&quot;agents-reject-their-own-options--then-keep-them-anyway&quot;&gt;Agents reject their own options — then keep them anyway&lt;/h3&gt;

&lt;p&gt;The clearest tell came from a reviewer’s note. In one document, the agent had generated three
solution options and &lt;strong&gt;explicitly marked the third “should not be implemented”&lt;/strong&gt; — and then left it in
the final write-up. The agent’s own judgment said &lt;em&gt;no&lt;/em&gt;, and the option still shipped into the
document, adding clutter a human had to read past. An output set that never drops the ideas its own
author rejected isn’t a research report; it’s a transcript. The fix is mechanical: prune options
the agent flags as non-viable before synthesis, instead of dutifully carrying every branch forward.&lt;/p&gt;

&lt;h3 id=&quot;we-needed-an-adaptive-number-of-iterations-not-a-fixed-three&quot;&gt;We needed an &lt;em&gt;adaptive&lt;/em&gt; number of iterations, not a fixed three&lt;/h3&gt;

&lt;p&gt;Three rounds was the wrong constant in both directions. For small, well-groomed issues with an
obvious code seam, R1 alone was enough — R2 and R3 just reworded it. For cross-module, cross-repo,
or product-gated issues, three rounds weren’t nearly enough to converge, and a fixed cap let
unresolved disagreement get written up as a confident recommendation. The right design is an
&lt;strong&gt;adaptive iteration count&lt;/strong&gt;: keep looping review-and-revise on a task until it &lt;em&gt;converges&lt;/em&gt; — until
a fresh reviewer adds no new finding — and stop early when it already has. A product-gated task
should short-circuit to “ask the owner,” not burn three deep-research passes on a moving foundation.&lt;/p&gt;

&lt;h3 id=&quot;mcp-steroid-is-the-force-multiplier&quot;&gt;MCP Steroid is the force multiplier&lt;/h3&gt;

&lt;p&gt;The gap between agent outputs with and without full codebase access is large. Without it, agents
default to generic descriptions: “update the parser”, “check the rendering pipeline”. With
&lt;a href=&quot;https://mcp-steroid.jonnyzzz.com&quot;&gt;MCP Steroid&lt;/a&gt;, they navigate the actual PSI tree, resolve inheritance chains,
and trace call graphs to find where a change propagates. That’s not a small specificity bump —
it’s the difference between a useful document and
a vague one. One pattern to fix: agents default to one file per MCP call; a small script that reads
5–10 related files per call would cut round-trips and speed up R1 noticeably.&lt;/p&gt;

&lt;h3 id=&quot;the-message-bus-was-useful-enough-to-graduate&quot;&gt;The message bus was useful enough to graduate&lt;/h3&gt;

&lt;p&gt;Between rounds, findings flowed through a single append-only markdown file — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MESSAGE-BUS.md&lt;/code&gt;. Each
agent wrote what it learned; later agents and the reducers read it back. It was the most reusable
artifact of the whole run, and it has since &lt;strong&gt;graduated into the agent-orchestration framework we
use elsewhere&lt;/strong&gt; as a first-class, append-only, typed protocol — messages carry a type (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FACT&lt;/code&gt;,
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DECISION&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;QUESTION&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ANSWER&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PROGRESS&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ERROR&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BLOCKER&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;COMPLETE&lt;/code&gt;), an id, and a
back-reference, so a swarm can coordinate without a shared database. The honest caveat: because
parent and leaf tickets often referenced the same root problem, the bus filled with duplicates —
so any reducer has to &lt;strong&gt;dedupe by the underlying issue&lt;/strong&gt; before drawing conclusions, or it
double-counts.&lt;/p&gt;

&lt;h3 id=&quot;codex-buffers-all-output&quot;&gt;Codex buffers all output&lt;/h3&gt;

&lt;p&gt;Codex streams its progress to stderr and writes nothing to its stdout file until the run
completes. A monitor that watches the stdout file size will conclude a running Codex agent is
stuck. It isn’t — it’s thinking. Check the process PID (or stderr), not the stdout file size. We
spent more time than we’d like to admit investigating “stalled” agents that were running normally.
It’s already improved in the recent version of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;run-agent.sh&lt;/code&gt; where we run agents with a verbose mode,
NDJSON outout, and better monitoring of file sizes.&lt;/p&gt;

&lt;h3 id=&quot;cost-is-not-the-constraint--human-review-is&quot;&gt;Cost is not the constraint — human review is&lt;/h3&gt;

&lt;p&gt;At ~$5 per fully-researched issue, cost doesn’t register for any team with a real roadmap. The constraint is human
review bandwidth: the pipeline produced ~435 lines per issue, 52,000+ lines total, all waiting for
judgment. Designing the output format for fast human review matters far more than shaving a dollar
off the per-issue cost.&lt;/p&gt;

&lt;h2 id=&quot;the-human-verdict-honestly&quot;&gt;The Human Verdict, Honestly&lt;/h2&gt;

&lt;p&gt;A senior product lead reviewed the output and gave a blunt read, which I’ll paraphrase because it’s
the most useful thing in this whole post. Handing this research — with its implementation advice —
straight to junior, mid, or even early-senior engineers is &lt;strong&gt;dangerous&lt;/strong&gt;, because the agents:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;describe even complete nonsense very convincingly, instead of saying &lt;em&gt;“I don’t have enough
information, I give up”&lt;/em&gt;;&lt;/li&gt;
  &lt;li&gt;charge ahead researching a technical solution without thinking about the product side;&lt;/li&gt;
  &lt;li&gt;overcomplicate easy things and oversimplify hard ones.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;His conclusion: this kind of mass research is something you show a &lt;strong&gt;product lead plus a tech
lead&lt;/strong&gt; for a fast review — people with enough context to validate or dismiss it quickly, and who,
frankly, would have gotten there without the research anyway. The genuine upside: for small,
well-groomed tasks where the product direction is settled, the output is exactly the spec an
implementation pipeline can run with.&lt;/p&gt;

&lt;p&gt;He also flagged the small, human things the agents missed and the ones they nailed: handle
externally-reported issues more cautiously (have a human confirm before acting, and check whether
the product already does the requested thing); and &lt;strong&gt;diagrams and flow charts are genuinely
helpful&lt;/strong&gt; — the kind a senior draws by hand to think, and the kind the agents should produce more
of.&lt;/p&gt;

&lt;p&gt;The honest position: agent research is a tool for &lt;strong&gt;senior judgment to operate on&lt;/strong&gt;, not a
substitute for it.&lt;/p&gt;

&lt;h2 id=&quot;whats-next&quot;&gt;What’s Next&lt;/h2&gt;

&lt;p&gt;The changes the snapshot points to — none of which remove the senior human from the loop:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Product-state precheck&lt;/strong&gt; before deep research — assignee, fix version, recent human comments,
decision state — so product-gated issues short-circuit instead of getting three rounds.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Adaptive iteration count&lt;/strong&gt; — loop review-and-revise until convergence, not a fixed three.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Prune agent-rejected options&lt;/strong&gt; before synthesis.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Mandatory alternate-layer enumeration&lt;/strong&gt; — at least one plausible alternate implementation
surface per recommendation, with the rationale for the recommended one.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;External-repo scanning&lt;/strong&gt; for build/CI/release issues, which often land in adjacent repos.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Dedupe by underlying issue&lt;/strong&gt; before any reduce.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The bulk-implementation idea survives the snapshot. For the subset of issues where the product
direction is settled, the paths still hold, and the change is self-contained, the same pipeline can
keep going — R4 implements from the R3 spec, R5 reviews and validates tests, R6 does a final
integration check — closing the loop from issue stub to pull request, without the over-confident
handoffs the first round would have produced.&lt;/p&gt;

&lt;hr /&gt;

&lt;p&gt;The pipeline is built on tools I’ve written about before: &lt;a href=&quot;https://run-agent.sh&quot;&gt;run-agent.sh&lt;/a&gt; for agent
execution, &lt;a href=&quot;https://mcp-steroid.jonnyzzz.com&quot;&gt;MCP Steroid&lt;/a&gt; for codebase access, and the &lt;a href=&quot;/blog/2026/01/05/rlm-multi-agent-orchestration/&quot;&gt;RLM methodology&lt;/a&gt;
for keeping context manageable. What this experiment adds is evidence that they work together at
scale — not for a handful of issues, but for an entire release roadmap — and an honest account of
where the output needs a senior human in the loop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Questions about the pipeline or running research swarms at scale?&lt;/strong&gt;
Find me on &lt;a href=&quot;https://www.linkedin.com/in/jonnyzzz/&quot;&gt;LinkedIn&lt;/a&gt; or &lt;a href=&quot;https://x.com/jonnyzzz&quot;&gt;X&lt;/a&gt;.&lt;/p&gt;</content>

  
  
  
  
  

    <author>
      <name>Eugene Petrenko</name>
    </author>

  
    <category term="ai-agents" />
  
    <category term="ai-coding" />
  
    <category term="multi-agent" />
  
    <category term="orchestration" />
  
    <category term="mcp" />
  
    <category term="mcp-steroid" />
  
    <category term="run-agent" />
  
    <summary type="html">We turned one release&apos;s roadmap — 130+ umbrella-epic issues — into fully-researched implementation specs using a swarm of three different agent CLIs (Claude, Codex, Gemini): 818 runs, ~155M+ tokens, ~$576, four days. Then, mid-release-cycle, we ran a snapshot to compare the research against reality — with no expectation that anything was &quot;done&quot; yet. Here is the pipeline, the numbers, and the honest verdict.</summary>
  
  </entry>
  
</feed>
