<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Gabriel Anhaia</title>
    <description>The latest articles on DEV Community by Gabriel Anhaia (@gabrielanhaia).</description>
    <link>https://dev.to/gabrielanhaia</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F425693%2F531a245a-bc24-453e-bb2b-eb7077a3da8b.png</url>
      <title>DEV Community: Gabriel Anhaia</title>
      <link>https://dev.to/gabrielanhaia</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/gabrielanhaia"/>
    <language>en</language>
    <item>
      <title>Claude Code Told the Model 'File Unchanged' to Save Tokens. 2.1.317 Removed the Stub</title>
      <dc:creator>Gabriel Anhaia</dc:creator>
      <pubDate>Sat, 07 Nov 2026 17:01:22 +0000</pubDate>
      <link>https://dev.to/gabrielanhaia/claude-code-told-the-model-file-unchanged-to-save-tokens-21317-removed-the-stub-ikm</link>
      <guid>https://dev.to/gabrielanhaia/claude-code-told-the-model-file-unchanged-to-save-tokens-21317-removed-the-stub-ikm</guid>
      <description>&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Book:&lt;/strong&gt; &lt;a href="https://www.amazon.com/dp/B0GYJZ2XJD" rel="noopener noreferrer"&gt;AI Agents Pocket Guide&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The series:&lt;/strong&gt; &lt;em&gt;Pocket Guides for Developers&lt;/em&gt; — 8 short books on agents, RAG, prompts, observability, events, databases and system design — &lt;a href="https://www.amazon.com/dp/B0GYLH38X4" rel="noopener noreferrer"&gt;the whole series&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;My project:&lt;/strong&gt; &lt;a href="https://hermes-ide.com" rel="noopener noreferrer"&gt;Hermes IDE&lt;/a&gt; | &lt;a href="https://github.com/hermes-hq/hermes-ide" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; — an IDE for developers who ship with Claude Code and other AI coding tools&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Me:&lt;/strong&gt; &lt;a href="https://xgabriel.com" rel="noopener noreferrer"&gt;xgabriel.com&lt;/a&gt; | &lt;a href="https://github.com/gabrielanhaia" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;You delete a few lines in your editor and save. You tell the agent the file changed and ask it to look again. It calls Read and gets back one line: &lt;code&gt;Unchanged since last read&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That is the repro in &lt;a href="https://github.com/anthropics/claude-code/issues/69876" rel="noopener noreferrer"&gt;issue #69876&lt;/a&gt;, filed against &lt;a href="https://code.claude.com/docs/en/how-claude-code-works" rel="noopener noreferrer"&gt;Claude Code&lt;/a&gt; 2.1.185 on June 21, 2026. A Read of the whole file a moment later returned 33 lines, so the edit was on disk. The tool had answered from its own memory of the file. The thread has no reply from a maintainer. A bot labeled it stale on July 19 and closed it as "not planned" on August 2.&lt;/p&gt;

&lt;p&gt;On November 3, &lt;a href="https://code.claude.com/docs/en/changelog" rel="noopener noreferrer"&gt;Claude Code 2.1.317&lt;/a&gt; shipped this line:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Removed the Read tool's "file unchanged since your last Read" stub: reading an unchanged file again returns its content&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;No reason is given, and no issue is linked.&lt;/p&gt;

&lt;p&gt;The stub is gone, but the design question behind it applies to any agent harness that caches tool results. A read cache has to answer two questions, and the reports show it answering only one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the stub was and why it existed
&lt;/h2&gt;

&lt;p&gt;The feature arrived in &lt;a href="https://github.com/anthropics/claude-code/releases/tag/v2.1.86" rel="noopener noreferrer"&gt;2.1.86&lt;/a&gt;, released March 27: "Read tool now uses compact line-number format and deduplicates unchanged re-reads, reducing token usage". Agents re-read files a lot. They read, edit, then read again to check. Each full read puts the whole file back into the context window, so skipping a repeat read of an unchanged file saves real tokens.&lt;/p&gt;

&lt;p&gt;Instead of the file, the model got a short note. Its wording changed across versions. &lt;a href="https://github.com/anthropics/claude-code/issues/55042" rel="noopener noreferrer"&gt;Issue #55042&lt;/a&gt;, on 2.1.123, quotes: "File unchanged since last read. The content from the earlier Read tool_result in this conversation is still current — refer to that instead of re-reading." &lt;a href="https://github.com/anthropics/claude-code/issues/60722" rel="noopener noreferrer"&gt;Issue #60722&lt;/a&gt;, on 2.1.140, quotes a blunter one: "Wasted call — file unchanged since your last Read. Refer to that earlier tool_result instead." In the terminal you saw the short form, &lt;code&gt;Unchanged since last read&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Read the second wording again. It makes two claims. The file has not changed, and the earlier tool result is still there for the model to look at. Either claim can be false on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question one: did the file change on disk?
&lt;/h2&gt;

&lt;p&gt;A dedup cache keys each entry by path and keeps something cheap to compare: the modification time, the size, or a hash of the content. On the next read it compares again and returns the stub if nothing moved.&lt;/p&gt;

&lt;p&gt;The bug reports show the first claim failing when something other than the agent touched the file. &lt;a href="https://github.com/anthropics/claude-code/issues/60684" rel="noopener noreferrer"&gt;Issue #60684&lt;/a&gt; (2.1.144) got the stub after the user saved a notebook in their editor, then got the real content through a shell &lt;code&gt;cat&lt;/code&gt;. Its author's theory is that the check tracked only the agent's own Edit and Write calls; no maintainer confirmed that. A bot closed it for inactivity, as it did #69876. Neither got a fix.&lt;/p&gt;

&lt;p&gt;Mtime is a weak signal, because content can change while it stays put. Claude Code's own Edit check hit exactly that: the &lt;a href="https://github.com/anthropics/claude-code/releases/tag/v2.1.295" rel="noopener noreferrer"&gt;2.1.295 release&lt;/a&gt; includes "Fixed Edit treating a file as fully read when its contents changed without its modification time advancing". A content hash avoids that, and it is cheap here. Hashing means reading the bytes from disk, which costs almost nothing. The expensive part is sending those bytes to the model, and a hash lets you skip only that part.&lt;/p&gt;

&lt;p&gt;A hash has its own blind spot. &lt;a href="https://github.com/anthropics/claude-code/issues/45748" rel="noopener noreferrer"&gt;Issue #45748&lt;/a&gt; (2.1.96) got the stub after switching branches away and back with &lt;a href="https://git-scm.com/docs/git-checkout" rel="noopener noreferrer"&gt;git checkout&lt;/a&gt;, which rewrote the file twice and landed on the same bytes. The reporter says the cache compared content hashes, so it saw nothing change. That issue was also closed by a bot. After an A-to-B-to-A round trip, "unchanged" is only safe if the model can still see A. That is the second question.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4x4uoqb0xjcujwjqf68u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4x4uoqb0xjcujwjqf68u.png" alt="A black rubber stamp coming down on a document page that a separate burnt-orange pencil has just rewritten" width="800" height="800"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The cache stamps "unchanged" from memory. The disk already says otherwise.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Question two: can the model still see the earlier result?
&lt;/h2&gt;

&lt;p&gt;This is the part a disk check can never answer. The stub tells the model to go back to an earlier tool result. That only works if the result is still in the context window.&lt;/p&gt;

&lt;p&gt;Two things remove it. A rewind (Esc twice, or &lt;code&gt;/rewind&lt;/code&gt;) rolls the conversation back to before the read. &lt;a href="https://github.com/anthropics/claude-code/issues/55042" rel="noopener noreferrer"&gt;Issue #55042&lt;/a&gt; did exactly that, then read five files in parallel: four came back as stubs pointing at results the rewind had just removed. &lt;a href="https://github.com/anthropics/claude-code/issues/46749" rel="noopener noreferrer"&gt;Issue #46749&lt;/a&gt; reported the same after compaction. When the context fills up, Claude Code &lt;a href="https://code.claude.com/docs/en/how-claude-code-works" rel="noopener noreferrer"&gt;compacts the conversation into a summary&lt;/a&gt; that "keeps your requests and key code snippets". A full file listing is not guaranteed to be one of them.&lt;/p&gt;

&lt;p&gt;The workarounds in those threads were Read with an explicit &lt;code&gt;offset&lt;/code&gt; and &lt;code&gt;limit&lt;/code&gt;, which skipped the dedup, or reading through the shell instead. Both depend on the model noticing that it has been handed a pointer to nothing.&lt;/p&gt;

&lt;p&gt;A community hook called &lt;a href="https://dev.to/boucle2026/read-once-a-claude-code-hook-that-stops-redundant-file-reads-4bjk"&gt;read-once&lt;/a&gt; hit the same wall from outside. It blocks repeat reads too, and expires its entries after 20 minutes because, in its author's words, "There's no way to detect compaction events from a hook".&lt;/p&gt;

&lt;p&gt;Inside the harness you can detect it. Here is a minimal cache that asks both questions. It is an illustration, not Claude Code's code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createHash&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:crypto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;readFileSync&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:fs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;epoch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Seen&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;epoch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Call this whenever compaction or a rewind&lt;/span&gt;
&lt;span class="c1"&gt;// removes earlier tool results from context.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;contextDropped&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;epoch&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;epoch&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;same&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;epoch&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;epoch&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;same&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;File unchanged since last read.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hash answers question one. The epoch answers question two: any event that drops tool results from context invalidates every entry at once. It is still not complete. A partial read with a different range, a subagent with its own context, or a forked session each need their own answer, and every one you miss sends the model back to a result it no longer has.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhzgi5mrydq68r4e6ao0q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhzgi5mrydq68r4e6ao0q.png" alt="A long paper scroll being folded into a small card by a press, while a black hand points at an empty gap where a page used to be" width="800" height="800"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Compaction keeps a summary. The full tool result the stub points at is gone.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the removal costs
&lt;/h2&gt;

&lt;p&gt;The cost is context. &lt;a href="https://github.com/anthropics/claude-code/issues/100265" rel="noopener noreferrer"&gt;Issue #100265&lt;/a&gt; measured it on 2.1.292: a second Read of a 26 KB file returned the full 27,807 characters again, and context went from 70,281 to 83,274 tokens. That is about 13,000 tokens for a file the model had already seen. A long session that re-reads big files fills its window sooner and compacts more often.&lt;/p&gt;

&lt;p&gt;That issue also suggests many users stopped getting the stub before 2.1.317 shipped. Its author found no "file unchanged" result in 753 local sessions from 2.1.209 to 2.1.292. A commenter on 2.1.310 found &lt;code&gt;"tengu_read_dedup_killswitch": true&lt;/code&gt; in the cached remote config inside their &lt;code&gt;~/.claude.json&lt;/code&gt;, with the stub last appearing in their transcripts under 2.1.286. That is one user's reading of their own config, not an official explanation. The 2.1.317 entry makes the behavior official for everyone.&lt;/p&gt;

&lt;p&gt;Run &lt;code&gt;claude --version&lt;/code&gt;, and &lt;code&gt;claude update&lt;/code&gt; (&lt;a href="https://code.claude.com/docs/en/cli-reference" rel="noopener noreferrer"&gt;CLI reference&lt;/a&gt;) if you are behind. If you still want fewer repeat reads, ask for line ranges on large files rather than the whole thing. A stale answer cost more than the tokens did: a model working from an old copy of a file writes code against lines that are no longer there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Review the diff from disk
&lt;/h2&gt;

&lt;p&gt;The model's picture of your repository is whatever came back in its tool results, filtered through compaction. When you review an agent's work, read the diff from git, which comes from the files.&lt;/p&gt;

&lt;p&gt;I build &lt;a href="https://hermes-ide.com" rel="noopener noreferrer"&gt;Hermes IDE&lt;/a&gt;, so weigh this part with that in mind. Hermes runs Claude Code, Codex and other agent CLIs in real terminals, and its review side works from disk. Per the &lt;a href="https://github.com/hermes-hq/hermes-ide" rel="noopener noreferrer"&gt;README&lt;/a&gt;, every agent turn is saved, so you can diff or restore any single turn, with undo, and the Review Desk (⌘G) shows changes by turn or by file. The same README says Hermes observes "what agents report on purpose and what git shows".&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8cgisbligwk7mx2lpa8d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8cgisbligwk7mx2lpa8d.png" alt="The Review Desk in By turn view: two turns listed, turn 1's diff of src/db.ts adding a refunded status, the changed files and a drafted commit message" width="800" height="444"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The Review Desk (⌘G) in By turn view for a Claude Code task in orders-api: two turns, turn 1's diff of src/db.ts, the changed-files list and a commit message drafted from the turns.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So if an agent works from a stale copy and writes the old lines back over your edit, the turn's diff in the Review Desk is where you would see your lines being removed, whatever the model believes it read. Clicking that turn in the strip of agent replies shows its changes or restores your files to that point. Hermes does not change what Claude Code's Read tool returns, so it cannot fix the agent's view. In a task with extra folders, that strip tracks changes in the main project only.&lt;/p&gt;




&lt;h2&gt;
  
  
  If this was useful
&lt;/h2&gt;

&lt;p&gt;The stub was a tool-calling design choice: a tool result that pointed at another tool result and trusted it to still be there. The &lt;a href="https://www.amazon.com/dp/B0GYJZ2XJD" rel="noopener noreferrer"&gt;AI Agents Pocket Guide&lt;/a&gt; covers tool design, agent loops and the guardrails around them, which is where bugs like this one come from. It is one of 8 short books in &lt;a href="https://www.amazon.com/dp/B0GYLH38X4" rel="noopener noreferrer"&gt;Pocket Guides for Developers&lt;/a&gt;, on agents, RAG, prompts, observability, events, databases and system design. Hermes IDE is free, source-available under BSL 1.1, on macOS, Windows and Linux, and not affiliated with Anthropic: &lt;a href="https://hermes-ide.com/download" rel="noopener noreferrer"&gt;download it&lt;/a&gt; or &lt;a href="https://github.com/hermes-hq/hermes-ide" rel="noopener noreferrer"&gt;read the source&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.amazon.com/dp/B0GYJZ2XJD" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffzkyb8bqzkmlt4qzgqbo.jpg" alt="AI Agents Pocket Guide: Patterns for Building Autonomous Systems with LLMs" width="800" height="1200"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claudecode</category>
      <category>devtools</category>
      <category>programming</category>
    </item>
    <item>
      <title>Find Dead Code With knip, Then Let a Coding Agent Delete It One Package per Worktree</title>
      <dc:creator>Gabriel Anhaia</dc:creator>
      <pubDate>Sat, 07 Nov 2026 16:01:55 +0000</pubDate>
      <link>https://dev.to/gabrielanhaia/find-dead-code-with-knip-then-let-a-coding-agent-delete-it-one-package-per-worktree-2ef9</link>
      <guid>https://dev.to/gabrielanhaia/find-dead-code-with-knip-then-let-a-coding-agent-delete-it-one-package-per-worktree-2ef9</guid>
      <description>&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Book:&lt;/strong&gt; &lt;a href="https://www.amazon.com/dp/B0GW2NTCR5" rel="noopener noreferrer"&gt;Ship It — the pragmatic startup tech stack&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Also by me:&lt;/strong&gt; &lt;em&gt;Thinking in Go&lt;/em&gt; (2-book series) — &lt;a href="https://xgabriel.com/go-book" rel="noopener noreferrer"&gt;Complete Guide to Go Programming&lt;/a&gt; + &lt;a href="https://xgabriel.com/hexagonal-go" rel="noopener noreferrer"&gt;Hexagonal Architecture in Go&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;My project:&lt;/strong&gt; &lt;a href="https://hermes-ide.com" rel="noopener noreferrer"&gt;Hermes IDE&lt;/a&gt; | &lt;a href="https://github.com/hermes-hq/hermes-ide" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; — an IDE for developers who ship with Claude Code and other AI coding tools&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Me:&lt;/strong&gt; &lt;a href="https://xgabriel.com" rel="noopener noreferrer"&gt;xgabriel.com&lt;/a&gt; | &lt;a href="https://github.com/gabrielanhaia" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;On August 1, 2012, Knight Capital's order router still held a function that, in the &lt;a href="https://www.sec.gov/newsroom/press-releases/2013-222" rel="noopener noreferrer"&gt;SEC's words&lt;/a&gt;, "was not meant to be used." The router misbehaved for the first 45 minutes of trading, and the firm eventually lost more than $460 million.&lt;/p&gt;

&lt;p&gt;Your repo's dead code will probably never cost that much. It still gets read, type-checked and shipped, and an agent that finds an old helper may copy it.&lt;/p&gt;

&lt;p&gt;Deleting it is the safest job you can hand a coding agent, on one condition: a tool picks the targets and the agent doesn't guess. Most wrong deletions fail the type checker or the tests. The rest are dynamic imports and files loaded by path, and the prompt below tells the agent to leave those alone. The agent adds no behaviour of its own; it removes code a static analyser reports as unused.&lt;/p&gt;

&lt;p&gt;Here is the whole loop: &lt;a href="https://knip.dev" rel="noopener noreferrer"&gt;knip&lt;/a&gt; finds the unused code, &lt;a href="https://jqlang.org/manual/" rel="noopener noreferrer"&gt;jq&lt;/a&gt; cuts the report into one slice per package, each slice goes to its own agent on its own branch, and nothing lands until &lt;code&gt;tsc --noEmit&lt;/code&gt; and the tests pass.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: get the list from knip, as JSON
&lt;/h2&gt;

&lt;p&gt;Run knip once by hand first. It reads your &lt;code&gt;package.json&lt;/code&gt;, finds entry files through its plugins, and reports unused files, exports, types and dependencies. Knip's &lt;a href="https://knip.dev/guides/handling-issues" rel="noopener noreferrer"&gt;handling-issues guide&lt;/a&gt; says "a surprising result is usually a real finding or a configuration gap, not a false positive to silence." Fix the gaps before any agent sees the list. Two common ones: "Dynamic import specifiers aren't resolved," and, per the same guide, generated files have to exist before knip can follow them.&lt;/p&gt;

&lt;p&gt;When the plain report looks right, ask for JSON:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx knip &lt;span class="nt"&gt;--reporter&lt;/span&gt; json &lt;span class="nt"&gt;--no-exit-code&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--include&lt;/span&gt; files,exports,types &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; knip.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--include&lt;/code&gt; limits the report to the issue types you name. &lt;code&gt;--no-exit-code&lt;/code&gt; exits 0 even when there are findings, while config and internal errors still exit 2 (&lt;a href="https://knip.dev/reference/cli" rel="noopener noreferrer"&gt;CLI reference&lt;/a&gt;). Dependencies are left out on purpose: removing them rewrites the shared lockfile, so they go last, as one task.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://knip.dev/features/reporters" rel="noopener noreferrer"&gt;reporter docs&lt;/a&gt; describe the shape: one object with an &lt;code&gt;issues&lt;/code&gt; array, one entry per file, and a key per enabled issue type. This is their example, trimmed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"issues"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"file"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"src/legacy.ts"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"files"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"src/legacy.ts"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"file"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"src/math.ts"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"exports"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"factorial"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"line"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"col"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;file&lt;/code&gt; is relative to where you ran knip, so in a monorepo every path starts with its package folder. That is what makes slicing by package easy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: slice it per package with jq
&lt;/h2&gt;

&lt;p&gt;First, see where the dead code lives. This counts files with at least one finding, per package, assuming a &lt;code&gt;packages/&amp;lt;name&amp;gt;/&lt;/code&gt; layout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.issues[]
  | select(del(.file, .owners)
      | [.[] | length] | add &amp;gt; 0)
  | .file | split("/") | .[:2] | join("/")'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  knip.json | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;del(.file, .owners)&lt;/code&gt; leaves only the issue arrays (&lt;code&gt;owners&lt;/code&gt; shows up when you have a CODEOWNERS file). Summing their lengths drops entries with nothing in them. Files at the repo root show up under their own names.&lt;/p&gt;

&lt;p&gt;Now one small script that prints the slice for one package. Commit it to &lt;code&gt;main&lt;/code&gt; before you start, because a new git worktree only holds tracked files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# scripts/knip-slice.sh packages/api&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail
npx knip &lt;span class="nt"&gt;--reporter&lt;/span&gt; json &lt;span class="nt"&gt;--no-exit-code&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--include&lt;/span&gt; files,exports,types &lt;span class="se"&gt;\&lt;/span&gt;
| jq &lt;span class="nt"&gt;--arg&lt;/span&gt; p &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;/"&lt;/span&gt; &lt;span class="s1"&gt;'[.issues[]
    | select(.file | startswith($p))
    | select(del(.file, .owners)
        | [.[] | length] | add &amp;gt; 0)]'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pipefail&lt;/code&gt; matters here. Without it, a knip crash would hand jq an empty input, and the pipeline would still report success. The script reruns knip each time instead of reading a saved file, so the agent always sees the current state of its own branch.&lt;/p&gt;

&lt;p&gt;The script also gives you a done check. &lt;code&gt;jq -e&lt;/code&gt; exits 1 when the last value is &lt;code&gt;false&lt;/code&gt; and 4 when there was no valid input at all (&lt;a href="https://jqlang.org/manual/" rel="noopener noreferrer"&gt;jq manual&lt;/a&gt;), so this line passes only on an empty slice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;scripts/knip-slice.sh packages/api | jq &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'length == 0'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2jsmachkeqojpj36h5oj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2jsmachkeqojpj36h5oj.png" alt="A row of package boxes on a workbench, each with a few dead branches marked in orange and a separate pair of scissors assigned to it" width="800" height="800"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;One slice per package, one pair of scissors per slice.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 3: one package, one agent, one branch
&lt;/h2&gt;

&lt;p&gt;The task prompt does most of the safety work. It names the source of truth, forbids the two shortcuts an agent might take, and says what done means:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Delete dead code in packages/api and nothing else.

Targets: the output of
  scripts/knip-slice.sh packages/api

Rules:
- Delete only what that list names. For an unused
  export, remove it if nothing in its file uses it,
  otherwise drop the export keyword.
- Do not edit any other package.
- Do not add ignore entries to the knip config. If
  a finding looks wrong (dynamic import, public API,
  a file loaded by path), leave it and tell me which
  one and why.
- After each round, run the script again. Deleting
  a file can leave more code unused.

Done when the script prints [] and both
`npx tsc --noEmit` and `npm test` pass.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The "run it again" rule follows knip's own advice: "Findings come in chains, so reducing unused files also reduces unused exports and dependencies." If the deletion is purely mechanical, knip can do it without an agent: &lt;a href="https://knip.dev/features/auto-fix" rel="noopener noreferrer"&gt;&lt;code&gt;knip --fix&lt;/code&gt;&lt;/a&gt; removes the &lt;code&gt;export&lt;/code&gt; keyword from unused exports, and &lt;code&gt;--allow-remove-files&lt;/code&gt; lets it delete unused files. The agent earns its place on the chain that follows: the local function nobody calls after its export went away, the test file for a module that no longer exists, the finding that turns out to be a dynamic import.&lt;/p&gt;

&lt;p&gt;By hand, each slice gets a &lt;a href="https://git-scm.com/docs/git-worktree" rel="noopener noreferrer"&gt;worktree&lt;/a&gt; and a branch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git worktree add &lt;span class="nt"&gt;-b&lt;/span&gt; dead-code/api ../app-dead-api
&lt;span class="nb"&gt;cd&lt;/span&gt; ../app-dead-api &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm ci
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then you start your agent in that folder and paste the prompt. Repeat per package.&lt;/p&gt;

&lt;p&gt;I build &lt;a href="https://hermes-ide.com" rel="noopener noreferrer"&gt;Hermes IDE&lt;/a&gt;, so weigh this part with that in mind. In Hermes, ⌘N (Ctrl+Shift+N on Windows and Linux) opens the task sheet. You type the prompt and pick the agent (&lt;a href="https://docs.anthropic.com/en/docs/claude-code" rel="noopener noreferrer"&gt;Claude Code&lt;/a&gt;, &lt;a href="https://github.com/openai/codex" rel="noopener noreferrer"&gt;Codex&lt;/a&gt; or another CLI), and the task starts on its own &lt;code&gt;hermes/&lt;/code&gt; branch in its own worktree, with the prompt as the agent's first message. Press ⌘N again for the next package, with the same agent or a different one.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffszpiznbqz77gsn3b75u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffszpiznbqz77gsn3b75u.png" alt="The Hermes New task sheet with a task typed in, chips for Claude Code, the project, a new hermes/ worktree, Accept edits, model and effort, and a preview of the command Hermes will run" width="800" height="415"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The ⌘N New task sheet in Hermes 2.3.4: agent, project, a new &lt;code&gt;hermes/&lt;/code&gt; worktree, approval mode, model and effort, and the "Hermes will run" preview of the agent command.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Per the &lt;a href="https://github.com/hermes-hq/hermes-ide" rel="noopener noreferrer"&gt;Hermes README&lt;/a&gt;, new worktrees get their dependencies in seconds, and your own checks can decide when a task is done, so &lt;code&gt;npx tsc --noEmit&lt;/code&gt; and &lt;code&gt;npm test&lt;/code&gt; can be those checks. The per-repository setup is described in the &lt;a href="https://github.com/hermes-hq/hermes-ide/blob/main/docs/worktree-recipes.md" rel="noopener noreferrer"&gt;worktree recipes doc&lt;/a&gt;. The package-specific knip check stays in the prompt, because that setup is shared by every task in the repo. If your monorepo uses TypeScript project references, put your own type-check script in place of &lt;code&gt;npx tsc --noEmit&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 4: read every deletion, then land
&lt;/h2&gt;

&lt;p&gt;A dead-code branch should mostly remove lines. This lists every file on the branch that gained lines, which is where to look first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git diff &lt;span class="nt"&gt;--numstat&lt;/span&gt; main...dead-code/api | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'$1 &amp;gt; 0'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A new &lt;code&gt;// @ts-ignore&lt;/code&gt;, or a test rewritten to keep passing, usually shows up in that list. The three dots compare against the point where the branch split off, so work that landed on &lt;code&gt;main&lt;/code&gt; in the meantime stays out of the diff.&lt;/p&gt;

&lt;p&gt;In Hermes, ⌘G (Ctrl+Shift+G on Windows and Linux) opens the Review Desk. Every agent turn is saved, so you read the deletions turn by turn or file by file, and you can restore any single turn, with undo. A line comment goes back to the agent that wrote that line. The desk also shows risk flags on the changes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8cgisbligwk7mx2lpa8d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8cgisbligwk7mx2lpa8d.png" alt="The Hermes Review Desk in By turn view, with two turns listed, the diff of the first turn, the changed-files list and a commit message drafted from the turns" width="800" height="444"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The Review Desk (⌘G) in By turn view: two turns listed, turn 1's diff, the changed-files list, and a commit message drafted from the turns.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Land&lt;/strong&gt; then commits, opens a pull request or merges locally into the branch the task started from, and if a landing was a mistake, it can be undone.&lt;/p&gt;

&lt;p&gt;Leave unused dependencies for last, as one task across the whole repo. In an npm workspace there is one lockfile at the root, so two branches that each remove a dependency both rewrite &lt;code&gt;package-lock.json&lt;/code&gt;, and a lockfile conflict is a miserable thing to resolve by hand. After the file and export slices land, run knip with &lt;code&gt;--include dependencies&lt;/code&gt;, give that list to one agent, and read its lockfile change line by line in review.&lt;/p&gt;
&lt;h2&gt;
  
  
  Keep it from coming back
&lt;/h2&gt;

&lt;p&gt;Once the slices are empty, make knip a CI step so new dead code fails the build. Knip exits 1 when it finds at least one issue (&lt;a href="https://knip.dev/reference/cli" rel="noopener noreferrer"&gt;CLI reference&lt;/a&gt;), so in a &lt;a href="https://docs.github.com/actions" rel="noopener noreferrer"&gt;GitHub Actions&lt;/a&gt; job, after &lt;code&gt;actions/checkout&lt;/code&gt; and &lt;code&gt;actions/setup-node&lt;/code&gt;, two steps are enough:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx knip&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you can't reach zero yet, &lt;code&gt;--max-issues&lt;/code&gt; sets how many issues are allowed before the exit code turns non-zero. Lower it as the slices land.&lt;/p&gt;

&lt;p&gt;Hermes is free, source-available under BSL 1.1, and runs on macOS, Windows and Linux. It isn't affiliated with Nous Research, Anthropic, OpenAI or any agent vendor. Everything above except the Hermes steps is plain knip, jq and git, and works with any agent in any terminal.&lt;/p&gt;




&lt;h2&gt;
  
  
  If this was useful
&lt;/h2&gt;

&lt;p&gt;A knip step in CI is the kind of guardrail that keeps a small team's codebase from filling up with code nobody calls. &lt;em&gt;Ship It&lt;/em&gt; is my book about the rest of the road between localhost and production for a small team: hosting, payments, auth, CI/CD and monitoring. If you want to try the agent half of this post, &lt;a href="https://hermes-ide.com/download" rel="noopener noreferrer"&gt;download Hermes IDE&lt;/a&gt;, and if it saves you a cleanup, &lt;a href="https://github.com/hermes-hq/hermes-ide" rel="noopener noreferrer"&gt;star it on GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.amazon.com/dp/B0GW2NTCR5" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxz8vayf7yoku0ph6c6dy.jpg" alt="Ship It — the pragmatic startup tech stack" width="335" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>javascript</category>
      <category>tutorial</category>
      <category>ai</category>
    </item>
    <item>
      <title>RabbitMQ's Default Prefetch Is Unlimited. One Slow Worker Can Hoard the Whole Queue</title>
      <dc:creator>Gabriel Anhaia</dc:creator>
      <pubDate>Sat, 07 Nov 2026 15:01:25 +0000</pubDate>
      <link>https://dev.to/gabrielanhaia/rabbitmqs-default-prefetch-is-unlimited-one-slow-worker-can-hoard-the-whole-queue-lom</link>
      <guid>https://dev.to/gabrielanhaia/rabbitmqs-default-prefetch-is-unlimited-one-slow-worker-can-hoard-the-whole-queue-lom</guid>
      <description>&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Book:&lt;/strong&gt; &lt;a href="https://www.amazon.com/dp/B0GX3B8371" rel="noopener noreferrer"&gt;Event-Driven Architecture Pocket Guide&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Also by me:&lt;/strong&gt; &lt;em&gt;Thinking in Go&lt;/em&gt; (2-book series) — &lt;a href="https://xgabriel.com/go-book" rel="noopener noreferrer"&gt;Complete Guide to Go Programming&lt;/a&gt; + &lt;a href="https://xgabriel.com/hexagonal-go" rel="noopener noreferrer"&gt;Hexagonal Architecture in Go&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;My project:&lt;/strong&gt; &lt;a href="https://hermes-ide.com" rel="noopener noreferrer"&gt;Hermes IDE&lt;/a&gt; | &lt;a href="https://github.com/hermes-hq/hermes-ide" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; — an IDE for developers who ship with Claude Code and other AI coding tools&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Me:&lt;/strong&gt; &lt;a href="https://xgabriel.com" rel="noopener noreferrer"&gt;xgabriel.com&lt;/a&gt; | &lt;a href="https://github.com/gabrielanhaia" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Your deploy stops all ten workers, ships, and starts them again. While they were down, 8,000 jobs piled up in a classic queue. The first worker back opens a channel, starts consuming, and the broker pushes it the whole backlog. The other nine come up a few seconds later and find nothing to take. That first worker happens to sit on a busy node and runs at 300 ms a job, so it will need 40 minutes to get through what it holds.&lt;/p&gt;

&lt;p&gt;The management UI shows 0 ready and 8,000 unacked. Nothing is stuck in the queue; everything is stuck in one process. On RabbitMQ 4.3 or later, no consumer timeout will take those messages back either, because classic queues &lt;a href="https://www.rabbitmq.com/blog/2026/04/23/rabbitmq-4.3-release" rel="noopener noreferrer"&gt;no longer evaluate consumer timeouts&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The cause is one missing &lt;code&gt;basic.qos&lt;/code&gt; call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Unlimited is the default
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.rabbitmq.com/" rel="noopener noreferrer"&gt;RabbitMQ&lt;/a&gt; caps how many unacknowledged deliveries a consumer can hold through the prefetch count set by &lt;code&gt;basic.qos&lt;/code&gt;. The &lt;a href="https://www.rabbitmq.com/docs/consumer-prefetch" rel="noopener noreferrer"&gt;consumer prefetch guide&lt;/a&gt; says: "A value of &lt;code&gt;0&lt;/code&gt; is treated as infinite, allowing any number of unacknowledged messages." If your client never sends &lt;code&gt;basic.qos&lt;/code&gt;, the broker applies &lt;code&gt;default_consumer_prefetch&lt;/code&gt;, and in the &lt;a href="https://github.com/rabbitmq/rabbitmq-server/blob/main/deps/rabbit/Makefile" rel="noopener noreferrer"&gt;RabbitMQ server source&lt;/a&gt; that default is &lt;code&gt;{false, 0}&lt;/code&gt;: per-consumer, zero, unlimited.&lt;/p&gt;

&lt;p&gt;Combine that with how dispatch works. Active consumers on a queue &lt;a href="https://www.rabbitmq.com/docs/consumers" rel="noopener noreferrer"&gt;receive messages round-robin&lt;/a&gt;, but only consumers with spare capacity are &lt;a href="https://www.rabbitmq.com/docs/consumer-prefetch" rel="noopener noreferrer"&gt;eligible&lt;/a&gt;, and an unlimited consumer always has spare capacity. When a backlog exists and one consumer attaches, it is the only eligible consumer, so it gets everything. Even when every worker is connected before the jobs arrive, round-robin hands the slow one its full share up front, and the fast ones go idle once theirs is done.&lt;/p&gt;

&lt;p&gt;There is a memory cost too. The &lt;a href="https://www.rabbitmq.com/docs/confirms" rel="noopener noreferrer"&gt;acknowledgements guide&lt;/a&gt; warns that "automatic acknowledgement mode or manual acknowledgement mode with unlimited prefetch should be used with care," because consumers that take in many messages without acking them grow memory on the node they are connected to. Quorum queues cap consumer prefetch at 2,000 to limit Raft log growth, per the same guide, and 2,000 is still a lot of messages to park behind one slow handler.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnw2zfinqtaogv58g263b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnw2zfinqtaogv58g263b.png" alt="A single worker silhouette buried under a tall stack of envelopes while a second worker stands beside an empty tray" width="800" height="800"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;With no prefetch limit, the consumer that attaches first takes the backlog with it.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The demo: one slow worker, one fast one
&lt;/h2&gt;

&lt;p&gt;You need &lt;a href="https://docs.docker.com/compose/" rel="noopener noreferrer"&gt;Docker Compose&lt;/a&gt; and nothing else. The broker is the official &lt;a href="https://hub.docker.com/_/rabbitmq" rel="noopener noreferrer"&gt;RabbitMQ 4.4 image&lt;/a&gt; and the client is &lt;a href="https://amqp-node.github.io/amqplib/" rel="noopener noreferrer"&gt;amqplib&lt;/a&gt; running on &lt;a href="https://nodejs.org/" rel="noopener noreferrer"&gt;Node.js&lt;/a&gt; 24 (amqplib needs Node 18 or newer, per its &lt;a href="https://registry.npmjs.org/amqplib/latest" rel="noopener noreferrer"&gt;package metadata&lt;/a&gt;).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose.yml&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;rabbitmq&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rabbitmq:4.4-management&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;RABBITMQ_DEFAULT_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;demo&lt;/span&gt;
      &lt;span class="na"&gt;RABBITMQ_DEFAULT_PASS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;demo&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;15672:15672"&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rabbitmq-diagnostics"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-q"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;
             &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;check_port_connectivity"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt;
  &lt;span class="na"&gt;demo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node:24-alpine&lt;/span&gt;
    &lt;span class="na"&gt;working_dir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/app&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./:/app&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;AMQP_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;amqp://demo:demo@rabbitmq:5672&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;rabbitmq&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script fills a quorum queue with 1,000 jobs, starts a worker that spends 100 ms per job, waits half a second, then starts a worker that spends 10 ms per job. Each worker processes one job at a time, like a handler doing real work would.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// demo.mjs&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;amqp&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;amqplib&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AMQP_URL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;QUEUE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;jobs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TOTAL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PREFETCH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TIMEOUT_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TIMEOUT_MS&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sleep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;t0&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;conn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;amqp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createConfirmChannel&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertQueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;QUEUE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;durable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-queue-type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;quorum&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;purgeQueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;QUEUE&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;TOTAL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendToQueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;QUEUE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;messageId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`job-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForConfirms&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;workMs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;conn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;amqp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createChannel&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prefetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PREFETCH&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;chain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;onMessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cancelled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// the broker cancelled us&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;redelivered&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;redelivered&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;held&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;peak&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;peak&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;held&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;chain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cancelled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;workMs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cancelled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;held&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;done&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastAckAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;t0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;consume&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;QUEUE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onMessage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;noAck&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;done&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;held&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;peak&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;redelivered&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;lastAckAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;cancelled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;slow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fast&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;slowArgs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;TIMEOUT_MS&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-consumer-timeout&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TIMEOUT_MS&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;

&lt;span class="nx"&gt;t0&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;conns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;slow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;slowArgs&lt;/span&gt;&lt;span class="p"&gt;)];&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// the fast worker comes up late&lt;/span&gt;
&lt;span class="nx"&gt;conns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fast&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;done&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;fast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;done&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;TOTAL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;secs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;t0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`prefetch=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;PREFETCH&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, all acked after &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;secs&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;s`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;slow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fast&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;conns&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it once per setting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; rabbitmq
docker compose run &lt;span class="nt"&gt;--rm&lt;/span&gt; demo npm &lt;span class="nb"&gt;install &lt;/span&gt;amqplib@2.2.1
docker compose run &lt;span class="nt"&gt;--rm&lt;/span&gt; demo node demo.mjs 0
docker compose run &lt;span class="nt"&gt;--rm&lt;/span&gt; demo node demo.mjs 1
docker compose run &lt;span class="nt"&gt;--rm&lt;/span&gt; demo node demo.mjs 10
docker compose run &lt;span class="nt"&gt;--rm&lt;/span&gt; demo node demo.mjs 50
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ch.prefetch(0)&lt;/code&gt; sends the same "no limit" the broker would assume anyway; amqplib documents that a falsey count &lt;a href="https://amqp-node.github.io/amqplib/channel_api.html" rel="noopener noreferrer"&gt;means no limit&lt;/a&gt;. The &lt;code&gt;peak&lt;/code&gt; column is the number to watch: it is how many unacked messages the worker held at its worst.&lt;/p&gt;

&lt;p&gt;One Node-specific trap is visible in the code. Delete the &lt;code&gt;chain&lt;/code&gt; and call the handler directly, and prefetch stops being a buffer size and becomes your concurrency limit. At prefetch 0, that is 1,000 handlers running at once, which in a real service means 1,000 open database queries.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the numbers should look like
&lt;/h2&gt;

&lt;p&gt;I haven't run this demo, so here is the arithmetic for what it should print, rounded. The slow worker clears 10 jobs a second and the fast one clears 100. The queue empties once the slow worker's acks plus its buffer plus the fast worker's acks reach 1,000. After that, the fast worker sits idle while the slow one works through whatever it still holds.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Prefetch&lt;/th&gt;
&lt;th&gt;Slow handles&lt;/th&gt;
&lt;th&gt;Fast handles&lt;/th&gt;
&lt;th&gt;Slow peak unacked&lt;/th&gt;
&lt;th&gt;Last ack&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;~1,000&lt;/td&gt;
&lt;td&gt;~0&lt;/td&gt;
&lt;td&gt;~1,000&lt;/td&gt;
&lt;td&gt;~100 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;~95&lt;/td&gt;
&lt;td&gt;~905&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;~9.5 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;~105&lt;/td&gt;
&lt;td&gt;~895&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;~10.5 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;~140&lt;/td&gt;
&lt;td&gt;~860&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;~14 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Your run will move around these with connection setup time and timer jitter. At 0, the slow worker takes the whole backlog before its sibling has finished connecting, and the run takes ten times longer than it needs to. At 50, the last five seconds are the fast worker idling while 50 jobs wait behind a 100 ms handler.&lt;/p&gt;

&lt;p&gt;Prefetch 1 looks like the winner here, and that is the demo flattering it. Both containers sit on one Docker network, so the round trip between an ack and the next delivery costs almost nothing. Over a real network, a worker at prefetch 1 waits a full round trip after every ack before it gets its next job, and the RabbitMQ docs say prefetch 1 "will significantly reduce throughput" (&lt;a href="https://www.rabbitmq.com/docs/confirms" rel="noopener noreferrer"&gt;acknowledgements guide&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Picking the number
&lt;/h2&gt;

&lt;p&gt;The docs give a starting range, "Values in the 100 through 300 range usually offer optimal throughput and do not run significant risk of overwhelming consumers," and add that finding the right value "is a matter of trial and error" (&lt;a href="https://www.rabbitmq.com/docs/confirms" rel="noopener noreferrer"&gt;acknowledgements guide&lt;/a&gt;). That range fits fast handlers. For slower ones, reason from two sides.&lt;/p&gt;

&lt;p&gt;The floor is keeping the worker busy. You want enough messages buffered to cover one network round trip. A rough rule: divide the round trip by the handler time and add one. A 2 ms handler 20 ms away from the broker wants about 11. A 500 ms handler wants 1 or 2, because the round trip is noise next to the work.&lt;/p&gt;

&lt;p&gt;The ceiling is how much work you are willing to strand. Multiply prefetch by the handler time. That product is how long a message can wait behind a slow or dying worker while its siblings sit idle, and it is how long that worker needs to drain on a graceful shutdown. If the answer is longer than your latency budget, the number is too high.&lt;/p&gt;

&lt;p&gt;Then check that product against your consumer timeout, which the next section covers. The timeout counts how long a consumer holds a delivery, and a message sitting in your client's buffer counts as held, even if no code has touched it yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  consumer_timeout only guards quorum queues now
&lt;/h2&gt;

&lt;p&gt;RabbitMQ's &lt;a href="https://www.rabbitmq.com/docs/consumers" rel="noopener noreferrer"&gt;consumer timeout&lt;/a&gt; limits how long a consumer can keep a delivery unacknowledged. The default &lt;code&gt;consumer_timeout&lt;/code&gt; is 1,800,000 ms, which is 30 minutes, and you can override it per queue with the &lt;code&gt;consumer-timeout&lt;/code&gt; policy key or the &lt;code&gt;x-consumer-timeout&lt;/code&gt; queue argument.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.rabbitmq.com/blog/2026/04/23/rabbitmq-4.3-release" rel="noopener noreferrer"&gt;RabbitMQ 4.3&lt;/a&gt;, released April 23, 2026, changed where this lives. Timeout handling moved into quorum queues (and the new JMS queues), and classic queues and streams stopped evaluating consumer timeouts. On a 4.3+ broker, a classic-queue consumer with unlimited prefetch and a stuck handler can hold its messages until its connection dies. Before 4.3, a timeout closed the whole AMQP 0-9-1 channel, taking down every consumer on it. Now, if the client supports consumer cancel notifications, the broker cancels only the timed-out consumer and returns its messages to the queue; clients without that support still get the channel closed with &lt;code&gt;PRECONDITION_FAILED&lt;/code&gt; (&lt;a href="https://www.rabbitmq.com/docs/consumers" rel="noopener noreferrer"&gt;consumers guide&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;amqplib advertises &lt;code&gt;consumer_cancel_notify&lt;/code&gt; in its &lt;a href="https://github.com/amqp-node/amqplib/blob/main/lib/connect.js" rel="noopener noreferrer"&gt;connection capabilities&lt;/a&gt;, and its docs say a server-side cancel invokes your consume callback with &lt;code&gt;null&lt;/code&gt;. That is the &lt;code&gt;msg === null&lt;/code&gt; branch in the demo. To try it, give the slow worker a per-consumer timeout, which the &lt;a href="https://www.rabbitmq.com/docs/quorum-queues" rel="noopener noreferrer"&gt;quorum queue guide&lt;/a&gt; documents as the &lt;code&gt;x-consumer-timeout&lt;/code&gt; consumer argument:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;TIMEOUT_MS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10000 demo &lt;span class="se"&gt;\&lt;/span&gt;
  node demo.mjs 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By the docs, the slow worker should get cancelled around the 10-second mark, with roughly 100 jobs done, and the fast worker should pick up the rest. Any job the slow worker had started is now in someone else's hands too. Your handler keeps running after the cancel. The broker has just stopped waiting for its ack. The 4.3 release post adds one more detail: a consumer timeout increments a message's &lt;code&gt;acquired-count&lt;/code&gt;, not its &lt;code&gt;delivery-count&lt;/code&gt;, so timeouts do not eat into the quorum queue &lt;a href="https://www.rabbitmq.com/docs/quorum-queues" rel="noopener noreferrer"&gt;delivery limit, which defaults to 20&lt;/a&gt; since RabbitMQ 4.0.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqzqi905d8reycsv1jtr8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqzqi905d8reycsv1jtr8.png" alt="A large hourglass tipping messages back into a queue while a worker silhouette keeps working on an envelope" width="800" height="800"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;After a timeout, the queue gets the messages back while the old handler is still working.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Redelivery means the handler must be idempotent
&lt;/h2&gt;

&lt;p&gt;When a channel or connection closes with manual acks, "any delivery (message) that was not acked is automatically requeued," and those redeliveries have the &lt;code&gt;redeliver&lt;/code&gt; flag set to &lt;code&gt;true&lt;/code&gt; (&lt;a href="https://www.rabbitmq.com/docs/confirms" rel="noopener noreferrer"&gt;acknowledgements guide&lt;/a&gt;), which amqplib exposes as &lt;code&gt;msg.fields.redelivered&lt;/code&gt;. The same guide spells out the consequence: "consumers must be prepared to handle redeliveries and otherwise be implemented with idempotence in mind," and "a consumer can receive a message that was previously delivered to another consumer."&lt;/p&gt;

&lt;p&gt;Prefetch decides how many messages a crash or a timeout throws back at once. It does not decide whether duplicates happen. A worker can finish the side effect and die before its ack reaches the broker, and the next worker will run the same job again. So key every job and record the key in the same transaction as the side effect. With &lt;a href="https://node-postgres.com/" rel="noopener noreferrer"&gt;node-postgres&lt;/a&gt; and a &lt;code&gt;processed (id text primary key)&lt;/code&gt; table, the insert's &lt;a href="https://node-postgres.com/apis/result" rel="noopener noreferrer"&gt;&lt;code&gt;rowCount&lt;/code&gt;&lt;/a&gt; tells you whether this is the first time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;applyJob&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messageId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;BEGIN&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;rowCount&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;INSERT INTO processed (id) VALUES ($1) &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ON CONFLICT DO NOTHING&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rowCount&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;applyJob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;COMMIT&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ROLLBACK&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ack only after &lt;code&gt;COMMIT&lt;/code&gt; returns. A duplicate then inserts nothing, skips the side effect, commits, and gets acked. Don't use the &lt;code&gt;redelivered&lt;/code&gt; flag as your dedupe check. It tells you the broker has handed this message out before, not whether your side effect ran.&lt;/p&gt;

&lt;p&gt;Before the next deploy, find every &lt;code&gt;consume(&lt;/code&gt; call in your codebase and check that a &lt;code&gt;prefetch(&lt;/code&gt; call runs on the same channel before it.&lt;/p&gt;




&lt;h2&gt;
  
  
  If this was useful
&lt;/h2&gt;

&lt;p&gt;Prefetch, acks and redelivery are where a queue-based design meets the real world, and most of the pain shows up as duplicates, stranded work or one consumer doing everything. The &lt;a href="https://www.amazon.com/dp/B0GX3B8371" rel="noopener noreferrer"&gt;Event-Driven Architecture Pocket Guide&lt;/a&gt; covers the patterns around that boundary (sagas, CQRS, the outbox) and the traps that come with them, in a format you can read before the next incident review.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.amazon.com/dp/B0GX3B8371" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fblk678n27i2r4chdp8zh.jpg" alt="Event-Driven Architecture Pocket Guide: Saga, CQRS, Outbox, and the Traps Nobody Warns You About" width="800" height="1200"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>rabbitmq</category>
      <category>node</category>
      <category>backend</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Agentjacking: A Fake Sentry Error Became a 'Fix' That Claude Code and Cursor Ran</title>
      <dc:creator>Gabriel Anhaia</dc:creator>
      <pubDate>Sat, 07 Nov 2026 14:01:51 +0000</pubDate>
      <link>https://dev.to/gabrielanhaia/agentjacking-a-fake-sentry-error-became-a-fix-that-claude-code-and-cursor-ran-5epn</link>
      <guid>https://dev.to/gabrielanhaia/agentjacking-a-fake-sentry-error-became-a-fix-that-claude-code-and-cursor-ran-5epn</guid>
      <description>&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Book:&lt;/strong&gt; &lt;a href="https://www.amazon.com/dp/B0GX38N645" rel="noopener noreferrer"&gt;Prompt Engineering Pocket Guide&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Also by me:&lt;/strong&gt; &lt;em&gt;Thinking in Go&lt;/em&gt; (2-book series) — &lt;a href="https://xgabriel.com/go-book" rel="noopener noreferrer"&gt;Complete Guide to Go Programming&lt;/a&gt; + &lt;a href="https://xgabriel.com/hexagonal-go" rel="noopener noreferrer"&gt;Hexagonal Architecture in Go&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;My project:&lt;/strong&gt; &lt;a href="https://hermes-ide.com" rel="noopener noreferrer"&gt;Hermes IDE&lt;/a&gt; | &lt;a href="https://github.com/hermes-hq/hermes-ide" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; — an IDE for developers who ship with Claude Code and other AI coding tools&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Me:&lt;/strong&gt; &lt;a href="https://xgabriel.com" rel="noopener noreferrer"&gt;xgabriel.com&lt;/a&gt; | &lt;a href="https://github.com/gabrielanhaia" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;You open your coding agent and type the most ordinary request of the week: fix the unresolved Sentry issues. The agent calls Sentry, reads the top issue, and finds a tidy section headed Resolution with one command in a code block. It runs the command. The person who wrote that command has never had access to your code, your Sentry account or your laptop.&lt;/p&gt;

&lt;p&gt;That is agentjacking, the attack &lt;a href="https://tenetsecurity.ai/blog/agentjacking-coding-agents-with-fake-sentry-errors/" rel="noopener noreferrer"&gt;Tenet Security&lt;/a&gt; published in June 2026 and &lt;a href="https://thehackernews.com/2026/06/agentjacking-attack-tricks-ai-coding.html" rel="noopener noreferrer"&gt;The Hacker News&lt;/a&gt; reported on June 12. Tenet found 2,388 organizations with Sentry DSNs it could inject into. In a controlled test against more than 100 organizations, it reports an 85% success rate, with &lt;a href="https://www.infosecurity-magazine.com/news/agentjacking-attacks-hijack-ai/" rel="noopener noreferrer"&gt;Claude Code, Cursor and Codex&lt;/a&gt; all acting on planted errors.&lt;/p&gt;

&lt;p&gt;No step in the chain broke a rule. Tenet's phrase is that "every step is authorized."&lt;/p&gt;

&lt;h2&gt;
  
  
  The key that is meant to be public
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://sentry.io" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt; is an error tracker. Your app reports crashes to it using a DSN, a URL with a key in it, and the browser SDK needs that DSN in your frontend JavaScript to work at all. Sentry's own &lt;a href="https://docs.sentry.io/concepts/key-terms/dsn-explainer/" rel="noopener noreferrer"&gt;DSN explainer&lt;/a&gt; says "DSNs are safe to keep public" because they "only allow submission of new events and related event data" and "do not allow read access to any information."&lt;/p&gt;

&lt;p&gt;That's true, and it's the whole problem. Write-only access to your error feed was harmless while only humans read the feed. The same page admits "there is a risk of abusing a DSN," calls it "a rare occurrence," and points to IP blocking and key rotation.&lt;/p&gt;

&lt;p&gt;Then the reader changed. The &lt;a href="https://mcp.sentry.dev/" rel="noopener noreferrer"&gt;Sentry MCP server&lt;/a&gt; connects Claude Code, Cursor, VS Code and any other MCP client to your Sentry account for "searching errors, analyzing performance, triaging issues." Whatever anyone wrote into your error feed is now text your agent reads while it holds a shell.&lt;/p&gt;

&lt;h2&gt;
  
  
  The chain, hop by hop
&lt;/h2&gt;

&lt;p&gt;Here is the attack as &lt;a href="https://tenetsecurity.ai/blog/agentjacking-coding-agents-with-fake-sentry-errors/" rel="noopener noreferrer"&gt;Tenet describes it&lt;/a&gt;, with the reason each hop was allowed.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Find a DSN.&lt;/strong&gt; Tenet lists website JavaScript, &lt;a href="https://censys.com" rel="noopener noreferrer"&gt;Censys&lt;/a&gt; searches for Sentry's ingest host, and GitHub code search. Allowed, because the DSN is designed to ship to every browser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;POST one error event.&lt;/strong&gt; The attacker sends a crafted event to Sentry's ingest endpoint. No authentication beyond the DSN, and Sentry answers HTTP 200. Allowed, because accepting events from strangers is what a frontend DSN is for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dress it as Sentry.&lt;/strong&gt; The payload sits in the event's message field and in the names of its context keys. It is markdown: headings, code blocks, and a fake &lt;code&gt;## Resolution&lt;/code&gt; section with an &lt;code&gt;npx&lt;/code&gt; command, laid out to look like the template Sentry's MCP output already uses. Allowed, because an error message is user input and can say anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wait for triage.&lt;/strong&gt; A developer asks the agent to fix unresolved Sentry issues. Tenet calls it "a plain triage request." Allowed, because the developer set up the MCP server and asked for exactly this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The MCP server hands it over.&lt;/strong&gt; The server signs in to your Sentry account over OAuth and returns real Sentry data, which happens to contain the attacker's markdown. Per Tenet, the agent can't tell a planted event from a real crash. Allowed, because the tool call did what it was asked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The agent runs the "fix."&lt;/strong&gt; It runs &lt;code&gt;npx&lt;/code&gt; on an npm package the attacker controls, with the developer's privileges. In Tenet's tests the package was its own validation package. It checked environment variables, probed the file sizes of &lt;code&gt;~/.aws/config&lt;/code&gt;, &lt;code&gt;~/.npmrc&lt;/code&gt; and &lt;code&gt;~/.docker/config.json&lt;/code&gt;, looked for a VPN, and sent two POSTs to a beacon. Tenet says it did not keep the probe data.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F82ld2jz5skb2qgd60w2s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F82ld2jz5skb2qgd60w2s.png" alt="A chain of six links running from a browser window to a developer's terminal, with one link shaped like a forged envelope bearing an official-looking seal" width="800" height="800"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Six hops, each one permitted on its own. The forged one sits in the middle and looks like the rest.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Tenet says the attack "bypasses EDR, WAF, IAM, VPN, Cloudflare, and firewalls" (&lt;a href="https://thehackernews.com/2026/06/agentjacking-attack-tricks-ai-coding.html" rel="noopener noreferrer"&gt;The Hacker News&lt;/a&gt;). There's no malware to scan for. There's an HTTPS request to Sentry, a tool call, and an &lt;code&gt;npx&lt;/code&gt; command that looks like a thousand others.&lt;/p&gt;

&lt;p&gt;The instinctive fix is a line in your agent's instructions: don't trust what you read in tool output. Tenet tested that. Agents ran the payload "even when explicitly instructed" to ignore untrusted data, and its conclusion is blunt: "You cannot fix this with a better prompt."&lt;/p&gt;

&lt;p&gt;Tenet's write-up doesn't say which permission mode the agents ran in, or whether a developer clicked through an approval prompt. It does say sandboxed, CI-based and internal-network agents were also reached, so a sandbox helps only as far as what it denies.&lt;/p&gt;
&lt;h2&gt;
  
  
  Sentry's answer was one string
&lt;/h2&gt;

&lt;p&gt;Tenet disclosed to Sentry on June 3, 2026, and Sentry replied the same day. It acknowledged the issue and declined to fix the root cause, calling it "technically not defensible" (&lt;a href="https://tenetsecurity.ai/blog/agentjacking-coding-agents-with-fake-sentry-errors/" rel="noopener noreferrer"&gt;Tenet&lt;/a&gt;). The &lt;a href="https://labs.cloudsecurityalliance.org/research/csa-research-note-agentjacking-mcp-sentry-injection-20260612/" rel="noopener noreferrer"&gt;Cloud Security Alliance's research note&lt;/a&gt; names the two root fixes Sentry turned down: authenticated ingestion, and sanitizing event content before the MCP server returns it. What Sentry did ship was a global content filter that blocks one specific payload string.&lt;/p&gt;

&lt;p&gt;Be fair to Sentry on the first one. A browser can't keep a secret, so authenticated ingestion from frontend code isn't really on the table. Sanitizing is harder to wave away. A &lt;a href="https://github.com/getsentry/sentry-mcp/pull/1056" rel="noopener noreferrer"&gt;draft pull request&lt;/a&gt; in Sentry's MCP repository wraps the attacker-writable parts of &lt;code&gt;get_issue_details&lt;/code&gt; (description, culprit, event details) in an &lt;code&gt;&amp;lt;untrusted_event_telemetry&amp;gt;&lt;/code&gt; section. As of November 7 it is still an open draft. Even merged, the model still reads everything inside the tag, and nothing stops it following an instruction there.&lt;/p&gt;

&lt;p&gt;Tenet picked Sentry, but any MCP tool that returns text an outsider can write works the same way: issue trackers, support inboxes, log search. The CSA note frames the injection pathway as architectural, and that matches the chain above.&lt;/p&gt;
&lt;h2&gt;
  
  
  Defenses that hold at the last hop
&lt;/h2&gt;

&lt;p&gt;Hops 1 through 5 happen on systems you don't control. Hop 6 happens on your machine, so that's where the controls go: one rule, and two settings that enforce it without depending on the model behaving.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Treat MCP output as data, and enforce it at execution.&lt;/strong&gt; A Sentry event can tell the agent where a bug is. It should never be the source of a command. Since instructions alone failed in Tenet's tests, that rule has to live in the approval step, where you read the command before it runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep approvals on for shell commands.&lt;/strong&gt; In Claude Code's Manual mode, every Bash command asks first, except a built-in read-only set like &lt;code&gt;ls&lt;/code&gt;, &lt;code&gt;cat&lt;/code&gt; and &lt;code&gt;grep&lt;/code&gt; (&lt;a href="https://code.claude.com/docs/en/permissions" rel="noopener noreferrer"&gt;permissions docs&lt;/a&gt;). &lt;code&gt;npx&lt;/code&gt; isn't on that list. Rules are checked in the order deny, ask, allow, and a matching ask rule prompts even when an allow rule also matches. So ask rules for package runners keep prompting even after someone saved a broad allow. And don't run triage in &lt;code&gt;bypassPermissions&lt;/code&gt;: the docs say to use it only "in isolated environments like containers or VMs." The &lt;a href="https://labs.cloudsecurityalliance.org/research/csa-research-note-agentjacking-mcp-sentry-injection-20260612/" rel="noopener noreferrer"&gt;CSA note&lt;/a&gt; makes the same call: if the integration stays on, require human approval before the agent runs commands or installs packages from MCP-retrieved content.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Take the credentials away from the shell.&lt;/strong&gt; Claude Code's &lt;a href="https://code.claude.com/docs/en/sandboxing" rel="noopener noreferrer"&gt;sandbox&lt;/a&gt; is off by default, and even when on, its default read access covers "credential files such as &lt;code&gt;~/.ssh&lt;/code&gt; and &lt;code&gt;~/.aws/credentials&lt;/code&gt;." The &lt;code&gt;sandbox.credentials&lt;/code&gt; setting denies files and environment variables to sandboxed commands. The network allowlist starts empty, so the first connection to a new host, like a beacon, stops and asks you in Manual or Accept edits mode instead of going out silently (&lt;code&gt;strictAllowlist&lt;/code&gt; refuses it outright). It runs on macOS, Linux and WSL2. A project &lt;code&gt;.claude/settings.json&lt;/code&gt; with the ask rules, the sandbox and the credential denies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ask"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(npx *)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(npm exec *)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(pnpm dlx *)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(bunx *)"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sandbox"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"enabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"allowUnsandboxedCommands"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"credentials"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"files"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"~/.aws"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"deny"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"~/.ssh"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"deny"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"~/.npmrc"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"deny"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"~/.docker/config.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"deny"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"envVars"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"AWS_SECRET_ACCESS_KEY"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"deny"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"GITHUB_TOKEN"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"deny"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"NPM_TOKEN"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"deny"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Turning the sandbox on changes the prompting. With &lt;code&gt;autoAllowBashIfSandboxed&lt;/code&gt; at its default of &lt;code&gt;true&lt;/code&gt;, Claude Code runs other sandboxed Bash commands without asking. Content-scoped ask rules like these &lt;code&gt;npx&lt;/code&gt; ones still force a prompt.&lt;/p&gt;

&lt;p&gt;Two limits. The docs say "there is no built-in credential deny list," so only what you list is protected. And a Bash rule "isn't a security boundary": &lt;code&gt;Bash(curl *)&lt;/code&gt; doesn't stop &lt;code&gt;sh -c 'curl ...'&lt;/code&gt;. The ask rules catch the usual form. For the rest, the sandbox limits what the command can read and where it can connect. &lt;a href="https://cursor.com/docs/agent/security/run-modes" rel="noopener noreferrer"&gt;Cursor&lt;/a&gt; and &lt;a href="https://developers.openai.com/codex/agent-approvals-security" rel="noopener noreferrer"&gt;Codex&lt;/a&gt; have their own approval and sandbox settings, so ask the same of them: what prompts, what runs sandboxed, and what's denied.&lt;/p&gt;

&lt;p&gt;On the Sentry side, the CSA note recommends disabling the MCP integration if you don't need it and rotating exposed DSNs (from the project's Client Keys settings, per &lt;a href="https://docs.sentry.io/concepts/key-terms/dsn-explainer/" rel="noopener noreferrer"&gt;Sentry's docs&lt;/a&gt;). It also suggests evaluating a server-side relay so the DSN stays out of client code. Scoping the MCP connection to one organization or project hides discovery tools you don't use (&lt;a href="https://mcp.sentry.dev/" rel="noopener noreferrer"&gt;Sentry MCP&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Hermes fits, and where it doesn't
&lt;/h2&gt;

&lt;p&gt;I build Hermes IDE, so weigh this section with that in mind. Hermes runs every agent in a real terminal, exactly as it would anywhere else. That cuts both ways. The permission rules and sandbox you set in Claude Code apply unchanged, and the agent has the same reach it has in your own shell. A worktree is a folder, not a sandbox: &lt;code&gt;~/.aws&lt;/code&gt; is the same file from every worktree. Hermes alone doesn't stop this attack. The settings above do the credential work.&lt;/p&gt;

&lt;p&gt;What it adds is around the approval step. ⌘N (Ctrl+Shift+N on Windows and Linux) starts the triage task on its own &lt;code&gt;hermes/&lt;/code&gt; branch and worktree, and the sheet shows the approval mode (Accept edits in the screenshot below) and a "Hermes will run" preview of the claude command. So you can see you're starting Claude Code in Accept edits, which auto-accepts file edits and common filesystem commands like &lt;code&gt;mkdir&lt;/code&gt; and &lt;code&gt;mv&lt;/code&gt;, not &lt;code&gt;npx&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffszpiznbqz77gsn3b75u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffszpiznbqz77gsn3b75u.png" alt="The Hermes New task sheet with a refund-endpoint task typed, chips for Claude Code, the orders-api project, a new hermes/ worktree, Accept edits, model and effort, and the Hermes will run preview of the claude command" width="800" height="415"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The ⌘N New task sheet: Claude Code, the orders-api project, a new &lt;code&gt;hermes/&lt;/code&gt; worktree, Accept edits, model and effort, and the "Hermes will run" preview of the claude command.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;When the agent stops to ask about an &lt;code&gt;npx&lt;/code&gt; command, the session shows "needs approval," the attention inbox counts it, and ⌘I (Ctrl+Shift+I) jumps to it. You answer in the agent's own terminal. Hermes never answers a permission prompt for you and never types into a terminal on its own.&lt;/p&gt;

&lt;p&gt;If a planted "fix" did change files, every agent turn is saved. The Review Desk (⌘G, or Ctrl+Shift+G) shows changes by turn with risk flags, and you can diff or restore a single turn with undo.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8cgisbligwk7mx2lpa8d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8cgisbligwk7mx2lpa8d.png" alt="The Hermes Review Desk in By turn view for a Claude Code task, with two turns listed, turn 1's diff of src/db.ts, the changed-files list and a drafted commit message" width="800" height="444"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The Review Desk (⌘G) by turn: two Claude Code turns, turn 1's diff of &lt;code&gt;src/db.ts&lt;/code&gt;, the changed files, and a commit message drafted from the turns.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Hermes is free, source-available under BSL 1.1, and runs on macOS, Windows and Linux (&lt;a href="https://hermes-ide.com/download" rel="noopener noreferrer"&gt;download&lt;/a&gt;, &lt;a href="https://github.com/hermes-hq/hermes-ide" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;). It isn't affiliated with Sentry, Anthropic, Cursor, OpenAI or Nous Research.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your error feed is now an inbox
&lt;/h2&gt;

&lt;p&gt;Count the MCP servers your agents use that return text a stranger can write. For each one, check what happens at the moment a command from that text reaches your shell: who reads it, and what it can reach once it runs.&lt;/p&gt;

&lt;p&gt;How does your team run Sentry triage with an agent today: approvals on, sandboxed, or MCP off? Tell me in the comments.&lt;/p&gt;




&lt;h2&gt;
  
  
  If this was useful
&lt;/h2&gt;

&lt;p&gt;Agentjacking works because the model can't tell an instruction it was given from text that only looks like one. If you write the prompts and instruction files your agents run on, the &lt;a href="https://www.amazon.com/dp/B0GX38N645" rel="noopener noreferrer"&gt;Prompt Engineering Pocket Guide&lt;/a&gt; is my short book on how models take instructions, which helps you see where a fake Resolution section gets its pull. And if you want one place to watch which agent is waiting on an approval, &lt;a href="https://hermes-ide.com/download" rel="noopener noreferrer"&gt;Hermes IDE&lt;/a&gt; is free.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.amazon.com/dp/B0GX38N645" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fg0d5pom6bpbranr5abrn.jpg" alt="Prompt Engineering Pocket Guide: Techniques for Getting the Most from LLMs" width="800" height="1200"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>ai</category>
      <category>mcp</category>
      <category>devtools</category>
    </item>
    <item>
      <title>An Agent That Clicks Your Screen Leaves No Diff. Keep Coding Agents Where Git Can See</title>
      <dc:creator>Gabriel Anhaia</dc:creator>
      <pubDate>Sat, 07 Nov 2026 13:01:24 +0000</pubDate>
      <link>https://dev.to/gabrielanhaia/an-agent-that-clicks-your-screen-leaves-no-diff-keep-coding-agents-where-git-can-see-1139</link>
      <guid>https://dev.to/gabrielanhaia/an-agent-that-clicks-your-screen-leaves-no-diff-keep-coding-agents-where-git-can-see-1139</guid>
      <description>&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Book:&lt;/strong&gt; &lt;a href="https://www.amazon.com/dp/B0GYLHMLMT" rel="noopener noreferrer"&gt;LLM Observability Pocket Guide&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Also by me:&lt;/strong&gt; &lt;em&gt;Thinking in Go&lt;/em&gt; (2-book series) — &lt;a href="https://xgabriel.com/go-book" rel="noopener noreferrer"&gt;Complete Guide to Go Programming&lt;/a&gt; + &lt;a href="https://xgabriel.com/hexagonal-go" rel="noopener noreferrer"&gt;Hexagonal Architecture in Go&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;My project:&lt;/strong&gt; &lt;a href="https://hermes-ide.com" rel="noopener noreferrer"&gt;Hermes IDE&lt;/a&gt; | &lt;a href="https://github.com/hermes-hq/hermes-ide" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; — an IDE for developers who ship with Claude Code and other AI coding tools&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Me:&lt;/strong&gt; &lt;a href="https://xgabriel.com" rel="noopener noreferrer"&gt;xgabriel.com&lt;/a&gt; | &lt;a href="https://github.com/gabrielanhaia" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Your coding agent finishes a task. You open the pull request and read 40 changed lines. You leave a comment on line 12, ask for a fix, and approve the second round. If it breaks next week, you revert one commit. Now picture the same agent finishing a task by clicking through a settings window for six minutes. What do you open to review it?&lt;/p&gt;

&lt;p&gt;On October 23, &lt;a href="https://runtimewire.com/article/cursor-agents-computer-use-desktop-apps" rel="noopener noreferrer"&gt;Runtime Wire reported&lt;/a&gt; that &lt;a href="https://cursor.com" rel="noopener noreferrer"&gt;Cursor&lt;/a&gt;'s coding agents can now click and type in desktop applications, including Apple's iOS Simulator, and that the agent can pick the tool on its own when a task needs it. Cursor's own docs describe &lt;a href="https://cursor.com/docs/cloud-agent/self-hosted/computer-use" rel="noopener noreferrer"&gt;computer use for self-hosted agents&lt;/a&gt;. OpenAI documents computer use for &lt;a href="https://learn.chatgpt.com/docs/computer-use" rel="noopener noreferrer"&gt;Codex&lt;/a&gt; and ChatGPT's desktop app on macOS and Windows, through a Computer Use plugin. Anthropic ships a &lt;a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/computer-use-tool" rel="noopener noreferrer"&gt;computer use tool&lt;/a&gt; in its API with members such as &lt;code&gt;screenshot&lt;/code&gt;, &lt;code&gt;left_click&lt;/code&gt; and &lt;code&gt;type&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A coding agent's work is reviewable and reversible for one reason: it lands as file changes that git can diff, bisect and revert. A click lands nowhere. Let GUI-driving agents look at the screen and report what they saw. Anything that changes state goes through files on a branch.&lt;/p&gt;

&lt;h2&gt;
  
  
  What computer use is actually for
&lt;/h2&gt;

&lt;p&gt;The vendors pitch it sensibly. Cursor's headline example is an agent driving the iOS Simulator to test the feature it just built (&lt;a href="https://runtimewire.com/article/cursor-agents-computer-use-desktop-apps" rel="noopener noreferrer"&gt;Runtime Wire&lt;/a&gt;). OpenAI's docs list testing desktop or simulator flows, reproducing GUI-only bugs, and "changing app settings that require clicking through a UI" (&lt;a href="https://learn.chatgpt.com/docs/computer-use" rel="noopener noreferrer"&gt;OpenAI docs&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Two of those three only read the screen. The third writes state. That split is the whole argument.&lt;/p&gt;

&lt;p&gt;Testing a flow is a check. The agent taps through signup, sees the button render off-screen, and goes back to the code to fix it. The fix is a file change. The click-through was evidence, and you can throw it away after reading it.&lt;/p&gt;

&lt;p&gt;Changing a setting through a UI is a mutation. The result lives in some app's preferences, a cloud console, or a database row behind an admin panel. Unless that app writes it to a file in your repo, git never hears about it.&lt;/p&gt;

&lt;p&gt;OpenAI's own docs are honest about this. Computer use actions can affect "app and system state outside your project workspace," so the docs recommend scoped tasks. They also note that desktop app changes "may not appear in the review pane until they're saved to disk and tracked by the project" (&lt;a href="https://learn.chatgpt.com/docs/computer-use" rel="noopener noreferrer"&gt;OpenAI docs&lt;/a&gt;). The review pane only sees what reaches disk.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqz18p76ke81eso4q8267.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqz18p76ke81eso4q8267.png" alt="A black silhouette of a developer holding a magnifier over a printed diff with burnt-orange highlighted lines, while a cursor arrow behind them clicks across empty app windows that leave no marks" width="800" height="800"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;A diff stays on the page after the work is done. A click doesn't.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Git is the review system you already trust
&lt;/h2&gt;

&lt;p&gt;Look at what you get for free when an agent's work is a set of commits on a branch.&lt;/p&gt;

&lt;p&gt;You get a diff. Every changed line is visible, in order, next to the line it replaced. On GitHub you can "hover over the line of code where you'd like to add a comment" and leave feedback on exactly that line (&lt;a href="https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/commenting-on-a-pull-request" rel="noopener noreferrer"&gt;GitHub Docs&lt;/a&gt;). That comment is something the agent can act on.&lt;/p&gt;

&lt;p&gt;You get undo per step. &lt;a href="https://git-scm.com/docs/git-revert" rel="noopener noreferrer"&gt;&lt;code&gt;git revert&lt;/code&gt;&lt;/a&gt; takes an existing commit and records a new one that reverses it. If the agent made five commits and the third was wrong, you revert the third and keep the rest.&lt;/p&gt;

&lt;p&gt;You get search through history. &lt;a href="https://git-scm.com/docs/git-bisect" rel="noopener noreferrer"&gt;&lt;code&gt;git bisect&lt;/code&gt;&lt;/a&gt; uses binary search to find the commit that introduced a bug, and &lt;code&gt;git bisect run&lt;/code&gt; automates it with any script that exits 0 for good and 1 to 127 (except 125) for bad.&lt;/p&gt;

&lt;p&gt;On an agent branch the whole loop is a handful of commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git switch &lt;span class="nt"&gt;-c&lt;/span&gt; agent/signup-fix
&lt;span class="c"&gt;# the agent works here, one commit per step&lt;/span&gt;

git log &lt;span class="nt"&gt;--oneline&lt;/span&gt; main..agent/signup-fix
git diff main...agent/signup-fix

&lt;span class="c"&gt;# step three was wrong: undo just that one&lt;/span&gt;
git revert &amp;lt;sha-of-step-three&amp;gt;

&lt;span class="c"&gt;# something broke and you don't know where&lt;/span&gt;
git bisect start agent/signup-fix main
git bisect run npm &lt;span class="nb"&gt;test
&lt;/span&gt;git bisect reset
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Try each of those on a GUI session. Clicks have no log you can comment on line by line, a toggled setting in a vendor console has no &lt;code&gt;revert&lt;/code&gt;, and six minutes of mouse movement can't be bisected. You might get screenshots in the chat. Cursor's self-hosted docs describe screenshots as the way you confirm the setup works (&lt;a href="https://cursor.com/docs/cloud-agent/self-hosted/computer-use" rel="noopener noreferrer"&gt;Cursor docs&lt;/a&gt;). A screenshot shows the end state, not the steps that produced it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The vendors already tell you to fence it
&lt;/h2&gt;

&lt;p&gt;None of this is a secret the vendors are hiding. Their docs say it in plain words.&lt;/p&gt;

&lt;p&gt;Anthropic recommends "a dedicated virtual machine or container with minimal privileges," keeping the model away from login details, and "asking a human to confirm decisions that might result in meaningful real-world consequences" (&lt;a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/computer-use-tool" rel="noopener noreferrer"&gt;Anthropic docs&lt;/a&gt;). It also warns that instructions on webpages or inside images can override yours.&lt;/p&gt;

&lt;p&gt;OpenAI says ChatGPT asks before it uses an app, "may also ask for permission before taking sensitive or disruptive actions," and that file edits and shell commands "still follow the sandbox and approval settings" (&lt;a href="https://learn.chatgpt.com/docs/computer-use" rel="noopener noreferrer"&gt;OpenAI docs&lt;/a&gt;). The docs also suggest a dedicated plugin or MCP server when one exists.&lt;/p&gt;

&lt;p&gt;Cursor's self-hosted docs say that on macOS the agent "clicks, types, and captures the screen of the desktop session the worker runs in" (&lt;a href="https://cursor.com/docs/cloud-agent/self-hosted/computer-use" rel="noopener noreferrer"&gt;Cursor docs&lt;/a&gt;). If the worker runs on your own machine, that is your live session, with your apps and your logins.&lt;/p&gt;

&lt;p&gt;Approval prompts are the GUI's answer to review. They help. But an approval happens once, before the action, with no reviewable artifact afterwards. A diff happens after the action, stays around, and can be read by someone who wasn't there.&lt;/p&gt;

&lt;h2&gt;
  
  
  A rule you can paste into your agent instructions
&lt;/h2&gt;

&lt;p&gt;Split the work by what it touches.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Screen-only tasks are fine.&lt;/strong&gt; Tap through the flow. Reproduce the bug. Check that the dark-mode toggle renders. Ask the agent to write what it saw to a file in the repo, say &lt;code&gt;qa/signup-check.md&lt;/code&gt; with the steps and screenshots, so the evidence lands in the diff too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;State-changing tasks go through files on a branch.&lt;/strong&gt; If the setting can live in a config file, an infrastructure template or a migration, make the agent change the file and let your normal deploy apply it. If the only path is a vendor's admin UI, a human does that click, after reading the PR that explains why.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When in doubt, ask "can I revert this tomorrow?"&lt;/strong&gt; If the answer is no, it does not belong in an unattended GUI session.&lt;/p&gt;

&lt;p&gt;This costs you a little speed on the rare task that really is GUI-only. It buys you the same review loop for every other change your agents make, and that is most of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Hermes fits
&lt;/h2&gt;

&lt;p&gt;I build &lt;a href="https://hermes-ide.com/download" rel="noopener noreferrer"&gt;Hermes IDE&lt;/a&gt;, so weigh this section with that in mind. Hermes is built around the same bet as this post: what an agent did should show up as something git can see.&lt;/p&gt;

&lt;p&gt;Hermes observes what agents report on purpose and what git shows. It never answers a permission prompt for you and never types into a terminal on its own (&lt;a href="https://github.com/hermes-hq/hermes-ide" rel="noopener noreferrer"&gt;README&lt;/a&gt;). Each task can get its own branch and worktree. Every agent turn is saved, so you can diff or restore any single turn, with undo. The Review Desk (⌘G) shows changes by turn or by file, and sends a line comment back to the agent that wrote it. &lt;strong&gt;Land&lt;/strong&gt; commits, opens a pull request or merges locally in one step, into the branch the task started from, and it can be undone.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8cgisbligwk7mx2lpa8d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8cgisbligwk7mx2lpa8d.png" alt="The Hermes IDE Review Desk in By turn view, listing two Claude Code turns with their diffs and a drafted commit message" width="800" height="444"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The Review Desk in By turn view: two turns of a Claude Code task in orders-api, turn 1's diff of src/db.ts, and a commit message drafted from the turns.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It runs several CLI coding agents side by side. It is free to download, source-available under BSL 1.1, on macOS, Windows and Linux, and it is not affiliated with Anthropic, OpenAI or any agent vendor. It can't review a click either. That is the point: keep the agent's changes in files, and the tools you already have will do the rest.&lt;/p&gt;

&lt;p&gt;Would you let a coding agent change state through a GUI with no diff behind it? Or does every change in your team go through a branch first? Tell me in the comments.&lt;/p&gt;




&lt;h2&gt;
  
  
  If this was useful
&lt;/h2&gt;

&lt;p&gt;The question under this whole post is "how do I know what the agent did?" The &lt;a href="https://www.amazon.com/dp/B0GYLHMLMT" rel="noopener noreferrer"&gt;LLM Observability Pocket Guide&lt;/a&gt; answers it for the rest of your LLM stack: traces, evals, cost and the tools that record them, so an agent's run leaves evidence you can read later. It's one of eight short books in &lt;a href="https://www.amazon.com/dp/B0GYLH38X4" rel="noopener noreferrer"&gt;Pocket Guides for Developers&lt;/a&gt;, covering agents, RAG, prompts, observability, events, databases and system design.&lt;/p&gt;

&lt;p&gt;If you run several coding agents on one repo, &lt;a href="https://hermes-ide.com/download" rel="noopener noreferrer"&gt;Hermes IDE&lt;/a&gt; keeps each one's work on its own branch, turn by turn (&lt;a href="https://github.com/hermes-hq/hermes-ide" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.amazon.com/dp/B0GYLHMLMT" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1d7icofo8dvvrrzc7c56.jpg" alt="LLM Observability Pocket Guide: Picking the Right Tracing &amp;amp; Evals Tools for Your Team" width="800" height="1200"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>git</category>
      <category>codereview</category>
      <category>productivity</category>
    </item>
    <item>
      <title>The 2017 S3 Outage Was One Wrong Input. AWS Fixed the Tool, Not the Engineer</title>
      <dc:creator>Gabriel Anhaia</dc:creator>
      <pubDate>Sat, 07 Nov 2026 12:01:27 +0000</pubDate>
      <link>https://dev.to/gabrielanhaia/the-2017-s3-outage-was-one-wrong-input-aws-fixed-the-tool-not-the-engineer-5a7b</link>
      <guid>https://dev.to/gabrielanhaia/the-2017-s3-outage-was-one-wrong-input-aws-fixed-the-tool-not-the-engineer-5a7b</guid>
      <description>&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Book:&lt;/strong&gt; &lt;a href="https://www.amazon.com/dp/B0GYMFPTWV" rel="noopener noreferrer"&gt;System Design Pocket Guide: Fundamentals&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Also by me:&lt;/strong&gt; &lt;em&gt;Thinking in Go&lt;/em&gt; (2-book series) — &lt;a href="https://xgabriel.com/go-book" rel="noopener noreferrer"&gt;Complete Guide to Go Programming&lt;/a&gt; + &lt;a href="https://xgabriel.com/hexagonal-go" rel="noopener noreferrer"&gt;Hexagonal Architecture in Go&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;My project:&lt;/strong&gt; &lt;a href="https://hermes-ide.com" rel="noopener noreferrer"&gt;Hermes IDE&lt;/a&gt; | &lt;a href="https://github.com/hermes-hq/hermes-ide" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; — an IDE for developers who ship with Claude Code and other AI coding tools&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Me:&lt;/strong&gt; &lt;a href="https://xgabriel.com" rel="noopener noreferrer"&gt;xgabriel.com&lt;/a&gt; | &lt;a href="https://github.com/gabrielanhaia" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;At 9:37 AM Pacific on February 28, 2017, an authorized member of the Amazon S3 team ran a command from an established playbook. It was meant to take a small number of servers out of one subsystem in us-east-1. &lt;a href="https://aws.amazon.com/message/41926/" rel="noopener noreferrer"&gt;AWS's own summary of the event&lt;/a&gt; says what went wrong next: "one of the inputs to the command was entered incorrectly and a larger set of servers was removed than intended."&lt;/p&gt;

&lt;p&gt;S3 in that region was not back to normal until 1:54 PM, &lt;a href="https://aws.amazon.com/message/41926/" rel="noopener noreferrer"&gt;per the same summary&lt;/a&gt;. That is more than four hours, from one input.&lt;/p&gt;

&lt;p&gt;AWS's fix went into the tool. If you let a coding agent run shell commands, that fix applies to you, because an agent enters inputs faster than any engineer does.&lt;/p&gt;

&lt;h2&gt;
  
  
  What AWS says happened
&lt;/h2&gt;

&lt;p&gt;The S3 team was debugging the S3 billing system, which was running slower than expected. The command was supposed to remove a few servers from a subsystem the billing process used. The bad input removed far more, and some of those servers supported two other subsystems, &lt;a href="https://aws.amazon.com/message/41926/" rel="noopener noreferrer"&gt;AWS wrote&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The index subsystem, which manages the metadata and location of every S3 object in the region. GET, LIST, PUT and DELETE requests all need it.&lt;/li&gt;
&lt;li&gt;The placement subsystem, which allocates storage for new objects and needs the index to work.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both needed a full restart. AWS was candid that it had "not completely restarted the index subsystem or the placement subsystem in our larger regions for many years," and after years of growth, the restart and the safety checks on the metadata took longer than expected. The index could serve GET, LIST and DELETE again by 12:26 PM and was fully recovered at 1:18 PM. Placement finished at 1:54 PM, which brought PUT back (&lt;a href="https://aws.amazon.com/message/41926/" rel="noopener noreferrer"&gt;AWS&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The damage reached past S3. The summary lists the S3 console, new EC2 instance launches, EBS volumes that needed data from an S3 snapshot, and AWS Lambda among the affected services. Even the AWS Service Health Dashboard could not show individual service status until 11:37 AM, because its admin console depended on S3.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7k9g7r5qli9nqgk4e7j9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7k9g7r5qli9nqgk4e7j9.png" alt="A black silhouette of an engineer at a large control dial, the dial's needle stopped by a burnt-orange block at the minimum mark, with a row of server racks behind" width="800" height="800"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The 2017 fix in one picture: the tool itself refuses to go below the floor.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix went into the tool
&lt;/h2&gt;

&lt;p&gt;"While removal of capacity is a key operational practice, in this instance, the tool used allowed too much capacity to be removed too quickly," &lt;a href="https://aws.amazon.com/message/41926/" rel="noopener noreferrer"&gt;AWS wrote&lt;/a&gt;. Then the change: the tool now removes capacity more slowly, and it refuses when a removal would take any subsystem below its minimum required capacity. AWS's reason, in its own words: "This will prevent an incorrect input from triggering a similar event in the future." It also said it was "auditing our other operational tools to ensure we have similar safety checks."&lt;/p&gt;

&lt;p&gt;None of the changes AWS listed is about the person who entered the input. The tool was changed so the same mistake fails safely the next time anyone makes it.&lt;/p&gt;

&lt;p&gt;The rest of the list was about recovering faster. AWS moved up its work to split the index subsystem into smaller cells, and it moved the Health Dashboard's admin console to run across multiple regions.&lt;/p&gt;

&lt;p&gt;Read as a design rule, it fits in one line: a destructive tool should cap how much it does per run and refuse to cross a floor, whatever input it gets.&lt;/p&gt;
&lt;h2&gt;
  
  
  Your agent types faster than you do
&lt;/h2&gt;

&lt;p&gt;A coding agent runs commands you didn't type, built from inputs it worked out on its own. Usually that is fine. But the S3 failure mode is one an agent can reproduce well. An agent that passes &lt;code&gt;20&lt;/code&gt; where the runbook meant &lt;code&gt;2&lt;/code&gt;, or acts on whatever cluster your kubeconfig currently points at, is the S3 scenario again: an authorized operator, a known command, one bad argument, and no pause before it runs.&lt;/p&gt;

&lt;p&gt;The approval prompt helps only while you read it. By the fortieth prompt of an afternoon, approving on reflex is easy. So the first guard should not depend on your attention. Put it in the script.&lt;/p&gt;
&lt;h2&gt;
  
  
  Put the floor inside the script
&lt;/h2&gt;

&lt;p&gt;Here is the AWS fix as a small wrapper around &lt;a href="https://kubernetes.io/docs/reference/kubectl/generated/kubectl_scale/" rel="noopener noreferrer"&gt;&lt;code&gt;kubectl scale&lt;/code&gt;&lt;/a&gt;. It refuses to remove more than &lt;code&gt;MAX_STEP&lt;/code&gt; replicas per run, refuses to go below &lt;code&gt;MIN_REPLICAS&lt;/code&gt;, won't start without an explicit cluster context, and only prints the command unless you pass &lt;code&gt;--apply&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# scripts/scale-down.sh DEPLOYMENT COUNT [--apply]&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;deploy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;:?deployment&lt;span class="p"&gt; name&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;remove&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;2&lt;/span&gt;:?replicas&lt;span class="p"&gt; to remove&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;mode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;3&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nv"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;KUBE_CONTEXT&lt;/span&gt;:?set&lt;span class="p"&gt; KUBE_CONTEXT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;MIN_REPLICAS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;MIN_REPLICAS&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;3&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;MAX_STEP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;MAX_STEP&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;2&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$remove&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
  &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="o"&gt;[!&lt;/span&gt;0-9]&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"COUNT must be a number"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;2 &lt;span class="p"&gt;;;&lt;/span&gt;
&lt;span class="k"&gt;esac&lt;/span&gt;

&lt;span class="nv"&gt;current&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;kubectl &lt;span class="nt"&gt;--context&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ctx&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; get deployment &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$deploy&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.spec.replicas}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; current &lt;span class="o"&gt;-&lt;/span&gt; remove &lt;span class="k"&gt;))&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;((&lt;/span&gt; remove &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; MAX_STEP &lt;span class="o"&gt;))&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"refusing: &lt;/span&gt;&lt;span class="nv"&gt;$remove&lt;/span&gt;&lt;span class="s2"&gt; is more than MAX_STEP=&lt;/span&gt;&lt;span class="nv"&gt;$MAX_STEP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi
if&lt;/span&gt; &lt;span class="o"&gt;((&lt;/span&gt; target &amp;lt; MIN_REPLICAS &lt;span class="o"&gt;))&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"refusing: &lt;/span&gt;&lt;span class="nv"&gt;$current&lt;/span&gt;&lt;span class="s2"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="nv"&gt;$target&lt;/span&gt;&lt;span class="s2"&gt; is below"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
       &lt;span class="s2"&gt;"MIN_REPLICAS=&lt;/span&gt;&lt;span class="nv"&gt;$MIN_REPLICAS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi

&lt;/span&gt;&lt;span class="nv"&gt;cmd&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;kubectl &lt;span class="nt"&gt;--context&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ctx&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; scale deployment &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$deploy&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nt"&gt;--current-replicas&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$current&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--replicas&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$target&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$mode&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"--apply"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"dry run: &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;[*]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both AWS changes are here. &lt;code&gt;MAX_STEP&lt;/code&gt; is "remove capacity more slowly": a &lt;code&gt;20&lt;/code&gt; typed where &lt;code&gt;2&lt;/code&gt; was meant gets refused instead of applied. &lt;code&gt;MIN_REPLICAS&lt;/code&gt; is the floor. &lt;code&gt;KUBE_CONTEXT&lt;/code&gt; has no default, and both &lt;code&gt;kubectl&lt;/code&gt; calls pass it as &lt;code&gt;--context&lt;/code&gt;, so the script never acts on whatever cluster your kubeconfig happens to point at. Export it in the shell you start the agent from, so the agent's command stays a plain &lt;code&gt;./scripts/scale-down.sh api 2&lt;/code&gt;. &lt;code&gt;--current-replicas&lt;/code&gt; is a precondition from kubectl itself. The scale only goes through if the deployment still has the replica count the script just read, so a change made in between makes it fail rather than land on a stale number. The dry run is the default, so an agent's first attempt shows you the exact &lt;code&gt;kubectl&lt;/code&gt; line it would run. I haven't run this script against a cluster myself, so set the floor for your own deployment and try it on a staging one first.&lt;/p&gt;

&lt;p&gt;The same shape fits most destructive commands an agent might reach for: a database reset that refuses when the connection string points anywhere but localhost, a cleanup job that caps deletions per run, a migration runner that won't drop a table without &lt;code&gt;--apply&lt;/code&gt;. The input can be wrong, so the tool checks it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deny rules are the second layer
&lt;/h2&gt;

&lt;p&gt;Next, steer the agent to the wrapper and away from the raw command. In &lt;a href="https://code.claude.com/docs/en/permissions" rel="noopener noreferrer"&gt;Claude Code&lt;/a&gt;, permission rules go in &lt;code&gt;.claude/settings.json&lt;/code&gt;, and the docs say "Rules are evaluated in order: deny, then ask, then allow":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"allow"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Bash(./scripts/scale-down.sh *)"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ask"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Bash(./scripts/scale-down.sh * --apply)"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"deny"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(kubectl scale *)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(kubectl delete *)"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dry runs go through without a prompt. Anything ending in &lt;code&gt;--apply&lt;/code&gt; matches the ask rule and prompts, even though it also matches the allow rule, because ask is checked first. Raw &lt;code&gt;kubectl scale&lt;/code&gt; and &lt;code&gt;kubectl delete&lt;/code&gt; are refused.&lt;/p&gt;

&lt;p&gt;Codex has the same idea in &lt;a href="https://learn.chatgpt.com/docs/agent-configuration/rules" rel="noopener noreferrer"&gt;rules files&lt;/a&gt;, for example &lt;code&gt;~/.codex/rules/default.rules&lt;/code&gt;. When several rules match, the most restrictive decision wins: forbidden, then prompt, then allow. OpenAI marks rules as experimental.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;prefix_rule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;pattern&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kubectl&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scale&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;forbidden&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;justification&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Use scripts/scale-down.sh&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To see how Codex classifies a command before you trust the rule, the docs give a checker. Here it is pointed at the rule above:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;codex execpolicy check &lt;span class="nt"&gt;--pretty&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--rules&lt;/span&gt; ~/.codex/rules/default.rules &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--&lt;/span&gt; kubectl scale deployment api &lt;span class="nt"&gt;--replicas&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The caveat comes from Anthropic's own page: a deny rule "covers the invocation Claude usually produces and isn't a security boundary around the program." Its example is that &lt;code&gt;Bash(rm *)&lt;/code&gt; stops &lt;code&gt;rm -rf build/&lt;/code&gt; but not &lt;code&gt;/bin/rm -rf build/&lt;/code&gt; or &lt;code&gt;bash -c 'rm -rf build/'&lt;/code&gt;. For enforcement that doesn't depend on the command text, the &lt;a href="https://code.claude.com/docs/en/permissions" rel="noopener noreferrer"&gt;same docs&lt;/a&gt; point to sandboxing.&lt;/p&gt;

&lt;p&gt;So neither layer is complete alone. The rule steers the agent to the script. The script refuses a bad number when it is used. If an agent should never be able to scale production at all, give it credentials that can't. That puts the floor outside the agent entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read what the agent ran
&lt;/h2&gt;

&lt;p&gt;I build Hermes IDE, so weigh this section with that in mind. Hermes runs each agent (Claude Code, Codex and others) in its own real terminal, so the agent's own permission prompts and rules still apply; Hermes doesn't replace them. It &lt;a href="https://github.com/hermes-hq/hermes-ide" rel="noopener noreferrer"&gt;never answers a permission prompt for you&lt;/a&gt; and never types into a terminal on its own.&lt;/p&gt;

&lt;p&gt;In the ⌘N sheet (Ctrl+Shift+N on Windows and Linux) you pick the agent, model, effort and account per task, and the task starts on its own &lt;code&gt;hermes/&lt;/code&gt; branch and worktree, with the task as the agent's first prompt.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffszpiznbqz77gsn3b75u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffszpiznbqz77gsn3b75u.png" alt="The Hermes New task sheet with a refund-endpoint task typed, chips for Claude Code, the orders-api project, a new hermes/ worktree, Accept edits, model and effort, and the preview of the claude command Hermes will run" width="800" height="415"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The ⌘N New task sheet in Hermes 2.3.4: agent, project, a new hermes/ worktree, the Accept edits approval mode, model and effort, and a preview of the claude command Hermes will run.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;When an agent stops on an approval prompt, it counts in the attention inbox, and ⌘I (Ctrl+Shift+I) jumps to the agent that has waited longest. You answer in that agent's terminal, where its prompt shows the command it wants to run. For a prompted command, that is your last look at the input before it runs.&lt;/p&gt;

&lt;p&gt;After the fact, the Review Desk (⌘G, or Ctrl+Shift+G) shows what changed by turn or by file, with risk flags. Every agent turn is saved, so you can diff or restore a single turn, with undo.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8cgisbligwk7mx2lpa8d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8cgisbligwk7mx2lpa8d.png" alt="The Hermes Review Desk in By turn view for a Claude Code task in orders-api, listing two turns, turn 1's diff of src/db.ts adding a refunded status, the changed files and a drafted commit message" width="800" height="444"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The Review Desk (⌘G) by turn in Hermes 2.3.4: two turns of a Claude Code task, turn 1's diff of src/db.ts, the changed files and a commit message drafted from the turns.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Be clear about the limits. Hermes observes what agents report and what git shows. It doesn't block commands, so the deny rule and the floor in your script are still the guards. Restoring a turn puts files back in your worktree; it won't bring back replicas an agent scaled away. Hermes is free, source-available under BSL 1.1, and runs on macOS, Windows and Linux. It is not affiliated with Nous Research, Anthropic, OpenAI or any other agent vendor. &lt;a href="https://hermes-ide.com/download" rel="noopener noreferrer"&gt;Download it&lt;/a&gt; or read the code &lt;a href="https://github.com/hermes-hq/hermes-ide" rel="noopener noreferrer"&gt;on GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Which destructive script in your repo would an agent find first, and does it have a floor? Tell me in the comments.&lt;/p&gt;




&lt;h2&gt;
  
  
  If this was useful
&lt;/h2&gt;

&lt;p&gt;The S3 summary is really a capacity story: two subsystems had a minimum below which a whole region stopped serving, and the tool that removed servers didn't know where that line was. My &lt;em&gt;System Design Pocket Guide: Fundamentals&lt;/em&gt; covers those core building blocks for scalable systems, so you can see where the floors in your own system sit before a script, or an agent, finds them. If you run several agents at once, Hermes IDE is where I keep an eye on which of them is waiting on me.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.amazon.com/dp/B0GYMFPTWV" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4dz9ehd0n8k7iax7x19i.jpg" alt="System Design Pocket Guide: Fundamentals — Core Building Blocks for Scalable Systems" width="800" height="1200"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>devops</category>
      <category>ai</category>
      <category>security</category>
    </item>
    <item>
      <title>Exhale: A Breathing Guide That Reads Your Breath While Claude Code Works</title>
      <dc:creator>Gabriel Anhaia</dc:creator>
      <pubDate>Sat, 07 Nov 2026 11:27:26 +0000</pubDate>
      <link>https://dev.to/gabrielanhaia/exhale-a-breathing-guide-that-reads-your-breath-while-claude-code-works-244a</link>
      <guid>https://dev.to/gabrielanhaia/exhale-a-breathing-guide-that-reads-your-breath-while-claude-code-works-244a</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/presage-2026-11-04"&gt;Presage SmartSpectra SDK Challenge&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You hand Claude Code a task. It says "Elucidating…" and the spinner starts. For the next two minutes you watch it. Shoulders up, jaw set, breath shallow, eyes locked on a terminal that isn't asking you for anything.&lt;/p&gt;

&lt;p&gt;That waiting time is the most common moment in AI-assisted coding, and it's dead time. Exhale turns it into a minute of slow breathing, with a twist: the horizon on screen is your own breathing, read live from an ordinary webcam by the &lt;a href="https://github.com/Presage-Security/SmartSpectra" rel="noopener noreferrer"&gt;Presage SmartSpectra SDK&lt;/a&gt;. No wearable, no chest strap.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flpzv7vvp7v3nwo059s9n.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flpzv7vvp7v3nwo059s9n.jpg" alt="Left: me during a live session. Right: the Exhale card, its horizon drawn from my chest movement, 83% in sync with the guide" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;Exhale is a small desktop app that listens for your coding agent. It's built on &lt;a href="https://www.electronjs.org" rel="noopener noreferrer"&gt;Electron&lt;/a&gt; and tested on macOS; the SDK also ships Windows and Linux runtimes.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You send &lt;a href="https://www.anthropic.com/claude-code" rel="noopener noreferrer"&gt;Claude Code&lt;/a&gt; a prompt.&lt;/li&gt;
&lt;li&gt;If the run lasts longer than 10 seconds, a small card rises in the corner of your screen. It's a sky at dusk.&lt;/li&gt;
&lt;li&gt;A sun climbs while you breathe in and sinks while you breathe out: 4 seconds in, 6 out, six breaths a minute.&lt;/li&gt;
&lt;li&gt;The horizon the sun sinks behind is &lt;strong&gt;your own breathing&lt;/strong&gt;, from the SDK's chest waveform.&lt;/li&gt;
&lt;li&gt;When the agent finishes, the camera turns off and the card shows a short recap.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fenq9ci5hvfzdhp3v13or.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fenq9ci5hvfzdhp3v13or.jpg" alt="The recap card: agent ran 1 min 27s, 30 s with the guide, breathing 6 to 7.4 a minute" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Short runs never turn the camera on. In daily use you don't click anything; the first time, the card asks before it uses the camera. It also works with any long command through &lt;code&gt;exhale run -- npm test&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/psJDRBy0cMk" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;Every reading in the video comes from one live session with the real SDK. In that run Claude worked for 87 seconds, the guide ran for 77, my breathing settled at 6 to 7.4 breaths a minute, and I was in sync with the guide for 30 of the 51 seconds Exhale could score (59%; scoring starts once the breathing waveform arrives). The narrator voice is AI-generated; the footage and the numbers are not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/gabrielanhaia/exhale" rel="noopener noreferrer"&gt;github.com/gabrielanhaia/exhale&lt;/a&gt;: setup in the README, plus a Docker setup with a simulated sensor so you can try the whole flow without a camera or an API key. The repo has 34 unit and integration tests and 8 Playwright tests that drive the real Electron app.&lt;/p&gt;

&lt;h2&gt;
  
  
  How It Responds to Human Signals
&lt;/h2&gt;

&lt;p&gt;Exhale requests four metrics from the SDK and nothing else:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;What Exhale does with it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;CHEST_BREATHING&lt;/code&gt; waveform&lt;/td&gt;
&lt;td&gt;Draws the horizon, and scores how closely you follow the guide&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BREATHING_RATE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Breaths per minute in the recap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BLINKING&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Suggests a 20-second look-away when you stop blinking&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TALKING&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Pauses the guide while you speak&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Following the guide.&lt;/strong&gt; Every second, Exhale takes the last 20 seconds of your chest waveform, resamples it to 10 Hz and compares its shape with the guide curve. The score is the best Pearson correlation over a lag of 0 to 2 seconds, because people follow a visual cue a little late. It uses the absolute value, since the SDK's trace is in arbitrary units and Exhale doesn't assume whether an inhale goes up or down. At 60% or above, the card's text turns gold and the sun's glow widens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Blinks.&lt;/strong&gt; Rising edges in the blink stream give a rolling blinks-per-minute over 60 seconds. Below 8 a minute, after at least 45 seconds of face time, the card suggests looking at something far away. At most once every five minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Talking.&lt;/strong&gt; The guide pauses only when most of the last 1.5 seconds of talking detections are confident ones, so a flicker of the mouth doesn't stop it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The plumbing.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Claude Code hooks (&lt;code&gt;UserPromptSubmit&lt;/code&gt;, &lt;code&gt;Stop&lt;/code&gt;, &lt;code&gt;SessionEnd&lt;/code&gt;) POST to a local server on &lt;code&gt;127.0.0.1&lt;/code&gt;. The hook skips runs where Claude Code reports the session as unattended (&lt;code&gt;CLAUDE_CODE_SESSION_ATTENDED=0&lt;/code&gt;). I checked that a headless &lt;code&gt;claude -p&lt;/code&gt; run sets it, so scheduled jobs don't turn the camera on.&lt;/li&gt;
&lt;li&gt;Each run gets a fresh child process that owns the SDK, with &lt;code&gt;useCamera()&lt;/code&gt; and explicit capture settings (720p at 30 fps).&lt;/li&gt;
&lt;li&gt;The child streams small frames over IPC to a session state machine (idle → pending → active → summary). The UI renders from Server-Sent Events.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The SDK hands over protobuf packets. This is the function in &lt;code&gt;src/engine/sdk-engine.js&lt;/code&gt; that turns each one into the small frame the rest of the app understands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;toFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;breathing&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;face&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;tsToSec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;upperTrace&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;t&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;sec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;v&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})),&lt;/span&gt;
    &lt;span class="na"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rate&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;t&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;sec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;stable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})),&lt;/span&gt;
    &lt;span class="na"&gt;blinks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;blinking&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;t&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;sec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;detected&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;detected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})),&lt;/span&gt;
    &lt;span class="na"&gt;talking&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;talking&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;t&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;sec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;detected&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;detected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;stable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})),&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Four things the SDK taught me
&lt;/h3&gt;

&lt;p&gt;These came up while testing on real hardware (Node SDK 3.4.0, macOS on Apple Silicon).&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A second &lt;code&gt;sdk.on('metrics')&lt;/code&gt; replaced the first.&lt;/strong&gt; I added a debug logger as another metrics listener, and the handler that fed the UI went quiet. No error, just a flat horizon. Keep one handler per event and do everything in it. There's now a regression test for this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The metric streams start at different times.&lt;/strong&gt; In my logs, blink detections arrived from the first packet and the breathing waveform about 20 seconds later; the breathing rate needs a 30-second window on top of that. The card says "Reading your breathing…" until the waveform shows up. &lt;code&gt;toFrame&lt;/code&gt; keeps the SDK's timestamps; the session then lines each batch up by when it arrived, keeping the spacing between samples, instead of assuming every stream shares one clock.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Frame rate matters more than resolution.&lt;/strong&gt; My 4K webcam, in a fairly dim room, kept falling under the frame rate the SDK needs, and the SDK said so through its validation status. During one of those sessions the native pipeline also stopped with an OpenCV assertion about mismatched image sizes, and the whole app went down with it. Now each run's SDK lives in its own process: a crash ends that run with a clear message, and the app stays up. An iPhone through Continuity Camera, in the same room, gave no frame-rate warnings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The talking detector is sensitive.&lt;/strong&gt; During my sessions the guide kept pausing while I wasn't speaking; small mouth movements were enough to trigger it. That's why the rule above needs a sustained, confident signal.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best Thoughtful Real-World Use:&lt;/strong&gt; it fits a moment developers already have dozens of times a day, asks before it uses the camera, runs only during that moment, stores nothing by default, and never pretends to be a medical tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Most Creative or Delightful Use:&lt;/strong&gt; the breathing signal isn't shown as a number on a chart. It becomes the landscape the guide's sun sets behind.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Consent and Data Handling
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Consent.&lt;/strong&gt; The first time a long run starts, the card explains what will happen and asks before the camera is used. Until you click "Turn on camera", it stays off. You can withdraw consent from the Exhale window at any time, which turns the camera off immediately. On macOS the system camera prompt appears once as well.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When the camera is on.&lt;/strong&gt; Only while an agent run is in progress and the card is showing. The card says "camera on" and the menu-bar item shows a dot. "Turn off camera" stops it at once. When the run ends, the camera is released. Unattended runs never trigger it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where video goes.&lt;/strong&gt; Frames go from the camera into the SmartSpectra SDK inside a process Exhale starts for that run. Presage's README says the SDK computes the vitals on the device. Exhale never writes video or images to disk and never sends them anywhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What leaves the computer.&lt;/strong&gt; In my SDK logs, the SDK contacts Presage to check the API key, verify usage and fetch its models. Its optional aggregate telemetry is described in &lt;a href="https://github.com/Presage-Security/SmartSpectra/blob/main/docs/telemetry-and-privacy.md" rel="noopener noreferrer"&gt;Presage's telemetry docs&lt;/a&gt;. Exhale switches it off (&lt;code&gt;enableTelemetry: false&lt;/code&gt;). Exhale has no analytics of its own and doesn't use the SDK's optional LLM Insights.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What is stored, and for how long.&lt;/strong&gt; By default one file, &lt;code&gt;~/.exhale/settings.json&lt;/code&gt;: when you gave consent, your history preference and your chosen camera. History is opt-in. When it's on, each run adds one summary line to &lt;code&gt;~/.exhale/history.jsonl&lt;/code&gt; (duration, percent with the guide, start and end breathing rate, look-away reminders). No waveforms, no video. Both files are readable only by your user. They stay until you delete them: "Delete all Exhale data" removes both, and switching history off deletes the history file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sharing.&lt;/strong&gt; None. The local server listens on &lt;code&gt;127.0.0.1&lt;/code&gt; only and rejects requests from web pages (custom header plus Origin check) and from non-loopback host names (DNS-rebinding guard).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not medical.&lt;/strong&gt; Exhale is a breathing exercise and a screen-break reminder. It doesn't diagnose, treat or monitor any condition, and it isn't meant for health decisions. It never runs covertly: the camera works only after consent, only during your own agent runs, with a visible indicator.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Agent Session
&lt;/h2&gt;

&lt;p&gt;I built Exhale with Claude Code, in one long session inside &lt;a href="https://hermes-ide.com" rel="noopener noreferrer"&gt;Hermes IDE&lt;/a&gt;. I'm not embedding the transcript: it contains local machine paths and personal details that I won't publish. The repo history shows how it came together.&lt;/p&gt;




&lt;p&gt;If you spend your days waiting on agents too, try it for a week and tell me what your recap says. If you build with agents and want the patterns behind long-running tool loops, my &lt;a href="https://www.amazon.com/dp/B0GYJZ2XJD" rel="noopener noreferrer"&gt;AI Agents Pocket Guide&lt;/a&gt; covers them.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Book:&lt;/strong&gt; &lt;a href="https://www.amazon.com/dp/B0GYJZ2XJD" rel="noopener noreferrer"&gt;AI Agents Pocket Guide: Patterns for Building Autonomous Systems with LLMs&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Also by me:&lt;/strong&gt; &lt;em&gt;Thinking in Go&lt;/em&gt; (2-book series): &lt;a href="https://xgabriel.com/go-book" rel="noopener noreferrer"&gt;Complete Guide to Go Programming&lt;/a&gt; + &lt;a href="https://xgabriel.com/hexagonal-go" rel="noopener noreferrer"&gt;Hexagonal Architecture in Go&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;My project:&lt;/strong&gt; &lt;a href="https://hermes-ide.com" rel="noopener noreferrer"&gt;Hermes IDE&lt;/a&gt; | &lt;a href="https://github.com/hermes-hq/hermes-ide" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, an IDE for developers who ship with Claude Code and other AI coding tools&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Me:&lt;/strong&gt; &lt;a href="https://xgabriel.com" rel="noopener noreferrer"&gt;xgabriel.com&lt;/a&gt; | &lt;a href="https://github.com/gabrielanhaia" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://www.amazon.com/dp/B0GYJZ2XJD" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffzkyb8bqzkmlt4qzgqbo.jpg" alt="AI Agents Pocket Guide: Patterns for Building Autonomous Systems with LLMs" width="800" height="1200"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>presagechallenge</category>
      <category>ai</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Anthropic's Agent Memory Study: Rules on What to Save Cost Claude Much of the Gain</title>
      <dc:creator>Gabriel Anhaia</dc:creator>
      <pubDate>Sat, 07 Nov 2026 11:01:24 +0000</pubDate>
      <link>https://dev.to/gabrielanhaia/anthropics-agent-memory-study-rules-on-what-to-save-cost-claude-much-of-the-gain-3089</link>
      <guid>https://dev.to/gabrielanhaia/anthropics-agent-memory-study-rules-on-what-to-save-cost-claude-much-of-the-gain-3089</guid>
      <description>&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Book:&lt;/strong&gt; &lt;a href="https://www.amazon.com/dp/B0GX2YDC5Z" rel="noopener noreferrer"&gt;RAG Pocket Guide&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Also by me:&lt;/strong&gt; &lt;em&gt;Thinking in Go&lt;/em&gt; (2-book series) — &lt;a href="https://xgabriel.com/go-book" rel="noopener noreferrer"&gt;Complete Guide to Go Programming&lt;/a&gt; + &lt;a href="https://xgabriel.com/hexagonal-go" rel="noopener noreferrer"&gt;Hexagonal Architecture in Go&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;My project:&lt;/strong&gt; &lt;a href="https://hermes-ide.com" rel="noopener noreferrer"&gt;Hermes IDE&lt;/a&gt; | &lt;a href="https://github.com/hermes-hq/hermes-ide" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; — an IDE for developers who ship with Claude Code and other AI coding tools&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Me:&lt;/strong&gt; &lt;a href="https://xgabriel.com" rel="noopener noreferrer"&gt;xgabriel.com&lt;/a&gt; | &lt;a href="https://github.com/gabrielanhaia" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Give Claude Opus 4.7 a folder it can keep notes in between sessions, and its score on a database question task rises from 0.383 to 0.700. Tell it to record only its failures, and it loses much of that boost. Both results are in &lt;a href="https://www.anthropic.com/research/components-of-agent-memory" rel="noopener noreferrer"&gt;The components of agent memory&lt;/a&gt;, a post Anthropic's engineering team published on November 6, 2026.&lt;/p&gt;

&lt;p&gt;The post is research, not a launch: it announces no new model or API feature (&lt;a href="https://www.anthropic.com/research/components-of-agent-memory" rel="noopener noreferrer"&gt;Anthropic&lt;/a&gt;). It does link two recipes, a &lt;a href="https://platform.claude.com/cookbook/managed-agents-cma-dreaming-with-a-dynamic-workflow" rel="noopener noreferrer"&gt;dreaming cookbook&lt;/a&gt; and a &lt;a href="https://github.com/anthropics/claude-quickstarts/tree/main/managed-agents/knowledge-wiki" rel="noopener noreferrer"&gt;knowledge-wiki quickstart&lt;/a&gt;, and it builds on the &lt;a href="https://platform.claude.com/docs/en/managed-agents/dreams" rel="noopener noreferrer"&gt;Dreams endpoint&lt;/a&gt;, which is in research preview for &lt;a href="https://platform.claude.com/docs/en/managed-agents/overview" rel="noopener noreferrer"&gt;Claude Managed Agents&lt;/a&gt;. If you run long-lived agents, or let &lt;a href="https://code.claude.com/docs/en/memory" rel="noopener noreferrer"&gt;Claude Code&lt;/a&gt; keep a &lt;code&gt;MEMORY.md&lt;/code&gt;, the findings map onto choices you already make.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Anthropic measured
&lt;/h2&gt;

&lt;p&gt;The main benchmark is the database task from &lt;a href="https://continual-learning-bench.com/news/cl-bench-1-0/" rel="noopener noreferrer"&gt;CL-Bench 1.0&lt;/a&gt;. In Anthropic's words, the task "asks Claude to answer a series of questions by looking up information in a database." Each question arrives in a fresh session, and the only thing that carries over is a memory directory that persists between sessions (&lt;a href="https://www.anthropic.com/research/components-of-agent-memory" rel="noopener noreferrer"&gt;Anthropic&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Mean reward, stateless baseline against a memory store, as reported in &lt;a href="https://www.anthropic.com/research/components-of-agent-memory" rel="noopener noreferrer"&gt;Anthropic's post&lt;/a&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;No memory&lt;/th&gt;
&lt;th&gt;Memory store&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Opus 5.5&lt;/td&gt;
&lt;td&gt;0.655&lt;/td&gt;
&lt;td&gt;0.838&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sonnet 5.5&lt;/td&gt;
&lt;td&gt;0.644&lt;/td&gt;
&lt;td&gt;0.833&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Opus 5&lt;/td&gt;
&lt;td&gt;0.564&lt;/td&gt;
&lt;td&gt;0.828&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Opus 4.7&lt;/td&gt;
&lt;td&gt;0.383&lt;/td&gt;
&lt;td&gt;0.700&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sonnet 5&lt;/td&gt;
&lt;td&gt;0.306&lt;/td&gt;
&lt;td&gt;0.610&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sonnet 4.6&lt;/td&gt;
&lt;td&gt;0.330&lt;/td&gt;
&lt;td&gt;0.364&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Sonnet 4.6 is the outlier. The post says it "often didn't read files from its memory directory and failed to test its theories." Prompting it to read its memory files before answering, and then to test its theories, "nearly doubled its benchmark score," to 0.641 (&lt;a href="https://www.anthropic.com/research/components-of-agent-memory" rel="noopener noreferrer"&gt;Anthropic&lt;/a&gt;; &lt;a href="https://ai-tldr.dev/releases/anthropic-components-of-agent-memory/" rel="noopener noreferrer"&gt;ai-tldr summary&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;A memory directory the model never opens is just disk usage. If an older model seems not to benefit from memory, check that it reads its notes before you drop the idea.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four layers, and the top one stays small
&lt;/h2&gt;

&lt;p&gt;The post describes memory as a hierarchy (&lt;a href="https://www.anthropic.com/research/components-of-agent-memory" rel="noopener noreferrer"&gt;Anthropic&lt;/a&gt;):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Transcripts:&lt;/strong&gt; "a searchable event log" of the agent's sessions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory notes:&lt;/strong&gt; "salient observations written by Claude."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Representations:&lt;/strong&gt; structures such as wikis, "a collection of pages written and maintained by Claude."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Injected context:&lt;/strong&gt; material "loaded into every session."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F81necmdwo6mjpbmr7178.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F81necmdwo6mjpbmr7178.png" alt="Flat vector illustration of four stacked translucent layers: a long scroll at the bottom, a row of index cards above it, a linked web of pages above that, and one small glowing orange card on top" width="800" height="800"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Four layers of agent memory, as Anthropic describes them. Only the small top layer loads into every session.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The cost sits at the top. "Injected memories use up valuable tokens, and an incorrect note can be carried into every session," the post warns, adding that "Loading a note also doesn't guarantee that Claude acts on it." Its conclusion: "the top layer of the memory hierarchy has to stay relatively small" (&lt;a href="https://www.anthropic.com/research/components-of-agent-memory" rel="noopener noreferrer"&gt;Anthropic&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Claude Code already caps that layer. Its &lt;a href="https://code.claude.com/docs/en/memory" rel="noopener noreferrer"&gt;memory docs&lt;/a&gt; put auto memory in &lt;code&gt;~/.claude/projects/&amp;lt;project&amp;gt;/memory/&lt;/code&gt;, with a &lt;code&gt;MEMORY.md&lt;/code&gt; index, and state: "The first 200 lines of &lt;code&gt;MEMORY.md&lt;/code&gt;, or the first 25KB, whichever comes first, are loaded at the start of every conversation." Claude moves detail into separate topic files and reads them when it needs them (&lt;a href="https://code.claude.com/docs/en/memory" rel="noopener noreferrer"&gt;Claude Code docs&lt;/a&gt;).&lt;/p&gt;
&lt;h2&gt;
  
  
  Say what memory is for, not how to use it
&lt;/h2&gt;

&lt;p&gt;The finding that cuts against instinct is about steering. A rule like "only record what went wrong" looks like good engineering. In Anthropic's runs it backfired: told to record only failures, "both Opus 5 and Opus 4.7 lost much of the performance boost that memory gave them" (&lt;a href="https://www.anthropic.com/research/components-of-agent-memory" rel="noopener noreferrer"&gt;Anthropic&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The post sums it up as "it helps to tell Claude what memory is for, but not how to use it," and adds: "For stronger models, giving them freedom to judge salience was more effective than setting fixed rules in advance." With frontier models, Anthropic says it typically gives "simple guidance to create memory notes" when a person asks for one or when something is clearly worth remembering, and leaves that call to Claude (&lt;a href="https://www.anthropic.com/research/components-of-agent-memory" rel="noopener noreferrer"&gt;Anthropic&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The Pokémon Red runs in the same post show the shift across generations. Sonnet 3.7 wrote 413 memory files across 225 folders and earned one badge. Fable 5 wrote nine files in two folders plus a &lt;code&gt;global.md&lt;/code&gt;, and earned four (&lt;a href="https://www.anthropic.com/research/components-of-agent-memory" rel="noopener noreferrer"&gt;Anthropic&lt;/a&gt;). In that comparison, fewer notes went with more progress.&lt;/p&gt;

&lt;p&gt;The catch is that the right amount of steering depends on the model: Sonnet 4.6 needed explicit instructions just to read its memory. Test your own memory prompt on your own tasks instead of copying anyone else's.&lt;/p&gt;
&lt;h2&gt;
  
  
  Dreaming: an offline pass that fixes the notes
&lt;/h2&gt;

&lt;p&gt;Notes go wrong. The post says "Claude can make incorrect predictions or write rules to memory that are wrong from the start." Anthropic's answer is dreaming, "reflecting on logs to fix mistakes or produce new notes and representations," and "Dreaming gives Claude a way to test its notes against the original transcripts" (&lt;a href="https://www.anthropic.com/research/components-of-agent-memory" rel="noopener noreferrer"&gt;Anthropic&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;In the Pokémon run, dreaming corrected a wrong location note, and the agent reached Cerulean City at action 1,935. The runs with uncorrected memory and with no memory never got there (&lt;a href="https://www.anthropic.com/research/components-of-agent-memory" rel="noopener noreferrer"&gt;Anthropic&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The wiki experiment is the one closest to retrieval work. Claude built a wiki over a Stainless corpus of "about 350,000 files, roughly 420M tokens" and answered 25 graded questions. Wiki alone rose from 13% to 48% of graded criteria as dreaming went from 2 to 128 sessions. With search, "Claude's aggregate score rose from 54% to 61% as that wiki grew," against 53% for search alone, and the pair "saved about 20,000 tokens per question." The costs are in the post too. On a separate legal-brief task, "the wiki grew larger than the documents themselves," and dreaming in general "requires compute" (&lt;a href="https://www.anthropic.com/research/components-of-agent-memory" rel="noopener noreferrer"&gt;Anthropic&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flyevgkwdlgker1iljr1d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flyevgkwdlgker1iljr1d.png" alt="Flat vector illustration of a sleeping silhouette at a desk under a crescent moon while a small robot arm sorts a pile of paper scrolls into a neat stack of index cards, one card crossed out in orange" width="800" height="800"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Dreaming, in Anthropic's sense: an idle-time pass that checks notes against transcripts and rewrites the wrong ones.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;On Managed Agents, dreaming is an API call. The &lt;a href="https://platform.claude.com/docs/en/managed-agents/dreams" rel="noopener noreferrer"&gt;Dreams docs&lt;/a&gt; describe an asynchronous job that reads a memory store plus 1 to 100 past sessions and writes a new, reorganized store: "The input store is never modified, so you can review the output and discard it if you don't like the result." Dreams need the &lt;code&gt;dreaming-2026-04-21&lt;/code&gt; beta header (the SDK sets it), "typically take minutes to a few hours," and bill at standard token rates for the model you pick (&lt;a href="https://platform.claude.com/docs/en/managed-agents/dreams" rel="noopener noreferrer"&gt;Dreams docs&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;This is the Python call from those docs, reflowed to fit. &lt;code&gt;store_id&lt;/code&gt; and the session IDs come from your own Managed Agents runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Anthropic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;dream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;beta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dreams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;inputs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;memory_store&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;memory_store_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;store_id&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sessions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;session_ids&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;session_a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session_b&lt;/span&gt;&lt;span class="p"&gt;]},&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claude-opus-5-5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;instructions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Focus on coding-style preferences; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ignore one-off debugging notes.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I haven't run the Dreams API or the cookbooks myself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to change in your own agent setup
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Keep what loads into every session short. In Claude Code that is the top of &lt;code&gt;MEMORY.md&lt;/code&gt; plus your CLAUDE.md files, and the docs say to "target under 200 lines per CLAUDE.md file" (&lt;a href="https://code.claude.com/docs/en/memory" rel="noopener noreferrer"&gt;Claude Code docs&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;Tell the agent what its memory is for, drop rules about what to save, and compare scores on your own tasks.&lt;/li&gt;
&lt;li&gt;Check that the model reads its notes. In Claude Code, messages like "Recalled 2 memories" mean Claude is reading from the memory directory, and &lt;code&gt;/memory&lt;/code&gt; opens the auto memory folder so you can see what is in it (&lt;a href="https://code.claude.com/docs/en/memory" rel="noopener noreferrer"&gt;Claude Code docs&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;Audit notes against what happened. Auto memory files "are plain markdown you can edit or delete at any time" (&lt;a href="https://code.claude.com/docs/en/memory" rel="noopener noreferrer"&gt;Claude Code docs&lt;/a&gt;), and a wrong one reaches every session until someone fixes it.&lt;/li&gt;
&lt;li&gt;If you dream, aim it at what changes. The post suggests to "focus dreaming on information that's likely to change" and to "carefully tune the cadence of dreaming" (&lt;a href="https://www.anthropic.com/research/components-of-agent-memory" rel="noopener noreferrer"&gt;Anthropic&lt;/a&gt;).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I build Hermes IDE, so weigh this with that in mind. Hermes has a top layer of its own: per its &lt;a href="https://github.com/hermes-hq/hermes-ide" rel="noopener noreferrer"&gt;README&lt;/a&gt;, "facts and pinned files saved for a project reach every later session's agent, also after a restart." Anthropic's rule applies there too. Keep pins few and correct, because each later session's agent gets them.&lt;/p&gt;

&lt;p&gt;How much does your agent write to memory today, and when did you last open &lt;code&gt;MEMORY.md&lt;/code&gt; and read it?&lt;/p&gt;




&lt;h2&gt;
  
  
  If this was useful
&lt;/h2&gt;

&lt;p&gt;I build &lt;a href="https://hermes-ide.com/download" rel="noopener noreferrer"&gt;Hermes IDE&lt;/a&gt;: free, source-available (BSL 1.1), on macOS, Windows and Linux, for running Claude Code, Codex and other agents side by side, each on its own branch. It's on &lt;a href="https://github.com/hermes-hq/hermes-ide" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, and it isn't affiliated with Nous Research, Anthropic or OpenAI.&lt;/p&gt;

&lt;p&gt;The wiki result is a retrieval result: a structured layer on top of search beat search alone and spent fewer tokens per question. The &lt;a href="https://www.amazon.com/dp/B0GX2YDC5Z" rel="noopener noreferrer"&gt;RAG Pocket Guide&lt;/a&gt; covers the search half of that, from chunking to reranking, so the pages your agent pulls are the right ones.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.amazon.com/dp/B0GX2YDC5Z" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpkc0hvypembd4wgh6t0k.jpg" alt="RAG Pocket Guide: Retrieval, Chunking, and Reranking Patterns for Production" width="800" height="1200"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claude</category>
      <category>agents</category>
      <category>llm</category>
    </item>
    <item>
      <title>Import a 1M-Row CSV Through One Use Case: a Generator Adapter, No Eloquent in the Loop</title>
      <dc:creator>Gabriel Anhaia</dc:creator>
      <pubDate>Sat, 07 Nov 2026 10:01:35 +0000</pubDate>
      <link>https://dev.to/gabrielanhaia/import-a-1m-row-csv-through-one-use-case-a-generator-adapter-no-eloquent-in-the-loop-5fc3</link>
      <guid>https://dev.to/gabrielanhaia/import-a-1m-row-csv-through-one-use-case-a-generator-adapter-no-eloquent-in-the-loop-5fc3</guid>
      <description>&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Book:&lt;/strong&gt; &lt;a href="https://www.amazon.com/dp/B0H1WVFBYY" rel="noopener noreferrer"&gt;Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Also by me:&lt;/strong&gt; &lt;em&gt;Thinking in Go&lt;/em&gt; (2-book series) — &lt;a href="https://xgabriel.com/go-book" rel="noopener noreferrer"&gt;Complete Guide to Go Programming&lt;/a&gt; + &lt;a href="https://xgabriel.com/hexagonal-go" rel="noopener noreferrer"&gt;Hexagonal Architecture in Go&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;My project:&lt;/strong&gt; &lt;a href="https://hermes-ide.com" rel="noopener noreferrer"&gt;Hermes IDE&lt;/a&gt; | &lt;a href="https://github.com/hermes-hq/hermes-ide" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; — an IDE for developers who ship with Claude Code and other AI coding tools&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Me:&lt;/strong&gt; &lt;a href="https://xgabriel.com" rel="noopener noreferrer"&gt;xgabriel.com&lt;/a&gt; | &lt;a href="https://github.com/gabrielanhaia" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Call &lt;a href="https://www.php.net/manual/en/function.file.php" rel="noopener noreferrer"&gt;&lt;code&gt;file()&lt;/code&gt;&lt;/a&gt; on a 1M-row CSV and PHP builds a million strings before the first email check runs. In plenty of &lt;a href="https://laravel.com/docs/artisan" rel="noopener noreferrer"&gt;Laravel&lt;/a&gt; apps, that email check lives inside an Artisan command's &lt;code&gt;handle()&lt;/code&gt;, and so does every other business rule for the import, including the "skip rows with no name" patch someone added after a bad Monday. Then product asks for the same import from an upload form, and the rules can't leave the command without copy-paste.&lt;/p&gt;

&lt;p&gt;The command usually looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;argument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'path'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;array_slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$lines&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$country&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;str_getcsv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;','&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'"'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;filter_var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;FILTER_VALIDATE_EMAIL&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nc"&gt;Customer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;compact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'email'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'country'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;SUCCESS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The problems are all visible. &lt;a href="https://www.php.net/manual/en/function.file.php" rel="noopener noreferrer"&gt;&lt;code&gt;file()&lt;/code&gt; reads the entire file into an array&lt;/a&gt;, so the whole CSV sits in memory before any row is checked. &lt;a href="https://laravel.com/docs/eloquent#inserts" rel="noopener noreferrer"&gt;&lt;code&gt;create()&lt;/code&gt; inserts one record per call and returns a model instance&lt;/a&gt;, and every call goes through the &lt;a href="https://laravel.com/docs/eloquent#events" rel="noopener noreferrer"&gt;model lifecycle events&lt;/a&gt;. And the validation rules belong to an Artisan command, which is the one place no other caller can reach them.&lt;/p&gt;

&lt;p&gt;The fix below moves the rules into a plain &lt;code&gt;ImportCustomers&lt;/code&gt; use case. A generator feeds it rows, and a batch-insert port does the writing. The code targets PHP 8.3+, because it uses &lt;a href="https://www.php.net/releases/8.3/en.php" rel="noopener noreferrer"&gt;typed class constants&lt;/a&gt;. The snippets skip &lt;code&gt;namespace&lt;/code&gt;, &lt;code&gt;use&lt;/code&gt; and &lt;code&gt;declare(strict_types=1)&lt;/code&gt; lines to stay short; put each class in its own &lt;a href="https://www.php-fig.org/psr/psr-4/" rel="noopener noreferrer"&gt;PSR-4&lt;/a&gt; file.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsph72exmg4r7crd8vrd4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsph72exmg4r7crd8vrd4.png" alt="A stream of small row cards flowing from a paper file through a narrow pipe into a hexagon, then out to a database cylinder" width="800" height="800"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;One row in flight at a time. The port writes in batches of 500.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The use case: plain PHP, typed results
&lt;/h2&gt;

&lt;p&gt;Start with the inside. The use case knows nothing about files, HTTP or Eloquent. It takes an iterable of rows keyed by record number and returns a summary.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;NewCustomer&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="nc"&gt;RowStatus&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="no"&gt;Accepted&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="no"&gt;Rejected&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each row produces a &lt;code&gt;RowResult&lt;/code&gt;. The constructor is private, so the only ways to build one are the two named constructors, and a rejected row can't carry a customer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RowResult&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;RowStatus&lt;/span&gt; &lt;span class="nv"&gt;$status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;?NewCustomer&lt;/span&gt; &lt;span class="nv"&gt;$customer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="nv"&gt;$reason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;accepted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;NewCustomer&lt;/span&gt; &lt;span class="nv"&gt;$c&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;self&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;RowStatus&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Accepted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$c&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;rejected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$why&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;self&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;RowStatus&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Rejected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$why&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The port is one method. It speaks in domain objects. Nothing in it knows the table's column names:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;CustomerRepository&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cd"&gt;/** @param list&amp;lt;NewCustomer&amp;gt; $customers */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;insertBatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$customers&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The summary counts everything but keeps only a sample of rejections, so a file full of garbage can't grow it without bound:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ImportSummary&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$accepted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$rejected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="cd"&gt;/** @var list&amp;lt;RowResult&amp;gt; */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$samples&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;RowResult&lt;/span&gt; &lt;span class="nv"&gt;$r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$keep&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;rejected&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;samples&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nv"&gt;$keep&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;samples&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$r&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The use case batches accepted rows and flushes them whenever the batch fills. The batch size is a constructor argument with a default of 500, because the right number depends on the database behind the port:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ImportCustomers&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="no"&gt;SAMPLES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;CustomerRepository&lt;/span&gt; &lt;span class="nv"&gt;$customers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$batchSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="cd"&gt;/** @param iterable&amp;lt;int, list&amp;lt;?string&amp;gt;&amp;gt; $rows */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;iterable&lt;/span&gt; &lt;span class="nv"&gt;$rows&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;ImportSummary&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$summary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ImportSummary&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nv"&gt;$batch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$rows&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$line&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$fields&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$fields&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$result&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nc"&gt;RowStatus&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Rejected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nv"&gt;$summary&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;SAMPLES&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="nv"&gt;$batch&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$result&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$batch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;batchSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$batch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$summary&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="nv"&gt;$batch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$batch&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$batch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$summary&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$summary&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$batch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;ImportSummary&lt;/span&gt; &lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;customers&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;insertBatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$batch&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;accepted&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$batch&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rules live in one private method on the same class:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cd"&gt;/** @param list&amp;lt;?string&amp;gt; $fields */&lt;/span&gt;
&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$fields&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;RowResult&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$fields&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;RowResult&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;rejected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'expected 3 columns'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$country&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;array_map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nv"&gt;$fields&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;filter_var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;FILTER_VALIDATE_EMAIL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;RowResult&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;rejected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'invalid email'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;RowResult&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;rejected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'missing name'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;preg_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/^[A-Z]{2}$/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$country&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;RowResult&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;rejected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'bad country code'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nv"&gt;$customer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;NewCustomer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nb"&gt;strtolower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$email&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;RowResult&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;accepted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$customer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every row comes back as a &lt;code&gt;RowResult&lt;/code&gt;, accepted with a &lt;code&gt;NewCustomer&lt;/code&gt; or rejected with a reason. The only state that lives across rows is two counters, at most 50 samples and at most one batch of pending customers. That ceiling is what the memory test further down checks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The driving adapter: a generator over fgetcsv
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://www.php.net/manual/en/language.generators.overview.php" rel="noopener noreferrer"&gt;PHP manual describes generators&lt;/a&gt; as a way to feed a &lt;code&gt;foreach&lt;/code&gt; "without having to build an array in memory ahead of time". That is the whole job of the CSV side: hand over one record before reading the next.&lt;/p&gt;

&lt;p&gt;Most streaming-CSV examples reach for &lt;code&gt;SplFileObject&lt;/code&gt;. Check the calendar first. The &lt;a href="https://wiki.php.net/rfc/deprecations_php_8_6" rel="noopener noreferrer"&gt;Deprecations for PHP 8.6 RFC&lt;/a&gt; deprecates &lt;code&gt;SplFileObject::fgetcsv()&lt;/code&gt;, &lt;code&gt;fputcsv()&lt;/code&gt;, &lt;code&gt;setCsvControl()&lt;/code&gt; and &lt;code&gt;getCsvControl()&lt;/code&gt;, and that poll closed 25 yes, 5 no, 15 abstain. The &lt;a href="https://www.php.net/manual/en/splfileobject.fgetcsv.php" rel="noopener noreferrer"&gt;SplFileObject::fgetcsv manual page&lt;/a&gt; already lists "8.6.0: This method has been deprecated", and &lt;a href="https://www.php.net/archive/2026.php#2026-11-05-1" rel="noopener noreferrer"&gt;PHP 8.6.0 RC5 went out for testing on 5 November 2026&lt;/a&gt;. The global &lt;a href="https://www.php.net/manual/en/function.fgetcsv.php" rel="noopener noreferrer"&gt;&lt;code&gt;fgetcsv()&lt;/code&gt;&lt;/a&gt; carries no such entry, so the adapter uses a plain stream:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cd"&gt;/** @implements IteratorAggregate&amp;lt;int, list&amp;lt;?string&amp;gt;&amp;gt; */&lt;/span&gt;
&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CsvRows&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;IteratorAggregate&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getIterator&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;Generator&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;fopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'rb'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$h&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RuntimeException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Cannot open &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nv"&gt;$row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$h&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nv"&gt;$n&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$n&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;$row&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// header, or a blank line&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="nv"&gt;$n&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$row&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nb"&gt;fclose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$h&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="cd"&gt;/** @param resource $h */&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$h&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// escape '' = no proprietary escape, no 8.4 deprecation&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;fgetcsv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;','&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'"'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details come straight from the manual. Since PHP 8.4, &lt;a href="https://www.php.net/manual/en/function.fgetcsv.php" rel="noopener noreferrer"&gt;relying on the default &lt;code&gt;$escape&lt;/code&gt; value is deprecated&lt;/a&gt;, so the call passes &lt;code&gt;''&lt;/code&gt; explicitly, which also disables PHP's non-standard escape mechanism. And a blank line comes back as an array holding a single &lt;code&gt;null&lt;/code&gt;, not as an error, which is why the loop skips &lt;code&gt;[null]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The key &lt;code&gt;$n&lt;/code&gt; is the record number, header included, so a rejection that says "line 48211" points at the 48,211th record. It drifts from the physical line number only when a quoted field contains a newline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing without a database
&lt;/h2&gt;

&lt;p&gt;The port has one method, so the fake is a dozen lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;InMemoryCustomerRepository&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;CustomerRepository&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cd"&gt;/** @var list&amp;lt;NewCustomer&amp;gt; */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$saved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$batches&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;insertBatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$customers&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;batches&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nb"&gt;array_push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="nv"&gt;$customers&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://phpunit.de" rel="noopener noreferrer"&gt;PHPUnit&lt;/a&gt; tests pass plain arrays and inline generators, so there is no CSV fixture and no &lt;code&gt;RefreshDatabase&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ImportCustomersTest&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;TestCase&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;test_it_reports_why_rows_fail&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$repo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;InMemoryCustomerRepository&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nv"&gt;$rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'alice@example.com'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Alice'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'DE'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'not-an-email'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Bob'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'US'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'carol@example.com'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'FR'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'dave@example.com'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Dave'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Germany'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;];&lt;/span&gt;

        &lt;span class="nv"&gt;$s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ImportCustomers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$repo&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$rows&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;assertSame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;accepted&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;assertSame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;rejected&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$repo&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;assertSame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'alice@example.com'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$first&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;assertSame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'invalid email'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'missing name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'bad country code'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="nb"&gt;array_map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;RowResult&lt;/span&gt; &lt;span class="nv"&gt;$r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$r&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;samples&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second test goes in the same class and pins the batch boundary: 1,001 rows must become 500, 500 and a final flush of 1. That trailing flush is an easy one to forget in a hand-rolled batch loop, and here a unit test with no database catches it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;test_it_flushes_in_batches_of_500&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$repo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;InMemoryCustomerRepository&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;1002&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"u&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="s2"&gt;@example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"User &lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'PT'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;})();&lt;/span&gt;

    &lt;span class="nv"&gt;$s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ImportCustomers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$repo&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$rows&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;assertSame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1001&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;accepted&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;assertSame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$repo&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;batches&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Measure the memory instead of claiming it
&lt;/h2&gt;

&lt;p&gt;To check the streaming claim, generate a big file and run the use case against a repository that only counts. Storing a million objects in the fake would only tell you how big the fake got.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;
&lt;span class="c1"&gt;// php bin/gen.php 1000000 &amp;gt; customers.csv&lt;/span&gt;
&lt;span class="nv"&gt;$n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;1_000_000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;fopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'php://stdout'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'wb'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;fputcsv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'email'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'country'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s1"&gt;','&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'"'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nv"&gt;$n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'broken'&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"user&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="s2"&gt;@example.com"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nb"&gt;fputcsv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"User &lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'DE'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s1"&gt;','&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'"'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;
&lt;span class="c1"&gt;// php bin/import.php customers.csv&lt;/span&gt;
&lt;span class="k"&gt;declare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strict_types&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;require&lt;/span&gt; &lt;span class="k"&gt;__DIR__&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/../vendor/autoload.php'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CountingCustomerRepository&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;CustomerRepository&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;insertBatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$customers&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$customers&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;$import&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ImportCustomers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CountingCustomerRepository&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="nv"&gt;$summary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$import&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CsvRows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt;

&lt;span class="nb"&gt;printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;"accepted=%d rejected=%d peak=%.1f MiB&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;$summary&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;accepted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;$summary&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;rejected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nb"&gt;memory_get_peak_usage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1048576&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The counts follow from the generator: every 50th row is broken, so a million rows gives &lt;code&gt;accepted=980000 rejected=20000&lt;/code&gt;. The peak figure depends on your PHP build, and I haven't run this myself, so read it off your own run. &lt;a href="https://www.php.net/manual/en/function.memory-get-peak-usage.php" rel="noopener noreferrer"&gt;&lt;code&gt;memory_get_peak_usage(true)&lt;/code&gt;&lt;/a&gt; reports the real memory allocated from the system; without &lt;code&gt;true&lt;/code&gt; you get only what PHP's own &lt;code&gt;emalloc()&lt;/code&gt; handed out.&lt;/p&gt;

&lt;p&gt;The number to watch is the difference between runs. Generate 100,000 rows, then 1,000,000, and compare the two peaks. With the ceilings above, nothing in the pipeline grows per row, so the figure should barely move. If it climbs with the row count, something is accumulating, and the counting repository rules out the storage layer as the culprit.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fofnp1cmvypp2aiicr4gv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fofnp1cmvypp2aiicr4gv.png" alt="A single hexagon at the centre with three doors leading into it: a terminal window, an upload form, and a test flask" width="800" height="800"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Three callers, one use case. The rules live in the middle; each door only translates.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Same use case, three doors
&lt;/h2&gt;

&lt;p&gt;Inside Laravel, the driven adapter uses the query builder, which can &lt;a href="https://laravel.com/docs/queries#insert-statements" rel="noopener noreferrer"&gt;insert several records at once from an array of arrays&lt;/a&gt;. Eloquent never hydrates a model in the loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DbCustomerRepository&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;CustomerRepository&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;insertBatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$customers&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'customers'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;array_map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;NewCustomer&lt;/span&gt; &lt;span class="nv"&gt;$c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="s1"&gt;'email'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$c&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$c&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'country'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$c&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="nv"&gt;$customers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A batch of 500 with three columns binds 1,500 parameters in one statement. Check that against your driver's limit: &lt;a href="https://www.sqlite.org/limits.html" rel="noopener noreferrer"&gt;SQLite's default host-parameter cap is 32,766 since 3.32.0, and was 999 before it&lt;/a&gt;, so on an old SQLite construct the use case with &lt;code&gt;batchSize: 300&lt;/code&gt;. Duplicate emails are a job for a unique index. If you'd rather skip them than fail the batch, &lt;code&gt;insertOrIgnore&lt;/code&gt; exists, but &lt;a href="https://laravel.com/docs/queries#insert-statements" rel="noopener noreferrer"&gt;the docs warn&lt;/a&gt; it can ignore other errors too, depending on the engine.&lt;/p&gt;

&lt;p&gt;Bind the port once in a service provider:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;CustomerRepository&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;DbCustomerRepository&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Artisan command, renamed so it doesn't clash with the use case class, shrinks to translation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ImportCustomersCommand&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Command&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nv"&gt;$signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'customers:import {path}'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;ImportCustomers&lt;/span&gt; &lt;span class="nv"&gt;$import&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;argument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'path'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$import&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CsvRows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s1"&gt;'accepted=%d rejected=%d'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;accepted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;rejected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;SUCCESS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The upload endpoint calls the same use case on the uploaded file's &lt;a href="https://laravel.com/docs/requests#retrieving-uploaded-files" rel="noopener noreferrer"&gt;path&lt;/a&gt;. The &lt;a href="https://laravel.com/docs/validation#rule-mimes" rel="noopener noreferrer"&gt;&lt;code&gt;mimes&lt;/code&gt; rule&lt;/a&gt; inspects the file's contents, not only its name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CustomerImportController&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;ImportCustomers&lt;/span&gt; &lt;span class="nv"&gt;$import&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;JsonResponse&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="s1"&gt;'file'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'required'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'file'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'mimes:csv,txt'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;]);&lt;/span&gt;

        &lt;span class="nv"&gt;$path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'file'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;path&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nv"&gt;$s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$import&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CsvRows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;response&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="s1"&gt;'accepted'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;accepted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'rejected'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;rejected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'samples'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;array_map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;RowResult&lt;/span&gt; &lt;span class="nv"&gt;$r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$r&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$r&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;samples&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The third door is the test suite, which never boots the framework at all. The rule about country codes now exists in exactly one place. If a million-row upload outlives your request timeout, store the file and hand its path to a queued job; the job's &lt;code&gt;handle()&lt;/code&gt; is one more three-line door into the same &lt;code&gt;ImportCustomers&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  If this was useful
&lt;/h2&gt;

&lt;p&gt;This post is one use case behind a port, with doors that only translate. &lt;em&gt;Decoupled PHP&lt;/em&gt; is the book-length version of that move: clean and hexagonal architecture for PHP applications, with Laravel or Symfony kept at the edge so the code that holds your rules outlives the framework version it started on. The runnable examples are &lt;a href="https://github.com/gabrielanhaia/php-clean-hexagonal-examples" rel="noopener noreferrer"&gt;public on GitHub&lt;/a&gt; if you want to read code before prose.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.amazon.com/dp/B0H1WVFBYY" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcz4ytbajureytql7wwi4.jpg" alt="Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework" width="333" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>laravel</category>
      <category>architecture</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Record Your Coding Agent's Session With asciinema, Then Replay Where It Went Wrong</title>
      <dc:creator>Gabriel Anhaia</dc:creator>
      <pubDate>Sat, 07 Nov 2026 09:01:33 +0000</pubDate>
      <link>https://dev.to/gabrielanhaia/record-your-coding-agents-session-with-asciinema-then-replay-where-it-went-wrong-ddj</link>
      <guid>https://dev.to/gabrielanhaia/record-your-coding-agents-session-with-asciinema-then-replay-where-it-went-wrong-ddj</guid>
      <description>&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Book:&lt;/strong&gt; &lt;a href="https://www.amazon.com/dp/B0GYJZ2XJD" rel="noopener noreferrer"&gt;AI Agents Pocket Guide&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The series:&lt;/strong&gt; &lt;em&gt;Pocket Guides for Developers&lt;/em&gt; — 8 short books on agents, RAG, prompts, observability, events, databases and system design — &lt;a href="https://www.amazon.com/dp/B0GYLH38X4" rel="noopener noreferrer"&gt;the whole series&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;My project:&lt;/strong&gt; &lt;a href="https://hermes-ide.com" rel="noopener noreferrer"&gt;Hermes IDE&lt;/a&gt; | &lt;a href="https://github.com/hermes-hq/hermes-ide" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; — an IDE for developers who ship with Claude Code and other AI coding tools&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Me:&lt;/strong&gt; &lt;a href="https://xgabriel.com" rel="noopener noreferrer"&gt;xgabriel.com&lt;/a&gt; | &lt;a href="https://github.com/gabrielanhaia" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;The agent says the flaky test is fixed, and the suite agrees. Somewhere in the few hundred lines it printed while you were in another tab, it wrote one sentence about why. You scroll up to find it. The interface has redrawn itself a dozen times since, and the line you want is either gone or buried between two diffs.&lt;/p&gt;

&lt;p&gt;You can ask the agent what it did, but you get its summary, not what it printed at the time.&lt;/p&gt;

&lt;p&gt;A terminal recording fixes this. &lt;a href="https://docs.asciinema.org/" rel="noopener noreferrer"&gt;asciinema&lt;/a&gt; saves everything the terminal displayed, with timing, into a small text file you can replay and search. It works the same for a &lt;a href="https://code.claude.com/docs/en/interactive-mode" rel="noopener noreferrer"&gt;Claude Code&lt;/a&gt; session as for a &lt;a href="https://github.com/openai/codex" rel="noopener noreferrer"&gt;Codex&lt;/a&gt; one. The catch is that the file holds every byte the agent printed, secrets included, so it stays on your machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: record the agent, not a shell
&lt;/h2&gt;

&lt;p&gt;This uses asciinema 3.x. Version 3.0 was a &lt;a href="https://blog.asciinema.org/post/three-point-o/" rel="noopener noreferrer"&gt;Rust rewrite released on 15 September 2025&lt;/a&gt;, and its &lt;a href="https://github.com/asciinema/asciinema/releases/tag/v3.0.0" rel="noopener noreferrer"&gt;release notes&lt;/a&gt; add the &lt;code&gt;convert&lt;/code&gt; command and the asciicast v3 file format. A 2.x install has no &lt;code&gt;convert&lt;/code&gt; and writes the older file format, so step 4 won't work on it. That matters on Linux: &lt;a href="https://packages.debian.org/search?keywords=asciinema" rel="noopener noreferrer"&gt;Debian&lt;/a&gt; and &lt;a href="https://packages.ubuntu.com/search?keywords=asciinema" rel="noopener noreferrer"&gt;Ubuntu&lt;/a&gt; still package 2.4, so &lt;code&gt;apt install&lt;/code&gt; gets you the old one. Homebrew &lt;a href="https://formulae.brew.sh/formula/asciinema" rel="noopener noreferrer"&gt;ships 3.2.1&lt;/a&gt;. On Debian or Ubuntu, take the static binary from the &lt;a href="https://github.com/asciinema/asciinema/releases" rel="noopener noreferrer"&gt;releases page&lt;/a&gt; or build the development version with Cargo, as the &lt;a href="https://docs.asciinema.org/manual/cli/quick-start/" rel="noopener noreferrer"&gt;quick start&lt;/a&gt; shows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;asciinema        &lt;span class="c"&gt;# macOS, 3.x&lt;/span&gt;

&lt;span class="c"&gt;# Debian, Ubuntu: apt has 2.4, so build from git&lt;/span&gt;
cargo &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--locked&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--git&lt;/span&gt; https://github.com/asciinema/asciinema

asciinema &lt;span class="nt"&gt;--version&lt;/span&gt;           &lt;span class="c"&gt;# needs 3.x&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then start the agent through it. &lt;code&gt;-c&lt;/code&gt; runs one command instead of your shell, so the recording ends when the agent exits (&lt;a href="https://man.archlinux.org/man/asciinema-record.1.en" rel="noopener noreferrer"&gt;&lt;code&gt;asciinema rec&lt;/code&gt; man page&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/casts
asciinema rec &lt;span class="nt"&gt;-i&lt;/span&gt; 2 &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="s2"&gt;"fix flaky login test"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt; claude ~/casts/fix-login.cast

asciinema rec &lt;span class="nt"&gt;-i&lt;/span&gt; 2 &lt;span class="nt"&gt;-c&lt;/span&gt; codex ~/casts/tidy-auth.cast
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-i 2&lt;/code&gt; caps every pause at two seconds. The man page says the original timing is kept in the file and the limit is applied when you play it back, so an agent that thought for 40 seconds replays as a two-second gap. &lt;code&gt;-t&lt;/code&gt; stores a title in the file's header.&lt;/p&gt;

&lt;p&gt;The casts go in &lt;code&gt;~/casts&lt;/code&gt;, outside the repository, for reasons that come up in step 5. And since 3.0, &lt;code&gt;rec&lt;/code&gt; needs a file name and no longer uploads anything on its own (&lt;a href="https://github.com/asciinema/asciinema/releases/tag/v3.0.0" rel="noopener noreferrer"&gt;release notes&lt;/a&gt;). Keep it that way.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgesbe1qtqetqcrr3nnk5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgesbe1qtqetqcrr3nnk5.png" alt="A terminal window unspooling a long strip of recorded tape, with small burnt-orange flags marking a few frames" width="800" height="800"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The recording keeps every frame the agent printed, in order, with timing.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 2: drop a marker when something looks off
&lt;/h2&gt;

&lt;p&gt;You will often notice a bad moment while it happens: the agent says "I'll skip this test for now" and keeps going. asciinema can add a marker at that instant, but there is no default key for it (&lt;a href="https://docs.asciinema.org/manual/cli/markers/" rel="noopener noreferrer"&gt;markers docs&lt;/a&gt;). Set one in the 3.x config file, under &lt;code&gt;[session]&lt;/code&gt; (&lt;a href="https://docs.asciinema.org/manual/cli/configuration/v3/" rel="noopener noreferrer"&gt;configuration docs&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="c"&gt;# ~/.config/asciinema/config.toml&lt;/span&gt;
&lt;span class="k"&gt;[&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="k"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;idle_time_limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;
&lt;span class="n"&gt;add_marker_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"^]"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The docs' example key is &lt;code&gt;^x&lt;/code&gt;. Don't use that one with Claude Code, which starts several shortcuts with &lt;code&gt;Ctrl+X&lt;/code&gt;, such as &lt;code&gt;Ctrl+X Ctrl+K&lt;/code&gt; to stop background subagents (&lt;a href="https://code.claude.com/docs/en/interactive-mode" rel="noopener noreferrer"&gt;Claude Code shortcuts&lt;/a&gt;). &lt;code&gt;Ctrl+]&lt;/code&gt; isn't in that list. Pick whatever key your agent leaves free.&lt;/p&gt;

&lt;p&gt;Adding a marker shows a desktop notification, so you know it landed. The other recording key is &lt;code&gt;Ctrl+\&lt;/code&gt;, which pauses and resumes capture (&lt;a href="https://docs.asciinema.org/manual/cli/shortcuts/" rel="noopener noreferrer"&gt;shortcuts&lt;/a&gt;). Press it before you paste anything you don't want on file, and again after.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: replay at 4x and stop on the markers
&lt;/h2&gt;

&lt;p&gt;Play the cast back in a terminal at least as large as the one you recorded in, because asciinema doesn't adapt control sequences to a smaller window (&lt;a href="https://docs.asciinema.org/manual/cli/quick-start/" rel="noopener noreferrer"&gt;quick start&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;asciinema play &lt;span class="nt"&gt;-s&lt;/span&gt; 4 &lt;span class="nt"&gt;-i&lt;/span&gt; 1 ~/casts/fix-login.cast
asciinema play &lt;span class="nt"&gt;-s&lt;/span&gt; 4 &lt;span class="nt"&gt;-i&lt;/span&gt; 1 &lt;span class="nt"&gt;-m&lt;/span&gt; ~/casts/fix-login.cast
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-s 4&lt;/code&gt; is four times speed. &lt;code&gt;-i 1&lt;/code&gt; on &lt;code&gt;play&lt;/code&gt; overrides the limit stored in the recording, so you can squeeze idle time harder than you recorded it. &lt;code&gt;-m&lt;/code&gt; pauses at every marker (&lt;a href="https://man.archlinux.org/man/asciinema-play.1.en" rel="noopener noreferrer"&gt;&lt;code&gt;asciinema play&lt;/code&gt; man page&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;While it plays, Space pauses and resumes. While paused, &lt;code&gt;.&lt;/code&gt; steps forward one event and &lt;code&gt;]&lt;/code&gt; skips to the next marker. Ctrl+C stops. Stepping one event at a time through the stretch around a marker is how you read a line that flashed past at full speed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: turn the cast into text you can grep
&lt;/h2&gt;

&lt;p&gt;Watching works when you know roughly where to look. When you don't, search. &lt;code&gt;asciinema convert&lt;/code&gt; exports plain text when the output file ends in &lt;code&gt;.txt&lt;/code&gt; (&lt;a href="https://man.archlinux.org/man/asciinema-convert.1.en" rel="noopener noreferrer"&gt;&lt;code&gt;asciinema convert&lt;/code&gt; man page&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;asciinema convert ~/casts/fix-login.cast ~/casts/fix-login.txt
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"skip|fail|todo"&lt;/span&gt; ~/casts/fix-login.txt

&lt;span class="c"&gt;# or straight to stdout&lt;/span&gt;
asciinema convert &lt;span class="nt"&gt;-f&lt;/span&gt; txt ~/casts/fix-login.cast - | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; skip
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The text export runs the output through a &lt;a href="https://docs.rs/crate/asciinema/3.2.0/source/src/encoder/txt.rs" rel="noopener noreferrer"&gt;virtual terminal&lt;/a&gt; and drops colours, control sequences and timing. It tells you what was left on screen and in scrollback. It doesn't tell you when. Lines the agent's interface redrew over may not survive the export; the raw cast still has them.&lt;/p&gt;

&lt;p&gt;For when, search the cast itself. In asciicast v3 each line after the header is &lt;code&gt;[interval, code, data]&lt;/code&gt;, where the interval is the time since the previous event and code &lt;code&gt;o&lt;/code&gt; is output, &lt;code&gt;m&lt;/code&gt; is a marker (&lt;a href="https://docs.asciinema.org/manual/asciicast/v3/" rel="noopener noreferrer"&gt;asciicast v3 spec&lt;/a&gt;). A marker with an interval of &lt;code&gt;0.0&lt;/code&gt; sits at the same moment as the event before it and shifts nothing after it. So you can insert one in front of the first line that mentions your word:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'NR &amp;gt; 1 &amp;amp;&amp;amp; !done &amp;amp;&amp;amp; /skip/ {
       print "[0.0, \"m\", \"first skip\"]"; done = 1 }
     { print }'&lt;/span&gt; ~/casts/fix-login.cast &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; ~/casts/fix-login-marked.cast

asciinema play &lt;span class="nt"&gt;-s&lt;/span&gt; 4 &lt;span class="nt"&gt;-m&lt;/span&gt; ~/casts/fix-login-marked.cast
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Playback now pauses just before the agent prints that word. Two limits: an agent's interface can split one word across two writes or wrap it in colour codes, so search for something short, and expect the raw cast to be noisier than the &lt;code&gt;.txt&lt;/code&gt; export. If the first match is the wrong one, change &lt;code&gt;/skip/&lt;/code&gt; to a longer phrase you saw in the text export.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: keep the .cast local
&lt;/h2&gt;

&lt;p&gt;A cast is a plain text file with every byte your terminal displayed. For an agent session that includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;your prompts, because the agent's interface echoes what you type&lt;/li&gt;
&lt;li&gt;file contents the agent printed while reading your code&lt;/li&gt;
&lt;li&gt;output of every command it ran, including an &lt;code&gt;env&lt;/code&gt;, a &lt;code&gt;cat .env&lt;/code&gt; or a &lt;code&gt;curl -v&lt;/code&gt; that prints an &lt;code&gt;Authorization&lt;/code&gt; header&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is without input capture. With &lt;code&gt;-I&lt;/code&gt; / &lt;code&gt;--capture-input&lt;/code&gt;, asciinema also records your keystrokes, and the &lt;a href="https://man.archlinux.org/man/asciinema-record.1.en" rel="noopener noreferrer"&gt;man page&lt;/a&gt; warns that passwords and other sensitive input are recorded too. The CLI leaves input capture off by default (&lt;a href="https://docs.asciinema.org/manual/asciicast/v3/" rel="noopener noreferrer"&gt;asciicast v3 spec&lt;/a&gt;). Leave it off. Environment variables are safer by default: only &lt;code&gt;SHELL&lt;/code&gt; goes into the header unless you pass &lt;code&gt;--capture-env&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd7aw5andcolivy3z5qhd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd7aw5andcolivy3z5qhd.png" alt="A cassette tape held shut by a burnt-orange padlock, sitting on a desk away from an open laptop" width="800" height="800"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;A cast is a full copy of the screen. Treat it like a log file with secrets in it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Record into &lt;code&gt;~/casts&lt;/code&gt;, not into the repo. If you ever record inside a project, add &lt;code&gt;*.cast&lt;/code&gt; to &lt;code&gt;.gitignore&lt;/code&gt; first. Before you send a cast to anyone, grep for the shapes your secrets take. Search the cast as well as the text export, since a key that was printed and then redrawn over can be missing from the &lt;code&gt;.txt&lt;/code&gt; and still sit in the cast:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-nE&lt;/span&gt; &lt;span class="s2"&gt;"sk-|ghp_|AKIA|PRIVATE KEY"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  ~/casts/fix-login.cast ~/casts/fix-login.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An empty result means none of those patterns matched, which is not the same as clean. Read the part you plan to share. &lt;code&gt;asciinema upload&lt;/code&gt; sends the file to asciinema.org or your own server and prints a secret link (&lt;a href="https://docs.asciinema.org/manual/cli/quick-start/" rel="noopener noreferrer"&gt;quick start&lt;/a&gt;). For agent sessions on real code, share a trimmed text excerpt instead, and delete the casts you no longer need.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: pair the recording with what each reply changed
&lt;/h2&gt;

&lt;p&gt;The cast has every line the agent printed, but nothing about the state of your files after each reply. Claude Code has its own transcript viewer on Ctrl+O, with tool usage and a timestamp on each assistant message (&lt;a href="https://code.claude.com/docs/en/interactive-mode" rel="noopener noreferrer"&gt;docs&lt;/a&gt;), and that is worth opening first for a Claude session. The file side is where &lt;a href="https://hermes-ide.com" rel="noopener noreferrer"&gt;Hermes IDE&lt;/a&gt; comes in. I build it, so weigh this with that in mind.&lt;/p&gt;

&lt;p&gt;Hermes saves every agent turn, so you can diff or restore any single one (&lt;a href="https://github.com/hermes-hq/hermes-ide" rel="noopener noreferrer"&gt;README&lt;/a&gt;). Under each session, a strip labelled "Agent replies" shows one chip per reply with the lines it changed (+added −removed). Click a chip to see that reply's changes or to restore your files to that point (&lt;a href="https://hermes-ide.com/changelog" rel="noopener noreferrer"&gt;2.3.1 release notes&lt;/a&gt;). ⌘G (Ctrl+Shift+G on Windows and Linux) opens the Review Desk, where the By turn view lists each turn with its diff.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8cgisbligwk7mx2lpa8d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8cgisbligwk7mx2lpa8d.png" alt="The Hermes Review Desk in By turn view, listing two turns of a Claude Code task with turn 1's diff of src/db.ts and a drafted commit message" width="800" height="444"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The Review Desk (⌘G) in By turn view for a Claude Code task in orders-api: two turns listed (T1: 3 files +69 −1, T2: 1 file +1 −0), turn 1's diff of src/db.ts adding a refunded status, the changed-files list, and a commit message drafted from the turns.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Used together: the marker in the cast tells you which reply went wrong ("the third one, where it said it would skip the test"), and that reply's chip shows exactly which lines it touched, with a restore if you want them back. I haven't tested running asciinema inside a Hermes session, so I won't promise how the two interact. Record in a terminal of your own if you want both.&lt;/p&gt;

&lt;p&gt;A last difference. Hermes removes turn snapshots from worktree tasks 14 days after the session closes (&lt;a href="https://github.com/hermes-hq/hermes-ide/releases/tag/v2.2.9" rel="noopener noreferrer"&gt;2.2.9 release notes&lt;/a&gt;). Your casts stay until you delete them, which is one more reason to keep them in one folder and clean it out.&lt;/p&gt;




&lt;h2&gt;
  
  
  If this was useful
&lt;/h2&gt;

&lt;p&gt;Hermes IDE is free, source-available under BSL 1.1, and runs on macOS, Windows and Linux: &lt;a href="https://hermes-ide.com/download" rel="noopener noreferrer"&gt;download it&lt;/a&gt; or &lt;a href="https://github.com/hermes-hq/hermes-ide" rel="noopener noreferrer"&gt;read the source on GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;A recording is how you find the bad turn. The &lt;em&gt;AI Agents Pocket Guide&lt;/em&gt; is about the loop that got it there: how agents plan, call tools, decide they're done, and where you put the guardrails so a skipped test gets caught before you replay anything. It is one of 8 short books in the &lt;em&gt;Pocket Guides for Developers&lt;/em&gt; series, on agents, RAG, prompts, observability, events, databases and system design — &lt;a href="https://www.amazon.com/dp/B0GYLH38X4" rel="noopener noreferrer"&gt;the whole series is here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.amazon.com/dp/B0GYJZ2XJD" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffzkyb8bqzkmlt4qzgqbo.jpg" alt="AI Agents Pocket Guide: Patterns for Building Autonomous Systems with LLMs" width="800" height="1200"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>terminal</category>
      <category>debugging</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Your Agent's Command Printed Nothing for 2 Minutes. stdout Wasn't a Terminal</title>
      <dc:creator>Gabriel Anhaia</dc:creator>
      <pubDate>Sat, 07 Nov 2026 08:02:16 +0000</pubDate>
      <link>https://dev.to/gabrielanhaia/your-agents-command-printed-nothing-for-2-minutes-stdout-wasnt-a-terminal-1baj</link>
      <guid>https://dev.to/gabrielanhaia/your-agents-command-printed-nothing-for-2-minutes-stdout-wasnt-a-terminal-1baj</guid>
      <description>&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Book:&lt;/strong&gt; &lt;a href="https://www.amazon.com/dp/B0HBTDV75G" rel="noopener noreferrer"&gt;AI That Acts&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The series:&lt;/strong&gt; &lt;em&gt;AI in TypeScript&lt;/em&gt; — 5 books, from your first LLM call to agents in production — &lt;a href="https://xgabriel.com/ai-in-typescript" rel="noopener noreferrer"&gt;all five here&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;My project:&lt;/strong&gt; &lt;a href="https://hermes-ide.com" rel="noopener noreferrer"&gt;Hermes IDE&lt;/a&gt; | &lt;a href="https://github.com/hermes-hq/hermes-ide" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; — an IDE for developers who ship with Claude Code and other AI coding tools&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Me:&lt;/strong&gt; &lt;a href="https://xgabriel.com" rel="noopener noreferrer"&gt;xgabriel.com&lt;/a&gt; | &lt;a href="https://github.com/gabrielanhaia" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;code&gt;Command did not complete within its 120s timeout and was moved to the background&lt;/code&gt;. That is what &lt;a href="https://code.claude.com/docs/en/tools-reference" rel="noopener noreferrer"&gt;Claude Code&lt;/a&gt; hands back after two minutes, the Bash tool's default timeout, along with the path of the file the output is going to. The command was an import script that prints a line every second when you run it by hand: &lt;code&gt;step 1&lt;/code&gt;, &lt;code&gt;step 2&lt;/code&gt;, &lt;code&gt;step 3&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Claude reads that file and finds it empty. The script wasn't stuck. It called &lt;code&gt;print&lt;/code&gt; for every line, and those lines are sitting in a buffer inside the process.&lt;/p&gt;

&lt;p&gt;The C library and Python each pick a buffering mode for stdout from one check, &lt;code&gt;isatty()&lt;/code&gt;, and under an agent that check fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  Line-buffered on a terminal, block-buffered everywhere else
&lt;/h2&gt;

&lt;p&gt;The rule is old and written down. From glibc's &lt;a href="https://man7.org/linux/man-pages/man3/setvbuf.3.html" rel="noopener noreferrer"&gt;setvbuf(3)&lt;/a&gt;: "Normally, all files are block buffered. If a stream refers to a terminal (as stdout normally does), it is line buffered. The standard error stream stderr is always unbuffered by default." The &lt;a href="https://man.freebsd.org/cgi/man.cgi?query=setbuf&amp;amp;sektion=3" rel="noopener noreferrer"&gt;FreeBSD page&lt;/a&gt; says the same thing in nearly the same words.&lt;/p&gt;

&lt;p&gt;Line-buffered means every &lt;code&gt;\n&lt;/code&gt; sends the line out. Block-buffered means output collects in memory until the buffer fills, the program calls &lt;code&gt;fflush&lt;/code&gt;, or the program exits normally. &lt;a href="https://man7.org/linux/man-pages/man3/exit.3.html" rel="noopener noreferrer"&gt;exit(3)&lt;/a&gt; is where "all open stdio(3) streams are flushed and closed."&lt;/p&gt;

&lt;p&gt;Python makes the same choice in its own I/O layer. The &lt;a href="https://docs.python.org/3/library/sys.html" rel="noopener noreferrer"&gt;sys docs&lt;/a&gt;: "When interactive, the stdout stream is line-buffered. Otherwise, it is block-buffered like regular text files." Since 3.9, stderr is line-buffered either way.&lt;/p&gt;

&lt;p&gt;Node doesn't follow this rule. Its &lt;a href="https://nodejs.org/api/process.html#a-note-on-process-io" rel="noopener noreferrer"&gt;note on process I/O&lt;/a&gt; says writes to pipes are asynchronous on POSIX and synchronous on Windows. The Node trap is a different one: call &lt;code&gt;process.exit()&lt;/code&gt; while a write is still pending and that output can be lost.&lt;/p&gt;

&lt;p&gt;An agent's shell tool captures what your command prints so it can hand the text to the model. Whether the capture is a pipe or a file, it isn't a terminal. An open &lt;a href="https://github.com/anthropics/claude-code/issues/9881" rel="noopener noreferrer"&gt;feature request&lt;/a&gt; asks for PTY support in Claude Code's Bash tool because interactive commands hang there, and the thread confirms the tool runs commands without a controlling terminal. So the moment an agent runs your script, it switches to block buffering.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproduce it in a minute
&lt;/h2&gt;

&lt;p&gt;Ten lines of C:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="cp"&gt;#include&lt;/span&gt; &lt;span class="cpf"&gt;&amp;lt;stdio.h&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;
#include&lt;/span&gt; &lt;span class="cpf"&gt;&amp;lt;unistd.h&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;
&lt;/span&gt;
&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"step %d&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Build it with &lt;code&gt;cc -o tick tick.c&lt;/code&gt;. Run &lt;code&gt;./tick&lt;/code&gt; and the five lines come out one second apart. Run &lt;code&gt;./tick | cat&lt;/code&gt; and nothing shows for five seconds, then all five lines land at once when the program exits and flushes, because &lt;code&gt;cat&lt;/code&gt; took the terminal away from its stdout.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbauh7msp5wxesggn3eqc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbauh7msp5wxesggn3eqc.png" alt="A terminal window letting short orange lines flow out one by one, next to a pipe that holds the same lines in a tank until it bursts at the end" width="800" height="800"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Same program, same lines. On a terminal they leave one at a time; on a pipe they wait for the buffer.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The Python version behaves the same way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;step &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;python3 tick.py&lt;/code&gt; streams. &lt;code&gt;python3 tick.py | cat&lt;/code&gt; waits, then dumps.&lt;/p&gt;

&lt;p&gt;The case closest to a tool timeout uses &lt;a href="https://man7.org/linux/man-pages/man1/timeout.1.html" rel="noopener noreferrer"&gt;timeout(1)&lt;/a&gt;, which on Linux sends SIGTERM when time runs out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;timeout &lt;/span&gt;3 python3 tick.py | &lt;span class="nb"&gt;cat&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get no lines at all, even though &lt;code&gt;print&lt;/code&gt; ran three times. They were in the buffer when SIGTERM arrived. Python's &lt;a href="https://docs.python.org/3/library/signal.html" rel="noopener noreferrer"&gt;signal docs&lt;/a&gt; list only SIGPIPE and SIGINT among the handlers it installs by default, so SIGTERM ends the process without the flush a normal exit would do. The C program loses its lines the same way.&lt;/p&gt;

&lt;p&gt;To see what your agent gives a command, ask it to run this, then run it in your own terminal and compare:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; 1 &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"tty"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"not a tty"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-t 1&lt;/code&gt; is true when file descriptor 1, stdout, "is opened on a terminal" (&lt;a href="https://man7.org/linux/man-pages/man1/test.1.html" rel="noopener noreferrer"&gt;test(1)&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  What Claude Code does with a quiet command
&lt;/h2&gt;

&lt;p&gt;Claude Code's &lt;a href="https://code.claude.com/docs/en/tools-reference" rel="noopener noreferrer"&gt;tools reference&lt;/a&gt; lays out the path. The Bash tool runs each command in a separate process and "streams a command's output to a working file as the command runs." When the command finishes, Claude Code reads that file back. The foreground timeout is two minutes by default (&lt;code&gt;BASH_DEFAULT_TIMEOUT_MS&lt;/code&gt;). Claude can ask for more on a call, up to a ten-minute ceiling (&lt;code&gt;BASH_MAX_TIMEOUT_MS&lt;/code&gt;), per the &lt;a href="https://code.claude.com/docs/en/env-vars" rel="noopener noreferrer"&gt;environment variables reference&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;When a foreground command hits its timeout, Claude Code doesn't stop it. It moves the command to the background (unless it starts with &lt;code&gt;sleep&lt;/code&gt;) and returns that "moved to the background" message with a task ID and the output file's path. If the command block-buffers, the file holds only what has been flushed so far, which can be nothing. The model ends up reading a progress log the program hasn't written yet.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh421w5e6blbqtxpx2o8a.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh421w5e6blbqtxpx2o8a.png" alt="A blank sheet of paper with an empty progress bar on a desk, next to a sealed glass jar packed with orange lines of output" width="800" height="800"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The output file the agent reads, and the buffer the lines are actually in.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It bites in two more places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;With &lt;code&gt;CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1&lt;/code&gt; or in bare mode, a command that reaches its timeout "stops instead." If that stop ends the process with a signal, the buffered lines go with it, exactly like the &lt;code&gt;timeout 3&lt;/code&gt; demo.&lt;/li&gt;
&lt;li&gt;The Monitor tool runs a script in the background and "receives each output line as it arrives," with a five-minute default deadline. A block-buffering watcher hands it lines in bursts, or only at exit, so Claude reacts late, or not before the deadline.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  The fixes, cheapest first
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Python: turn the buffer off.&lt;/strong&gt; Run &lt;code&gt;python3 -u script.py&lt;/code&gt;, or set &lt;code&gt;PYTHONUNBUFFERED=1&lt;/code&gt;, which "is equivalent to specifying the -u option" (&lt;a href="https://docs.python.org/3/using/cmdline.html" rel="noopener noreferrer"&gt;command-line docs&lt;/a&gt;). For one call site, &lt;code&gt;print(..., flush=True)&lt;/code&gt; forces the flush (&lt;a href="https://docs.python.org/3/library/functions.html#print" rel="noopener noreferrer"&gt;print()&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make it stick for the agent.&lt;/strong&gt; Claude Code's settings have an &lt;code&gt;env&lt;/code&gt; key that sets variables "for every session and its subprocesses" (&lt;a href="https://code.claude.com/docs/en/settings-reference" rel="noopener noreferrer"&gt;settings reference&lt;/a&gt;). Put it in the project's &lt;code&gt;.claude/settings.json&lt;/code&gt;. Most committed &lt;code&gt;env&lt;/code&gt; values, &lt;code&gt;PYTHONUNBUFFERED&lt;/code&gt; among them, apply once each teammate &lt;a href="https://code.claude.com/docs/en/settings" rel="noopener noreferrer"&gt;trusts the folder&lt;/a&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"env"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"PYTHONUNBUFFERED"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Your own C code: say what you want.&lt;/strong&gt; Call &lt;code&gt;setvbuf(stdout, NULL, _IOLBF, 0);&lt;/code&gt; at the top of &lt;code&gt;main&lt;/code&gt; and stdout is line-buffered wherever it points. Or call &lt;code&gt;fflush(stdout)&lt;/code&gt; after each progress line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On Linux, someone else's binary that uses C stdio: &lt;code&gt;stdbuf -oL&lt;/code&gt;.&lt;/strong&gt; Stock macOS doesn't ship it. From GNU coreutils' &lt;a href="https://man7.org/linux/man-pages/man1/stdbuf.1.html" rel="noopener noreferrer"&gt;stdbuf(1)&lt;/a&gt;: "If MODE is 'L' the corresponding stream will be line buffered." &lt;code&gt;stdbuf -oL ./tick | cat&lt;/code&gt; streams again. The same page names the limits: a command that sets its own buffering (&lt;code&gt;tee&lt;/code&gt;, for one) overrides it, and filters like &lt;code&gt;dd&lt;/code&gt; and &lt;code&gt;cat&lt;/code&gt; "don't use streams for I/O." Python buffers in its own I/O layer, so use &lt;code&gt;-u&lt;/code&gt; there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Last resort: hand it a terminal with &lt;code&gt;script&lt;/code&gt;.&lt;/strong&gt; With util-linux, &lt;code&gt;script -q -c "./tick" /dev/null&lt;/code&gt;. The BSD &lt;a href="https://man.freebsd.org/cgi/man.cgi?query=script&amp;amp;sektion=1" rel="noopener noreferrer"&gt;script(1)&lt;/a&gt;, as on macOS, takes the file first: &lt;code&gt;script -q /dev/null ./tick&lt;/code&gt;. The command now sees a pseudo-terminal and line-buffers. It may also switch on colors, progress bars and prompts. The util-linux &lt;a href="https://man7.org/linux/man-pages/man1/script.1.html" rel="noopener noreferrer"&gt;man page&lt;/a&gt; says "It is not recommended to run script in non-interactive shells" and warns against using it in pipes, so save it for one stubborn binary.&lt;/p&gt;

&lt;h3&gt;
  
  
  If you build the shell tool yourself
&lt;/h3&gt;

&lt;p&gt;A homemade &lt;code&gt;run_command&lt;/code&gt; tool builds the same pipe. Node's &lt;code&gt;child_process.spawn&lt;/code&gt; defaults stdio to &lt;code&gt;['pipe', 'pipe', 'pipe']&lt;/code&gt; (&lt;a href="https://nodejs.org/api/child_process.html#optionsstdio" rel="noopener noreferrer"&gt;docs&lt;/a&gt;), so every child sees a stdout that isn't a terminal. Set the environment in the tool and read output as it arrives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;spawn&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:child_process&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;runCommand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;child&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;spawn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sh&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-c&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;PYTHONUNBUFFERED&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;child&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;data&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="nx"&gt;child&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;data&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="nx"&gt;child&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;child&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;close&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;PYTHONUNBUFFERED&lt;/code&gt; covers Python children. For C programs on Linux, prefix the command with &lt;code&gt;stdbuf -oL&lt;/code&gt;. The &lt;code&gt;data&lt;/code&gt; handlers fire when the child writes, and that's the reason to care: a tool that wants to report partial output, or tell the model "still running, last line was X", needs the child to write as it goes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hermes gives the agent a terminal. Its commands still don't get one
&lt;/h2&gt;

&lt;p&gt;I build &lt;a href="https://hermes-ide.com" rel="noopener noreferrer"&gt;Hermes IDE&lt;/a&gt;, so weigh this section with that in mind. Hermes runs every agent "in a real terminal by default, running the agent's own interface" (&lt;a href="https://github.com/hermes-hq/hermes-ide" rel="noopener noreferrer"&gt;README&lt;/a&gt;). Claude Code's own stdout there is a terminal, which is why its screen, colors and prompts look the way they do anywhere else.&lt;/p&gt;

&lt;p&gt;The commands Claude Code runs are its child processes, and the Bash tool decides where their output goes: the working file from the docs above. Launching the agent in Hermes or in any other terminal app doesn't change that, because the agent's terminal belongs to the agent. Given those docs, expect the &lt;code&gt;[ -t 1 ]&lt;/code&gt; check to come back &lt;code&gt;not a tty&lt;/code&gt; from the Bash tool wherever you started &lt;code&gt;claude&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvubo4xjvjq8dhkk462u3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvubo4xjvjq8dhkk462u3.png" alt="Two agent sessions side by side in Hermes split panes, with the sessions sidebar on the left" width="800" height="500"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Hermes 2.3.4: Codex on orders-types and Claude Code on orders-api side by side, each in its own hermes/ worktree, with the sessions sidebar (status, context use, cost, branch) on the left.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;What Hermes helps with is the comparison. Split panes put sessions side by side, and a plain shell opens in a real terminal just like an agent does. Run the slow command yourself in a shell next to the agent. If it streams there and stays silent under the agent, you're looking at buffering, not a hang, and you know before you spend a turn asking the agent to debug a script that works. Add &lt;code&gt;PYTHONUNBUFFERED&lt;/code&gt; to the project settings and let the agent run it again.&lt;/p&gt;

&lt;p&gt;Hermes is free, source-available under BSL 1.1, and runs on macOS, Windows and Linux. &lt;a href="https://hermes-ide.com/download" rel="noopener noreferrer"&gt;Download it&lt;/a&gt; or read the code on &lt;a href="https://github.com/hermes-hq/hermes-ide" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. It isn't affiliated with Nous Research, Anthropic, OpenAI or any agent vendor.&lt;/p&gt;




&lt;h2&gt;
  
  
  If this was useful
&lt;/h2&gt;

&lt;p&gt;The shell tool is usually the first tool an agent gets, and those details decide whether the model has anything to reason about. My book &lt;em&gt;AI That Acts&lt;/em&gt; covers the tool layer in TypeScript: tool calling, functions, and a first working agent.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.amazon.com/dp/B0HBTDV75G" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc55a6fyls9jo0wih62g7.png" alt="AI That Acts — AI in TypeScript" width="600" height="900"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It is book 3 of &lt;a href="https://xgabriel.com/ai-in-typescript" rel="noopener noreferrer"&gt;&lt;em&gt;AI in TypeScript&lt;/em&gt;&lt;/a&gt;, a five-book series that runs from your first LLM call to agents in production.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://xgabriel.com/ai-in-typescript" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsbtwrac5ncy3vi72824c.png" alt="AI in TypeScript — the five-book series" width="799" height="265"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>linux</category>
      <category>python</category>
      <category>devtools</category>
    </item>
    <item>
      <title>9 Timeouts in Claude Code, Codex and Gemini CLI That Cut Long Test Runs Short</title>
      <dc:creator>Gabriel Anhaia</dc:creator>
      <pubDate>Sat, 07 Nov 2026 07:01:18 +0000</pubDate>
      <link>https://dev.to/gabrielanhaia/9-timeouts-in-claude-code-codex-and-gemini-cli-that-cut-long-test-runs-short-17eb</link>
      <guid>https://dev.to/gabrielanhaia/9-timeouts-in-claude-code-codex-and-gemini-cli-that-cut-long-test-runs-short-17eb</guid>
      <description>&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Book:&lt;/strong&gt; &lt;a href="https://www.amazon.com/dp/B0GYMFPTWV" rel="noopener noreferrer"&gt;System Design Pocket Guide: Fundamentals&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Also by me:&lt;/strong&gt; &lt;em&gt;Thinking in Go&lt;/em&gt; (2-book series) — &lt;a href="https://xgabriel.com/go-book" rel="noopener noreferrer"&gt;Complete Guide to Go Programming&lt;/a&gt; + &lt;a href="https://xgabriel.com/hexagonal-go" rel="noopener noreferrer"&gt;Hexagonal Architecture in Go&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;My project:&lt;/strong&gt; &lt;a href="https://hermes-ide.com" rel="noopener noreferrer"&gt;Hermes IDE&lt;/a&gt; | &lt;a href="https://github.com/hermes-hq/hermes-ide" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; — an IDE for developers who ship with Claude Code and other AI coding tools&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Me:&lt;/strong&gt; &lt;a href="https://xgabriel.com" rel="noopener noreferrer"&gt;xgabriel.com&lt;/a&gt; | &lt;a href="https://github.com/gabrielanhaia" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Your integration suite takes 14 minutes on a clean run. You ask &lt;a href="https://code.claude.com/docs/en/settings" rel="noopener noreferrer"&gt;Claude Code&lt;/a&gt; to run it. Two minutes in, the Bash tool stops waiting, and the result Claude gets back reads &lt;code&gt;Command did not complete within its 120s timeout and was moved to the background&lt;/code&gt; (&lt;a href="https://code.claude.com/docs/en/tools-reference" rel="noopener noreferrer"&gt;tools reference&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;In your terminal, the suite keeps going in the background. Run the same job in CI with &lt;code&gt;claude -p&lt;/code&gt; and a text prompt, and a moved command gets 10 more minutes before Claude Code stops it (&lt;a href="https://code.claude.com/docs/en/tools-reference" rel="noopener noreferrer"&gt;same page&lt;/a&gt;). If Claude didn't ask for a longer timeout, that is 2 minutes in the foreground plus 10 in the background: 12 minutes for a 14-minute suite.&lt;/p&gt;

&lt;p&gt;That is one of nine limits that Claude Code, &lt;a href="https://learn.chatgpt.com/docs/config-file/config-reference" rel="noopener noreferrer"&gt;Codex&lt;/a&gt; and &lt;a href="https://geminicli.com/docs/reference/configuration/" rel="noopener noreferrer"&gt;Gemini CLI&lt;/a&gt; put on long work. Each item gives the setting's name, its default, where it goes and the symptom, from each vendor's docs as of November 6, 2026. I haven't measured any of them myself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Claude Code's Bash tool: three clocks
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. &lt;code&gt;BASH_DEFAULT_TIMEOUT_MS&lt;/code&gt;: 2 minutes
&lt;/h3&gt;

&lt;p&gt;This is the timeout for a foreground Bash or PowerShell command when Claude passes no &lt;code&gt;timeout&lt;/code&gt; of its own: 120000 ms (&lt;a href="https://code.claude.com/docs/en/env-vars" rel="noopener noreferrer"&gt;env vars&lt;/a&gt;). Claude can pass a longer &lt;code&gt;timeout&lt;/code&gt; on any call. You can't set one per command yourself.&lt;/p&gt;

&lt;p&gt;At the limit, Claude Code doesn't kill the command. It moves it to the background and says so, with the task ID and the output file. The exception: with &lt;code&gt;CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1&lt;/code&gt; or in bare mode, a command that hits its timeout stops instead (&lt;a href="https://code.claude.com/docs/en/tools-reference" rel="noopener noreferrer"&gt;tools reference&lt;/a&gt;). Set that variable to keep sessions tidy, and a long test run can stop at the 2-minute mark.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. &lt;code&gt;BASH_MAX_TIMEOUT_MS&lt;/code&gt;: 10 minutes
&lt;/h3&gt;

&lt;p&gt;This is the ceiling on what Claude may request: 600000 ms by default. The effective ceiling is the larger of this value and &lt;code&gt;BASH_DEFAULT_TIMEOUT_MS&lt;/code&gt; (&lt;a href="https://code.claude.com/docs/en/env-vars" rel="noopener noreferrer"&gt;env vars&lt;/a&gt;). So even when Claude knows your suite is slow and asks for 20 minutes, it gets 10.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. The background time limit: 30 minutes, or 10 in &lt;code&gt;claude -p&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;This applies only to unattended sessions (v2.1.285 or later): &lt;code&gt;-p&lt;/code&gt; runs, Agent SDK apps, CI jobs and cloud sessions. A local terminal, desktop or VS Code session has no limit on background commands (&lt;a href="https://code.claude.com/docs/en/tools-reference" rel="noopener noreferrer"&gt;tools reference&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;A command Claude starts in the background gets 30 minutes, or the &lt;code&gt;timeout&lt;/code&gt; Claude passes, up to 2 hours. A command moved there at its timeout gets 30 minutes from the move. In a &lt;code&gt;-p&lt;/code&gt; run whose prompt you pass as text, both defaults are 10 minutes. At the limit, the notice reads &lt;code&gt;Background command "&amp;lt;description&amp;gt;" was stopped after reaching its background time limit&lt;/code&gt;, and Claude can start it again with a longer &lt;code&gt;timeout&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The same two variables fix all three. &lt;code&gt;BASH_DEFAULT_TIMEOUT_MS&lt;/code&gt; above 1800000 replaces the 30-minute background default (above 600000 replaces the 10-minute one in a text-prompt &lt;code&gt;-p&lt;/code&gt; run), and &lt;code&gt;BASH_MAX_TIMEOUT_MS&lt;/code&gt; above 7200000 raises the 2-hour maximum. Neither can shorten a limit. A settings file's &lt;code&gt;env&lt;/code&gt; block applies however &lt;code&gt;claude&lt;/code&gt; was launched (&lt;a href="https://code.claude.com/docs/en/env-vars" rel="noopener noreferrer"&gt;env vars&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"env"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"BASH_DEFAULT_TIMEOUT_MS"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1200000"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"BASH_MAX_TIMEOUT_MS"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2400000"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives commands 20 minutes by default and lets Claude ask for up to 40, so the 14-minute suite finishes in the foreground. Put it in &lt;code&gt;~/.claude/settings.json&lt;/code&gt;, or in the repo's &lt;code&gt;.claude/settings.json&lt;/code&gt; for the team, where most &lt;code&gt;env&lt;/code&gt; values wait until each teammate trusts the folder (&lt;a href="https://code.claude.com/docs/en/settings" rel="noopener noreferrer"&gt;settings&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx7a74twzo1upg0bg7e4p.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx7a74twzo1upg0bg7e4p.png" alt="A long test run drawn as a ribbon of checkmarks, cut short by three stopwatches of different sizes" width="800" height="800"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Three separate clocks sit on one Bash command in Claude Code.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Claude Code: MCP servers, hooks and the API
&lt;/h2&gt;
&lt;h3&gt;
  
  
  4. &lt;code&gt;MCP_TIMEOUT&lt;/code&gt;: 30 seconds to start a server
&lt;/h3&gt;

&lt;p&gt;The startup timeout for an MCP server is 30000 ms (&lt;a href="https://code.claude.com/docs/en/env-vars" rel="noopener noreferrer"&gt;env vars&lt;/a&gt;). It bounds each server's connect attempt. A server that downloads itself on first launch, such as an &lt;code&gt;npx&lt;/code&gt; package, can miss that window, and its tools won't be in the session. The &lt;a href="https://code.claude.com/docs/en/mcp" rel="noopener noreferrer"&gt;MCP docs&lt;/a&gt; give the shape of the fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;MCP_TIMEOUT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;60000 claude
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  5. MCP tool calls: 60 seconds per request on remote servers
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;MCP_TOOL_TIMEOUT&lt;/code&gt; defaults to about 28 hours, so it rarely bites. Two quieter limits do. HTTP, SSE and claude.ai connector servers time out each request after 60 seconds unless &lt;code&gt;MCP_TOOL_TIMEOUT&lt;/code&gt; or the server's own &lt;code&gt;timeout&lt;/code&gt; is above 60000. And &lt;code&gt;CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT&lt;/code&gt; aborts a call when the server sends nothing, not even progress, for 5 minutes on network servers or 30 on stdio (&lt;a href="https://code.claude.com/docs/en/env-vars" rel="noopener noreferrer"&gt;env vars&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The symptom is a remote MCP test runner or browser tool that works on short jobs and errors on long ones. Raise it per server in &lt;code&gt;.mcp.json&lt;/code&gt;, in milliseconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"test-runner"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://mcp.example.com/mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"timeout"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1800000&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;timeout&lt;/code&gt; is a hard wall-clock limit that progress notifications don't extend, and it also sets the server's minimum idle window (&lt;a href="https://code.claude.com/docs/en/mcp" rel="noopener noreferrer"&gt;MCP docs&lt;/a&gt;). A stdio server has no 60-second timer. There, the limit you'll hit is the 30-minute idle window, and because a per-server &lt;code&gt;timeout&lt;/code&gt; is a hard wall clock, set it above your longest run or leave it out.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Hook &lt;code&gt;timeout&lt;/code&gt;: 10 minutes, less on some events
&lt;/h3&gt;

&lt;p&gt;Hook timeouts are in seconds. The defaults are 600 for &lt;code&gt;command&lt;/code&gt;, &lt;code&gt;http&lt;/code&gt; and &lt;code&gt;mcp_tool&lt;/code&gt; hooks, 30 for &lt;code&gt;prompt&lt;/code&gt; hooks and 60 for &lt;code&gt;agent&lt;/code&gt; hooks. Claude Code lowers the 600 to 30 on &lt;code&gt;UserPromptSubmit&lt;/code&gt; (&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;hooks reference&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;A hook that reaches its timeout is canceled and its output thrown away, so on most events it makes no decision. If your Stop hook runs the full suite as a gate and the suite takes 12 minutes, the gate says nothing. For that gate, raise the hook's &lt;code&gt;timeout&lt;/code&gt; (in seconds) above the suite's run time, for example &lt;code&gt;"timeout": 1200&lt;/code&gt;. On &lt;code&gt;PreToolUse&lt;/code&gt; it's worse: a hook that times out doesn't block the tool call; the call goes on through the normal permission flow. To make a stall count as a no on a &lt;code&gt;command&lt;/code&gt; or &lt;code&gt;http&lt;/code&gt; &lt;code&gt;PreToolUse&lt;/code&gt; hook (v2.1.295 or later), set &lt;code&gt;"onFailure": "block"&lt;/code&gt;. That key has no effect on Stop hooks (&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;hooks reference&lt;/a&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  7. &lt;code&gt;API_TIMEOUT_MS&lt;/code&gt;: 10 minutes for response headers
&lt;/h3&gt;

&lt;p&gt;This one guards the model call. It sets how long each API request attempt has to receive its response headers: 600000 ms by default. It doesn't limit how long a streaming response runs after that. Raise it on slow networks or behind a proxy; &lt;code&gt;0&lt;/code&gt; removes it from v2.1.305 (&lt;a href="https://code.claude.com/docs/en/env-vars" rel="noopener noreferrer"&gt;env vars&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyc0rfmkani520ms1zkfr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyc0rfmkani520ms1zkfr.png" alt="Nested clock faces, each one smaller and inside the last, around a terminal window" width="800" height="800"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;A per-request limit inside a per-call limit inside a session limit. Raise the outer one and the inner one can still fire.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Codex and Gemini CLI
&lt;/h2&gt;
&lt;h3&gt;
  
  
  8. Codex: &lt;code&gt;startup_timeout_sec&lt;/code&gt; is 10, &lt;code&gt;tool_timeout_sec&lt;/code&gt; is 60
&lt;/h3&gt;

&lt;p&gt;Codex's MCP keys live in &lt;code&gt;~/.codex/config.toml&lt;/code&gt;, or in &lt;code&gt;.codex/config.toml&lt;/code&gt; in a project you trust. A server gets 10 seconds to start and 60 seconds per tool call by default (&lt;a href="https://learn.chatgpt.com/docs/config-file/config-reference" rel="noopener noreferrer"&gt;config reference&lt;/a&gt;, &lt;a href="https://learn.chatgpt.com/docs/extend/mcp?surface=cli" rel="noopener noreferrer"&gt;MCP docs&lt;/a&gt;). That startup window is a third of Claude Code's.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="k"&gt;[&lt;/span&gt;&lt;span class="n"&gt;mcp_servers&lt;/span&gt;&lt;span class="k"&gt;.&lt;/span&gt;&lt;span class="n"&gt;test_runner&lt;/span&gt;&lt;span class="k"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;command&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"node"&lt;/span&gt;
&lt;span class="n"&gt;args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"mcp/test-runner.js"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;startup_timeout_sec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;
&lt;span class="n"&gt;tool_timeout_sec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;900&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For optional servers, Codex waits &lt;code&gt;mcp_optional_startup_grace_ms&lt;/code&gt; (1000 ms by default) while it builds the first tool list; &lt;code&gt;0&lt;/code&gt; makes it wait for each server's &lt;code&gt;startup_timeout_sec&lt;/code&gt;. Set &lt;code&gt;required = true&lt;/code&gt; on a server if a slow start should fail the launch. I couldn't find a key for shell command timeouts in the Codex config reference.&lt;/p&gt;

&lt;h3&gt;
  
  
  9. Gemini CLI: &lt;code&gt;tools.shell.inactivityTimeout&lt;/code&gt; is 300 seconds
&lt;/h3&gt;

&lt;p&gt;This is "the maximum time in seconds allowed without output from the shell command" (&lt;a href="https://geminicli.com/docs/reference/configuration/" rel="noopener noreferrer"&gt;configuration&lt;/a&gt;). It counts silence, not total time. A test runner with a quiet reporter, or a build step that buffers its output until the end, can hit it long before it's done.&lt;/p&gt;

&lt;p&gt;Units change inside the same file. The shell setting is in seconds. An MCP server's &lt;code&gt;timeout&lt;/code&gt; is in milliseconds, with a default of 600000 (&lt;a href="https://geminicli.com/docs/tools/mcp-server/" rel="noopener noreferrer"&gt;MCP server docs&lt;/a&gt;). A hook's &lt;code&gt;timeout&lt;/code&gt; is also milliseconds, default 60000 (&lt;a href="https://geminicli.com/docs/hooks/" rel="noopener noreferrer"&gt;hooks&lt;/a&gt;). The shell and MCP settings go in &lt;code&gt;~/.gemini/settings.json&lt;/code&gt; or the project's &lt;code&gt;.gemini/settings.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tools"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"shell"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"inactivityTimeout"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;900&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"test-runner"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"node"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"mcp/test-runner.js"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"timeout"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;900000&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One status note from the same docs: for unpaid-tier and Google One users, Gemini CLI "was replaced by Antigravity CLI on June 18th, 2026." I haven't checked Antigravity CLI's own timeout keys, so they aren't on this list.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cheat sheet
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Agent&lt;/th&gt;
&lt;th&gt;Where&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BASH_DEFAULT_TIMEOUT_MS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Claude Code&lt;/td&gt;
&lt;td&gt;env / &lt;code&gt;env&lt;/code&gt; block&lt;/td&gt;
&lt;td&gt;120000 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BASH_MAX_TIMEOUT_MS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Claude Code&lt;/td&gt;
&lt;td&gt;env / &lt;code&gt;env&lt;/code&gt; block&lt;/td&gt;
&lt;td&gt;600000 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Background limit&lt;/td&gt;
&lt;td&gt;Claude Code&lt;/td&gt;
&lt;td&gt;via 1 and 2&lt;/td&gt;
&lt;td&gt;30 min (10 in &lt;code&gt;-p&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;&lt;code&gt;MCP_TIMEOUT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Claude Code&lt;/td&gt;
&lt;td&gt;env&lt;/td&gt;
&lt;td&gt;30000 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Per-request MCP limit&lt;/td&gt;
&lt;td&gt;Claude Code&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.mcp.json&lt;/code&gt; &lt;code&gt;timeout&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;60 s (remote)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Hook &lt;code&gt;timeout&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Claude Code&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;settings.json&lt;/code&gt; hooks&lt;/td&gt;
&lt;td&gt;600 s (command)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;&lt;code&gt;API_TIMEOUT_MS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Claude Code&lt;/td&gt;
&lt;td&gt;env / &lt;code&gt;env&lt;/code&gt; block&lt;/td&gt;
&lt;td&gt;600000 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;startup_timeout_sec&lt;/code&gt;, &lt;code&gt;tool_timeout_sec&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Codex&lt;/td&gt;
&lt;td&gt;&lt;code&gt;config.toml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;10 s, 60 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tools.shell.inactivityTimeout&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Gemini CLI&lt;/td&gt;
&lt;td&gt;&lt;code&gt;settings.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;300 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Watch the units. Claude Code hooks take seconds and its env variables take milliseconds. Codex and Gemini CLI each mix both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Telling a finished run from a cut-off one in Hermes
&lt;/h2&gt;

&lt;p&gt;I build &lt;a href="https://hermes-ide.com" rel="noopener noreferrer"&gt;Hermes IDE&lt;/a&gt;, so weigh this with that in mind. None of the limits above live in Hermes. It opens each agent in a real terminal by default, running the agent's own interface (&lt;a href="https://github.com/hermes-hq/hermes-ide" rel="noopener noreferrer"&gt;README&lt;/a&gt;), so you set these limits in each agent's own config, and the agent's timeout notices appear in its own terminal.&lt;/p&gt;

&lt;p&gt;Hermes adds one view across agents. Every session shows one status: working, needs approval, asked you, done, error or idle, and Hermes says when a status is a guess. A notification fires when an agent is blocked on you or done. On a long suite, that tells you which session has stopped and which is still working, without opening every terminal.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvubo4xjvjq8dhkk462u3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvubo4xjvjq8dhkk462u3.png" alt="Two agent sessions side by side in Hermes IDE, Codex and Claude Code, each in its own worktree, with the sessions sidebar showing status, context use, cost and branch" width="800" height="500"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Codex on orders-types and Claude Code on orders-api, each in its own hermes/ worktree, with status, context use, cost and branch for each session in the sidebar.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;"Done" means the agent finished its turn, not that your suite passed. A turn can end right after a timeout notice. Since 2.3.1, the "Agent replies" strip under each session has one chip per reply with the lines it changed, and a click shows that reply's changes. Read the last one before you trust a green-looking summary.&lt;/p&gt;

&lt;p&gt;Your own checks can also decide when a task is done: the &lt;a href="https://github.com/hermes-hq/hermes-ide/blob/main/docs/worktree-recipes.md" rel="noopener noreferrer"&gt;worktree recipe docs&lt;/a&gt; show &lt;code&gt;done_when = ["npm test"]&lt;/code&gt; in &lt;code&gt;.hermes/worktree.toml&lt;/code&gt;. I haven't timed a 14-minute suite through it. On macOS and Linux, agents keep working while Hermes quits, updates or crashes.&lt;/p&gt;

&lt;p&gt;Hermes is free to &lt;a href="https://hermes-ide.com/download" rel="noopener noreferrer"&gt;download&lt;/a&gt;, source-available under BSL 1.1, and runs on macOS, Windows and Linux. It isn't affiliated with Nous Research, Anthropic, OpenAI or Google. If a limit caught you that I didn't list, tell me in the comments.&lt;/p&gt;




&lt;h2&gt;
  
  
  If this was useful
&lt;/h2&gt;

&lt;p&gt;Most of these are limits stacked on limits, like a per-request timeout sitting under a per-call one. That is ordinary timeout design for any distributed system, and the &lt;a href="https://www.amazon.com/dp/B0GYMFPTWV" rel="noopener noreferrer"&gt;System Design Pocket Guide: Fundamentals&lt;/a&gt; covers it, along with the other core building blocks, in a short read. And if you run more than one of these agents a day, &lt;a href="https://hermes-ide.com/download" rel="noopener noreferrer"&gt;Hermes IDE&lt;/a&gt; puts their status in one place.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.amazon.com/dp/B0GYMFPTWV" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4dz9ehd0n8k7iax7x19i.jpg" alt="System Design Pocket Guide: Fundamentals — Core Building Blocks for Scalable Systems" width="800" height="1200"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>devtools</category>
      <category>testing</category>
    </item>
  </channel>
</rss>
