July 2025
Your agent thinks fine, it's the shell that's killing you
An autonomous coding agent's dominant cost is environmental friction re-paid on every tool call, and writing the workaround into the docs only makes the bill bigger
So here is a thing I did, the way you might do it on a slow afternoon when you have convinced yourself it counts as work. I profiled a feature-dev dry run the way you would profile a slow function, with the smug prior expectation (the kind you have precisely so the universe can take it away from you) that the cost would be in the thinking. Surely, I told myself, it is the thinking. It is always the thinking. It was not the thinking.
Thirty-seven tool calls. And the code navigation, the part I had braced to be embarrassed by, was lean: six focused reads, about two greps, no thrashing, no flailing around the file tree like a person who has lost their keys. The expense was in Bash. Of twenty-three Bash invocations, fifteen did no work on the actual task at all. (Read that sentence again, because it is the whole essay, and everything after this is me failing to leave the room.) Fifteen of them re-exported a PATH so the pinned Node would win over the one Homebrew had rudely inserted first, hunted around for the repo-local moon binary, and re-ran npm ci. Sixty-five percent of the shell calls were the agent setting the table before every single bite. You sit down, it lays out the silverware. You take a forkful, it lays out the silverware again. You are, by the end, very well-laid-out and somewhat hungry.
The reason, and I want to be honest that it is not a glamorous reason, is mechanical and dull. Each Bash call in this harness is a fresh shell. There is no carried state between them, none, not a whisper. A fresh login shell on that box resolves node to the Homebrew install, version 25, with moon nowhere on the PATH, so the toolchain the project actually pins is simply absent until the agent re-exports it. Export it on call three, and call four wakes up brand new, blinking, innocent, with no memory that call three ever happened. (There is something almost poignant here about an agent that cannot remember the last thing it just did, but I promised the technical facts and I will keep the philosophy in parentheses where it belongs.) The friction is paid per invocation, which is the cruel part, because it means it scales with the number of tool calls and not with the size or difficulty of the task. A two-line change and a two-hundred-line change pay the exact same bootstrap tax per Bash call. And here is the genuinely funny twist, funny in the way that makes you want to lie down: the two-line change often makes more calls.
Now. The first fix anyone reaches for, and I include myself, I include past-me who reached for it with real enthusiasm, is to write it down. Put the export PATH=... incantation and the moon location in CLAUDE.md so the agent knows the right thing to do. We almost did this. We were, I would estimate, one approving nod away from doing this. And it is exactly wrong, and it is wrong because of what CLAUDE.md actually is. It is context the agent reads. So a workaround documented in prose is a workaround the agent re-reads and re-executes on every single occasion the workaround applies, which (you are ahead of me) is every Bash call. You have not removed the friction. You have transcribed it into the one file guaranteed to be in scope, the one file you can count on the agent dutifully reading, and you have thereby promoted a runtime accident into a standing instruction to keep paying it forever. Documenting the symptom industrializes the symptom. (I am a little proud of that sentence and a little ashamed of how close we came to earning it the hard way.)
And it turns out this was not the first time the machine had tried to tell us. A separate audit, on an entirely unrelated build campaign, had already been pointing a trembling finger at the same layer. Across eight builds we tallied where the effort went to die, and it sat almost entirely in the Bash layer: eighty-four denied calls, most of them tooling flailing, while Read, Edit, and Grep stayed disciplined and well-behaved the whole time, like the one well-adjusted sibling. The agent’s reasoning about the code was fine. Its reasoning about the code was, frankly, better than mine. Its relationship with the machine the code lived on was the problem.
But here is the part I actually want you to sit with, because it is the part that should make you a little uncomfortable, the way it made me uncomfortable. The instrument we used to find all this was itself unreliable. One of the counters reported reread_files, files the agent had supposedly opened more than once, and it showed a whole guilty pile of them. Open hand, ready to deliver the verdict. Then you read the underlying events and the rereads simply dissolve in your hands. They were different-offset windows of the same file, the agent paging through one long source file in chunks, like anyone reading something longer than a screen, and the counter had recorded each window as if the agent had forgotten the file and come crawling back to it. Corrected, the true reread count was zero. Not low. Zero. The metric whose entire job was to surface waste was, with great confidence, inventing it.
So the fix moved out of prose and into structure, which is where it always wanted to live. The toolchain is now provisioned once, by a SessionStart hook that runs before the agent makes its first call, so the pinned Node and moon are already sitting on the PATH that every shell inherits, and no individual Bash call has to arrange for them, ever, again. The other half of the mess lived in the git hooks, and it was sneakier. A commit was invoking npx --no-install moon, which quietly assumes node_modules already exists; in a fresh worktree or a fresh clone it does not yet, so the call failed and aborted the commit. Which looked, to anyone glancing at it, like a gate doing its solemn duty. It was not. It was a tooling error wearing a gate’s uniform, a commit blocked because the environment was not ready, not because anything whatsoever was wrong with the change. We replaced it with a moon_cmd resolver that finds a working moon whether or not the local install has been populated, so the hook checks the thing it is actually supposed to check instead of tripping over its own shoelaces on startup.
And now the honest limit, because every essay like this owes you one and I would rather pay it than let you find it yourself. The fresh-shell-per-call model belongs to the harness, not to the agent. You can pre-provision the toolchain so the very first call lands in a sane environment, and you can stop the git hooks from confusing setup with policy, and both of those are real wins I would defend in a parking lot. But you cannot hand the agent a shell that remembers the last command. As long as each Bash call is a new process inheriting nothing beyond what the harness seeds into it, some quantity of re-establishing context per call is structural, baked in, not a bug you get to swat. The only real lever you have left is making fewer, fatter calls. And the thing you would naturally reach for to check whether you have pulled that lever, the reread and grep counters, is the very same instrument that already, once, reported zero true rereads as a towering stack of them. So you are now tuning against a gauge you have personally caught lying, in writing, with no particular reason to trust it the next time the needle swings high. (Which is, when you think about it, more or less the human condition, but I said I would keep that in parentheses, and look, I have.)