We Let Claude Code Run for 12 Days Straight
At Chief, our platform repo has sat at around thirty to fifty commits a week. Then last week it hit 220.
main, the last three monthsWe haven’t hired anyone. We just put a Claude Code subscription on a disposable VM, started seven loops in a terminal multiplexer, and wrote a script that restarts the ones that die. That’s it, as simple as you can get. The bot opened 229 pull requests in twelve days and 213 of them merged.
The whole thing costs $240 a month: $200 for the Claude subscription, $40 for the box. There is no API bill, no orchestration platform, no queue, and no database.
This post is the setup and the decisions behind it. I’ll go through the box, the loops, the supervisor, and the two failure modes that took the longest to get right.
The stack
Five layers. Four of them are things most engineering teams already pay for or already run.
We run Claude Max 20x at a flat $200 a month. The other options was an API key billed per token, or use Claude Code Code Review plugin, but both can be really expensive. This thing burns a lot of tokens by design: seven loops running all day, and cr opens a high-effort agent for every pull request it hasn’t seen and interacts with until it merges. On a per-token bill you find out what that costs at the end of the month and need to always be afraid of overspending. On a subscription you know in advance, and the usage limit gives you a ceiling to design against.
A subscription is tied to an account rather than a key, so someone has to SSH in and sign Claude back in every couple of days. That’s probably the only friction we’ve right now, and we haven’t found a way around it (but we are okay with it).
The box
The box is a stock Linux VM on exe.dev with 4 CPUs, 16 GB of RAM and a 200 GB disk. Provisioning is one idempotent shell script, shipped over SSH and re-runnable at any time.
It does a few things: installs a git identity with SSH commit signing, authenticates gh, clones the repos, installs the project’s runtime deps, installs Claude Code, links our dotfiles for a better shell experience and installs the supervisor script. It holds two secrets, a signing key and a GitHub token, and neither is stored in the repo.
There are two simple commands, and the split is deliberate:
task exedev:bootstrap # first boot: ship the script + the secrets and run it
task exedev:sync # re-run it in place; the secret steps self-skipbootstrap is the one that carries secrets, so it only runs when the box is new or has just been recreated. sync is the one anyone runs from their own laptop to push a config change, and it needs nothing but access, because the box already holds what it needs. Splitting them is what makes the VM disposable: recreating it is one command plus a bootstrap, and nothing else in the workflow knows the difference. Re-running either is safe, so “fix the box” and “provision the box” are the same operation.
Sessions that survive a disconnect
A /loop needs a terminal that outlives your SSH connection. We use herdr as the multiplexer because it also exposes the session as an API and has a great agent experience, which is what the supervisor also reads:
herdr tab list # JSON: every tab and its label
herdr pane list # JSON: every pane and the tab it belongs to
herdr pane process-info --pane <id> # JSON: shell pid + foreground processes
herdr pane read --pane <id> --lines 80 # the text on screenNone of this is only useful for automations. I’ve been running the same pieces for my own remote coding for a while now: exe.dev for the machine, herdr for the sessions, and Tailscale to reach them from wherever I am (even from my phone). That setup is a post of its own and I’ll write it eventually.
The loops
We run seven of them, one tab each:
| Loop | Every | What a tick does |
|---|---|---|
cr | 10m | Reviews every open PR whose head it hasn’t seen, one background agent per PR, at high effort |
dependabot | 60m | Takes one dependabot PR, fixes what the bump breaks, merges it or hands it to the code owners |
deadcode-sweep | 60m | Hunts the dead code CI structurally can’t see, one area at a time |
abstraction-police | 60m | Finds one decision re-derived at several call sites where the copies have drifted apart |
counterpart-drift | 60m | Finds a contract whose two halves stopped corresponding: encode without decode, a guard on one path and not the other |
test-pruner | 240m | Deletes tests that cover nothing, one package per tick |
test-writer | 240m | Writes tests for code that changed in the last 90 days and never got one |
Every loop owns its pull requests to the end: it opens the PR, answers the review, and arms auto-merge once the review is clean. A loop that opens a PR and walks away is just a machine for generating work. Often is fun to see agents interacting with each other in real time to get work done.
The supervisor
/loop runs for seven days and then stops (Claude Code limitation), so a loop started on Monday is gone by the following Monday even when nothing goes wrong. Usually something goes wrong first anyway: claude crashes, you might be prompted to try again due to recent outages or a session ends for a reason you never see.
What makes that hard to notice is the tab. It stays open with its label intact and an empty shell inside it, so a box with seven dead loops looks exactly like a box with seven healthy ones.
The supervisor therefore ignores labels. A cron entry runs loops.sh --start every ten minutes and rebuilds only what actually stopped. The seven-day cap is why this is automated rather than checked by hand, since every loop is guaranteed to expire eventually and nobody is logged in on the day it does.
loops.sh --startherdr tab list + herdr pane list, joinedherdr pane run "claude '/loop 10m /cr'"The classification reads the process table rather than the label, and it leans on one property of a terminal pane: the shell is always there. Every pane runs a shell, that shell has a pid, and it stays alive for the life of the pane. Start claude in it and claude becomes the foreground process while the shell waits behind it. Let claude exit and the shell is the only thing left in the foreground.
That makes the shell’s pid a baseline to compare against. herdr reports both parts, so the supervisor asks a single question of each pane: does the foreground hold anything that isn’t the shell?
- 4210 fish the shell itself
- 8817 claude work
- 9002 go test ./... work
- 4311 fish the shell itself
loop-cr has two of them, so something is running. loop-test-writer has none, so the loop is gone and the tab is an empty shell. In code:
# The pane's own shell is the only process left once claude exits, so anything in the
# foreground group other than that pid means something is still running.
foreground="$(python3 -c '
import json, sys
info = json.load(sys.stdin)["result"]["process_info"]
shell = info["shell_pid"]
print("agent" if any(p["pid"] != shell for p in info["foreground_processes"]) else "shell")
' <<<"$info")"The comparison is on pids, never on names, and that’s what keeps loop-cr alive. A tick spends most of its time inside tools, so the process actually in the foreground is often go test or git rather than claude. A supervisor that looked for the name claude would call that pane dead, close the tab, and take the test run with it.
Scoping the check to one pane is what makes seven concurrent sessions supervisable at all. Each loop is looked up by label, then by tab, then by pane, and every pane is compared against its own shell. A box-wide pgrep claude returns seven pids and can’t say which of the seven is the dead one.
There’s a third state that isn’t obvious until you hit it. A session that reaches a usage limit doesn’t exit. It sits there holding the tick it was part-way through and picks it up again when the limit resets, which can be hours later.
That pane fails both of the tests above in the wrong direction. It has a live process, so the pid check calls it healthy, and it hasn’t printed anything for hours, so it looks stuck. Rebuild it and the half-finished tick is gone, and with a cron on ten minutes you destroy it again every ten minutes until the limit window closes.
Nothing exposes that state, so the only signal left is the text on screen:
LIMIT_PATTERN='hit your (session|weekly|usage|5-hour) limit|limit reached|limit exceeded'Screen-scraping a CLI’s output is not a design I’m proud of, since it breaks the day Anthropic rewords that message, but it works and solves our problem for now.
We also added a setting called CLAUDE_CODE_RETRY_WATCHDOG that makes the CLI retry capacity errors indefinitely. A subscription usage limit has no setting, the reason we need to deal with it this way.
Ticks keep no state
Nothing on the box is worth backing up. Every tick reads what it needs from GitHub (we basically use it as a data source for orchestration): the open pull requests, the tracking issues where the sweeps record which areas they’ve claimed, the review comments. Recreating the VM costs exactly the ticks it missed.
The ten-minute cron is safe for the same reason. A restart is free, so the supervisor never has to weigh whether a rebuild is worth it. It also means the loops have no idea they were ever restarted, which removes the entire class of bugs where a resumed job double-processes its queue.
The intervals are the usage limit
As you probably know, a flat subscription doesn’t mean unlimited. It means a usage limit, and every interval in our loops is really an answer to the question of how much work fits inside one.
The cr loop costs more per tick than everything else combined, because it fans out one high-effort agent per pull request whose head it hasn’t reviewed. The sweeps are charged twice over: each PR a sweep opens is another PR cr then reviews. Those are the numbers we tuned. Ten minutes for cr, an hour for the sweeps and four for the test loops is what keeps seven loops running through a day without all of them parking on a limit at once.
There’s a second throttle in the skills themselves. A sweep with five or more of its own pull requests already open ends the tick instead of proposing a sixth. That ties the work to how fast we can review and merges rather than to the clock, and it stops the bot from burying us.
What came out
Separately, the dependabot loop drained 21 dependency bumps in the same window, merged strictly one at a time so that package lockfiles regeneration can’t race. Those PRs are authored by dependabot, so they aren’t in the count above.
The $240 also buys a calendar month, and we are twelve days into one. If the rate holds, the same bill covers something closer to 500 pull requests by the time it renews.
Again, these numbers measure volume, not quality, and I don’t want to claim otherwise. Every one of those PRs went through the same CI and the same review as a human’s, plus a high-effort review from cr on top, and nothing landed that wasn’t green or had an human behind the keyboard reviewing and approving it. That’s the bar we already had, and none of it moved, we just adopted our development process to the new agentic world we are living in.
I do believe the next steps would be defering to the bot more tasks around features and integrations with other systems to fix bugs and ship new features from other integrations, but we have not yet done that.
Wrapping up
I see every day a new tool solving this kind of issue with a new framework, a new platform and a new way that forces you to change the way you work. We opted to keep things simple and trusting some primitives that already exists for years, as we basically don’t need more than that.
My goal with this post was to show that you don’t need to go wild and reinvent the wheel every time you want to solve a problem, and neither spend a lot of time and money on it too. What we ended up with: a $200 subscription, a $40 VM, a multiplexer with a JSON CLI, /loop, and a supervisor script whose hardest job is telling three states apart. The state lives in GitHub, which was already our source of truth.
If you want to try it, my suggestions is to start with one loop, test, evaluate and iterate. The supervisor is only worth writing once you’ve had a tab sit there looking like it was working when it wasn’t.