wts

skill
Security Audit
Fail
Health Warn
  • License — License: MIT
  • Description — Repository has a description
  • Active repo — Last push 0 days ago
  • Low visibility — Only 5 GitHub stars
Code Fail
  • rm -rf — Recursive force deletion command in docs/demo/record.zsh
  • rm -rf — Recursive force deletion command in libexec/wts/wts-db.zsh
  • network request — Outbound network request in share/wts/layouts/default.yml
  • rm -rf — Recursive force deletion command in test/bench-big.zsh
  • network request — Outbound network request in test/bench-big.zsh
  • rm -rf — Recursive force deletion command in test/smoke.zsh
Permissions Pass
  • Permissions — No dangerous permissions requested

No AI report is available for this listing yet.

SUMMARY

Git worktree + tmux session launcher for parallel Claude Code agents

README.md

wts — worktree sessions for parallel coding agents

wts creates a git worktree and a tmux session on it in one command, from a
layout you pick — by default an editor next to a Claude Code pane. It is made for
running several agents on the same repository at once without them stepping on
each other, and for finding your way back afterwards:

  • Survives reboots. Every session is recorded as (name, layout, context) in a
    small SQLite database, so wts restore rebuilds them all — worktrees survive a
    reboot, tmux does not.
  • Lets the agents know about each other. Each Claude Code agent started in a
    wts session is told which other sessions are running and what they are on, and
    can query the shared state and leave notes for the others (wts db).
  • Shows what each agent is doing. wts ls and the fzf switcher (prefix+s)
    tell you which Claude Code agent is blocked, idle or working, with a stale guard
    that catches agents claiming to work on a frozen pane.
  • Cleans up after squash merges. wts gc compares patch-ids against
    origin/<base>, so branches merged by squash or rebase are recognized, not left
    to pile up.
  • Tells you where you left off. wts brief prints a two-line done / next per
    session, from git and the agent's transcript.
  • Starts from a sentence. wts "rate-limit the public API per key" names the
    session for you and hands the sentence to Claude.
  • Carries the context you keep re-pasting. wts doc add <url|path> puts a
    spec, an architecture page or a file of conventions in a small library, fetched
    once, and --doc <slug> attaches it: the agent opens on the document.

It is opinionated — zsh, tmux, tmuxinator, Claude Code — because it was built for
one person's workflow. It is shared in case that workflow is also yours.

Upgrading from 0.x? 1.0 moves the state to SQLite. The import is automatic;
see Upgrading to 1.0 for the steps and the rollback.

wts: a context document added to the library, a session started from a sentence with that document attached, prefix+a jumping straight to the agent that is blocked, the switcher answering it, attaching the document to an agent already running and stopping a finished session, then wts ls, wts brief, wts restore bringing the stopped session back and wts gc

Requirements

  • macOS (Linux is untested)
  • zsh, git, tmux, tmuxinator, fzf, jq,
    sqlite3 (3.38+, JSON built in), perl, curl (macOS ships sqlite3; jq only with
    recent releases, and Homebrew installs it)
  • Optional: Claude Code, tested with 2.1.x — agent
    state columns, naming from a phrase, wts brief, resume on restore. Without it
    everything else works and the agent columns show -.
  • Optional: direnv, for a per-repository WTS_SUBDIR
  • Optional: gh, for ctrl-o in the switcher and wts pr
    (open the session's pull request). Without it the key is not offered.

Install

Homebrew (dependencies and zsh completion included). Recent Homebrew versions
only load formulae from taps you trust, so trust the tap first:

brew trust maximilientyc/tap
brew install maximilientyc/tap/wts

From source:

brew install tmux tmuxinator fzf jq
git clone https://github.com/maximilientyc/wts
cd wts
make install PREFIX=~/.local

Then make sure ~/.local/bin is in your PATH, and add the completion directory
to fpath in ~/.zshrc, before compinit runs (before oh-my-zsh, if you use it):

fpath=(~/.local/share/zsh/site-functions $fpath)

tmux integration (optional, recommended):

wts setup tmux              # read it first
wts setup tmux >> ~/.tmux.conf && tmux source-file ~/.tmux.conf
Key Action
prefix+s session switcher — replaces tmux's default choose-tree -s
prefix+a jump to the next agent that needs you
prefix+: then wts … create a session from a freshly fetched base
prefix+g the same prompt, pre-filled with wts

The snippet uses absolute paths: tmux runs command-alias programs directly,
without a shell, so neither ~ nor PATH lookups are reliable there. Homebrew
paths point to the stable opt/wts location and survive brew upgrade.

Claude Code integration (optional, recommended): a SessionStart hook that
tells every agent started in a wts session about the other sessions and about
wts db — see Agents share state.

wts setup claude             # read it first
wts setup claude --install   # adds it to ~/.claude/settings.json (backup kept)

Quick start

cd ~/code/myapp
wts auth-form                          # ../myapp-worktrees/auth-form, branch auth-form
wts "rate-limit the public API per key"   # Claude proposes the name, starts on the task
wts ls                                 # agent state, branch, git delta, tmux state
wts stop auth-form                     # tmux session only; wts restore brings it back
wts rm auth-form -f                    # session + worktree + branch + registry entry

Usage

wts <name> ["<phrase>"] [layout] [context...]
wts "<phrase>" [layout] [context...]
wts new [-p layout] <name|"phrase">...
wts doc add <url|path> | ls | show | sync | rm | use <slug> [name]
wts ls
wts status [--json|--table|--fzf]
wts brief [name...]
wts restore [name...]
wts stop <name>
wts pr [name]
wts rm <name> [-f]
wts gc [--apply] [--no-fetch]
wts layouts
wts keys
wts db path | schema | sql "<SELECT ...>" | notes [--all] | get | set | del
wts setup tmux | git | claude [--install]
wts help | wts version
wts auth-form feature                  # your "feature" layout: branch feature/auth-form
wts fix-ABC123 sentry ABC123           # "ABC123" reaches the layout as $WTS_CONTEXT
wts new cors rate-limit csv-export     # three worktrees and sessions at once
wts review/login-flow                  # an existing origin branch: checked out for review
wts auth-form "rate-limit it" --doc api-spec   # with a context document attached
wts rm auth-frm -f                     # typo-tolerant: resolves to auth-form

Slashes become - in the worktree folder and session name (review/login-flow →
review-login-flow); the git branch keeps its full name. wts rm, wts stop and wts brief
tolerate typos: substring match, then fzf fuzzy match, then edit distance; when
several sessions match, an fzf picker opens.

Branch resolution

When creating the worktree, wts picks the branch in this order:

  1. Existing local branch → checkout.
  2. Existing branch on origin → fetch, then checkout with tracking. This is the
    review case: you pick up existing work. Skipped offline or without origin.
  3. Otherwise → new branch from the base branch.

The branch name is the layout's branch prefix followed by the session
name. To review an origin branch by its exact name, use a layout without prefix,
such as default.

In case 3 the branch is created with --no-track. Otherwise, with a remote base
(origin/main, which prefix+: wts uses), git would make the new branch track the
base: git status would say "behind origin/main by 47" — which an agent reads and
acts on —, git pull would merge the base, and wts gc would never see the remote
branch disappear.

Starting from a phrase

wts csv-export "the export times out above 10k rows"   # name given: instant
wts "the export times out above 10k rows"              # name proposed: export-timeout-large-rows
wts "the export times out above 10k rows" sentry       # layout after the phrase

A phrase is an argument containing a blank, in first or second position — no
session, branch or layout name contains one. It is removed from the positional
arguments (layout and context keep their places), then the Claude pane starts with
claude "<phrase>", and the phrase is stored in the registry, where it fills the
SUBJECT column until Claude names the conversation.

Without a name, wts-name asks Claude Haiku for a 2–4 word kebab-case slug
(3 to 15 s). The call is isolated: --safe-mode, hooks disabled, no MCP server, no
tool, no transcript, run outside any worktree. Past WTS_NAME_TIMEOUT, without
claude, with WTS_NO_LLM=1, or when the answer is not a name, the name is derived
locally from the phrase and a warning on stderr says which of those happened — an
unrecognized WTS_MODEL included, which claude answers in prose rather than with
a failure.

A proposed name is always unique. Otherwise branch resolution would silently
check out another session's branch. It gets -2, -3… until it is free
everywhere: worktree folder, registry, tmux session, local or remote branch. A name
you give yourself keeps the normal behavior, checkout of an existing branch included.

Quote the phrase. wts fix the export splits it and takes the as a layout —
the error message says so. In the tmux prompt, an apostrophe needs quotes too:
wts "don't cache the header".

Agent state

wts ls and the switcher show the Claude Code agent running in each worktree. The
data comes from claude agents --json, joined on the agent's cwd being inside
the worktree, so WTS_SUBDIR keeps working.

Shown Source Meaning
blocked state: blocked / status: waiting waiting for a permission or an answer
working status: busy / state: working turn in progress
idle status: idle turn finished, prompt available
done state: done background session finished
failed state: failed the turn failed
stopped state: stopped, or no tmux session session stopped, or wts stopped: the switcher shows it for a dead tmux session
stuck? stale guard says working, but the pane is frozen
- no agent found worktree without a Claude session

The stale guard hashes the agent's pane on every refresh: an agent reported as
working whose pane has not changed for WTS_STALE_AFTER seconds (10) is shown as
stuck?. Without it, a Ctrl-C leaves a session "working" forever.

Sessions are sorted by what needs a human first: stuck?, blocked, failed,
idle, working, then the rest. wts status --json exposes the same data
(agent_state, stale, git counters, tmux state) for scripts.

Where each session stands: wts brief

auth-form  idle  feature/auth-form  +322/-0 ^4  PR #42
  done: server-side email validation before sending
  next: decide the wording of the rate-limit error

For each session whose worktree exists, in wts ls order, wts-brief gathers
facts without calling anything: git (commits unique to the branch, excluding both
<base> and origin/<base>; delta; uncommitted changes), the agent state, and the
worktree's Claude transcript (title, PR link, your last message, the tail of the
agent's messages, the starting task). Claude Haiku turns them into two lines, with
the same isolation as naming, WTS_BRIEF_JOBS calls at a time.

Each summary is cached in the state database (table briefs), keyed on HEAD,
uncommitted changes and the transcript's size and date: while nothing moved,
wts brief answers instantly. The transcript used is the live agent's, else the
most recent one of the worktree — never one older than the session, which would
belong to a previous session of the same name. Without claude, with
WTS_NO_LLM=1, or when the answer is malformed, the raw facts are shown, under the
reason the summary is missing. The model is never called by wts ls or the switcher.

Privacy. wts brief sends to the model, through your own claude -p: the
branch's commit log and diff stats, the session's starting prompt, your last
message to the agent, and the tail (about 1,500 characters) of the agent's recent
messages. wts "<phrase>" sends the phrase. Set WTS_NO_LLM=1 to never call the
model.

Session switcher

prefix+s opens an fzf popup: sessions sorted by urgency, with agent state,
branch, git delta and a live preview of the agent's pane. The keys are written
under the list
, so there is nothing to remember: one line by default, and ?
unfolds the whole table — the tmux bindings included, since those are the ones
you cannot press from inside the popup. enter switches, ctrl-x
kills the selected tmux session (wts stop, after a y/N prompt: the worktree, the
branch and the registry entry stay, the popup stays open and the row reads
stopped), ctrl-d removes it entirely (wts rm), ctrl-o opens the branch's
pull request on GitHub (wts pr, through gh pr view --web: without a PR the popup
says so and stays open; without gh the key is neither bound nor listed),
ctrl-e attaches a context document to the session
and tells its agent (wts doc use, picker included),
ctrl-f / ctrl-b scroll the preview by half a page, ctrl-r reloads. The
current session is never killed from the popup, which it would close. tmux
sessions unknown to wts are listed after, and ctrl-x works on them too.

The footer is sized to the list: a narrow popup keeps the keys you press most
and drops the rest, ? still shows them all. While you are filtering the list,
? is typed into the query instead (the AGENT column has stuck? in it), and in
reply mode the footer shows what enter and esc do there. wts keys prints
the same table in a terminal, from the same source, for when the popup is not
open. The footer needs fzf 0.65 or later; older versions keep the plain switcher
and wts keys.

The list is a table sized to the popup: the session and branch columns take
the width of their longest value, capped so that every column stays visible, and
a cell too long for its column is cut with … rather than pushing its row out
of line. * after a name marks the session you came from. The preview takes the
right half.

tab answers the agent without leaving the popup: the prompt becomes
reply to <session>>, what you type no longer filters the list, and enter sends
the line to the agent's pane followed by Enter — a number for Claude's numbered
questions and permission prompts, a sentence for the rest, nothing at all for a
bare Enter. The preview keeps refreshing, so the agent's reaction shows up in
place; esc or tab brings the list back (enter switches again). The reply
stays pinned to the session you pressed tab on, even if the list re-sorts under
the cursor, and ctrl-d / ctrl-x are disabled meanwhile. While the agent column shows -,
wts does not know the agent's pane yet and the reply goes to the session's active
pane. Needs fzf 0.45 or later; older versions keep the plain switcher.

The popup is drawn at once, on a list built from the registry and tmux alone —
no git, no agent call. fzf then swaps in the agent states (load, then
reload-sync of a pass that asks Claude and tmux but not git: tens of
milliseconds), and the first refresh brings the git columns. Until then the
columns show -: an agent state one refresh old is worse than no state at all.

The list and the preview refresh every 2 s (WTS_SWITCH_REFRESH, 0 for a
static list). fzf has no timer event, so the refresh goes through --listen: a
background poller pushes reload-sync(...)+refresh-preview to fzf's unix socket.
reload-sync avoids a blinking empty list and keeps the cursor and query. The
poller never replaces a pass still running, and waits at least as long as the
last pass took before starting the next one: on a large repository where a pass
takes seconds, the list fills after one pass and the preview keeps refreshing in
between. The poller dies with the popup. Without curl, or when the socket path
exceeds the 104 bytes of sun_path, the switcher silently falls back to a static
list. The cursor is kept by index: if a session changes urgency, the highlighted
line can change session under you.

The scroll offset lives outside fzf, in a small file the preview command reads:
every refresh-preview resets fzf's own preview offset, so native scrolling would
be undone within two seconds. At rest the preview follows the end of the pane;
ctrl-b goes back through the real tmux history (WTS_SWITCH_SCROLLBACK lines).
Moving to another session returns to live. Trade-off: ctrl-f / ctrl-b no longer
move the cursor in the query — the arrow keys do.

The preview window is as wide as the agent's pane, up to the right half it
starts with. The preview is a raw capture-pane, text at the pane's width: with
the editor as the main pane, a 56-column agent pane drawn in a 110-column window
left half of it blank while the list was squeezed to 76 columns and lost the
subject. The width follows the highlighted session (focus → transform →
change-preview-window, fzf 0.46 or newer; older versions keep the half-width
window). The list is laid out for the other half, so a wider pane is truncated on
the right rather than pushed into the list.

prefix+a jumps straight to the next agent that needs you (stuck?, blocked,
failed, idle), without a popup; pressing it again cycles through them.

Tip, not included in the snippet: choose-tree assigns jump keys to its lines, so
j / k select a line instead of moving once enough sessions are open. This keeps
jump keys to digits:

bind w choose-tree -Zw -K '#{?#{e|<:#{line},10},#{line},}'

Creating from tmux

prefix+: opens tmux's command prompt; type wts followed by the usual wts
arguments
(prefix+g pre-fills it). You get the prompt's history for free.

wts auth-form
wts fix-ABC123 sentry "timeout on /api/v2"
wts "the PWA header hides the profile button"

The command goes through wts-fresh, which adds two guarantees:

  1. outside a git repository it refuses — nothing is created;
  2. the branch is cut from a freshly fetched origin/<default>. If the fetch
    fails (offline, no origin), it aborts: a worktree started from a stale base
    is exactly what it exists to prevent. Offline, wts <name> from a shell still
    works, on the local base.

The default branch is detected, never hardcoded: WTS_BASE_BRANCH, local
origin/HEAD, git ls-remote --symref origin HEAD, then origin/main and
origin/master. A missing origin/HEAD is restored along the way. The local base
branch is fast-forwarded when it is safe — a bonus, never blocking.

Run from inside a wts session, creation is attached to the main repository, not the
current worktree (no nested worktrees). If the branch already exists, it is checked
out and the base is not used; nothing is ever rebased. tmux splits the arguments
itself, so quote contexts with spaces; an unclosed apostrophe swallows the rest of
the line, and wts-fresh says so. The new window does not see the calling pane's
environment: when the repository has an .envrc and direnv is installed, wts
runs through direnv exec. A phrase without a name is named in the background
while the fetch runs.

ls, status, brief, restore, layouts, setup, help and version are
passed through without fetching; gc and rm run from the main repository.

Batch creation

wts new cors rate-limit csv-export       # default layout
wts new -p sentry ABC123 DEF456
wts new "rate-limit the API per key" "export as CSV"   # names proposed one by one

wts a & wts b & wts c does not work: the normal flow ends with
exec tmuxinator start, so the first call replaces the process. wts new starts
each session detached, then attaches to the first. Creations are sequential: in
parallel, two similar phrases could get the same name.

Context documents: wts doc

A technical spec in Notion, an architecture page, a file of conventions on disk:
the same context, pasted by hand into every new agent. wts doc keeps a small
library of those documents — fetched once, cached — and --doc attaches one to a
session.

wts doc add https://www.notion.so/Payments-architecture-abc123   # once, ~10-30 s
wts auth-form "limit the rate per key" --doc payments-architecture
wts auth-form "limit the rate per key" --doc    # pick one from the library
wts fix-typo "a comma too many"                 # nothing attached

Nothing is attached unless you ask for it. There is deliberately no
per-repository pinning: several projects run at once, and the document that
matters to one worktree is noise in the next.

--doc is repeatable and takes a slug of wts doc ls, a URL, or a path. It
always consumes the next argument — a bare --doc at the very end of the
line opens an fzf picker instead. wts <name> <layout> --doc <slug> is the
unambiguous order, and --doc=<slug> works anywhere.

Where it lands

wts writes <worktree>/.wts/context.md — every attached document one after
another, each with its title, source and fetch date — and the Claude pane starts
on claude "Read @.wts/context.md first, …". The folder carries its own
.gitignore containing *, so it never appears in git status, never makes a
worktree look dirty to wts gc, and goes away with the worktree.

Layouts receive the path in WTS_DOC, relative to the pane's working directory
(Claude Code resolves an @ reference from the pane's cwd). A layout of your own
picks it up with the few lines the built-in default.yml uses.

Fetching: whatever this machine can read

A URL is fetched by a headless claude -p started with this machine's own MCP
configuration
, and the model uses whatever tool can read it. Nothing about a
particular provider is hardcoded, on purpose: the same page sits behind a Notion
connector on one machine and behind a gateway with entirely different tool names
on another, and both work with no configuration.

The allow list is built per server from claude mcp list (cached for a day —
the CLI refuses a bare mcp__* wildcard in an allow rule), plus WebFetch, with
every write-shaped tool denied. WTS_DOC_TOOLS='mcp__<server>__*' pins it when
that enumeration is noisy or when only one connector should ever be used.

A local markdown file never calls the model at all, and is re-read on every attach.

When nothing can read it, the document degrades to a pointer: the context
file carries the URL and asks the agent to fetch it itself. The agent in the pane
has your full set of connectors and often succeeds where the headless call could
not. The same happens offline, without claude, or with WTS_NO_LLM=1.

Freshness

wts doc add fetches. On attach, a URL older than WTS_DOC_TTL (24 h) is
fetched again, under a timeout, falling back to the cache when the network or the
connector is missing — so an attach is never blocked by them. wts doc sync
refreshes on demand. A fetch that comes back a fraction of the cached size is
refused and the cache kept (--force accepts it): silently replacing a good spec
with a stub is the worst thing this could do.

Attaching to a session already running

wts doc use payments-architecture             # from inside the worktree
wts doc use payments-architecture auth-form   # or by name, typo-tolerant

The context file is rewritten and the reference is sent into the agent's pane, so
an agent already working picks it up without being restarted. In the switcher,
ctrl-e does the same on the highlighted row, picker included.

wts doc add <url|path> [--name <slug>] [--force]   add a document
wts doc ls                                         the library
wts doc show <slug>                                what will be injected
wts doc sync [<slug>...] [--force]                 fetch again
wts doc rm <slug>                                  forget it
wts doc use <slug> [<session>]                     attach to a running session
wts doc tools [--refresh]                          what the fetch may use

The library itself is ~/.config/wts/docs.json (WTS_DOCS_PATH), four keys per
entry and meant to be edited by hand; the fetched content is a cache, in the
state database (table doc_cache).

Privacy. wts doc add and wts doc sync send the document's URL to the
model through your own claude -p, which then reads the page with your own
connectors. The content is written in clear text inside the worktree. Set
WTS_NO_LLM=1 to never call the model — documents then stay pointers.

Garbage collection

wts gc              # dry run: lists, deletes nothing
wts gc --apply      # does it
wts gc --no-fetch   # without contacting the remote (offline)

The remote is the source of truth. wts gc starts with
git fetch --all --prune, then compares against origin/<base> rather than a local
base that may lag behind. Six categories, limited to the current repository:

  1. Husk folders in <repo>-worktrees/ — git worktree remove leaves git-ignored
    files behind, so a folder without .git remains after each wts rm.
  2. Worktrees on a dead branch — removed with their tmux session and branch.
  3. Dead branches without a worktree — their content is entirely in the base.
  4. Orphan branches — the remote branch is gone but the content is not in the
    base. Never deleted automatically: listed with their commit count, your call.
  5. Orphan registry entries whose worktree disappeared.
  6. Stale index.lock — a git process killed mid-operation leaves one behind, and
    from then on every write in that worktree fails with Unable to create '.git/worktrees/<session>/index.lock': File exists. Nothing reports it, so the
    worktree looks fine until the next git add.

Why not git branch --merged. It only recognizes merges by ancestry. A pull
request merged by squash or rebase rewrites the SHAs, so the branch is never
an ancestor of the base and piles up forever. wts gc also compares patch-ids
(the test behind git cherry, with the base hashed once for all branches rather
than once per branch): a branch whose every commit has an equivalent in the base is
entirely present in it, whatever the merge method, and is deleted with
git branch -D. A deleted remote branch is read from %(upstream:track) ==
[gone], which only --prune reveals.

Safety rules:

  • Only <repo>-worktrees/ is scanned for husks, and a folder with a .git is never
    proposed.
  • A branch with no commit since it was created is never collected: a fresh session
    whose agent has not committed yet looks "merged" to git.
  • A worktree with uncommitted changes, or whose agent is busy (working, blocked,
    stuck?), is left in place and listed as such.
  • The current tmux session is never killed.
  • An index.lock is only removed once it is empty, older than WTS_LOCK_STALE_AFTER
    (5 min) and held by no live process: deleting a lock somebody owns would corrupt
    their index.

Persistence and restore

A reboot kills the tmux server, not the worktrees. wts records every session
in the sessions table of its state database,
${XDG_STATE_HOME:-~/.local/state}/wts/wts.db:

$ wts db sql "select * from sessions where name = 'auth-form'" --json
[{"name":"auth-form","profile":"feature","repo_root":"/home/me/code/myapp",
  "worktree":"/home/me/code/myapp-worktrees/auth-form","branch":"feature/auth-form",
  "subdir":"","context":"","prompt":"validate the email server-side before sending",
  "docs":"[]","created_at":"2026-09-05T15:12:41Z"}]

The same database holds the wts brief cache, the fetched documents, the stale
guard's pane hashes and the agents' notes. It runs in WAL mode: the switcher and
any number of agents read while one process writes, and a second writer waits its
turn instead of overwriting the first — the lost update the old JSON file allowed.

wts restore [name...] replays tmuxinator start --no-attach for every registered
session missing from tmux whose worktree still exists — all of them without
arguments. It never attaches: restoring eight sessions should not steal your
terminal. wts ls purges entries whose worktree is gone. This is a declarative
replay
, not a snapshot like tmux-resurrect: a wts session is fully described by
its name, layout and context, so replaying the layout is more faithful.

wts stop <name> leaves the same state on purpose, without a reboot: the tmux
session is killed, everything else stays, and wts restore <name> replays it.

During wts restore, layouts see WTS_RESTORE=1 and skip heavy commands —
otherwise eight sessions mean eight claude and eight dependency installs at once.
The Claude pane is not empty for all that: the command is pre-filled on the
pane's zsh command line (print -z), press Enter to run it. When a conversation
exists for the pane's directory, WTS_RESUME=1 and claude --continue is proposed;
otherwise plain claude, since --continue would fail. The leading space keeps the
command out of your history (histignorespace).

<% claude_cmd =
    if restore
      ENV['WTS_RESUME'].to_s == '1' ? %q{" print -z 'claude --continue'"} : %q{" print -z claude"}
    elsif task.empty?
      'claude'
    else
      "claude #{task.shellescape}"
    end %>
        - <%= claude_cmd %>

wts restore is manual. To run it after a reboot, add to ~/.zshrc — it only fires
once, on a cold tmux server:

if [[ -z "$TMUX" ]] && ! tmux has-session 2>/dev/null; then
  wts restore >/dev/null 2>&1
fi

Agents share state: wts db

Agents running in parallel on one repository used to be blind to each other: two
of them could rework the same file, or one could change an API another was
building on, and only you knew. wts already knows every session, so it shares
that knowledge with the agents themselves.

Every agent is told, automatically. wts setup claude --install adds a
Claude Code SessionStart hook (user-wide, in ~/.claude/settings.json). In a
wts session it puts a short block at the top of the agent's context — again after
/clear, /compact and a resume:

# wts: you are in session `auth-form` (branch feature/auth-form, worktree …)
Other sessions (same repository first):
- rate-limit (feature/rate-limit): rate-limit the public API per key — last brief: done: … / next: …
- csv-export (feature/csv-export): export users as csv
Latest notes left by the other agents:
- rate-limit/api-contract (2026-09-28T09:12:03Z): /login now answers 429 with Retry-After
Query it when your work may overlap another session's …
  wts db sql "select name, branch, prompt from sessions" --json
  wts db notes --all
Leave a short note when you change something another session may depend on …
  wts db set <key> "<one line>"

Anywhere else — a Claude started outside wts — the hook prints nothing. It reads
the database only: no git status, no model call, about 0.1 s.

What an agent (or you) can do:

wts db sql "<SELECT ...>" [--json]    read anything: sessions, briefs, notes, doc_cache...
wts db schema                         the tables
wts db notes [--all] [--json]         this session's notes, or everyone's
wts db get <key>                      one note of this session
wts db set <key> <value|->            write a note ('-' reads stdin)
wts db del <key>
wts db path

Reads cover every table. Writes cover only notes, keyed by the session the
command runs in — found from the tmux pane ($TMUX_PANE), else from the worktree
containing the working directory; --session <name> overrides it. wts db sql
opens the database read-only and in sqlite3's safe mode (no .shell, no
ATTACH, no readfile), so no query can damage the registry. wts rm and
wts gc drop the notes of the sessions they remove.

Upgrading to 1.0

1.0 moves all of wts's state from files to one SQLite database. Nothing to do by
hand beyond upgrading, but here is what happens and how to check it.

  1. Upgrade: brew update && brew upgrade wts (or git pull && make install).
    wts --version prints wts 1.0.0. sqlite3 must be on the PATH: macOS
    ships it.
  2. Optional backup: cp -R ~/.local/state/wts ~/.local/state/wts.bak-0.x
    (or under your XDG_STATE_HOME).
  3. Run any wts command — wts ls will do. The first one imports the old state
    into wts.db and says so:
    → state imported into …/wts/wts.db (8 sessions; old file kept as sessions.json.migrated).
    Imported: the registry (sessions.json) and the fetched documents
    (docs/*.md). Dropped, and rebuilt on first use: the wts brief cache
    (brief/) and the stale guard's pane hashes (panehash/).
  4. Check: wts ls lists the same sessions as before, and
    wts db sql "select count(*) from sessions" gives their number.
  5. Let the agents see each other: wts setup claude, read it, then
    wts setup claude --install. Agents already running pick it up at their next
    /clear, /compact or restart.
  6. Scripts that read sessions.json directly: switch to wts status --json
    (unchanged contract) or wts db sql "…" --json.

Rolling back to 0.4.3: reinstall it, then
mv ~/.local/state/wts/sessions.json.migrated ~/.local/state/wts/sessions.json.
Sessions created under 1.0 are missing from that file (their worktrees and
branches are untouched; wts <name> in the repository registers one again).

Layouts

A layout is a tmuxinator project file
(ERB + YAML). wts <name> <layout> looks it up in this order:

  1. $WTS_LAYOUTS_PATH/<layout>.yml, by default ${XDG_CONFIG_HOME:-~/.config}/wts/layouts/
  2. the built-in <prefix>/share/wts/layouts/<layout>.yml

A user layout shadows a built-in one of the same name; wts layouts lists what is
found, with paths. The built-in default layout opens $EDITOR next to Claude
Code, plus a shell window. tmuxinator is started with
--project-config <file> --name <session>, so your own ~/.config/tmuxinator
projects are untouched.

mkdir -p ~/.config/wts/layouts
cp "$(wts layouts | awk -F'\t' '$1 == "default" { print $2 }')" ~/.config/wts/layouts/feature.yml

examples/layouts/ has two richer ones: feature (nvim, Claude,
dev servers, lazygit) and sentry (Claude starts investigating the issue id given
as context).

Branch prefix. A layout declares the prefix of the branches it creates in a
comment line, conventionally the first one; without it, the branch is the session
name.

# wts: branch_prefix=feature/

WTS_BRANCH_PREFIX overrides it for one call, even when empty:
WTS_BRANCH_PREFIX= wts login-flow feature.

Variables exposed to layouts, readable in ERB with ENV['…']:

Variable Value
WTS_NAME session name (given, or proposed from the phrase)
WTS_ROOT absolute path of the worktree
WTS_WORKDIR WTS_ROOT + WTS_SUBDIR when set, else WTS_ROOT
WTS_CONTEXT remaining arguments, joined
WTS_PROMPT the phrase of wts "<phrase>" (empty otherwise)
WTS_RESTORE 1 during wts restore
WTS_RESUME 1 during wts restore when a Claude conversation exists

Use WTS_WORKDIR for root: and WTS_ROOT for commands that must run from the
worktree root. Panes do not inherit the environment of wts (the tmux server is
already running): read variables in ERB, not in pane commands. To start Claude with
the phrase, copy the claude_cmd block of default.yml, which escapes it for the
pane's shell. Keep layout files ASCII, comments included: without a UTF-8
locale (LANG=C), Ruby refuses to read them ("invalid byte sequence in US-ASCII").

Configuration

Variable Default Role
WTS_BASE_BRANCH detected base of new branches; accepts a remote ref (origin/main)
WTS_WORKTREES_BASE ../<repo>-worktrees where worktrees are created
WTS_LAYOUTS_PATH ~/.config/wts/layouts user layouts, searched before the built-in ones
WTS_SUBDIR (none) subdirectory of the worktree where panes start
WTS_BRANCH_PREFIX from the layout branch prefix override, even empty
WTS_MODEL haiku model used for naming and wts brief
WTS_NO_LLM (none) 1: never call the model
WTS_NAME_TIMEOUT 30 naming timeout, seconds
WTS_BRIEF_TIMEOUT 45 timeout of one summary, seconds
WTS_BRIEF_JOBS 4 concurrent summaries
WTS_DOCS_PATH ~/.config/wts/docs.json the context document library
WTS_DOC_TTL 86400 seconds before an attached URL document is fetched again
WTS_DOC_TIMEOUT 90 fetch timeout, seconds (MCP handshakes are slow)
WTS_DOC_MODEL sonnet model used to fetch a document (fidelity over latency)
WTS_DOC_TOOLS (enumerated) pinned allow patterns for the fetch, space-separated
WTS_DOC_TOOLS_TTL 86400 seconds the claude mcp list enumeration is cached
WTS_DOC_MAX_BYTES 200000 cap on a document, and on all of them together
WTS_STALE_AFTER 10 seconds before a frozen working agent shows stuck?
WTS_SWITCH_REFRESH 2 switcher refresh interval, 0 for a static list
WTS_SWITCH_SCROLLBACK 2000 lines of tmux history reachable in the preview
WTS_LOCK_STALE_AFTER 300 seconds before wts gc calls an index.lock stale
CLAUDE_CONFIG_DIR ~/.claude where Claude Code keeps sessions and transcripts

XDG_STATE_HOME and XDG_CONFIG_HOME are honored.

WTS_MODEL is passed to claude --model as is. The haiku alias resolves on the
Claude API; behind Amazon Bedrock or Google Vertex AI it need not — ids are prefixed
(anthropic.claude-haiku-4-5) or dated with an @ there. Set WTS_MODEL to the id
your platform accepts: an id claude does not recognize is answered in prose, which
wts reports as claude: [claude-code:unrecognized_model] and falls back from.

Base branch detection

Without WTS_BASE_BRANCH, wts uses, in order: origin/HEAD, a local main, a
local master, the current branch. This detection does not fetch —
prefix+: wts is what guarantees a fresh base. On an old
clone without origin/HEAD, fix it with git remote set-head origin -a.

Everything that compares against the base — the delta and ^ahead in wts ls,
the switcher and wts status --json, and wts gc and wts brief — uses
origin/<base> when that ref exists, since that is what branches are cut from. Your
local copy of the base is usually behind, and against it a branch with no commits of
its own is credited with everything the base was missing.

Monorepo: WTS_SUBDIR

When you always work in a subdirectory, set WTS_SUBDIR and every pane starts
there instead of the worktree root; commands anchored at the root keep using
WTS_ROOT. Per repository, with direnv:

# <repo>/.envrc   (ignore it globally if the repository is shared)
export WTS_SUBDIR=apps/api

Or once: WTS_SUBDIR=apps/api wts my-feature. If the directory does not exist in
the worktree, wts warns and starts at the root.

Large repositories

Every wts ls, prefix+a and switcher refresh runs git status on each
registered worktree. On a 150k-file repository that is half a second per
worktree, and with several worktrees the tree walks no longer fit the OS file
cache. Two git settings make it a few hundredths of a second: fsmonitor (git's
built-in file watcher, git 2.37+ on macOS) and the untracked cache. wts setup git
prints them; they apply to the repository and all its worktrees:

git config core.fsmonitor true
git config core.untrackedCache true

wts ls says so once on stderr when a pass takes more than three seconds and
the repository has not enabled them. Measurements and the reasoning are in
docs/big-repo-analysis.md.

Idempotence

  • An existing worktree is reused.
  • An existing session is detected with tmux has-session -t =<name> — an exact
    match: without =, tmux accepts a prefix and fix-login would match
    fix-login-2 — and wts attaches to it (or switches client, inside tmux) without
    running tmuxinator again.
  • wts restore skips sessions that are already running.
  • The registry entry is rewritten on each launch; created_at and prompt are kept.

Known limitations

  • Session names are global to the tmux server: two auth-form worktrees in two
    repositories share one session and one registry key. Prefix the name
    (api-auth-form).
  • macOS first. Linux is untested.
  • Some Claude Code internals are undocumented: the tmux field of
    ~/.claude/sessions/<pid>.json (used to target the preview pane), the record types
    of transcript .jsonl files and the way their directory is named. When they
    change, the affected columns and summaries degrade to - or raw facts; nothing
    else breaks.
  • The restore pre-fill (print -z) assumes zsh in the panes.
  • wts rm and wts gc act on the repository of the current directory.
  • A document fetched through WebFetch is a model's rendering of the page, not
    the page.
    That tool summarizes whatever it reads, and asking it not to does not
    change that. A document read through an MCP connector comes back verbatim; check
    with wts doc show <slug> when fidelity matters.
  • A document's content is written in clear text inside the worktree
    (.wts/context.md). Keep secrets out of the library.
  • A session named doc is shadowed by the subcommand, as pr and rm already are.

Contributing

Issues and pull requests are welcome. Run zsh test/smoke.zsh before submitting:
it drives a throwaway repository on an isolated tmux server, without calling the
model.

License

MIT

Reviews (0)

No results found