A durable place for every work stream. Not a throwaway worktree per branch —
a place you keep: ui-changes, prod-reviews, mcp-server. Each place is a
git worktree + a tmux session running your AI CLI (claude, codex, opencode, …)
— with --spare, a second pane installs deps and gives you a shell — and it lives as long
as the work does: an afternoon, or weeks of async iteration until it's right.
Branches flow through places. The place keeps the expensive parts
(directory, AI CLI history, node_modules, the running session); a branch is the
unit of work currently on it. Ship a branch, switch the place to the next one,
keep working. The place is named after its first branch unless --name says
otherwise; switch moves it to the next branch without touching anything
expensive.
worktrees new feat/checkout # worktree + branch + tmux (AI | deps+shell)
worktrees ls # what places exist, what's on them
worktrees switch feat/checkout-v2 # same place, next branch (run inside it)
worktrees open feat-checkout # reattach later
worktrees rm feat-checkout # tear the place down when it's done
Works in any git repo. No config required.
Same engine, a native window. The macOS app (Tauri) links the identical Rust core in-process — no daemon, no subprocess — so everything the CLI knows, the app shows live: every place grouped by how active it is, ahead/behind and dirty state per branch, and a tmux-attached terminal for each session right in the window. Create a place, drop into its session, switch branches, tear it down — without leaving the app.
New → list → open — driven headlessly against the built-in mock harness. The real app embeds a live tmux terminal running your AI CLI.
Every place, grouped by how active it is, with a home panel to resume where you left off:
Open a place to see its branch state, notes, and embedded terminal:
Install it alongside the CLI on macOS (WORKTREES_INSTALL_APP=1, or answer the
installer prompt; from a clone, make install-app) — see Install.
Fresh machine (installs the latest release to ~/.local/bin):
curl -fsSL https://raw.githubusercontent.com/penard-monkey/worktrees/main/install.sh | bashTeams should pin a release for reproducibility. Setting the version is the form that works for every tag:
curl -fsSL https://raw.githubusercontent.com/penard-monkey/worktrees/v0.29.0/install.sh | WORKTREES_INSTALL_VERSION=v0.29.0 bashFor tags after v0.29.0 the tagged script installs its own release on its own,
so …/vX.Y.Z/install.sh | bash is enough. The app can also install any
release, including an older one, from Settings → Updates.
The installer fetches the prebuilt binary for your platform (macOS/Linux,
x86_64/arm64); with no match, or WORKTREES_INSTALL_FROM_SOURCE=1, it builds
from source with cargo.
On macOS it also offers the desktop app (worktrees.app → /Applications,
checksum-verified; the unsigned bundle's quarantine attr is stripped on your
explicit opt-in). Non-interactive runs skip the prompt — opt in/out explicitly:
WORKTREES_INSTALL_APP=1 \
curl -fsSL https://raw.githubusercontent.com/penard-monkey/worktrees/main/install.sh | bashFrom a clone, make install-app builds the app locally and installs it to
/Applications (no signing or quarantine involved).
Or clone and build (make install compiles the release binary and symlinks it —
git pull && make install upgrades):
git clone https://github.com/penard-monkey/worktrees && cd worktrees && make installRe-running the installer upgrades. install.sh --uninstall removes the binary
(your repos' worktrees and tmux sessions are untouched).
Re-running the installer is the updater — it resolves the latest release, prints the old → new version, verifies the checksum, and replaces the binary in place:
curl -fsSL https://raw.githubusercontent.com/penard-monkey/worktrees/main/install.sh | bash
worktrees --version # confirmRoll back (or hold) a version by pinning the release tag:
WORKTREES_INSTALL_VERSION=v0.1.0 \
curl -fsSL https://raw.githubusercontent.com/penard-monkey/worktrees/main/install.sh | bashThe same re-run updates the desktop app when you opt in (WORKTREES_INSTALL_APP=1
or answer the prompt); quit + reopen the app to pick up the new version.
From a clone instead: git pull && make install (note: make install symlinks
the clone's release build — later cargo builds in that clone update it too.
For a frozen copy, install -m 755 target/release/worktrees ~/.local/bin/worktrees).
App from a clone: git pull && make install-app.
Updates never touch your repos' state: worktrees under .worktrees/, the
declared store (.worktrees.places.json, schema-versioned), tmux sessions, and
~/.config/worktrees/config all survive binary swaps. Running sessions keep
running — the CLI attaches to tmux, it doesn't own it.
Requires: git ≥ 2.23. tmux ≥ 1.9 recommended (new degrades to --no-tmux
without it; open needs it). Prebuilt binaries for macOS + Linux (x86_64/arm64);
building from source needs a Rust toolchain.
worktrees new <branch> [base] create a worktree + tmux (one AI pane; --spare adds a shell)
worktrees new <branch> --name <topic> ...place named independently of the branch
worktrees co <branch> checkout a REMOTE branch (fetch if needed)
worktrees switch [<worktree>] <branch> [base] move a worktree to another branch
worktrees open <name> reopen a worktree's tmux session
worktrees close <name> [name...] end the tmux session (worktree stays; also: main)
worktrees ls [--json] list worktrees + their state (--json: machine-readable)
worktrees rm <name> [name...] tear one (or more) down
worktrees (no args) → ls
new/co/switch are do-what-I-mean: reuse an existing worktree, check out an
existing local or remote branch (fetching it first), or create a new branch
off [base] (default main). origin/feat/x is accepted and normalized to
feat/x. If a branch already lives in a differently-named worktree (after a
switch), new/open find and reuse that place instead of failing.
Flags:
| Command | Flags |
|---|---|
new/co/open |
-r/--resume (append the AI resume flag) · --ai <cmd> (AI pane command for this run) · --model <m> (the agent's model for this launch) · --force (launch despite an advisory refusal — see below) · --spare (add a spare shell as pane 1; for new it runs the detected install there — without it the install is printed as a then: hint) · --no-spare (accepted, and the default: single pane) |
new/co |
--no-install · --no-tmux · --no-attach · --no-fetch · --name <topic> · --brief <text> (write the agent's task to .planning/brief.md and launch the selected agent on it) |
switch |
--force (despite uncommitted changes) · --no-fetch · -y |
rm |
--branch (delete the branch too) · --force · -y/--yes |
An agent is not started on a plan window that is nearly spent. Before a
session is created, the harness asks its own provider how much of the current
window is left and refuses the LAUNCH when the provider's own grade says
warning (Codex: 80%) — naming the window, the percentage and when it resets.
The worktree, the branch and the brief are created either way: only the agent
waits, so nothing has to be re-typed once the window rolls over. --force
launches anyway (the app: Launch anyway; MCP: force: true), and
worktrees open <slug> --force is the retry it prints.
It is yours to tune, in Settings → Behavior → Plan limits or in
~/.config/worktrees/config.toml (a repo's .worktrees.toml cannot set it):
[quota]
gate = false # never refuse a launch on usage
weekly_warn_pct = 90 # weekly windows refuse at >= 90%, whatever the provider gradesThe percentage applies to weekly windows only; the 5-hour window keeps the provider's grade unless the gate is off. Both are read at launch time, so a change reaches the next launch everywhere, running agents' MCP servers included.
It fails open in every direction — no reading, an unreadable one, a provider
with no allowance to report, or --no-tmux (no agent starts at all) and the
launch simply proceeds. It is deliberately not a burst limiter: lanes bill
after their first turns, so eight started at once are all admitted and the gate
first speaks once the window is already spent. WORKTREES_USAGE_PROBE=off
switches the gate off entirely (the app's usage meter keeps reading). The check
costs a launch one reading of the provider's usage — usually cached, but on a
network that hangs rather than fails it can hold a new for up to ~15s
(Claude's GET) or ~13s (Codex's probe) before failing open; --force skips
the reading altogether, and so does gate = false.
Guards you'll be glad exist: dirty worktrees refuse to switch/rm (override
with --force); a stale unregistered dir under .worktrees/ is never treated
as a worktree (git would silently operate on your main checkout); switch from
inside worktree A targeting worktree B asks first; a typo'd worktree name can't
silently mint a junk branch.
Each worktree gets a session named <prefix>-<slug>: pane 0 launches your AI
CLI through an interactive shell (so shell aliases resolve), and that is the
whole session by default. --spare adds pane 1, which runs the detected
package-manager install (pnpm/bun/yarn/npm, by lockfile) and drops to a shell;
without it new prints the install command as a then: hint. Sessions are reused, never duplicated — open finds a session already
living in the worktree even under a different name.
worktrees ls --json (or WORKTREES_JSON=1 worktrees ls) emits a machine-readable
snapshot instead of the table — for editors, scripts, and tooling. The human ls
output is byte-for-byte unchanged. Shape (schema_version 1):
- a wrapper
{schema_version, repo, prefix, places_file, places:[…]}; - the main checkout first (
slug:"(main)", is_main:true), then each worktree in the same recency order as the table; - per place:
slug, path, branch(null when detached, withdetached:true),dirty, dirty_files, ahead, behind, upstream(the last three null when there's no upstream),created, created_epoch, last_commit_epoch, last_commit_subject, tmux_session:{name,up}, claude_session_present, install_cmd, andlifecycle_effective.
All state is derived live on every call — nothing is cached. stack and declared
are reserved (null for now). No jq required to produce it.
Precedence: flag > environment > user config > default. User config lives at
~/.config/worktrees/config (respects $XDG_CONFIG_HOME) — key = value
lines, # comments. It is parsed as data, never executed.
| What | Flag | Env | Config key | Default |
|---|---|---|---|---|
| AI pane command | --ai <cmd> |
WORKTREES_AI_CMD |
ai_cmd |
claude |
AI resume argument (-r appends it) |
— | WORKTREES_AI_RESUME_ARG |
ai_resume_arg |
Claude: -r; Codex: exact session (override ignored) |
| Session/name prefix | — | WORKTREES_PREFIX |
prefix |
repo dir name |
Codex permissions (ask, auto-review, full) |
— | WORKTREES_CODEX_PERMISSIONS |
codex_permissions |
auto-review |
# ~/.config/worktrees/config
ai_cmd = codex
ai_resume_arg = resume-
ai_cmd = none(or--ai none) → pane 0 is a plain shell, no AI. -
WORKTREES_CLAUDE_CMDis honored as a deprecated alias ofWORKTREES_AI_CMD. -
A repo can pin its prefix with a committed
.worktree-prefixfile (one line); the env var wins over it. -
Pane 0 hands the command to your
$SHELL -ic— aliases work; assumes a POSIX-ish (bash/zsh/sh) login shell. -
The app's Settings → Codex → Permissions overrides env and config for launches the app makes; the CLI (and MCP
create_worktree) use env/config.auto-reviewis--approve-for-meplus a sandbox that can also write the repo's git common dir (sogit commitworks in a worktree — that includes.git/hooksand.git/config) and reach the network. -
Every Codex launch also passes
-c project_doc_fallback_filenames=["CLAUDE.md"], so a CLAUDE.md-only project briefs Codex without a repo change.worktrees agent-setupchecks the default branch and can make AGENTS.md the one instruction file (CLAUDE.md becomes an@AGENTS.mdstub) on a localagent-instructionsbranch (fix --pushalso pushes it and opens the PR), and link your~/.claude/skillsinto~/.agents/skillswhere Codex looks. -
worktrees rules applywrites the worktrees way of working into the repo, for every agent including ones worktrees did not start: a managed block at the end of AGENTS.md (places, lanes, the MCP tools, planning, finishing), theclose-outskill, and a.claude/close-out.mdthat is the repo's to edit. Same local branch, same layout fix;rules statussays whether it is current, and a re-apply updates the block in place without touching anything outside its markers. MCP:rules_status,apply_rules.
Examples: --ai claude, --ai "claude --model opus", --ai codex,
--ai opencode, --ai none.
Claude and Codex can both be configured, but each worktree runs one provider
session at a time. Switching providers closes the current managed tmux session
before opening the other; each provider keeps its own saved conversation. Settings →
Claude and Settings → Codex can both be configured. When both CLIs are available,
the New worktree dialog asks which agent to start; it preselects Settings →
Commands → Default agent. Entering an existing place keeps its running provider;
the default is used when no agent is running. Changing the default does not
stop a running session. The CLI keeps its
ai_cmd default and accepts open <place> --ai claude|codex and
close <place> --ai claude|codex. An unqualified close closes both managed
agent sessions. Codex resumes the newest interactive conversation started in
that exact place with codex resume <session-id>, excluding subagents and
scripted runs. Without a matching conversation it starts fresh. The generic
resume-argument override does not apply to Codex: its --last selector can
include sibling worktrees, so Worktrees always names the session explicitly.
Worktrees-launched Codex sessions require ChatGPT account sign-in. Run
codex login once and complete its browser flow; codex login status shows
the active method. Worktrees uses Codex's saved credentials and does not ask
for or store an OpenAI API key. If Codex is currently signed in with an API
key, sign out with codex logout and sign back in with codex login before
opening it from Worktrees.
Connect Worktrees tools separately in Settings → Claude and Settings → Codex,
or run worktrees mcp --install --ai codex for Codex. The unqualified MCP setup
command still targets Claude. The MCP create_worktree tool accepts an optional
provider: "claude" | "codex"; without one it uses the project's AI command.
Agents in different places of one project can talk through the same server,
whichever provider each one is. report posts a message to another place
((main) unless told otherwise; reply_to threads an answer), messages
reads what was sent to your place, and wait blocks for up to two minutes
until another place's agent goes idle or a message arrives, so an agent
waits instead of polling. The sender is the place the agent is working in,
never a name it passes; anything running as your user can still write the
log, so that is the trust boundary. Messages live in the repository's shared git data, untracked, and expire
after a week. With --mutations, send types one line into another place's
Codex, labelled as coming from the sending place and never while that Codex is
waiting on an approval; for a Claude place it answers with the session name to use with
Claude's own SendMessage.
- macOS: stock
/bin/bash3.2 is fully supported (CI runs the whole suite on it). .worktrees/is added to.git/info/excludeautomatically — worktrees never show up as untracked files.rmdeletes the worktree and its tmux session; the branch survives unless you pass--branch.
git clone --recurse-submodules https://github.com/penard-monkey/worktrees
make check # shellcheck + bash-3.2 gates + bats suite
make test-real-tmux # 3 integration smokes against real tmuxTests are bats-core (vendored as submodules); the suite fakes tmux with a PATH shim so every pane command is assertable, and CI runs ubuntu + macos, the latter twice — once under stock bash 3.2.
The app is Tauri, so cargo is driven for you — there is no cargo run. Node
must match .nvmrc (pnpm 11 refuses anything older), and dependencies are
installed once per clone:
nvm use # or any node >= the version in .nvmrc
cd app && corepack pnpm install # first time only
make dev-app # or, from app/: corepack pnpm tauri devRun corepack pnpm, from app/ — not a bare pnpm, and not
pnpm --dir app from the root. From the root, corepack fixes its version from
the current directory's package.json before pnpm reads the flag, and the root
has none, so you get ERR_PNPM_BAD_PM_VERSION against the pnpm@11.5.2 pin.
And a bare pnpm may not exist at all: nvm installs it per node version, and
the version .nvmrc pins ships only corepack node npm npx, so nvm use can
take pnpm off your PATH. corepack comes with node and is always there. The
make targets do both for you.
That builds the app crate and serves the frontend on port 1420, with hot
reload on the TypeScript/CSS side; a Rust change rebuilds and relaunches the
window. Real git and tmux are used, so it acts on whatever projects you have
registered.
To work on the UI alone — no Rust build, no tmux, fake backend — use the mock
harness, which runs the real App.tsx against fixtures in a plain browser:
cd app && corepack pnpm dev:mock --port 1425 # any port but 1420Both are development loops. To actually use a locally built app, see
make install-app under Install — that produces the bundle and puts
it in /Applications.
| Script | What it does | Touches |
|---|---|---|
sandbox.sh |
Builds the current branch and hands you an isolated worktrees to test it in. --app launches the desktop app against it. |
a scratch repo, or one you name with --repo |
record-readme.sh |
Regenerates the README's desktop media (docs/media/desktop-*.{gif,png}). |
nothing — mock harness |
shoot-profiles.sh |
Regenerates the AI-profiles screenshots for docs/ai-profiles.html. |
nothing — mock harness |
record-profiles.sh |
Regenerates that page's walkthrough clip (walkthrough.mp4). |
nothing — mock harness |
The three media scripts drive the mock harness — the real App.tsx against
fixtures, in a plain browser — so they are deterministic and cannot touch your
projects. The .py file beside each is the Playwright driver; run the .sh,
which starts the harness, waits for it, drives it, encodes, and cleans up.
sandbox.sh is the exception: it builds and runs the REAL binary, against a
scratch repo by default and against whatever you pass to --repo if you ask.
They need Python Playwright and Chromium once — pip install playwright && playwright install chromium — plus ffmpeg for the two that record video.
eval "$(app/scripts/sandbox.sh)" # CLI: isolated env + a scratch repo
app/scripts/sandbox.sh --app # …or the desktop app against that sandbox
app/scripts/sandbox.sh --cleanThis matters more than it sounds. tmux session names are <prefix>-<slug>
derived from the repo, so a second build computes the same name and attaches to
the session your open app is using — closing it in one kills it in the other.
The sandbox takes a per-branch prefix (sbx-<branch>-<slug>), which both
prevents that and is how you tell them apart:
tmux ls | grep sbx- # sandboxes, one prefix per branch
tmux ls | grep -v sbx- # yours, untouchedIt also isolates the AI-profile store and, with --app, overrides the bundle
identifier so the sandbox app gets its own ui-state.json instead of sharing
yours.
MIT


