Local worktrees: many workers on one machine
cr-worker runs each worker as a headless agent session in its own git
worktree, on its own epic branch, with its profile in the environment.
cd path/to/repo # any worktree of the project
cr-worker new backend proj-12 login # worktree + branch claude/proj-12-login
cr-worker start backend # the session, with the opening message
cr-worker brief backend brief.md # the brief, as the next user message
cr-worker brief backend answer.md # later: an answer, a question, the next brief
cr-worker status backend # epic, branch, in a turn or not, last line
new <profile> <epic> <slug>fetches the integration branch and makes a worktree at$CR_WORKTREES/<profile>(default<repo>/../<repo>-workers/) on<prefix><epic>-<slug>fromorigin/<integration>. The branch does not track the integration branch, so a baregit pushcannot land there. The profile, the epic, the branch and a session id go into the worktree’s own git directory (.git/worktrees/<profile>/cr-worker/), where no commit can pick them up. For the worker’s next epic,newagain switches the same worktree to the new branch, if it is clean, and keeps the session.start <profile>runs the agent in the worktree with the profile variable set, and sends the opening message (opening.md, filled in) as the first turn of a new session. The worker answersWORKER <profile> READY.brief <profile> <file>sends the file as the next user message in that session. A file that does not startEPIC <id>: branch ...still goes through, with a warning: the worker will treat it as a question.-
status <profile>prints the worker’s epic and branch, whether it is in a turn (not started,idle, orin a turn since <time> (pid <n>)), and the last line it printed:profile: backend epic: proj-12 branch: claude/proj-12-login turn: idle last: EPIC proj-12 READY <sha> <pr-url> path <profile>prints the worktree;env <profile>printsexport CR_PROFILE=<profile>for a shell of your own (eval "$(cr-worker env backend)").
Everything the agent prints is shown and appended to
.git/worktrees/<profile>/cr-worker/log.
One turn at a time. start and brief refuse, with exit status 75 and
nothing sent, while the worker is still in a turn: a second message would
resume the same session twice at once. new refuses too, rather than switch
the branch under a working agent. The turn is marked by
.git/worktrees/<profile>/cr-worker/turn/, which holds cr-worker’s pid while
the agent runs. It is removed when the turn ends, however it ends. A marker
whose process is gone (a killed machine) is stale and taken over.
From a coordinator loop
A scheduled coordinator (docs/coordinator-loop.md)
must not wait for a worker’s turn, which can take an hour. It starts the brief
in the background, with the output kept in the worker’s log:
nohup cr-worker brief docs brief.md >/dev/null 2>&1 &
and on its next run reads cr-worker status docs beside the branch, the
handoff and the pull request. Exit 75 from brief means the worker is in a
turn: the loop tries on its next run. The loop’s landings and full checks run
under scripts/land-lock (the landing lock, and the heavy-job lock below).
How the session works (Claude Code)
start runs, in the worktree:
claude -p "<opening>" --session-id <uuid>
and each brief runs:
claude -p "<brief>" --resume <uuid>
Each call is one headless turn: it returns when the worker ends its turn, with
its last line (WORKER … READY, EPIC … READY …, EPIC … BLOCKED …).
Verified on Claude Code 2.1.282, on 2026-09-24:
claude --helplists--session-id <uuid>(“must be a valid UUID”) and-r, --resume;- the headless documentation shows
claude -p "..." --resume "$session_id"continuing a conversation, and says the session is found by id in any project on the machine (https://code.claude.com/docs/en/headless); - a real run:
claude -p … --session-id <uuid>answeredWORKER docs READYafter readingCR_PROFILEwith a shell command; thenclaude -p "<brief>" --resume <uuid>answered with its previous reply, under the samesession_id. The brief arrived as a user message in the same conversation.
Permissions. A headless session cannot ask anyone to approve a tool. Give
it what a worker needs through the project’s .claude/settings.json
(allow rules, and scripts/guard as a PreToolUse hook, see
scripts/README.md) and through CR_AGENT_ARGS, for example:
CR_AGENT_ARGS="--permission-mode acceptEdits --permission-prompts none"
--permission-prompts none denies whatever nothing else allows, instead of
waiting. Choose the mode for your project; bypassPermissions belongs only
in a sandbox.
The agent’s instructions. A project’s CLAUDE.md or AGENTS.md usually
tells a worker from the coordinator by CLAUDE_CODE_REMOTE=true, which only
cloud sessions have. For local workers, branch on the profile variable
(CR_PROFILE set) instead, or as well.
Configuration
control-room.json at the repository root: integration_branch (main),
branch_prefix (claude/), profile_variable (CR_PROFILE).
Environment: CR_AGENT (the agent CLI, default claude), CR_AGENT_ARGS
(extra arguments for every call, split on spaces), CR_WORKTREES,
CR_OPENING (another opening template; <profile>, <profile-variable>,
<integration-branch>, <prefix> and <branch> are filled in).
Shared-machine hazards
Worktrees share one repository, and workers share one machine.
- The git stash is shared between all worktrees. A bare
git stashin one worker’s worktree andgit stash popin another’s moves one worker’s work into another’s branch. Never use a baregit stash; commit instead, or name the stash and check it is yours. git add -Astages whatever is in the worktree, including files a tool or another process dropped there. Stage explicit paths only.- Heavy jobs in parallel turn green into red. Two full test suites or
builds on a small machine time out, run out of memory, or fight over ports
and caches, and a failure that is load, not code, costs a landing. Run
heavy jobs one at a time (a lock such as
flock /tmp/cr-heavy.lock make checkis enough, orscripts/land-lock --lock /tmp/cr-heavy.lock --wait 1800 -- make check), and give each worker its own ports and cache paths where the stack needs them. - Branches and refs are shared. A branch is checked out in one worktree
at a time;
newrefuses a branch that exists. The coordinator deletes landed branches, never a worker. - One login for all. Every session uses the same agent and GitHub credentials. The guard stops a worker from pushing someone else’s branch; nothing stops it from reading another worktree.
The opening message tells the worker the first three.
Tests
tests/cr-worker.bats runs cr-worker in a temporary repository with a fake
agent (tests/fixtures/fake-agent) that records its arguments, working
directory and profile.