Parallel Claude Code Sessions, Part 2

Davy Cardinaal ·

Nine months ago I wrote about running multiple Claude Code sessions in parallel with git worktrees. Two Ruby scripts in bin/, a port per worktree, a database suffix per worktree, and suddenly three Claude sessions could work on three features at the same time. That post is still the most-read thing on this blog, and the setup still works.

But I have been using it every day since, across every project I work on, and the cracks started to show. This post is about what I replaced it with: three small commands, worktree, proxy and awake, that live outside my projects and do the same job for all of them.

Where the first version started to hurt

The original scripts were good at creating a worktree. They were less good at being shared.

Every project had its own copy. bin/worktree and bin/worktree_rm lived in each repository. Whenever I improved one, I had four other copies that didn't get the improvement. Within a few weeks no two projects behaved the same.

Ports are not names. Terminal 2 ran on port 3001 and terminal 3 on 3002. Which one was the authentication branch again? After a day with five worktrees across two projects, localhost:3004 tells you nothing.

Cookies ignore the port. Browsers scope cookies to a host, not to a host and port. That is how cookies work, not a Rails quirk. So localhost:3000 and localhost:3001 share one session cookie: log in on one worktree, and the other one logs you out. Mail links with localhost:3002 in them had the same problem.

The database suffix leaked everywhere. myapp_development_feature_auth meant database.yml had to derive a name from the directory, the test databases needed the same trick, and the removal script had to search for every database that matched a pattern. It worked, but it was a lot of code for a thing Docker solves in one line.

The Mac went to sleep. Once Claude Code could work through an issue autonomously, a run could take an hour. Halfway through, macOS put the laptop to sleep and the session stalled. A loose caffeinate in a terminal fixed that, until I forgot about it and found it still running two days later.

None of these are hard problems. They were just the wrong problems to solve per project.

Claude Code has since grown a --worktree flag of its own, and if all you need is an isolated checkout per session, that is the place to start. It creates the git worktree. Everything below is about what a web application needs on top of that: a port, a hostname, a database and an environment that are the worktree's own.

The new shape: the harness owns the lifecycle

Claude Code reads its user-level configuration from ~/.claude. I keep that directory in git, and I started calling it my harness: the rules, hooks and skills that apply to every project. The three commands live there too, in ~/.claude/bin, and a small installer links them into /usr/local/bin:

~/.claude/bin/install
# links worktree, awake and proxy into /usr/local/bin

From that point on, a project contributes nothing but the Rails-conventional pieces it already has:

In the project

Role

bin/setup --skip-server

Prepares a checkout: gems, databases. Idempotent. Knows nothing about ports

bin/dev

Starts the server on whatever port and hostname it is handed

compose.yml

PostgreSQL only: publishes the database on a port from the environment

.worktreeinclude

Optional: gitignored files a new worktree needs, such as config/master.key

No bin/worktree in the repository. No database suffix. No port range to maintain. When I improve the harness, every project gets it the next time I type worktree new.

worktree: one issue, one worktree, one command

The command has five subcommands and they map to the lifecycle you would expect:

worktree new 42-password-reset   # create, prepare, open a Claude session in it
worktree open 42-password-reset # open a session in an existing worktree
worktree cd 42-password-reset # open a shell in the worktree, environment loaded
worktree ls # what this clone has, and what each one holds
worktree rm 42-password-reset # worktree, branch, databases, hostname: gone

Most of the time I don't type the name at all. open, cd and rm without a name show a menu of the worktrees of this clone, with what each one holds, and you pick with the arrow keys:

$ worktree cd
Which worktree? (arrows move, enter chooses, q cancels)
> 42-password-reset session running
57-csv-export clean
61-admin-search 2 uncommitted changes

For rm the same menu lets you tick several with the space bar, or all of them with a. And when worktree cd is not talking to a terminal, it prints the path instead of opening a shell, so a script can do cd "$(worktree cd 42-password-reset)".

A name is kebab-case and names the branch too. The leading number is the GitHub issue, and one issue gets one worktree. That convention sounds small, but it is what makes everything else possible: the hostname, the Compose project and the pull request all derive from it.

Worktrees no longer live in sibling directories (../myapp-feature-auth). They live inside the repository, in .claude/worktrees/<name>, which the harness keeps out of git. Opening a project in the editor shows me its worktrees, and git worktree list stays readable.

What new does

$ worktree new 42-password-reset --no-session
== Fetching origin ==

== Creating 42-password-reset ==
Branching 42-password-reset off origin/main.

== Copying 2 file(s) from .worktreeinclude ==
config/master.key
config/credentials/development.key

Wrote PORT, APP_HOST, COMPOSE_PROJECT_NAME, PGHOST, PGPORT, PGUSER to .envrc.

== Preparing 42-password-reset ==
== Starting database ==
== Preparing database ==
...

== 42-password-reset is ready ==
/Users/davy/Development/myapp/.claude/worktrees/42-password-reset
http://myapp-42.localhost
http://myapp-42.localhost:3003
PostgreSQL on 127.0.0.1:13003 in the Compose project myapp-42-password-reset

In order:

  1. Checks out the branch. 42-password-reset when this clone or origin already has it, otherwise a fresh branch off the default branch. I usually start the branch in the worktree, but after a review round the branch exists and worktree new just picks it up.
  2. Claims a hostname and a port through proxy claim. More on that below.
  3. Writes .envrc in the worktree, with everything the worktree needs to be itself.
  4. Copies the gitignored files listed in .worktreeinclude: credentials keys, maybe an SQLite file to start from the main checkout's data.
  5. Runs the project's own bin/setup with that environment.
  6. Opens a Claude Code session in the worktree, unless --no-session says to leave it ready.

.envrc and direnv

The environment is the part I am happiest with. Every worktree gets one small file:

# Written by worktree new; direnv loads it in this worktree.
source_up_if_exists
export PORT=3003
export APP_HOST=myapp-42.localhost
export COMPOSE_PROJECT_NAME=myapp-42-password-reset
export PGHOST=127.0.0.1
export PGPORT=13003
export PGUSER=postgres

direnv loads it into every process that runs inside the worktree: the Rails server, bin/setup, a psql you open by hand, the test suite. worktree new, open and cd all start through direnv exec, so a Claude session in the worktree has the right environment before it runs its first command. And source_up_if_exists first loads the nearest .envrc above the worktree, so anything I set for a whole project in the directory that holds the clones (a CLAUDE_CONFIG_DIR, say) reaches the worktree just as it reaches the main checkout.

Nothing in the project knows about any of this. rails server reads PORT. database.yml reads PGPORT. Docker Compose reads COMPOSE_PROJECT_NAME. The harness sets the variables and the conventions do the rest.

Databases: a server per worktree instead of a name per worktree

This is where the first version had the most code and the new version has almost none. With PostgreSQL, the project's compose.yml publishes the database on a port from the environment:

name: myapp

services:
postgres:
image: postgres:18
environment:
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- "127.0.0.1:${PGPORT:-5432}:5432"
volumes:
- postgres:/var/lib/postgresql
healthcheck:
test: pg_isready --username postgres
interval: 2s
timeout: 5s
retries: 15

volumes:
postgres:

And database.yml connects to that port:

development:
<<: *default
database: myapp_development
host: 127.0.0.1
port: <%= ENV.fetch("PGPORT", 5432) %>
username: postgres

Because the worktree's .envrc sets COMPOSE_PROJECT_NAME=myapp-42-password-reset and PGPORT=13003 (the web port plus 10000), the docker compose up in bin/setup starts a PostgreSQL container of the worktree's own, with its own volume, on its own port. The database is still called myapp_development. Migrations, seeds, the parallel test databases: everything keeps its normal name, and docker compose ls shows one project per worktree.

Removing a worktree is then docker compose down --volumes, and the data goes with it. No DROP DATABASE loop, no pattern matching on database names.

SQLite projects need none of this. The database files live in the worktree, and .worktreeinclude can copy storage/development.sqlite3 so a new worktree starts with the main checkout's data.

ls and rm

$ worktree ls
42-password-reset [42-password-reset] session running
57-csv-export [57-csv-export] clean
61-admin-search [61-admin-search] 2 uncommitted changes, 3 commits no remote has

worktree rm takes the Compose project down with its volumes, removes the worktree and its branch, and releases the hostname. It refuses a worktree a session is still standing in, and, unless you pass --force, one with uncommitted changes or commits that the default branch does not have yet. That last check has saved me more than once.

proxy: myapp-42.localhost instead of localhost:3003

This is the change I notice most during the day. Every checkout answers on a hostname without a port:

  • http://myapp.localhost for the main checkout
  • http://myapp-42.localhost for the worktree of issue 42
  • http://shop.localhost and http://shop-118.localhost for another project

The .localhost top-level domain resolves to loopback by definition, so there is no /etc/hosts to edit and no DNS to run. Behind the hostnames sits one Traefik container on 127.0.0.1:80, started with proxy up from a Compose file in ~/.claude/proxy. Port 80 can be held once per machine, which is exactly why the proxy belongs in the harness and not in a project.

Two things fall out of this. Cookies are scoped per hostname, so every worktree has its own session and I can be logged in to five of them at once. And a multi-tenant application that picks its tenant from the host works too: every subdomain of myapp-42.localhost routes to the same worktree, so acme.myapp-42.localhost just works.

The route files are the port registry

Traefik watches a directory of route files, and proxy claim writes one file per port:

$ ls ~/.claude/proxy/routes
3000.yml 3001.yml 3002.yml 3003.yml
# 3003.yml, written by proxy claim
http:
routers:
myapp-42:
rule: 'Host(`myapp-42.localhost`) || HostRegexp(`^.+\.myapp-42\.localhost$`)'
entryPoints: [web]
service: myapp-42
services:
myapp-42:
loadBalancer:
servers:
- url: "http://host.docker.internal:3003"

That directory is the whole state. The file name is the port, so no two registrations can claim the same one. proxy claim gives a hostname that already has a port the same port again, and otherwise takes the first port from 3000 up that nothing has claimed and nothing listens on. proxy release deletes the file. There is no database and no daemon besides Traefik itself, and git worktree cannot get out of sync with it, because the worktree commands are the ones writing the files.

What a project does: three lines in bin/dev

#!/usr/bin/env sh
export PORT="${PORT:-$(proxy claim)}"
export APP_HOST="${APP_HOST:-$(proxy host)}"
proxy up || echo "bin/dev: no proxy, use http://$APP_HOST:$PORT" >&2

exec ./bin/rails server "$@"

In a worktree, PORT and APP_HOST are already set by .envrc, so the claim is a no-op. In the main checkout, the first bin/dev claims myapp.localhost and a port, and keeps them from then on. proxy up starts Traefik when it is not running, and if Docker is down or port 80 is taken it only warns: http://myapp-42.localhost:3003 keeps working, the hostname is just a convenience on top of the port.

The one Rails change is mail. Links in development mail should carry the hostname, so that a password reset link opens the right worktree, logged in:

# config/environments/development.rb
config.action_mailer.default_url_options = {
host: ENV.fetch("APP_HOST") { "localhost:#{ENV.fetch("PORT", 3000)}" }
}

Rails allows .localhost in config.hosts by default in development, so there is nothing to add there.

proxy ls: what is running right now

This is the command I run when I come back from lunch:

$ proxy ls
HOST PORT NAME ROOT STATE
myapp.localhost 3000 myapp ~/Development/myapp up
myapp-42.localhost 3003 42-password-reset ~/Development/myapp/.claude/worktrees/42-... up
myapp-57.localhost 3004 57-csv-export ~/Development/myapp/.claude/worktrees/57-... down
shop.localhost 3001 shop ~/Development/shop down
shop-118.localhost 3002 118-checkout-flow ~/Development/shop/.claude/worktrees/118-... gone

up means something listens on the port, down means the registration exists but nothing serves, and gone means the checkout directory no longer exists: a worktree removed with git worktree remove instead of worktree rm. proxy sweep releases those. And --json turns the table into something jq can chew on:

proxy ls --json | jq -r '.[] | select(.state == "gone") | .host'

One table, every project, every worktree, and whether it is alive. The first version could not have answered that question without opening five terminals.

awake: long runs without a sleeping Mac

The third command is the smallest and the one I would least like to lose. macOS ships caffeinate, which keeps the machine awake while it runs. The problem is not starting it, it is keeping track of it. A caffeinate started by a script outlives its session when the step that kills it is never reached, and ps cannot tell you what it was for.

awake wraps it with a name and a session:

awake on "resolve issue 42"   # caffeinate under that name, tied to this session
awake ls # every caffeinate on the machine, with age, session and name
awake off "resolve issue 42" # or: awake off <pid>
$ awake ls
PID AGE SESSION NAME
76246 01:12:45 7388 ~/Development/myapp/.claude/worktrees/42-... resolve issue 42
31306 00:01:07 11001 (this) ~/Development/shop caffeinate -i -t 300

The second line is Claude Code's own short caffeinate for a tool call; the first is mine. awake on finds the nearest claude process (or the shell, or a pid you name) and ends when that session ends, so a forgotten awake cannot outlive the work. ls shows a session that is gone as gone, and off takes it down.

Where this pays off is autonomous runs. I have a skill that takes one or more GitHub issues, makes a worktree for each, implements, tests and opens the pull request, without me at the keyboard. The first thing it does is awake on "resolve-issues 86 88"; the last thing is awake off by the same name. My global CLAUDE.md says the same for anything that runs unattended: a subagent fleet, a full test suite, a CI run. The Mac stays awake exactly as long as the work takes, and awake ls tells me from any terminal what is keeping it up.

A day with the new setup

The workflow from the first post, nine months on:

# Terminal 1: the main checkout, serving the stable branch
cd ~/Development/myapp
bin/dev # http://myapp.localhost

# Terminal 2: a worktree for issue 42, Claude session included
worktree new 42-password-reset # http://myapp-42.localhost

# Terminal 3: the same for issue 118, on another project
cd ~/Development/shop
worktree new 118-checkout-flow # http://shop-118.localhost

# Somewhere in between
proxy ls # what is up
worktree ls # what each worktree holds
awake ls # what keeps the Mac awake

# Back to a worktree after lunch
worktree open # pick one from the menu, Claude session included
worktree cd # or just a shell in it, environment loaded

# After the merge
worktree rm # tick the ones to remove: compose down --volumes, branch gone, hostname released

Each session sees only its own worktree. Each worktree has its own hostname, its own cookies, its own database server and its own environment. And none of it is project-specific: shop and myapp run the same three commands from the same place.

What I would do differently from the first post

Looking back, the first version solved the right problem in the wrong place. Port allocation, database isolation and cleanup are not properties of a project; they are properties of the machine the projects run on. Moving them out of bin/ and into one harness did three things:

  • One implementation. A fix lands once and every project has it.
  • Conventions instead of configuration. The project reads PORT, PGPORT and COMPOSE_PROJECT_NAME, which it would read anyway. The harness sets them.
  • Visibility. proxy ls, worktree ls and awake ls answer "what is running" across everything, which five copies of a script never could.

The scripts themselves are a few hundred lines of Ruby on the system Ruby with the standard library only, with a test file beside each one, and they are more opinionated than the ones I shared last time: they assume Rails conventions, Docker Desktop, direnv and an issue number at the start of every branch. I am not publishing them in this post, because they are tied to how my harness works as a whole. If you want to build the same thing for your own setup, or you would like to see the code, get in touch. I am happy to walk through it.