Worktrees for parallel coding agents

One git worktree per task, side by side.

Give each coding agent its own lane: a reusable worktree that keeps its dependencies and build caches, held with a lease so no other session takes it, with shared things (the emulator, heavy builds, ports) handed out one at a time, and a check that the lanes still pass together before and after each merge.

  • No daemon, no database: git worktree list is the state
  • Windows, macOS, Linux
  • MIT
lane new
$ lane new dfk feat/core --sparse packages/core --link-deps
fetching dfk...
lane dfk-2 (new slot): branch feat/core from origin/develop, held for feat/core, lease eb9fc0
sparse: packages/core (and the files at the root)
dependencies: linked from dfk-1 (45216 files, hard links)

next: cd F:/demo/wt/dfk-2 && claude   (agents: lane brief dfk-2; when pushed: lane done --lease eb9fc0)
lane new takes a slot of the pool for a task: it locks the worktree, makes the task's branch, copies the env files and installs only when the lockfiles changed. Each held slot has its own agent and pushes its own pull request. Free slots keep their dependencies and caches. Lanes claim shared things such as the emulator and two build slots. When a pull request merges, lane verify, lane sync and lane clean check the other lanes on the new base and give merged slots back.
All lanes of a repository share one .git; each has its own files, dependencies and build output. The state is git worktree list itself: no daemon, no database. Open the diagram

What it handles

git worktree gives each task its own folder. The hard parts come after: installing again in every new folder, two agents on one emulator or one port, four builds at once running out of memory, and two branches that merge cleanly as text but break each other.

Reused slots

Numbered worktrees <repo>-<n> that come back to a pool. A task gets the slot it had before, else a free one; dependencies are installed again only when a lockfile changed, so a new task starts in seconds. In a big repository a slot can hold only the folders its task needs (--sparse), and npm dependencies can come as hard links from a lane on the same lockfile (--link-deps).

Holds with a lease

A slot is held with git worktree lock and read back with this hold's lease, so two sessions never take the same slot. lane done refuses to give back work that is not on the remote.

Shared things, one at a time

lane claim emulator waits for the Android emulator, lane with build -- <cmd> lets two heavy builds run at once, and lane port 3000 gives each slot its own range of ports.

Checked together

lane verify merges every open lane on top of the base and runs the checks before the first one merges. lane sync rebases the rest after a merge and checks them again.

Merges found offline

lane clean gives back the lanes whose work merged, squash merges included, even without asking GitHub. A lane still held for follow-up rounds stays.

Notes for the agent

Each lane gets a CLAUDE.local.md with these rules, and lane brief prints them for a sub-agent. Heavy git hooks such as pre-push wait for a build slot.

A round with two tasks

Two agents on one repository, from the first command to the slots given back.

$ lane new myrepo feat/a          # slot myrepo-1, branch feat/a
$ lane new myrepo feat/b          # slot myrepo-2, branch feat/b
$ lane list                       # held or free, branch, changes, PR and CI
$ lane verify myrepo              # before merging: do the two pass together?
# merge feat/a's pull request, then:
$ lane sync myrepo --push         # feat/b onto the new base, checked again, pushed
$ lane clean --yes                # merged slots back to the pool, main clone pulled

With junto

With junto each lane keeps its own task and quality gates, and /junto:status all shows every lane's task in one table (or a board with /junto:status html). junto warns when the base moved since a lane was verified, the same lesson lane sync acts on.

A real round, replayed

lane 0.10.1 on a copy of dynamic-field-kit (1,145 npm packages): two tasks, the second sparse with its dependencies linked from the first. 37 seconds of real time; every line is what the commands printed.

Press Play to watch the round.

$ lane new dfk feat/formsfetching dfk...lane dfk-1 (new slot): branch feat/forms from origin/develop, held for feat/forms, lease c9a58edependencies: no other lane installed from this lockfile yet> npm ci --no-audit --no-fundnpm warn deprecated @ngtools/webpack@22.2.5: Angular's Webpack support is deprecated. Use the esbuild and Vite-based "@angular/build" package instead.npm warn deprecated @angular-devkit/build-angular@22.2.5: Angular's Webpack support is deprecated. Use the esbuild and Vite-based "@angular/build" package instead.> prepare> husky && git config commit.template .git-commit-template.txt || trueadded 1145 packages in 14snpm warn install-scripts 5 packages have install scripts not yet covered by allowScripts:npm warn install-scripts   @parcel/watcher@2.6.0 (install: node scripts/build-from-source.js)npm warn install-scripts   esbuild@0.28.2 (postinstall: node install.js)npm warn install-scripts   lmdb@3.5.6 (install: node-gyp-build-optional-packages)npm warn install-scripts   msgpackr-extract@3.0.4 (install: node-gyp-build-optional-packages)npm warn install-scripts   unrs-resolver@1.12.2 (postinstall: node postinstall.js)npm warn install-scriptsnpm warn install-scripts Run `npm install-scripts ls` to review, or `npm install-scripts approve <pkg>` to allow.hooks: pre-push wait for a build slot in this lanenext: cd F:/demo/wt/dfk-1 && claude   (agents: lane brief dfk-1; when pushed: lane done --lease c9a58e)$ lane new dfk feat/core --sparse packages/core --link-depsfetching dfk...lane dfk-2 (new slot): branch feat/core from origin/develop, held for feat/core, lease eb9fc0sparse: packages/core (and the files at the root)dependencies: linked from dfk-1 (45216 files, hard links)next: cd F:/demo/wt/dfk-2 && claude   (agents: lane brief dfk-2; when pushed: lane done --lease eb9fc0)# the two agents work and push$ lane list --locallane   state                    branch                             local                  PR / CIdfk-1  held: feat/forms, 1 min  feat/forms                         0 changed, 0 unpushed  -dfk-2  held: feat/core, 0 min   feat/core (sparse: packages/core)  0 changed, 0 unpushed  -$ lane verify dfkfetching dfk...verifying in dfk-3lane   branch      on origin/developdfk-1  feat/forms  mergeddfk-2  feat/core   mergeddependencies: linked from dfk-1 (45276 files, hard links, 4 node_modules folders)> npx vitest run --root packages/core   (in dfk-3) RUN  v5.0.5 F:/demo/wt/dfk-3/packages/core Test Files  26 passed (26)      Tests  395 passed (395)   Start at  22:33:32   Duration  1.67s (transform 52%, import 20%, environment 17%, tests 7%, worker 4%)2 lane(s) together on origin/develop: checks passgave back dfk-3 (was: verify)# you merge the pull request of feat/forms$ lane sync dfk --pushfetching dfk...> npx vitest run --root packages/core   (in dfk-2) RUN  v5.0.5 F:/demo/wt/dfk-2/packages/core Test Files  26 passed (26)      Tests  395 passed (395)   Start at  22:33:37   Duration  1.56s (transform 53%, import 20%, environment 16%, tests 8%, worker 3%)lane   onto origin/developdfk-1  skipped: work already in origin/develop (lane clean gives it back)dfk-2  rebased, checks pass on its folders only (lane verify checks the whole), pushed$ lane clean --yes --forcefetching dfk...give back  dfk-1  (no PR, work already in origin/develop)keep       dfk-2  (no PR)gave back dfk-1 (was: feat/forms); local branch feat/forms deleted (its work is on the remote or merged)dfk: main clone pulled to 6d713a5 feat(react): validate on blur$ lane list --locallane   state                    branch                             local                  PR / CIdfk-1  free (last: feat/forms)  (detached)                         0 changed              -dfk-2  held: feat/core, 1 min   feat/core (sparse: packages/core)  0 changed, 0 unpushed  -dfk-3  free (last: verify)      (detached)                         0 changed              -
  1. Two slots: one whole, one sparse with linked dependencies
  2. The agents work and push
  3. Checked together before any merge
  4. You merge one pull request
  5. The other lane rebased and checked again
  6. The merged slot goes back to the pool
Slots held
1
Tests passed
790
First lane: npm ci
16 s
Second lane: linked
6 s, 45,216 files

A recording, not a live system. The origin was a local copy, so "you merge" was a push of feat/forms into develop there. Recorded with replay/record.sh; the raw output is replay/lane.jsonl.

Commands

lane new <repo> <task> Take a slot for a task: new branch, submodules, env files, install when lockfiles changed, notes for the agent. --pr <n> works on a pull request's branch; --sparse <folder> checks out only those folders; --pool review keeps reviews in slots of their own.
lane done [<lane>] Give the slot back once its work is pushed or merged.
lane list Every lane: held or free, branch, local changes, pull request and CI.
lane verify <repo> · lane sync <repo> All open lanes merged together and checked; after a merge, the rest rebased and checked again.
lane clean · lane free · lane rm Give back merged lanes, free a hold left behind by a closed session, delete a slot for good.
lane claim · lane with · lane port The emulator for one lane at a time, a cap on heavy builds, a range of ports per slot.
lane brief The rules for an agent working in a lane, to paste into its task.

Get started

Built and used daily on Windows; CI runs it on Windows, Ubuntu and macOS.

Prerequisites

  • Node 22.18 or newer: lane is TypeScript that Node runs as is, with no build step.
  • git
  • gh, logged in, for pull request and CI state (optional).

Install from a clone

Node does not run .ts files inside node_modules, so lane is not on npm.

$ git clone https://github.com/vannt-dev/lane
$ cd lane && npm install && npm link

Tell lane where things are

LANE_REPOS holds your clones, LANE_ROOT the slots. Both are required.

# Windows
$ setx LANE_REPOS D:/code
$ setx LANE_ROOT D:/wt
# macOS / Linux (shell profile)
export LANE_REPOS="$HOME/code"
export LANE_ROOT="$HOME/wt"

Limits