cd /news/developer-tools/several-agents-one-dirty-index-dispo… · home topics developer-tools article
[ARTICLE · art-73351] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

Several agents, one dirty index: disposable multi-repo workspaces for Claude Code

A developer built facet, a Go CLI tool that creates disposable multi-repo workspaces for Claude Code agents, solving the problem of multiple agents fighting over a single dirty git index. The tool reads GitHub issues to infer which repos are in scope, clones them, and writes a CLAUDE.md for agent context, then reaps the workspace when work is merged.

read12 min views4 publishedJul 25, 2026

Before facet existed, I was at work, locked into a Windows machine. I much prefer Linux, and the honest reason I run a Mac at home is that it keeps me from bricking my OS every two weeks. When you can't change the OS, you go tool-hunting instead, so I set out to make the terminal livable: first GlazeWM, a tiling window manager for people with i3 muscle memory, and through that rabbit hole, zellij. Rediscovering terminal multiplexing did something dangerous to my workflow. A new pane suddenly cost nothing, and I'm a skeptic by default, but agentic coding had just gotten usable enough that I started launching Claude sessions left and right. Soon I had a few running on different projects at once.

Some context on the shape of my work, because it explains the whole design: I'm a DevOps and infrastructure guy. My tasks are rarely one repo at a time. Publishing a new Helm chart means touching the Terraform repo and the Kubernetes manifests repo together, and I want them open side by side in the same VS Code window, not switched between. A piece of work, for me, is a small set of repos assembled around one task. Multiply that by a few tasks in flight, plus the agents, and you have the picture.

That's when the mess started. I tried git worktrees, on my own and with the agents. I'd open a codebase and be unsure which branch or which worktree I was looking at, and the agents were just as confused as I was. Several agents, or several people, working several issues will fight over one working tree: one branch, one dirty index. Worktrees were supposed to fix that. In my experience they moved the confusion around rather than removing it.

So I started building what I actually wanted: something like VS Code workspaces, but in the terminal. Then some git plumbing so ten workspaces over one repo don't cost ten clones. Then launching a Claude session straight into a fresh workspace, tied to a GitHub issue, disposable when the issue closes.

That last part is where I got it wrong. My first attempt wired zellij in, and the integration opened sessions and terminals almost at random, spawned Claude sessions with no name and no reference on disk, and when something crashed there was nothing to reattach to. It was a disaster. I lost a couple of sessions outright, work included. It took me a day of fighting, a Windows crash log, and eventually deleting about 1500 lines to understand why.

The tool is called facet. It's a small Go CLI, and this post is about the decisions behind it more than the features. One disclosure before we start: a good part of the later hardening work was done by Claude Code agents working through an issue backlog, with me reviewing and merging. Given what this post argues, that's not a footnote. It's the point.

A facet workspace is a directory containing checkouts of one or more repositories, described by a manifest. You spawn one from a GitHub issue: facet reads the issue, works out which repos are in scope, clones them, writes a CLAUDE.md

from the issue body so the agent starts with context, moves the issue to "In progress" on the project board, and stops. When the work is merged, you reap the workspace and it's gone.

The "which repos are in scope" part deserves a sentence, because it shaped a rule I'll come back to. Labels can't decide this. A label describes a topic, and the same topic label gets used across several repos. So facet infers a repo set from the issue (form fields, cross-references, blocked-by links) and then shows you the guess before doing anything. The inference is displayed, never silently trusted. You confirm or correct it. This sounds like a small UX choice. It turned out to be the same choice I would make, or unmake and remake, everywhere else in the tool.

There's a fun bug behind it too. A GitHub issue form with a checkboxes

field renders every option into the issue body, ticked or not. Tokenize that blindly and every unticked repo name resolves, and facet spawn

would happily clone the world. The fix is to parse the task-list syntax honestly and drop the [ ]

entries. An unticked checkbox is not a selection.

This is how I actually drive it day to day now. Two directories: ~/Projects

, the long-lived clones of everything I work on, and ~/Workspaces

, where facet builds. In ~/Workspaces

I keep a Claude session open whose only job is to run facet: read the incoming GitHub issues, do the inference, spawn the workspace. Then in another tab I cd into the fresh workspace, open Claude or my editor, and start working. The dispatcher itself is an agent now. That only works because every gate still holds: the inference is shown, the writes are confirmed, and everything that leaves a workspace goes through a PR with CI in front of it, in every repo. Nothing broken gets published just because an agent was fast.

Ten workspaces over the same repos shouldn't cost ten full clones. The first version solved this the obvious way: hardlink-clone straight from my projects directory, the live checkouts I already had on disk. And the workspaces worked, honestly too well. Spinning one up was so cheap that I ran more agents, and as soon as two workspaces shared a repo, with my own working clone as their common source, the contention I had built the tool to kill came back through the source. I gave worktrees a second chance at this point. They failed me a second time.

The fix was to remove the shared live thing entirely. facet now keeps a dedicated bare mirror per repository, a clone source that nobody, human or agent, ever works in, and clones from it with git clone --local

, which hardlinks the object files. A second workspace over the same repo costs its working tree and zero bytes of objects.

I chose hardlinks over the fancier options (--shared

, alternates) for a boring reason: an inode outlives the mirror's directory entry, so a repack or GC on either side can't corrupt anything. Correctness never depends on the mirror being fresh. Every clone's origin

still points at the forge, so if a mirror fetch fails, that's a warning, not a broken workspace.

This is also why facet shells out to real git

and gh

instead of using a pure-Go git library: credential helpers, SSH agents, Git-LFS, multi-account gh

auth, and decisively the --local

hardlink clone. None of that comes with go-git

. The cost is that half the codebase is subprocess calls and porcelain parsing. I'd make the same trade again.

Now the part I got wrong.

The original vision ended with you inside a running session. facet spawn 67

and you're in a multiplexer tab, in the workspace, agent running, ready. That was the whole promise: from issue to working session in seconds.

The first commit already shipped a zellij integration. Of course it did: zellij was the reason the sessions had multiplied in the first place, my daily driver on that Windows machine. Then, on the same day, the log reads like a small war:

action new-tab

, not by attaching a clientSeven multiplexer commits in one afternoon. The "never exec the agent directly" one has a crash log attached: on Windows, zellij's backend tried to CreateProcessW

the npm claude

shim, which is a #!/bin/sh

script, got os error 193

(not a valid Win32 application), panicked, and took the entire session down with every other tab in it. I also got to watch a session accumulate three tabs named #67

and two named workspace

, because my session-reuse logic and zellij's had different opinions about what already existed.

Somewhere in there I had also written a branch that switched your active session automatically, overriding what you'd asked for, because I "knew" where you wanted to be. I caught myself, gated it behind an explicit flag, and pinned it with a test literally named TestPlanNeverSwitchesUnlessAsked

.

By late afternoon the commit that mattered landed: set the workspace up and stop; you open it.

That could have been the end of it, but I kept the multiplexer code around for another week, defanged, opt-in behind --attach

. Then it came time to publish a v0, and I had a decision to make.

The multiplexer was the feature I'd spent the most hours on. It was also the least reliable code in the repo, the only part that behaved differently per platform, and the only part that had ever destroyed a user's running session, the user being me. A first public release carrying my most fragile code as its headline feature seemed like a bad trade.

So the v0 commit deletes internal/mux/

entirely. Minus 519 lines of mux.go

, minus 699 lines of its tests, minus the session command. Net, the release removed 1513 lines and added 203. I kept the code on a branch, because I wasn't done wanting it. But main

shipped without it, and the README got reframed around what the tool reliably does: set up, show its work, stop.

Cutting the feature you like most is not a natural act. I've watched releases held hostage to exactly this, and I nearly did it to myself on a project with one user. What made it possible was the branch: deletion wasn't destruction, it was sequencing.

Here's the pattern I only saw clearly after the deletion. Every fight I'd had, and every fix that survived, was the same fight:

git rev-list --count HEAD --branches --not --remotes

rather than @{u}..HEAD

, specifically so it catches a branch that was never pushed at all. The branch most easily lost.One sentence covers all of it: the tool must not act behind the operator's back.

That's an unfashionable thing to build right now. The current instinct, mine included, is to automate yourself out of the loop: agents that plan, execute, commit, and open the PR while you sleep. I kept trying to build that and the tool kept teaching me otherwise, one panicked session and one duplicated tab at a time. What actually made the multi-agent setup workable wasn't removing me. It was removing contention, and keeping every irreversible step (cloning, deleting, moving your session, writing to an issue) either visible, confirmable, or refused when the tool can't verify it's safe.

The agents, for what it's worth, work fine under this rule. The audit backlog that hardened v0.1.1 (sixteen issues, from lockfile races to a path traversal in the mirror layout) was worked mostly by Claude Code sessions spawned into facet workspaces, one issue each, while I reviewed the diffs and pressed merge. The tool that refuses to act behind my back is the same tool that lets me run a small fleet without losing track of it.

I still want the original ending, and I'm building it again, this time on tmux, at home on my lab's Mac Mini, which is where my sessions actually live these days. The design is one tmux session per issue, so tmux list-sessions

becomes the dashboard of running agents and attaching joins exactly the one you want. The zellij lessons carried over: the agent runs through a login shell, never exec'd directly, and a test forbids driving it via send-keys

, because that leaks into your shell history.

That branch is unmerged and in progress as I write this. The tmux attach works; the Windows fallback (Windows Terminal tabs) is honestly degraded, since its tabs can't be re-attached once closed, which is exactly the property tmux is wanted for. Remote control of running agents is designed in my head and not in the code. I'm saying that plainly because the whole post is about not pretending the tool does things it doesn't.

There's more under the surface I'll write about separately: hand-building Windows directory junctions through DeviceIoControl

so no Developer Mode is needed, a CommonMark fence tracker that exists as an injection defense against crafted issue bodies, and a heartbeat lockfile that survives long clones without the classic stale-timeout bug. Each of those is its own story.

facet is at v0.1.1, early, held together by a real test suite, used daily on my machines including Windows. If you run several agents against several repos and you've felt the one-dirty-index problem, try it and tell me where it breaks.

And if you take one thing from the arc: when you put agents to work, the valuable thing to automate away is the contention, not the operator. I deleted 1500 lines to learn that. The branch is still there, though. I wasn't done wanting it, I was done letting it act without me.

One last confession. My mantra has always been to build and publish the tools you'd actually use, and facet is exactly that, developed inside the very workflow it manages. The hidden cost of the mantra is that dogfooding makes it easy to skip the boring test-in-a-sandbox step. Between eagerness and hurry, I've lost more Claude sessions and more work building this tool than in anything else I've done, precisely because I test against the setup I'm actively working in. It happened again today, with an unreleased monitoring daemon on my tailnet. But that's another story.

         /\
        /  \
       / /\ \
      /_/  \_\        f a c e t
      \ \  / /
       \ \/ /         one task, many repositories
        \  /          one disposable view
         \/

Task-scoped workspaces over many git repositories. A workspace is a directory that assembles several repositories into one view for one task — and because its whole layout is declared in .workspace.json

, the directory is regenerable from the manifest. Nothing about it is precious.

That is the core, and it stands on its own: no GitHub, no issues, no agents — just a clean way to lay several repositories side by side and rebuild them anywhere. The issue-driven features further down grew on top of it and became mainstays, but the workspace is the thing.

~/Workspaces/
  delivery/               # a long-lived, topical workspace
    .workspace.json
    platform/             # a clone this workspace owns outright
    infra/
  iss-platform-67-…/      # an ephemeral workspace, one GitHub

── more in #developer-tools 4 stories · sorted by recency
── more on @facet 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/several-agents-one-d…] indexed:0 read:12min 2026-07-25 ·