{"slug": "isolation-file-ownership-and-cleanup-the-boring-half-of-running-coding-agents-in", "title": "Isolation, file ownership and cleanup: the boring half of running coding agents in parallel on Windows", "summary": "Alex Shev's comment inspired a NestMux developer to detail the practical challenges of running coding agents in parallel on Windows, focusing on isolation, file ownership, and cleanup. The post explains how to properly redirect home directories for separate agent accounts, the pitfalls of hardlinks versus symlinks for shared configs, and why git worktree removal fails with misleading permission errors on Windows. The developer shares specific commands and code snippets to address these issues.", "body_md": "Someone left a comment on my last post that was a better outline than the post was:\n\nParallel agents are only practical when the workspace boundaries are boring and explicit. On Windows especially, I would care less about the launch trick and more about isolation, logs, file ownership, and cleanup after failed runs.\n\nThat is Alex Shev, and he is right. Four agents in a pane grid is the screenshot. The four things he listed are what decide whether you are still using the setup in a month.\n\nI work on NestMux, so I have a stake in this. Most of what is below is plain Windows and plain git, and the commands run the same whether or not you use it. Where I describe a decision we made, I say so, and I say where it still falls short.\n\nTwo agents running under the same Windows account share `~/.claude`\n\n, `~/.codex`\n\n, `~/.gemini`\n\n. Same config, and more importantly the same authenticated session. If you want two Claude accounts side by side, they need separate home directories.\n\nOn Windows, `HOME`\n\nis not the variable that gets you there. It is a POSIX convention that some tools honor and Windows itself does not. Set only `HOME`\n\nand you get a half-redirected agent: the CLI writes its config to the new location while git writes `.gitconfig`\n\nto the old one, and you will not notice until two panes start sharing a git identity. What you actually need per process:\n\n```\nHOME       = <accountDir>\nUSERPROFILE= <accountDir>\nHOMEDRIVE  = C:\nHOMEPATH   = \\path\\to\\accountDir\n```\n\nGemini CLI wants `GEMINI_CLI_HOME`\n\npointing at its own subdirectory on top of that.\n\nOne trap if you are building the launcher rather than using one: Node's `os.homedir()`\n\ndoes not reliably reflect a `USERPROFILE`\n\nyou injected at spawn time on Windows. If your app reads `homedir()`\n\nto decide where its own storage lives, and it also redirects `USERPROFILE`\n\nfor child processes, those two will disagree. We ended up with a dedicated env var for storage and a separate function for \"where a new shell should start\", because collapsing them meant every new terminal opened inside the agent's config directory.\n\nFull isolation is not what anyone wants. Your `CLAUDE.md`\n\n, your `settings.json`\n\nand your skills should be the same in every pane. So they get linked back to the global copy instead of duplicated.\n\nOn Windows that link is more annoying than it sounds. `mklink`\n\nfor a file symlink needs elevation or Developer Mode. Junctions do not, so directories are fine. For files the fallback is a hardlink.\n\nThat fallback has a sting. A hardlink is not a symlink, so `lstat().isSymbolicLink()`\n\nreturns false for it. If you later offer a \"detach this account from the shared config\" button and implement it as \"replace symlinks with real copies\", the hardlinked files are skipped, and the account goes on silently editing your global config while the UI says it is detached. Detecting them means comparing the file id:\n\n``` js\nconst a = lstatSync(src,  { bigint: true })\nconst b = lstatSync(dest, { bigint: true })\nconst sameFile = a.dev === b.dev && a.ino === b.ino && a.ino !== 0n\n```\n\n`bigint: true`\n\nmatters. The regular `ino`\n\ncomes back as `0`\n\non some Windows configurations, which makes every pair of files look identical.\n\nThis is the one that generates support questions, and the error message actively points the wrong way.\n\nSet up a repo with a worktree, then put a process inside it with a real working directory:\n\n```\ngit -C C:\\dev\\repo worktree add C:\\dev\\feat -b feat\nStart-Process cmd -WorkingDirectory C:\\dev\\feat -ArgumentList '/c','timeout /t 60'\ngit -C C:\\dev\\repo worktree remove C:\\dev\\feat --force\nerror: failed to delete 'C:/dev/feat': Permission denied\n```\n\nPermissions have nothing to do with it. Windows will not delete a directory that is some process's current directory, and git reports the refusal as a permissions error. On Linux the same removal succeeds, which is why this tends to reach Windows users first as a bug report nobody can reproduce.\n\nTwo things I only found by trying to write a reliable teardown:\n\n**PowerShell's Set-Location does not reproduce it.** The PowerShell location is a provider concept layered on top of the process. The underlying working directory stays where the process started. So a\n\n`Start-Process powershell -Command \"Set-Location C:\\dev\\feat; ...\"`\n\nwill let the delete go through, and you will conclude the problem is not real. Use `-WorkingDirectory`\n\n, or `cmd`\n\n, or anything that sets the actual cwd.**Killing the shell is not enough.** In the run above I killed the `cmd.exe`\n\nand the delete still failed. The holder was `timeout.exe`\n\n, a child that inherited the working directory and outlived its parent. You need the process tree, not the process.\n\nThe practical consequence for anything that manages worktrees: before you touch the filesystem, you have to know which panes are sitting inside the directory. That means recording each pane's cwd at spawn time and killing by path prefix, normalized, because on Windows the same worktree shows up as both `C:\\dev\\feat`\n\nand `C:/dev/feat`\n\ndepending on who wrote the path.\n\nHere is the part I did not expect. A failed `git worktree remove`\n\nis not a no-op. Continuing from the failure above:\n\n```\n> git -C C:\\dev\\repo worktree list\nC:/dev/repo  39ca293 [master]\n\n> dir C:\\dev\\feat\n(empty)\n\n> git -C C:\\dev\\repo worktree remove C:\\dev\\feat --force\nfatal: 'C:\\dev\\feat' is not a working tree\n\n> git -C C:\\dev\\repo worktree prune -v\n(nothing)\n```\n\nGit deleted the worktree contents, deleted the administrative directory under `.git/worktrees`\n\n, and dropped the entry from `git worktree list`\n\n. Then it hit the locked top-level directory and stopped. What survives is an empty folder that git no longer recognizes, will not remove, and does not consider dangling. The branch is still there. `prune`\n\nhas nothing to prune because the metadata is already gone.\n\nSo the recovery is manual and the order matters:\n\n`git worktree prune`\n\nafterwards, for the case where git did not get as far as clearing its own metadata.Prune first and you can be pruning metadata that still references the directory you are about to delete.\n\nThe rule we ended up with in the app, which cost us a bug to learn: if either of those steps fails, keep the entry and report the failure. The tempting version is to drop your own record and call it removed, since the user asked for it to be gone. Then the next refresh reads `git worktree list`\n\n, or the leftover directory, and the worktree reappears looking healthy. Now it cannot be removed through the UI at all, because the code path for removing it assumes the state it just lost. A partial delete reported as success is worse than an error message.\n\nTwo smaller things in the same area:\n\n**Reconcile on read.** People delete worktrees outside your app, with `rm -rf`\n\nor plain git. If your list comes from your own store, entries go stale. Compare against `git worktree list`\n\non every listing, mark what git no longer knows about, and drop entries whose directory is gone from disk.\n\n**Never create a worktree inside .git.** An early version of ours put some under\n\n`.git/worktrees`\n\n, which is git's own metadata folder. Git will create it, then refuse to treat it as a working tree, so `git worktree remove`\n\nfails permanently and the only exit is manual deletion. If you build paths from a repo path plus a branch name, check where you are about to land.Most parallel-agent setups run something after creating a worktree: `npm install`\n\n, a `.env`\n\ncopy, a build. That is a place where failures get swallowed.\n\nThree things worth having, none of them clever:\n\n`npm install`\n\nis writing into, which puts you right back in the previous section.`TOKEN=...`\n\nlines from an env dump or a verbose install. If you persist those logs, strip them on the way in, not on the way out.This is where the comment landed hardest and where I have the least to show.\n\nWhat exists in NestMux today is a per-pane transcript you can save and export as markdown, and a setup log per worktree capped at 200 lines with secrets redacted. What does not exist is anything unified: one timeline across panes, with timestamps, exit codes, and which worktree each line belonged to.\n\nThat is exactly the artifact you need in the case the comment described. A run failed overnight, four agents were working, and the question is which one touched what, and in what order. A transcript per pane makes you reconstruct that by hand from four scrollbacks.\n\nI do not have a shipping date for it. I am writing it down as a gap rather than a plan, because the honest state is that pane-level transcripts were easy and a cross-pane log with correct attribution is not, particularly when panes come and go.\n\nThe launch trick genuinely is the easy part. Everything expensive is in teardown: who holds the handle, what state a failed delete leaves behind, and whether your own record of the world still matches the disk afterward. On Windows that is a different set of failures than on Linux, and the error messages are worse.\n\nIf you run agents in parallel and you have a teardown that survives a failed run, or a logging setup that actually answers \"which agent did this\", I would like to see it. Especially if it argues that the whole thing should be a script rather than an application.", "url": "https://wpnews.pro/news/isolation-file-ownership-and-cleanup-the-boring-half-of-running-coding-agents-in", "canonical_source": "https://dev.to/eliseomdq/isolation-file-ownership-and-cleanup-the-boring-half-of-running-coding-agents-in-parallel-on-1n0f", "published_at": "2026-08-23 22:50:34+00:00", "updated_at": "2026-08-23 23:14:26.959021+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents", "ai-tools"], "entities": ["NestMux", "Alex Shev", "Claude", "Codex", "Gemini", "Windows", "git"], "alternates": {"html": "https://wpnews.pro/news/isolation-file-ownership-and-cleanup-the-boring-half-of-running-coding-agents-in", "markdown": "https://wpnews.pro/news/isolation-file-ownership-and-cleanup-the-boring-half-of-running-coding-agents-in.md", "text": "https://wpnews.pro/news/isolation-file-ownership-and-cleanup-the-boring-half-of-running-coding-agents-in.txt", "jsonld": "https://wpnews.pro/news/isolation-file-ownership-and-cleanup-the-boring-half-of-running-coding-agents-in.jsonld"}}