@cestoliv/forest
v0.1.10
Published
wt (git worktrees + AI agents) and agent-spawner (Todoist daemon) in one package.
Readme
wt
A fast TUI for git worktrees — browse, create, open, and delete without leaving the terminal. Plus one-command AI agents:
wt agent fix-auth "Plan the auth refactor"→ spins up an isolated worktree and launches Claude in Zed, pre-loaded with your prompt.
Install
npm install -g @cestoliv/forest # provides `wt` and `agent-spawner`Requires Node.js 20+ and Git. The command is wt. This package also installs
the agent-spawner daemon (a Todoist-driven dispatcher for wt agent); see
the root README / root CLAUDE.md for its docs. Optionally install the
gh and/or glab
CLIs (authenticated) to let wt prune confirm merges via merged PRs/MRs — see
Prune.
Let your AI assistant set it up
Already using an AI coding assistant (Claude Code, Cursor, …)? Paste this prompt
— it reads wt skill to learn the tool, then configures wt for you:
Run `wt skill` to learn how the `wt` CLI works and what its config options are.
Then configure it for me: choose sensible values for my editor, base branch,
setup commands, and the `wt agent` settings — ask me about anything you can't
infer from this project. Write the result to the config file (find its path with
`wt config --path`), then show me the final config.Quick start
wt # Browse worktrees (interactive TUI)
wt create my-feat # New worktree, opens your IDE
wt agent my-feat "Plan the feature" # New worktree + AI agent in Zed (macOS)
wt agent fix-bug "Fix bug" --mode auto # Use auto mode instead of the default
wt agent big-job "Plan it" --model fable # Bigger model for one run
wt prune # Remove merged worktrees (per-branch confirm)
wt count # Count worktrees, total and per repo
wt config # Edit config in $EDITOR
wt skill # Print the skill file (for AI agents)wt agent <branch> <plan_prompt> [--mode <mode>] [--model <model>] [--repo <path>] [--ide <ide>] — the standout
wt agent feat/login "Read the codebase, then propose a plan for login."
wt agent fix-bug "Fix the auth bug" --mode auto
wt agent refactor "Refactor API layer" --mode default
wt agent feat/login "Plan login" --ide orca # start the agent in Orca instead of Zed
wt agent big-refactor "Plan the refactor" --model fable # use a bigger model for one runCreates a worktree exactly like wt create, then auto-starts your agent
(default claude, run with --permission-mode default) in Zed's integrated
terminal — pre-filled with your prompt and left interactive for you to take over.
Zed or Orca. The launch target is the ide config key (default zed),
overridable per invocation with --ide zed|orca. With --ide orca (or ide
set to orca) wt agent instead registers the repo with Orca (orca repo
add) and starts the same agent command inside a terminal attached to the
worktree (orca terminal create) — same worktree-creation flow, same command
string, different host. If Orca's runtime isn't running it launches it (orca
open) and waits a few seconds for it to come up before continuing; only if it
never becomes ready does it fall back to an actionable error without starting
the agent. Interactive runs also switch Orca to the new terminal (best-effort,
via orca terminal switch); the agent-spawner daemon deliberately doesn't (so
a batch of dispatches doesn't keep stealing focus).
Available modes (--mode, defaults to default; change the default with
the agent_mode config key):
default— Standard interactive mode with approval for each action (default)acceptEdits— Allow file changes but keep command execution controlledplan— Architecture-first mode with no surprise mutationsauto— Claude's safety model makes decisions instead of promptingdontAsk— Minimal interruptions in trusted environmentsbypassPermissions— Skip all permission checks (dangerous, CI/sandbox only)
Model. --model overrides the agent_model config key (default unset →
Claude Code's own default); any model string is accepted (e.g. fable,
opus), no validation.
Under the hood it writes a temporary .zed/tasks.json, installs a global Zed
keymap chord, opens Zed and fires the chord via osascript, then removes the
temp task so the repo stays clean.
Requires macOS, Zed, and Accessibility permission for the app running wt.
Not granted yet? wt agent opens System Settings → Privacy & Security →
Accessibility, waits while you grant it (you may need to quit and reopen the
app), then retries automatically. This Zed automation applies only when ide
is zed; with --ide orca the agent runs via the Orca CLI instead (no
keymap/Accessibility needed). On other platforms — or when ide is neither
zed nor orca — the worktree is still created and opened, just without the
agent.
If the path already exists, wt agent offers to open it — or open it and start
the agent — instead of erroring. A piped or scripted run has nobody to answer
that prompt, so it starts the agent in the existing worktree. A non-interactive
wt create still exits non-zero, since it has no agent to fall back on.
Tip: trust the parent directory of your worktrees in Claude once, and every worktree created beneath it starts hands-free.
Browse — wt
An interactive, fuzzy-searchable list of your worktrees:
MY-PROJECT
▶ main (main) ~/dev/my-project
fix: resolve auth bug (2h ago)
feat/dashboard ~/dev/my-project-feat-dashboard
wip: add chart component (1d ago)
↕ navigate · Enter open · D delete · P prune · C create · A agent · Q quitType to fuzzy-filter branches instantly. wt always shows worktrees across
all registered repos, regardless of where you run it — the current repo is
auto-registered for discovery, never used to scope the list.
C creates a worktree and A creates one and starts an AI agent in it — both
work from anywhere and are step-by-step wizards. They always prompt for the
repo first, then the branch (C stops there); A adds a plan prompt and a
permission mode — worktree (repo → branch) → plan prompt → permission
mode. Pressing Esc steps back to the previous question (answers preserved),
or back to the list from the first step. After creating, the list refreshes
and stays open (preserving your search and cursor) instead of exiting — only
Enter and Q/Esc leave the TUI. P prunes every worktree whose branch has
already been merged or whose PR/MR was closed without merging (see
wt prune below). Note that
a/c/d/p are command keys, so they can't be typed into the search box.
The main worktree is tagged (main) and is protected — D only removes linked
worktrees, never the main repository.
Create — wt create [branch] [--repo <path>] [--ide <ide>]
wt create feat/login # From base branch (origin/main by default)
wt create # Prompts for a branch name
wt create feat/login --repo ~/dev/my-project # Skip the picker, target a repo
wt create feat/login --ide orca # Open the new worktree in Orca instead of ZedCreates a worktree as a sibling directory (../my-project-feat-login), runs your
setup_commands, and opens it in your IDE. The branch name you type is
slugified into a valid git branch name first — spaces and characters git
forbids in a ref (e.g. detection issues 13-07) become dashes rather than
failing with fatal: … is not a valid branch name. Already-valid names,
including namespaced ones like feat/login, are left untouched; when the name
changes you're told which branch you got. It always prompts you to pick the
target repo from the registered repos (the current repo is auto-registered for
discovery but never assumed) — so in a non-interactive shell it exits non-zero
because the picker needs a TTY. Pass --repo <path> to name the target repo
explicitly and skip the picker (the path is validated as a git repo; a bad path
errors). wt agent accepts the same --repo <path> flag.
Pass --ide <ide> to override the configured ide for this run (precedence:
--ide → config.ide → the default zed). --ide orca opens the worktree in
Orca — it registers the repo (orca repo add) and opens a terminal on the
worktree (orca terminal create) rather than spawning an editor. wt agent
accepts the same --ide <ide> flag.
If the path already exists, wt create offers to open it in your IDE instead of
erroring (in a non-interactive shell it exits non-zero).
Prune — wt prune
wt prune # remove every merged worktree, one confirmation per branchCleans up the worktrees you're done with: it finds every worktree whose branch
has already been merged into the base branch (base_branch, default
origin/main) or whose PR/MR was closed without merging (the fix landed
another way, so the branch is dead) and removes it — always confirming each
branch individually,
and force-confirming when git refuses (submodules or uncommitted changes), just
like a manual D delete. The branch itself stays; only the worktree is removed.
Your teardown_commands run before each removal.
Prune (and the TUI D key) removes the current worktree — the one you ran
wt from — just like any other; the behaviour doesn't change with your launch
directory. The main worktree stays protected. When the removed worktree is the
one you're standing in, your shell is left in a directory that no longer exists,
so a warning printed as wt returns to your shell points you at a directory
that still exists to cd into. The per-branch confirmation is your chance to
say no first.
Removing a worktree — via wt prune or the TUI D key — also best-effort
stops that worktree's Orca agent and terminal (orca terminal stop --worktree
path:<worktree>) before the teardown commands and the git worktree remove, so
a live agent shell can't keep the directory busy. It only ever probes Orca
(orca status): it never launches Orca, and it's a silent no-op when Orca isn't
running, isn't installed, or never knew about that worktree (any non-Orca
worktree). Nothing it does can fail or block a deletion.
A worktree is pruned when any of four signals says so. The two offline ones run first, so most branches are decided without touching the network:
- Patch id (
git cherry): every commit on the branch already exists in base as an equivalent diff — a single-commit branch squash-merged through a PR, or a rebase-merge. Offline, no false positives. - No unique commits: the branch adds nothing base doesn't already have,
which is what a fast-forward / merge-commit merge leaves behind — but also
what a brand-new worktree holding only uncommitted work looks like. Git
can't tell them apart, so this counts only when the worktree is clean and
the branch was pushed. Work in progress is never mistaken for merged. (The
trade-off: an abandoned worktree you never pushed is never offered either —
delete it with
D.) - A merged PR/MR on the forge (via
ghfor GitHub orglabfor GitLab, including self-hosted, auto-detected from the remote). This is what catches a squash the forge rebased onto a newer base: the resulting commit has a different patch than yours and your branch is still ahead of base, so no amount of local git can see the merge. - A PR/MR closed without merging, with none still open on that branch — the fix landed another way, so the branch is dead. Like the previous signal it does no git ancestry checks, so it too can prune a branch that is ahead of base. The "none still open" part matters: closing a PR and opening a fresh one from the same branch is routine, and the superseded PR must not read as a death notice for work that is still in review.
Both forge lookups only count a PR/MR whose target is your configured
base_branch, so a branch merged into develop is never reported prunable
against main. Both are skipped for never-pushed branches (they can't have a
PR/MR), and everything fails closed: offline, missing CLI, or an
unresolvable base ref means nothing is removed. wt prune best-effort fetches
the remote first so detection sees up-to-date refs. Note that the forge is
queried by branch name: a branch recreated under the name of an old merged
or closed PR will match it — the per-branch confirmation is your backstop. The
TUI exposes the same action under the P key. Always runs across all registered
repos (each against its own base_branch).
Auto-pull after prune
Once at least one worktree is removed, wt prune fast-forwards the affected
repos' main worktree (git pull --ff-only) so your primary checkout picks
up the changes that were just merged. It only ever fast-forwards — never
fabricates a merge commit or a conflict in the primary checkout — and each repo
is skipped (with a note) rather than failing the prune when it can't pull:
- the main worktree isn't on the
base_branch(detached, or a feature is checked out there); - the main worktree has uncommitted changes;
- the repo has no matching remote;
- the fast-forward itself fails (e.g. the branch has diverged) — the message is surfaced and you're told to pull manually.
Pass --no-pull to skip this entirely. The TUI P key always auto-pulls
(there is no TUI opt-out; --no-pull is CLI-only).
Count — wt count
wt countPrints the total number of worktrees across every registered repo, plus a per-repo breakdown, sorted by count (descending, then by repo name):
Total: 4 worktrees
forest 2
overload 1
website 1The main checkout of each repo doesn't count — only linked worktrees do. Every
registered repo gets a row, even one with no linked worktrees (0).
Configuration
Edit with wt config (wt config --path prints the file location —
~/Library/Preferences/wt-nodejs/config.json on macOS). The file is
beautified before the editor opens, and if it's still invalid JSON when you
close the editor, wt config prints the parse error and exits 1.
| Key | Default | Description |
| --------------------- | --------------------------------- | ----------------------------------------------------------------------------------- |
| ide | "zed" | Where to open worktrees / start the agent — zed (editor + task automation) or orca (via the Orca CLI). Override per run with --ide |
| ide_open_args | ["-n"] | Extra args passed to the IDE command |
| base_branch | "origin/main" | Branch new worktrees are created from |
| worktree_path | "../" | Where worktrees are placed (relative to repo) |
| setup_commands | [] | Commands to run in new worktrees (supports {{…}} templating) |
| teardown_commands | [] | Commands to run in a worktree just before it is deleted (e.g. ["docker compose down -v"]; supports {{…}} templating) |
| agent_command | "claude" | Base command; --permission-mode <mode> injected. Prompt is substituted at {{prompt}} if present, else appended (supports {{…}} templating) |
| agent_mode | "default" | Default permission mode for wt agent (overridden by --mode) |
| agent_model | "" | Model passed to the agent as --model; empty = not passed (Claude Code default) |
| agent_trigger_chord | "ctrl-shift-cmd-c" | Zed keymap chord wt agent installs and presses |
| auto_refresh_minutes| 5 | How often the interactive list re-fetches worktrees (shows a "last refreshed" header); 0 disables it. Global only — not per-repo overridable |
| repo_overrides | {} | Per-repo overrides for the keys above (except the global-only auto_refresh_minutes) |
Override any key per repo (except the global-only auto_refresh_minutes):
{
"repo_overrides": {
"/path/to/repo": {
"base_branch": "origin/develop",
"setup_commands": ["pnpm install", "pnpm build"]
}
}
}Command templating
setup_commands, teardown_commands, and agent_command are expanded for
{{…}} placeholders just before they run, so you can weave the worktree's
branch, path, and more into them:
{
"agent_command": "claude --remote-control {{branch}}",
"setup_commands": ["direnv allow {{path}}"]
}wt agent feat/login "…" then runs claude --remote-control feat/login ….
| Variable | Expands to | Available in |
| --------------- | ---------------------------------- | ----------------------------------------------------- |
| {{branch}} | The worktree's branch name | setup_commands, teardown_commands, agent_command |
| {{project}} | The repo directory name (basename) | setup_commands, teardown_commands, agent_command |
| {{path}} | Absolute path to the worktree | setup_commands, teardown_commands, agent_command |
| {{repo_root}} | Absolute path to the repo root | setup_commands, teardown_commands, agent_command |
| {{prompt}} | The agent plan prompt | agent_command only |
In agent_command, {{prompt}} is replaced by the plan prompt: if you use it,
the prompt is placed exactly there instead of being auto-appended. If you omit
{{prompt}}, the prompt is appended automatically (single-quoted) at the end.
Whitespace inside the braces is allowed ({{ branch }} == {{branch}}) and
names are case-sensitive. An unknown or unavailable variable is left
verbatim — never blanked out. Values are inserted raw (no shell-escaping),
so quote them yourself if a value might contain spaces.
agent-spawner
A macOS daemon that polls Todoist for tasks labelled Agent Ready and
dispatches each one to a wt agent worktree + Zed session — in-process, via
the same runAgent seam wt agent itself uses (no subprocess, no version
skew between the daemon and wt). After dispatching it swaps the label to
Agent Working; on failure, or when no routing rule matches, it swaps to
Agent Error with an explanatory comment and leaves Agent Ready so it can
be retried. See packages/cli/CLAUDE.md's ## agent-spawner section for the
full architecture.
A Todoist due date acts as a start date. A task with no due date is picked up on the next tick. A task due later waits until that moment passes, so you set a due date in the future to schedule work for a later day.
Worktree caps hold work back too. Set maxWorktrees or maxWorktreesPerRepo
(see Configuration) and the daemon stops dispatching once a repo is full,
without labelling anything Agent Error.
Your Claude usage holds work back as well. Before each dispatch the daemon
reads the weekly and 5h rate-limit windows of your Claude subscription and
keeps a decreasing reserve for your own interactive work: early in the week it
protects several days of it, the evening before the reset almost none. See
usage in Configuration.
What one tick decides:
flowchart LR
tick([Tick]) --> due{"Any Agent Ready<br/>task due?"}
due -- no --> wait([Wait for the next tick])
due -- yes --> known{"Gate on and<br/>usage measured?"}
known -- yes --> pre{"Inside preResetHours<br/>of the reset?"}
pre -- yes --> spent{"Weekly limit<br/>fully spent?"}
spent -- yes --> hold
spent -- no --> bonus["Raise both caps by<br/>preResetBonusWorktrees"]
bonus --> caps
pre -- no --> reserve{"weeklyUsed + reserve < 100%?<br/>reserve = dailyReservePercent<br/>× days to the reset"}
reserve -- no --> hold
reserve -- yes --> ceiling{"5h window under<br/>sessionMaxPercent?"}
ceiling -- no --> hold
ceiling -- yes --> guard{"Night: 5h window free<br/>by morningGuardHour?"}
guard -- no --> hold([Hold: keep Agent Ready,<br/>log the reason])
guard -- yes --> caps{"Worktree caps<br/>have room?"}
known -- "no: off, no token,<br/>request failed" --> caps
caps -- no --> hold
caps -- yes --> spawn([Dispatch one task])Inside night.hours the night block replaces dailyReservePercent and
sessionMaxPercent, and adds the morning guard. The rest of the flow is the
same at every hour.
Installed by the same npm install -g @cestoliv/forest above.
agent-spawner run # foreground (use this first to grant Accessibility)
agent-spawner install # launchd auto-start on login
agent-spawner uninstall # remove the launchd LaunchAgent
agent-spawner logs # tail the daemon log
agent-spawner config [--path] # open config in $EDITOR, or print its pathConfiguration
Config essentials (agent-spawner config --path for the file location;
config.example.json is a ready-made starting point). Unlike wt config,
running agent-spawner config on a first run with no config file seeds it
with the defaults before opening the editor. As with wt config, the file is
beautified on open, and an invalid JSON edit prints the parse error and exits
1 instead of leaving you stuck:
token— a Todoist API token (or setTODOIST_API_TOKEN)labels— theready/working/errorTodoist label ids the daemon reads and swapsrules— ordered routing rules, each{ project, labels?, path, ide?, promptTemplate? }: the first rule whose Todoist project id matches (and whoselabels, if given, are all present on the task) wins, andpathis the absolute (or~-expandable) local repo root to dispatch into. Optionalide(zedororca) picks the launch target for that route; when omitted it falls back towt's configuredidedefault. OptionalpromptTemplateoverrides the globalpromptTemplatefor that route, with the same placeholders; when omitted the global one is usedmaxWorktrees(default0) andmaxWorktreesPerRepo(default{}) — worktree caps.maxWorktreescounts every repo therulespoint at together;maxWorktreesPerRepocaps one repo, keyed by the same path a rule uses (~is expanded).0, or an absent per-repo entry, means unlimited. A worktree you created by hand counts too; one whose directory you deleted does not. At a cap the daemon holds the task: it keepsAgent Ready, gets noAgent Error, and a later tick dispatches it once you prune a worktree. A task whose worktree already exists is never held, since running it adds none. Only the daemon obeys the caps, sowt createandwt agentstill work by hand. A key that matches norules[].pathis a config error, so a typo can't leave a cap that quietly does nothingusage— the usage gate, on by default. Before each dispatch the daemon readshttps://api.anthropic.com/api/oauth/usagewith the OAuth token Claude Code stores (macOS Keychain first,~/.claude/.credentials.jsonsecond), the same numbers Claude Code's/usageshows. Usage it cannot measure (no credentials, a refused or failed request) opens the gate, so an expired token never freezes the daemon. Fields:enabled(defaulttrue) —falseskips the read entirelydailyReservePercent(default13) — percent of the weekly limit to reserve per day left before the reset. The daemon holds a task when100 - weeklyUsed - dailyReservePercent × daysToResetdrops to zero, so it is conservative early in the week and aggressive the evening before the reset, with no ramp-up curve to tune.13is one heavy interactive day on a Max plansessionMaxPercent(default50) — utilization of the 5h window above which the daemon stops dispatching, so an agent cannot eat the window you're working inpreResetHours(default8) — the last hours of the week, where what the reserve holds back would be lost rather than saved. The reserve and the 5h ceiling both drop, leaving100 - weeklyUsedpreResetBonusWorktrees(default2) — worktrees allowed over both caps during those hours. They outlive the reset, so the repo stays over its cap until you prunenight(default{ "hours": [2, 6], "dailyReservePercent": 4, "sessionMaxPercent": 90, "morningGuardHour": 8 }, ornullfor one regime all day) — overrides for the local hours you're asleep, plus a morning guard: a night dispatch whose 5h window would still be open atmorningGuardHouris held, so it cannot eat the window you wake up into. Anhoursend at or before its start wraps midnight. The guard, nothours, is what ends the night in practice: with[2, 6]and a guard of8, a window opened after 03:00 would run past 08:00, so dispatches stop there. RaisemorningGuardHourto lengthen the night. The night regime only fires if the machine is awake then, which is apmsetmatter
pollIntervalSeconds(default600) andpromptTemplate(built with{{url}},{{title}},{{id}},{{description}},{{projectId}}placeholders) round out the poll loop and the prompt sent towt agent
Pre-release builds
Add the publish-dev label to a PR to publish that branch as a unique, pinned
prerelease (e.g. 0.1.0-pr12.gabc1234); the exact install command is posted as a
PR comment. There's no rolling dev channel — each build is a distinct version
you install explicitly.
License
MIT
