_____
/ ____|
| | __ _ _ __ ___ _ __ _ _
| | / _` | '_ \ / _ \| '_ \| | | |
| |___| (_| | | | | (_) | |_) | |_| |
\_____\__,_|_| |_|\___/| .__/ \__, |
| | __/ |
|_| |___/
TUI for managing git worktrees with paired tmux sessions, per-project setup hooks, and remote-host dispatch.
Status: v0.20, daily-driven by the author. APIs and on-disk state may still shift before v1.
canopy new and ten seconds later you're attached to a tmux session with nvim, claude, and a shell, all on a fresh git worktree against an isolated database with its own port. Reboot your laptop, canopy switch <name>, and you're back exactly where you left off — claude conversation included. Run canopy new --on tower --prompt "fix the timezone bug" and the same workspace lands on a beefier remote box while you keep working on your laptop.
AI-paired development means many parallel branches in flight at once: one agent refactoring auth, another fixing the timezone bug, plus the feature you're driving by hand. Raw git worktree + ad-hoc tmux new-session doesn't scale past three. Canopy is the missing orchestrator: per-workspace ports, per-workspace databases via scripts.setup, per-workspace tmux sessions with the same layout every time, agent-state badges so you can see which agent needs you, and one TUI that views every workspace across every host. See docs/landscape.md for where canopy sits next to Conductor, tmuxinator, raw git worktree, and the agent CLIs it hosts.
- Add a project from anywhere.
canopy initnow accepts a folder path or a git URL, so you can register a project withoutcd-ing into it:canopy init ~/code/fooorcanopy init https://github.com/foo/bar.git. URL form clones into your configured source-root (default~/.canopy/sources, override viacanopy config set source-root ~/Work) and registers in one shot. A new TUI Add Project form lives on the splash and on the Global tab (akeybind), withTabto cycle between local and registered hosts. Seedocs/getting-started.md. - Remote-host init.
canopy init <git-url> --on towerdispatches the clone+init to the remote canopy via SSH (reusing v0.17's ControlMaster plumbing) and auto-registers the new project in the laptop'shosts.jsonso the nextcanopy new --on towerresolves cleanly — no more manualcanopy project addafter init. canopy configsubcommand. Persistent user-level settings at~/.canopy/config.json. First key issource-root. Precedence: per-call dest >$CANOPY_SOURCE_ROOTenv > config file > built-in default. Get/set/list/unset, with(env)/(config)/(default)source labels so you can debug why a value isn't taking effect. Press,from any TUI tab to open the settings modal.
- v0.17 — Remote workspaces. Register an SSH-reachable host once with
canopy host add tower cassy@tower.tail.ts.net, then runcanopy new --on towerfrom your laptop. The heavy work runs on the host; one TUI views every workspace across every machine. Seedocs/remote-workspaces.md. Plus fire-and-forget agents (canopy new --prompt "..." --no-attach), in-TUIcanopy upgrade, and workspace identity that follows the branch (rename viagit branch -mand canopy's tmux session, statusline, terminal-tab title, and TUI rows pick up the new name within 15 seconds). - v0.18 / v0.19 — TUI picker for
canopy use; remote workspace observability (live ⚡ claude badge across SSH, ⊙ attached-client indicator, confirm-attach modal for remote rows, "⚠ stale Ns" pill when refresh data goes cold).
From inside any project that has a canopy.json:
canopy new # workspace with a random name (e.g. bold-falcon)
canopy new --name fix-bug # explicit name
canopy new --prompt "fix the bug" --no-attach # fire-and-forget claude
canopy new --pr 1214 # check out a GitHub PR into a workspace
canopy new --issue 42 # fresh branch, briefing seeded from issue body
canopy new --branch existing-feature # check out an existing remote branch
canopy new --on tower # dispatch to a remote host
canopy init <url> # clone a git URL + init in one shot (v0.20)
canopy init <url> --on tower # same, dispatched to a remote host (v0.20)
canopy main # tmux session anchored at the project root
canopy ls # workspaces in the current project
canopy ls --all # everything across every project + remote host
canopy switch <name> # attach (resurrect first if stopped)
canopy switch --on tower foo # attach over mosh+tmux to a remote workspace
canopy rm <name> # tear down (archive script + tmux + git + branch)
canopy retry <name> # re-run scripts.setup on a broken workspace
canopy rename [<name>] # sync labels to the current branch (--pin/--unpin)
canopy reconcile # update statuses to match disk + tmux realityEach workspace gets a 3-pane tmux session: nvim top-left, an agent (claude by default, with --continue on resurrect) top-right, and a shell full-width on the bottom. scripts.run (your dev server) launches on demand via canopy run rather than auto-starting — that way a stopped workspace resurrects to the same layout without a port collision.
Workspaces live at ~/.canopy/workspaces/<project>/<name> — canopy owns the storage so your source repo stays clean — and each one gets a unique TCP port via CANOPY_PORT.
canopy with no args launches a Bubbletea TUI: tabbed views (current project / Global / Remote hosts), arrow-key navigation, enter to attach, n to create, d to delete, i to inspect, U to upgrade, ? for help. CLI subcommands work alongside it; both call into the same workspace.Manager underneath.
Plus operational glue:
canopy init [path-or-url] [dest]— onboard a project. Three shapes:canopy initinits the cwd (createscanopy.json+ stubbin/canopy-*scripts; detectsconductor.jsonand adopts its schema);canopy init ~/code/fooinits a folder withoutcd-ing in;canopy init <git-url>clones + inits in one shot. Add--on <host>to dispatch the whole flow to a registered remote canopy. (v0.20)canopy config set|get|list|unset— user-level prefs at~/.canopy/config.json. First key issource-root(wherecanopy init <url>clones). Env override:CANOPY_SOURCE_ROOT. (v0.20)canopy host add <name> <ssh-target>— register a remote canopy host (with--interactivefor a guided form)canopy project add <name> <path> --on <host>— bind a project name to a path on a remote (auto-populated bycanopy init <url> --on <host>in v0.20+)canopy install tmux— write managed keybinds + statusline into~/.tmux.conf(idempotent, backed up)canopy upgrade— fetch + build the latest release;--checkfor a dry run,--dismissto silence pills until next releasecanopy use [target]— flip the active canopy binary betweenreleaseand any in-flight workspace's./canopycanopy version— version, commit, build date, active binary, DEV vs releasecanopy --debug— DEBUG-level JSON logs to~/.canopy/log/canopy.log(auto-rotated: 10 MB / 3 backups / 28 days / gzip)
Every workspace row in the TUI carries a small badge showing what the agent pane is doing — polled every 2 seconds across every workspace, local AND remote:
| Badge | Meaning |
|---|---|
⚡ (cyan) |
claude is thinking |
💤 (gray) |
claude is idle, ready for your next message |
✋ (yellow) |
claude is awaiting input (y/N or tool-permission popup blocking) |
· (subtle) |
workspace has no agent pane (or pane crashed to shell) |
Combined with canopy new --prompt "..." --no-attach, this turns canopy into a triage queue: spawn three claudes in parallel, do something else, glance at the TUI to see which one wants you.
The TUI's HINTS column surfaces problems before they bite. All inferred from git plumbing on every refresh — no extra config:
| Badge | Meaning |
|---|---|
⚠ conflict |
a merge conflict against origin/<default> is waiting for you |
⚠ rebasing / merging / pick / detached |
git is mid-operation; finish or abort it before doing more |
↑N ↓N *N |
N commits ahead of origin/<default>, N behind, N dirty files |
⇡N / ⇅ |
N commits unpushed to your branch's upstream, or upstream has diverged |
↗ rename-suggested |
branch is still on a namegen name (bold-falcon) but you've made progress — commits past origin/<default> OR tracked-file edits (untracked noise excluded). Try canopy rename. |
| PR status | open / approved / merged / closed (via gh pr view, polled out of band) |
Stuck-state badges (rebasing, merging, etc.) preempt the ↑N ↓N *N numbers because those numbers reflect git's transient internal state during the operation.
Every workspace gets a unique TCP port via CANOPY_PORT, allocated through a Conductor-style block plan:
- Each project's first workspace lands on
base_port(default 40000). - Subsequent workspaces in the same project step up by
workspace_stride(default 10): 40010, 40020, 40030, ... - A new project's first workspace lands
project_stridehigher than the previous project (default 1000): canopy → 40000, cravd → 41000, hey-cli → 42000.
Project-to-base assignments are first-come-first-served and persisted in state.json, so a workspace's port is stable across reboots.
Defaults are tweakable via ~/.canopy/config.json (optional file):
{
"ports": {
"base": 40000,
"project_stride": 1000,
"workspace_stride": 10
}
}Partial overrides are fine — any field you skip stays at the default.
If you live in tmux, three extra subcommands turn canopy into an always-one-keystroke-away workspace switcher and a glanceable status widget. All are inside-tmux-only.
One-shot install:
canopy install tmux # writes managed block to ~/.tmux.conf (with backup)
tmux source-file ~/.tmux.confThat's it. The installer is idempotent (refuses if already present; --force replaces in place), backs up ~/.tmux.conf before any change, and writes a clearly-marked managed block so you can see what canopy added:
# canopy:start (managed by `canopy install tmux` — edit only outside markers)
bind g run-shell "canopy popup"
bind -n C-M-c display-popup -E "CANOPY_IN_POPUP=1 canopy"
set -ag status-right " #(canopy statusline --format=current) "
set -g status-left-length 50
set -g set-titles on
# canopy:endWhat you get:
canopy popup—<prefix>gopens the global TUI in a tmux floating popup. Tabs: (current project's workspaces, default if launched from inside a project), Global (everything), Remote hosts (registered SSH boxes).Tabcycles tabs,/enters fuzzy search,Enterswitches to the selected workspace. Requires tmux 3.2+.Ctrl+Alt+c— no-prefix global chord that launches the same popup. One keystroke from anywhere in your terminal.canopy run—<prefix>rexecsscripts.run(e.g.bin/dev) from the nearestcanopy.jsonin a tmux popup. InheritsCANOPY_PORTand friends from the workspace tmux session.canopy statusline --format=current— appended tostatus-right, shows<project> / <branch> <glyph> :<port>when you're attached to a canopy workspace's tmux session, and empty otherwise. When the workspace folder name and branch have diverged (aftergit branch -m), both are shown:<project> / <wsName> / <branch>. When you're attached to a remote canopy viacanopy switch --on <host>, a yellow@<host>pill prefixes the line so you can tell at a glance you're working on a remote machine. Errors never propagate to stdout — your status bar stays clean even if state.json is corrupt or canopy crashes.
Manual install: paste the block above into ~/.tmux.conf and source-file it.
Workspaces don't have to live on your laptop. Register an SSH-reachable host once:
canopy host add tower cassy@tower.tail.ts.net
canopy host add tower --interactive # guided form, runs ssh-copy-id if needed
canopy host lsBind a project to a remote path:
canopy project add cravd /home/cassy/Work/cravd --on tower
canopy project lsThen dispatch from anywhere:
canopy new --on tower # uses the project path from the registry
canopy new --on tower --prompt "fix the bug" # remote fire-and-forget; prompt travels via base64+temp file, never in `ps`
canopy switch --on tower fix-bug # attaches via mosh+tmux (UDP, suspend-tolerant)
canopy switch --on tower fix-bug --share # multi-attach instead of stealing
canopy rm --on tower fix-bug # remote teardownThe TUI's Remote hosts tab shows every registered host with a live version, last-seen timestamp, and per-host workspace count:
From there:
enteropens a detail drawer with SSH target, projects, last-errornopens the in-TUI add-workspace picker (Fresh / Prompt / PR / Issue / Branch — parity with local; PR/Issue/Branch loaders SSHghandgit for-each-refagainst the remote project cwd)sdrops you into an interactivesshshell on the host (y/N gate first, refreshes when youexit)Urunscanopy upgrade --yeson the remote, streaming output to the TUISrunscanopy use releaseon the remote (recovery path for hosts running a DEV binary)are-runsssh-copy-idif a host lost key authdremoves a host (withFto force-delete a remote workspace whose worktree is hanging)
End-to-end guide with worked examples: docs/remote-workspaces.md.
Sample output of canopy ls --all with one local and one remote project:
TMUX PROJECT NAME BRANCH STATUS PORT SESSION
● canopy (main) — main 40000 canopy/main
● canopy polite-vale update-readme-and-docs ready 40010 canopy/update-readme-and-docs
● cravd (main) — main 41000 cravd/main
● cravd pr-1214 pd/follow-up-strategies ready 41020 cravd/pd-follow-up-strategies
↗ tower foo fix-the-bug ready 40010 canopy/fix-the-bug
The ↗ glyph in the TMUX column marks rows that live on a remote host.
curl -fsSL https://raw.githubusercontent.com/avinashjoshi/canopy/main/install.sh | shThat clones canopy to ~/.canopy/src, runs make install (which writes the binary to ~/.local/bin/canopy.bin and symlinks ~/.local/bin/canopy at it), and prints a PATH hint if ~/.local/bin isn't on your shell's PATH.
Idempotent: re-running on a machine that already has canopy installed prints "looks like canopy is already installed, run canopy upgrade instead" and exits 0.
| Tool | Version | Why |
|---|---|---|
git |
2.x+ | worktree creation per workspace |
tmux |
3.2+ | display-popup support (canopy popup keybind needs this) |
go |
1.22+ | canopy is built from source on install |
make |
any | drives the install pipeline |
mosh |
1.4+ (optional) | remote workspace attach (canopy switch --on <host>) |
ssh |
OpenSSH 8.x+ | remote dispatch + ControlMaster reuse |
install.sh enforces the required ones — if any are missing, it prints the exact install command for your OS and exits cleanly. Per-platform install lines:
- Arch / CachyOS / Omarchy:
sudo pacman -S git tmux neovim go mosh - Debian / Ubuntu:
sudo apt-get install git tmux neovim golang-go make mosh - macOS:
brew install git tmux neovim go mosh - Windows: canopy needs tmux, which doesn't run natively. Use WSL2 and run the Debian/Ubuntu line inside the Linux shell.
Canopy also expects nvim and claude (Claude Code) for the default tmux-pane layout — but workspaces can run anything (codex, aider, opencode), so these aren't checked at install time.
canopy upgradeThat fetches the latest VERSION from main, compares with what you're running, prints the CHANGELOG diff, and runs git pull --ff-only && make install in ~/.canopy/src. Refuses cleanly if you're on a dev binary (canopy use release first) or if ~/.canopy/src is missing/corrupt (re-run install.sh).
Flags:
canopy upgrade --check— compare versions without upgradingcanopy upgrade --force— rungit pull+make installeven when versions matchcanopy upgrade --yes— skip the changelog-confirm prompt (used by in-TUI upgrade)canopy upgrade --dismiss— silence the in-TUI upgrade pill until the next release ships
Canopy also auto-checks once every 6 hours in the background. When a newer release is out, the TUI's top-bar version pill mutates from v0.17.1.0 to v0.17.1.0 ⇑ v0.17.2.0 (yellow arrow), and canopy ls ends with one dim hint line. Press U inside the TUI to read the changelog in a scrollable viewport and run the upgrade without leaving canopy. Press D to dismiss the current available version. On the Remote hosts tab, U upgrades the selected remote (same streaming UX over SSH).
make -C ~/.canopy/src uninstall # remove ~/.local/bin/canopy{,.bin}
rm -rf ~/.canopy # remove the source clone, workspaces, state, and logsThe second line is destructive — it nukes every workspace on disk too. Only run it when you really mean to wipe canopy entirely.
canopy versionOutput looks like:
canopy v0.17.1.0+abc1234
binary: /home/you/.local/bin/canopy -> canopy.bin
commit: abc1234
built: 2026-05-13T12:34:56Z
mode: release
If you see command not found, ~/.local/bin isn't on your PATH:
export PATH="$HOME/.local/bin:$PATH"Run canopy init from your project root:
cd ~/Work/your-project
canopy init # writes canopy.json (no scripts)
canopy init --with-scripts # also writes bin/canopy-{setup,run,archive} stubsEdit the scripts, commit them, then run canopy new.
If the project already has a conductor.json (Conductor's config — same schema), canopy init detects it and copies the script paths verbatim. Your existing bin/conductor-* scripts keep working; CONDUCTOR_* env vars are exported alongside the canonical CANOPY_* ones so the scripts don't need changes. See docs/migrate-from-conductor.md.
{
"scripts": {
"setup": "bin/canopy-setup",
"run": "bin/dev",
"archive": "bin/canopy-archive"
}
}Three script paths, all optional. Each script gets the same env vars when canopy invokes it:
| Var | Meaning |
|---|---|
CANOPY_WORKSPACE_PATH |
absolute path to the workspace dir |
CANOPY_ROOT_PATH |
absolute path to the original repo root |
CANOPY_PORT |
allocated TCP port for this workspace |
setup runs once at workspace creation; failure flips the workspace to broken status (recoverable via canopy retry). run is the long-running server command, launched on demand by canopy run (or <prefix>r inside tmux). archive runs at workspace removal, before the worktree is deleted.
Full reference, including idempotency tips: docs/canopy-json.md.
# 1. Install
curl -fsSL https://raw.githubusercontent.com/avinashjoshi/canopy/main/install.sh | sh
canopy version
# 2. Onboard a project
cd ~/Work/your-project
canopy init --with-scripts
# … edit bin/canopy-setup, bin/dev, bin/canopy-archive …
git add canopy.json bin/canopy-* && git commit -m "canopy onboarding"
# 3. Install tmux keybinds
canopy install tmux
tmux source-file ~/.tmux.conf
# 4. Create a workspace
canopy new --name fix-timezone
# … you're now attached to a 3-pane tmux session at
# ~/.canopy/workspaces/your-project/fix-timezone …
# 5. Fire-and-forget a parallel claude
canopy new --prompt "fix the timezone bug in app/models/booking.rb" --no-attach
# 6. Glance at the queue
canopy ls
# Press Ctrl+Alt+c (or <prefix>g in tmux) to open the TUI popup instead
# 7. Resurrect after reboot
canopy switch fix-timezone
# Same layout, claude --continue picks up the conversation
# 8. Ship the workspace
gh pr create
canopy rm fix-timezone # archive script, drop branch, free port
# 9. (Optional) Add a remote host for heavy work
canopy host add tower cassy@tower.tail.ts.net
canopy project add your-project /home/cassy/Work/your-project --on tower
canopy new --on tower --prompt "expensive refactor" --no-attach
canopy switch --on tower expensive-refactor # mosh+tmux attachBug reports and PRs welcome — see CONTRIBUTING.md for setup, code conventions, and PR flow.
If you're hacking on canopy itself, you'll have multiple worktrees in flight (one per feature). The active canopy on PATH is a symlink — flip it between the released binary and any in-flight feature build with one command, no rebuild on the way back.
canopy use # show current target + list available
canopy use release # symlink → ~/.local/bin/canopy.bin
canopy use feature-A # symlink → workspace feature-A's ./canopy
canopy use --build feature-A # build feature-A's ./canopy first, then switchmake dev (from inside a worktree) is the muscle-memory wrapper for "build this and make it active"; make release flips back to the released binary without a rebuild. The active binary shows up as a DEV: <workspace> pill in the TUI top bar and a [DEV:<workspace>] suffix in the tmux statusline.
Convention: only run make install from main. From feature branches, make dev is the right tool — it doesn't touch the released canopy.bin, so parallel agents in other worktrees aren't affected.
Full make-task list:
make build # build ./canopy in the worktree (no install, no symlink change)
make install # build with ldflags, install to ~/.local/bin/canopy.bin, symlink canopy → canopy.bin
make dev # build + flip ~/.local/bin/canopy at this worktree's ./canopy
make release # flip ~/.local/bin/canopy back at canopy.bin (no rebuild)
make test # fast unit tests
make test-e2e # full E2E suite (real tmux, scratch repo, slow)
make lint # golangci-lint if installed
make uninstall # remove ~/.local/bin/canopy and canopy.bin
make clean # remove ./canopy in the worktree
User-facing guides:
docs/getting-started.md— 5-minute tour: install, init, first workspacedocs/remote-workspaces.md— end-to-end guide for v0.17 remote dispatchdocs/landscape.md— where canopy fits next to Conductor, tmuxinator, rawgit worktree, and the agent CLIs it hostsdocs/canopy-json.md— schema reference +~/.canopy/config.jsonsettingsdocs/migrate-from-conductor.md— step-by-step for projects withconductor.jsondocs/troubleshooting.md— common problems and fixes (local and remote)CHANGELOG.md— release notes
For contributors:
CONTRIBUTING.md— setup, code conventions, PR flowdocs/architecture.md— codebase layout, dependency direction, where to add thingsdocs/design/v0-canopy.md— design doc with premises, state machine, error conventionsdocs/reviews/v0-test-plan.md— test coverage plan and critical concurrency testsTODOS.md— deferred work, organized by milestone

