One harness loop, many projects: how I run parallel coding agents without losing the thread
I work on several projects at once. This is the harness loop I built around my coding agents (worktrees, tickets as a dependency graph, and a cleanup step), and how it evolved from a shell alias into something I trust.
I work on several projects at the same time. Different repos, different clouds, different tickets, and different accounts paying for the runs. That last one matters more than it sounds.
For a while my “AI setup” was just me, one terminal, one agent, one task. It worked fine, right up until it didn’t. The moment I tried to run two pieces of work in parallel, everything that was implicit became a problem: which branch am I on, which agent is waiting for me, did that one finish or is it stuck, and why is this repo full of directories from last Tuesday?
What fixed it was building a loop around the agent and being strict about who owns each step. People are calling this harness engineering now, and the name fits: the model is the engine, and the harness is what makes it useful on a Monday morning with four things in flight.
The shape of the loop
One ticket, one loop, and the same loop every time:
┌──────────────────────────────────────────────────────────┐
│ │
▼ │
scope ──▶ launch ──▶ agent works ──▶ hands back ──▶ I review the diff
(me) (harness) (unattended) (unstaged) (me, always)
│
▼
PR ──▶ merge ──▶ install
│
▼
sweep up
Two rules hold the whole thing together:
- The agent never commits. It hands work back unstaged. Every change I ship passed my eyes in a diff.
- Every launch leaves a known set of things behind, and there’s one command that finds them all again.
Everything below follows from those two rules.
Isolation: one worktree per ticket
The first real change was giving every task its own place to stand: a git worktree, a separate directory on disk, instead of a branch I switch between.
~/repos/project/myapp ← main checkout, stays on main
~/repos/project/myapp--feat-add-webhook ← ticket 1's worktree
~/repos/project/myapp--fix-retry-loop ← ticket 2's worktree
Two agents can now work at the same time without fighting over the index, so there’s no stashing and no “wait, finish that first”. Each one has a real checkout with its own branch, cut fresh from an up-to-date base.
I pair each worktree with a terminal workspace laid out the same way every time, three tabs, always in the same order:
agent: the coding agent, running unattendedreview: a live diff view of that worktreeshell: me, poking at things
The consistency is what makes it work. When I come back to a workspace two hours later, I don’t have to remember how I left it. Muscle memory works because every ticket looks identical.
Guardrails at the tool level
The agent runs unattended, which means nobody is there to click “deny” on a permission prompt at the wrong moment. So I enforce the rules where the agent can’t argue with them:
claude --model opus --effort medium \
--permission-mode bypassPermissions \
--disallowedTools "Bash(git add:*)" "Bash(git commit:*)" "Bash(git push:*)" \
"Bash(git stash:*)" "Bash(git reset:*)" "Bash(git rebase:*)" \
"Bash(git checkout:*)" "Bash(git switch:*)"
An instruction in a prompt is a suggestion that usually works. A blocked tool is a wall. If I’m going to let something run for twenty minutes while I’m in a meeting, I want the wall.
This is also the bit that makes the agent swappable in theory but not yet in practice. I checked whether I could point the same harness at a different CLI, and the honest answer was no: the one I tried has --model and a sandbox flag, but no equivalent of --disallowedTools. The guardrail that makes unattended work safe simply isn’t there. Worth knowing before you assume portability.
Tickets as a dependency graph
Once parallelism is possible, the question becomes what can safely run in parallel right now. A flat to-do list can’t answer that.
So tickets carry their blockers:
**Blocked by:** #12, #13
Which makes the board a small DAG, and lets the harness compute the frontier: the set of tickets whose blockers have all landed. That’s the set I’m allowed to launch. Everything else waits, visibly, with a reason.
This has a practical payoff I didn’t expect. It forces the scoping conversation to happen before anything runs. Slicing work into tickets that can actually be sequenced is the part that needs my judgement. The implementation is the part I’m happy to delegate.
One thing I learned the hard way: two tickets can safely touch the same file if they’re working on different sections of it. They cannot safely touch the same lines. Partition by section, and parallel work merges clean. Partition by line, and you’ll spend the time you saved resolving conflicts.
Settings I answer once
Three things govern every task a working session launches:
| Setting | What it decides | Default |
|---|---|---|
| Account | which account pays for the run | inherited from the directory |
| Model | which model implements it | from a config file |
| Effort | how hard that model thinks | medium |
The harness asks me all three once, at the first launch, and then holds them. Asking per ticket would just be the same answer retyped three times.
The account one needs care. My account switcher resolves the active account by directory, and the mapping inherits into subdirectories. Ticket worktrees are created as siblings of the main checkout, so they inherit whatever the parent directory is linked to, which is not always the account I want this particular task on.
So the launcher prints which account a task is running on, and verifies it against the running process instead of trusting what it asked for. The line reports that the process in this pane is using account X, which is a different claim from having requested account X.
Cleanup is its own step, and it’s opt-in
Every task leaves things behind. In my setup, six: a workspace, its tabs, its panes, a git worktree, a branch, and, when it ran on a named account, a directory link in the account switcher’s config.
They don’t disappear together. A normal gh pr merge --squash --delete-branch takes the directory, git’s registration and the branch, and leaves the terminal workspace with nothing on git’s side left to find it by. Close that workspace too, and the account link is orphaned with nothing on either side pointing at it.
So the cleanup command looks in four independent places:
| Source | Finds |
|---|---|
| git worktrees | the ordinary finished checkout |
| git branches | a branch whose worktree was removed by hand |
| the terminal’s own workspace list | tabs and panes git can no longer see |
| the account switcher’s links file | a link no tool can reach any more |
Each of those was added because I once got told “nothing left behind” while something was plainly still there. That sentence is now the thing the tool is written to never say wrongly: a source it couldn’t read says so on its own line, instead of quietly reading as absence.
Three rules keep the cleanup safe, and I’d suggest all three for anything similar:
- Listing is always safe. It changes nothing and I can run it any time.
- Removal is per item, confirmed by me. There is no “remove all” verb to reach for at 6pm.
- A worktree with uncommitted changes is never removed, not with
--forceand not on my own say-so.
That last one exists because of the dumbest mistake I made building this. I ran a removal with --force intending to test the uncommitted-changes guard, but the directory was clean, so --force sailed past a different guard (the one checking the branch had actually merged) and took a live workspace with it. Nothing was lost, because the PR was already pushed. But the lesson stuck: --force should never be able to reach the guard that protects unreviewed work. Some refusals shouldn’t be overridable, including by the person who wrote them.
Detecting “merged” is harder than it looks
A small one, but it burned an afternoon.
To know whether a branch is safe to delete, the obvious check is ancestry: is this branch’s tip an ancestor of main? On GitHub’s default merge strategy, that check gives a false negative every time. A squash merge collapses the branch into one new commit with a new SHA, so ancestry finds nothing. A tool trusting it would report a fully-merged board as entirely unmerged.
What works is three checks, first answer wins:
- Ancestry, against the remote base first, then the local one (an unpushed merge is only visible locally).
- The merged PR for that branch:
gh pr list --head <branch>, asked per branch. A board-wide query with a limit silently loses anything that merged beyond the newest N. - Patch IDs:
git cherry, which catches a rebase merge and needs no network at all.
If none of them can answer, the tool says unknown rather than unmerged. “I can’t tell” and “it hasn’t merged” are different facts, and only one of them justifies deleting things.
Memory that compounds, without the agent writing it
Most of my repos are long-running. The same context gets rediscovered every time an agent starts cold: where things live, which conventions aren’t written down, which command only works one particular way.
So each repo can keep a short, hand-curated file that gets folded into every task’s brief automatically. Injection is automatic; capture is not. Agents report what they learned in a ## Remember section at the end of their report, and I decide what’s worth keeping.
That asymmetry is deliberate. An agent has seen one task. “This surprised me” is a weaker claim than “this is true of the repo”. The gate is the same one every other change passes: a merge.
It’s also paid for on every single launch, so every line has to earn its tokens. A page is generous. Most repos will never have one, and that’s fine.
How it actually evolved
Nothing here was designed up front. The honest sequence:
- A shell alias that made a worktree. Saved me thirty seconds.
- A script, once I wanted the terminal workspace laid out the same way every time.
- Two scripts, because a quick task and a full one need different guardrails, which immediately became two copies of the same logic, drifting apart.
- A shared library, once I’d fixed the same bug twice in two places.
- A cleanup command, after I counted the stale directories.
- Three more sources in that cleanup command, each added the day something invisible turned out to be there.
- Account, model and effort as settings, once I was running work for different projects in the same afternoon.
Every step came from friction I actually hit, and none from imagining what a good system would have. I’d recommend that order to anyone: build the loop you need, one broken thing at a time. A harness designed in advance solves problems you don’t have and misses the one that’ll actually cost you an afternoon.
What it’s built on
None of this is bespoke, and all the pieces are public.
Herdr is the terminal workspace manager underneath everything: workspaces, tabs, panes and git worktrees, driven by a CLI complete enough that the agent in one pane can inspect the work in another. It’s the reason “one workspace per ticket” is a command instead of a habit I’d have abandoned by Thursday.
Matt Pocock’s skills, installed with /plugin install mattpocock-skills, cover spec and ticket flows, TDD, code review and domain modelling. I don’t use all of them, but reading how they’re written taught me more about scoping a task for an agent than any amount of prompt tinkering did.
herdr-reviewr is what lives in my review tab: a review pane for Herdr showing the agent’s diff beside the chat, with comments on a line or a range that get sent back to the agent. Leaving a note on line 40 without opening an editor is the difference between reading every diff and meaning to.
My own scoping, launch and cleanup commands are in DanielUlisses/ticket-skill. That’s the loop this post describes, in the state it’s actually in rather than a tidied-up version.
Copy any of it. It’s free, and the copying is the less useful half. My cleanup command looks in four places because of four specific things that went missing on my machine, and the four that bite you will be different ones. The parts worth taking are the ideas: a boundary you enforce with tooling instead of intention, a cleanup step that admits what it couldn’t see, settings you answer once, a merge check that says unknown when it doesn’t know. Fit those to the way you already work. A harness tuned to someone else’s workflow is just someone else’s friction.
The bit I’d tell you to steal
If you take one thing from this, take the boundary.
Decide where the human sits in your loop, make it the same place every time, and then enforce it with tooling rather than intention. For me that boundary is the commit: agents hand back unstaged work, I read the diff, I open the PR. Everything else in my setup, the worktrees, the tool blocks, the four-source cleanup, the verified account line, exists to protect that one boundary from erosion.
Put it somewhere else if it suits you. But put it somewhere, and let the harness hold it, because on the afternoon when three things are in flight and you’re context-switching between two projects, your intentions are the first thing to go.
Written by Daniel Ulisses
Senior DevOps Engineer & Cloud Architect. I write about cloud platforms, GitOps and bringing AI into DevOps workflows. Questions or feedback on this post? I'd love to hear it.