In my previous post about a cost budget advisor, I built a mechanism that checks how much quota is left before it runs anything. This time I want to take that idea one step further: instead of cycling a single Codex through jobs one after another, run several of them at the same time as a swarm.
I'll walk through the actual code for a three-layer protocol where workers declared in plan.json are expanded by orchestrate-worktrees.js into a tmux session plus git worktrees, so each Codex runs in parallel without interfering with the others.
The problem: running Codex serially on the same branch
When you run Codex jobs one after another on a single repository, you hit issues like:
- A commit from the previous job changes the preconditions for the next one
- Task B keeps waiting until Task A finishes
- When a conflict happens, the "which change is correct" decision bounces back to a human
Separating branches with git worktree reduces the conflict risk to zero. Combining that with tmux to launch everything in parallel is the design for this post.
The big picture: a three-layer protocol
plan.json ← declaration layer (what goes to which worker)
↓
orchestrate-worktrees.js ← orchestrator layer (creates worktrees, launches tmux)
↓
orchestrate-codex-worker.sh ← worker layer (runs Codex, writes artifacts)
The orchestrator doesn't know about the workers, and the workers don't know about each other. Artifacts are consolidated into three files under .orchestration/{session}/{worker_slug}/.
| File | Role |
|---|---|
task.md |
Work instructions for the worker (generated by the orchestrator) |
status.md |
State: not started → running → completed / failed
|
handoff.md |
Codex output + git status (written by the worker) |
Declaring the plan in plan.json
{
"sessionName": "refactor-sprint",
"repoRoot": "~/my-project",
"worktreeRoot": "~/worktrees",
"coordinationRoot": "~/my-project/.orchestration",
"baseRef": "HEAD",
"replaceExisting": true,
"launcherCommand": "bash ~/.claude/scripts/orchestrate-codex-worker.sh {task_file} {handoff_file} {status_file}",
"seedPaths": ["package.json", "tsconfig.json"],
"workers": [
{
"name": "api-types",
"task": "src/api/ のレスポンス型を zod スキーマに移行する。既存のテストが全て通ること。",
"seedPaths": ["src/api/"]
},
{
"name": "ui-cleanup",
"task": "src/components/ 内の PropTypes を削除し TypeScript 型に一本化する。",
"seedPaths": ["src/components/"]
},
{
"name": "test-coverage",
"task": "src/utils/ のユニットテストを追加しカバレッジ 80% 以上にする。",
"seedPaths": ["src/utils/"]
}
]
}
seedPaths can be specified in two places: globally and per worker. The global package.json / tsconfig.json are copied into every worker's worktree, while a worker's own seedPaths are dropped only into that worker.
What orchestrate-worktrees.js does
The entry point has three modes.
# ① dry-run (default): show what would be created, as JSON
node scripts/orchestrate-worktrees.js plan.json
# ② --write-only: no worktree creation; only writes task.md / status.md / handoff.md
node scripts/orchestrate-worktrees.js plan.json --write-only
# ③ --execute: full run, including worktree creation and tmux launch
node scripts/orchestrate-worktrees.js plan.json --execute
When you invoke --execute, executePlan from lib/tmux-worktree-orchestrator.js runs, processing in this order:
- Precondition checks with
git rev-parse --is-inside-work-treeandtmux -V - If
replaceExisting: true, clean up existing sessions, worktrees, and branches -
materializePlanwrites the three-file set under.orchestration/ - For each worker, run
git worktree add -b <branch> <path> <baseRef> - Create the session with
tmux new-session -d -s <session> - For each worker,
split-window -P -F '#{pane_id}'→ set the layout to tiled → set the pane title → send the launch command
Branch names and worktree paths are generated automatically.
branch: orchestrator-{session_name}-{worker_slug}
worktree: {repo_name}-{session_name}-{worker_slug}/ (directly under worktreeRoot)
To attach after running, all you need is tmux attach -t refactor-sprint. Three panes sit side by side, each running its own Codex.
Note
WithreplaceExisting: false(the default), the run stops with an error if a tmux session of the same name already exists. When re-running, either setreplaceExisting: trueor manuallytmux kill-session -t <name>first.
The worker side: orchestrate-codex-worker.sh
The worker script takes three arguments.
bash ~/.claude/scripts/orchestrate-codex-worker.sh \
<task-file> <handoff-file> <status-file>
You can wire this straight into plan.json's launcherCommand using template variables.
"launcherCommand": "bash ~/.claude/scripts/orchestrate-codex-worker.sh {task_file} {handoff_file} {status_file}"
Here's the spot inside that calls Codex (actual code):
cat > "$prompt_file" <<EOF
You are one worker in an ECC tmux/worktree swarm.
Rules:
- Work only in the current git worktree.
- Do not touch sibling worktrees or the parent repo checkout.
- Complete the task from the task file below.
- Do not spawn subagents or external agents for this task.
- Report progress and final results in stdout only.
- Do not write handoff or status files yourself; the launcher manages those artifacts.
...
Task file: $task_file
$(cat "$task_file")
EOF
if codex exec -p yolo -m gpt-5.4 --color never -C "$(pwd)" -o "$output_file" - < "$prompt_file"; then
The key point is - < "$prompt_file", which pipes the prompt into stdin. Because -C "$(pwd)" pins the execution directory to the worktree, there's no worry about Codex accidentally touching files in the parent repository.
On success, handoff.md is written with Codex's output and git status --short, and status.md changes to completed. On failure it becomes failed, with no impact on the next worker.
Wiring up template variables
The template variables available in launcherCommand are as follows (from the actual source):
| Variable | Contents |
|---|---|
{worker_name} |
Worker name (e.g. api-types) |
{worker_slug} |
Slugified name (e.g. api-types) |
{session_name} |
tmux session name |
{repo_root} |
Repository root path |
{worktree_path} |
This worker's worktree path |
{branch_name} |
This worker's branch name |
{task_file} |
Absolute path to task.md |
{handoff_file} |
Absolute path to handoff.md |
{status_file} |
Absolute path to status.md |
For each variable, a _sh suffix version (shell-quoted) and a _raw version are also generated. When passing a path that contains spaces to another shell script, using {worktree_path_sh} is the safe choice.
The renderTemplate implementation is simple: if it contains an undefined variable, it errors out immediately (Unknown template variable: xxx). Typos are caught before execution.
Dropping files into a worktree with seedPaths
A worktree right after git worktree add is a snapshot as of baseRef, but sometimes you need the latest locally-modified version of package.json or a config file. seedPaths solves that problem by copying.
function overlaySeedPaths({ repoRoot, seedPaths, worktreePath }) {
for (const seedPath of normalizedSeedPaths) {
const sourcePath = path.join(repoRoot, seedPath);
const destinationPath = path.join(worktreePath, seedPath);
fs.cpSync(sourcePath, destinationPath, {
dereference: false, force: true, preserveTimestamps: true, recursive: true
});
}
}
That said, seedPaths can't escape outside repoRoot. Paths like ../../../etc/passwd are rejected by the .. check in normalizeSeedPaths.
Warning
Files copied byseedPathsget committed after they're edited in the worktree. If you seed a globalpackage.jsonand then rewrite it in the worktree, that change rides along on the branch. To avoid unintended changes, seed only the files you actually need.
Rollback design
Even if executePlan fails midway, it rolls back the resources it was in the middle of creating. It records what has been created so far in createdState, and on error it calls rollbackCreatedResources.
1. kill-session the tmux session
2. git worktree remove --force each worktree
3. git worktree prune --expire now
4. git branch -D the corresponding branches
5. delete coordinationDir if it didn't originally exist
This order is the reverse of creation (delete the most recently created first). If one of the rollbacks fails partway through, it continues with the rest and throws a consolidated error at the end.
Pitfalls I hit
-
Re-running with
replaceExistingstillfalseand getting stuck → If a same-named session lingers, you get an error. Set it totrueat first, or manually kill it while developing. -
launchCommand breaks when the worktree path contains a space → Use the
_shsuffix versions like{task_file_sh}. -
Forgetting seedPaths so config files never land in the worktree → The worker can't read the correct config and fails. Check task.md with
--write-onlybefore--execute. -
Codex going to touch files in a different worktree via
codex exec→ worker.sh's prompt explicitly says "Work only in the current git worktree," but if you put absolute paths in the task description, it flies over there. Use only repository-relative paths in tasks. -
The script launching even in an environment without tmux →
executePlanchecks for existence by runningtmux -Vat the top. If it's missing, it errors immediately (with a clear message).
Summary
- Declare tasks in a
workersarray inplan.json, and a single--executelaunches worktree + tmux + Codex all at once - Separating branches and worktrees drops the risk of conflicts between workers to zero
- Artifacts are consolidated into three files,
task.md/status.md/handoff.md, so Claude Code can review them together afterward -
launcherCommandcan be freely wired with template variables and swapped out for workers other than Codex - Rollback on failure is automatic; even if it stops midway, the repository doesn't get dirty
Next time I'll write about the orchestrator loop where Claude Code reviews all the handoff.md files each worker wrote and decides whether they can be merged.
Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*
Top comments (0)