workmux

module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 7, 2026 License: MIT

README

workmux

Run several coding agents at once — one git worktree each — from a browser.

workmux

It serves the repo you're standing in at http://127.0.0.1:4315. Run it in another repo and that one joins the same page — one server, one port, every project you have in flight. A single static binary, no runtime, no config, and it works from a phone.

Status: the Go rewrite is in progress. The dashboard, the API and the the agent↔worktree mapping, terminal sessions, changes, actions and the MCP panel are the MCP panel are being ported from the original (a ~4,500-line Python implementation that has been in daily use); see Roadmap. The main branch always builds and passes its tests.

Why

Changes come in different sizes. Some are a backend tweak that needs nothing running; some need Docker up to test at all. You start three of them, hand each to an agent, and then spend your day answering the question which of these wants me right now — across terminal tabs, worktrees, and containers that may or may not be up.

workmux makes a piece of work the unit: a worktree + branch, the agents living in it, the PRs it has produced, its diff, and — only if the change needs it — its own container stack. The list is ordered by what wants you: needs-input first, then working, then whatever has containers up, then the rest.

Two details it gets right, because both were bugs first:

  • "Working" means a live process, not what an agent last wrote about itself. Agent state is written at turn boundaries, so the session you are watching work reads idle.
  • The base checkout sorts last however busy it looks. It's where you happen to stand, not a change in flight — and worktrees live inside it, so a naive prefix check marks it permanently busy.

What you get

  • Sessions are real PTYs, rendered by Ghostty's own emulator compiled to wasm — the same code as the native app rather than a reimplementation in JavaScript. Not a pipe either: a process with a controlling terminal has a foreground job, so ⌃C reaches the program you're looking at, less pages, and a full-screen agent UI draws. Sessions live in the server, so reload the page, switch worktrees or restart a stack and the browser reattaches and replays what it missed. Closing a tab detaches; ✕ kills the process group.
  • Agents mapped to worktrees exactly, read from the agent's own state rather than guessed — so "who is working here" and "is it running right now" are answers.
  • New work in a couple of seconds. Describe the task; the branch name comes from what you wrote. It branches off the remote, copies the gitignored files a worktree needs, starts an agent, and starts no containers.
  • Changes as HTML — files with status and line counts, a diff per file, and the commits this branch has that its base doesn't, laid out to be readable at 400px.
  • A stack per piece of work, if the project has one, with its own slot, URL and logs.
  • MCP servers with whether an agent can actually reach each one — registration isn't reachability, and both failure modes are otherwise silent.
  • It works on a phone. One surface at a time below 900px, a key bar for what a phone keyboard lacks, and a screenshot pasted into a session reaches the agent as an image.

Install

brew install trivial-corp/tap/workmux            # macOS
npx workmux                                      # no install at all
curl -fsSL https://raw.githubusercontent.com/trivial-corp/workmux/main/scripts/install.sh | sh
go install github.com/trivial-corp/workmux/cmd/workmux@latest
docker run ...                                   # see Docker below
git clone … && make build                        # from source

npx workmux works because the npm package contains the binary — one small package per platform, each declaring its own os/cpu, so npm downloads exactly one and skips the rest. No postinstall script, nothing fetched at install time, and no second runtime: node's only job is to exec the binary it already has. (An earlier attempt shipped a node shim around a python server, which needed two runtimes to run one file. This is the opposite of that.)

All of them give you the same single static binary. Requires git; docker only if your project has a stack and gh only for PR titles — both optional, and their absence costs you that column and nothing else. macOS and Linux.

Homebrew gets a cask rather than a formula, because what ships is a pre-built binary — which makes it macOS-only; on Linux use the install script, the container image, or go install.

While this repo is private, anonymous downloads of release assets 404 — so the install script needs a token (GITHUB_TOKEN=$(gh auth token)), go install needs GOPRIVATE=github.com/trivial-corp, and npx/brew don't work at all until either the packages are published or the repo is public. Building from source always works.

Docker

For a box without a Go toolchain — a homelab server, or managed alongside other services. See deploy/compose.yaml; the short version:

docker run -d -p 4315:4315 \
  -v /srv/myapp:/srv/myapp -w /srv/myapp \
  -v "$HOME/.claude:$HOME/.claude" -e HOME="$HOME" \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -e WORKMUX_TOKEN=pick-something \
  ghcr.io/trivial-corp/workmux --root /srv/myapp

The repeated paths are the point, not boilerplate. Agent state records absolute directories and a stack started through the mounted socket runs on the host daemon, so everything must sit at the same path inside the container as outside. A repo mounted at /repo produces a dashboard that agrees with nothing.

Two things to know: mounting the docker socket is root-equivalent access to the host, so only do it on a machine you'd already trust this tool on; and the image ships no coding agent — bake yours in (RUN npm i -g @anthropic-ai/claude-code) or run with "agent": null.

On a homelab box, from your laptop

Cross-compile here, install there. No Go toolchain, no checkout, no CI on the far side — and the architecture comes from the box itself, because guessing it is the one mistake that quietly produces a binary that won't run:

make deploy HOST=homelab                        # → ~/.local/bin/workmux
make deploy HOST=root@nas DEST=/usr/local/bin

Then on the box:

cd /srv/myrepo && workmux init && workmux --host 0.0.0.0

To keep it running, deploy/workmux.service is a user service — workmux runs your worktrees, your agent and your docker, so it should be you; as root it would hand a shell to anyone who reaches the port.

cp deploy/workmux.service ~/.config/systemd/user/     # then edit the path + token
systemctl --user enable --now workmux
loginctl enable-linger $USER                          # survive logout

Off loopback a token is required. It still speaks plain HTTP, so put Tailscale, a reverse proxy with TLS, or an SSH tunnel in front — ssh -L 4315:127.0.0.1:4315 homelab needs no token at all and is the safest way in.

Several projects, one page

Work isn't one repository. You're in trip1 in the morning and homelab in the afternoon, and both have agents running. So just run it where you are:

cd ~/code/trip1   && workmux      # starts a server on 4315
cd ~/code/homelab && workmux      # joins it — same page, same port

And then, tomorrow, from anywhere:

workmux                           # both of them again

The second invocation finds the first, hands over the repository it was started in, and exits. Nothing to configure, no second port to remember, no second tab. Or name them all up front:

workmux ~/code/trip1 ~/code/homelab
workmux --root ~/code/trip1 --root ~/code/homelab    # the same thing

The work list is merged, not tabbed. The whole point of the ordering is what wants me right now, and that question doesn't stop at a repository boundary — a blocked agent in homelab belongs above a quiet worktree in trip1. Rows say which repo they're from; the project name in the header is a filter when you want one repository's worth, and "All projects" when you don't.

Everything else stays per-repository, because it is: base branch, stack slots, the agent's MCP registry, what New work branches from. Selecting a piece of work aims those at its repo, so switching is something you do by clicking the work you meant.

The header menu also adds a repository by path and stops serving one. Stopping closes that project's sessions — a shell in a worktree the server no longer holds is a process nothing has a route to — and it says how many before it does it.

It remembers

The set of repositories is written down as it changes, so it survives a restart, a reboot and a kill -9. workmux with nothing named serves what the last server was serving, plus wherever you're standing if that's a repository — which is how the set grows: you run it in a new repo once, and it's there from then on.

Naming roots adds to the set — workmux ~/code/newthing serves that one too, alongside everything you already had. Nothing on a command line removes a repository, because a command about one repo must not be able to forget the others: make dev ROOT=x passes --root, and it would have.

Forgetting has one route, the one that says so: Stop serving in the header menu. A remembered repository that has been moved or deleted is dropped with a note on startup, rather than being a reason not to start. The list lives in $XDG_STATE_HOME/workmux/projects.json — rm -r ~/.local/state/workmux forgets everything workmux knows about you.

--standalone neither reads nor writes it: it serves exactly what you named.

How the second invocation finds the first: a running server writes its URL to $XDG_STATE_HOME/workmux/server.json (~/.local/state/workmux/server.json), and a later one reads it, checks a workmux is actually answering there, and posts to /api/projects. Joining always wins. Running workmux somewhere adds that repository to whatever is up — there is no invocation that would rather fail with a message about a port. --dev, --token and --no-terminal describe a server this process would have been and a join can't honour them, so they're reported as having had no effect rather than turning "add this repo" into an error.

Pass --standalone for a server of your own; it also doesn't become the one later invocations look for. That's the way to run a dev build beside the workmux you actually use.

workmux --standalone --port 4316 --root ~/code/scratch

Setting it up

workmux init

It reports what it found — name, base branch, worktree location, stack, agent, and the gitignored files a new worktree would be missing — and writes config only for that last item, because it's the one thing no amount of runtime detection can work out. Everything else is derived from the repo, so most projects get:

  Nothing to configure. Every default fits this repo — just run workmux.

At a terminal it asks about the few things it can't derive — whether to carry those files over, where the app opens, whether this project uses an agent at all — and shows you the file before writing it. Prompts, not a full-screen TUI: it's a handful of questions once, the answer is usually "nothing to configure", and the real interface is a browser.

workmux init --dry-run    # look without writing
workmux init --yes        # take every default, ask nothing
workmux init --force      # replace an existing workmux.json

Piped, scripted or run by an agent it asks nothing and takes the defaults, so it's safe in automation. Agents have their own instructions in AGENTS.md.

Stacks — an app per piece of work

Most changes need nothing running. Some can't be judged without the app up, and that's where a worktree per change stops being enough: two branches both want port 3000, both want the database, and you end up back to one thing at a time.

A stack is one running copy of your app, attached to one piece of work. Each gets its own slot — myapp1, myapp2 — and that slot name is the docker compose project name, which is what separates them: containers, networks and volumes are all scoped to a compose project, so two slots can't see each other's anything.

The one resource compose can't scope for you is a published port. So there are two ways to do this, and the choice is the whole design:

A port per slot localhost:8081, localhost:8082. Nothing to install. Start here.
A hostname per slot myapp1.localhost, api.myapp1.localhost. Needs a reverse proxy, once. Worth it when the app has several services, or when a cookie domain or an OAuth callback cares about the host.

Both examples are complete and runnable — a compose file, a workmux.json, and a README explaining every line.

The smallest version

With a compose file in the repo, workmux already found it. All you're adding is where a slot answers, because it can't know that and won't guess:

{
  "stack": {
    "url": "http://localhost:808{n}"
  }
}

{n} is the slot number. Then teach the app to take its port from the environment — ports: ["${PORT:-8080}:80"] — and pass it in:

{
  "stack": {
    "url": "http://localhost:808{n}",
    "commands": {
      "up": "PORT=808{n} docker compose -p {slot} -f {compose} up -d --build"
    }
  }
}

Unspecified commands keep their defaults, so overriding up alone is fine.

What you get

Per piece of work: ▶ start app, then ↗ open, Logs, ↻ restart and ■ stop. The Stacks panel adds the part you can't see from a list of buttons — what's running across every slot, what each is costing in CPU and memory, and what share of the machine that is, because six idle stacks is a laptop that has stopped being pleasant to use.

Starting a stack is never automatic. New work makes a worktree and starts an agent and starts no containers, because most changes don't need them and a minute of waiting is what stops people branching at all. You attach a stack later, if the change turns out to earn one.

Substitutions

Available in url and in every command:

{slot} the slot name — myapp2
{n} the slot number — 2. A URL that needs one gets no Open button for an unnumbered slot, rather than a wrong link
{compose} the compose file
{profiles} stack.profiles, or $COMPOSE_PROFILES
{path} the worktree the action was fired from
{base} the base branch

Commands run through a login shell from the worktree, with STACK and COMPOSE_PROFILES in the environment — so a project with its own script (per-slot databases, migrations, a proxy to attach) points the four commands at it and keeps everything it already has.

When there is no stack

"stack": null, or simply no compose file. You get worktrees, agents, sessions and diffs, and no app controls anywhere — not greyed out, not answering "not configured". An absent capability shouldn't look like a broken one.

Configuration

None required. Drop a workmux.json at the repo root when a default is wrong:

{
  "name": "myapp",                        // default: the repo directory's name
  "worktrees": {
    "path": ".claude/worktrees",          // default
    "copy": [".env", "config/*.key"]      // gitignored files each new worktree needs
  },
  "agent": {                              // defaults are Claude Code; null for none
    "command": "claude",                  // what a new agent session runs
    "spawn": "claude --bg {prompt}",      // how New work starts one
    "attach": "claude attach {id}",       // how to take over a running one
    "jobs": "~/.claude/jobs",             // where its state.json files live
    "mcp": "claude mcp"                   // enables the MCP panel
  },
  "stack": {                              // omit to detect; null for "no app"
    "compose": "compose.yaml",            // default: the compose file it finds
    "slots": "myapp{n}",                  // default: "{name}{n}" — one per work
    "url": "http://{slot}.localhost",     // default: none, so no Open button.
                                          // {n} is the slot number: localhost:808{n}
    "profiles": "app,tools",              // default: $COMPOSE_PROFILES
    "commands": {                         // default: plain `docker compose`
      "up": "docker compose -p {slot} -f {compose} up -d --build",
      "restart": "...", "stop": "...", "logs": "..."
    }
  }
}

Anything left blank is a capability the UI doesn't offer, rather than a button that fails:

you leave out you get
a compose file, or "stack": null worktrees, agents, sessions and diffs — no app controls at all
agent.spawn New work makes the worktree without starting an agent
agent.mcp no MCP panel
"agent": null no agents anywhere; worktrees and shells

{slot}, {n}, {compose}, {profiles}, {path} and {base} are substituted into url and into stack commands, so a project with its own wrapper script (per-slot databases, a reverse proxy to attach) keeps using it. See Stacks for what to put there.

Options

workmux [options] [DIR…]

  -p, --port N       port to listen on (default 4315)
      --host ADDR    interface to bind (default 127.0.0.1)
      --token TOK    pin the token instead of minting one
      --root DIR     repository to serve; repeatable, as are bare directories
                     (default: the current directory)
      --standalone   start a server of my own instead of joining one that is
                     already running
      --no-terminal  dashboard only — don't serve shells
      --open         open a browser once it's listening

Every flag has a WORKMUX_* environment variable (WORKMUX_PORT, WORKMUX_HOST, WORKMUX_TOKEN, WORKMUX_ROOT, WORKMUX_TERMINAL=0). WORKMUX_ROOT takes a list separated the way PATH is: WORKMUX_ROOT=~/code/trip1:~/code/homelab.

Reaching it from your phone

workmux --host 0.0.0.0

It prints a LAN URL with a token. Off-loopback requests need it; loopback stays exempt, so nothing changes on the machine itself. ?t=… is swapped for an HttpOnly cookie on first load.

This hands out a shell over plain HTTP. That's why it binds loopback only by default, refuses cross-origin WebSocket upgrades (CORS doesn't cover WebSockets), and requires a token off-box. Put TLS in front before exposing it beyond a network you trust, and use --no-terminal if you only want the dashboard.

API

The browser is one client; a mobile app is another. Everything the UI renders comes from the same JSON:

Routes come in two shapes, and the shape says what a request is about. /api/… is about the server; /api/p/{project}/… is about one repository, with the project in the path so every such request is legible in a log and repeatable with curl.

endpoint
GET /api/work the whole dashboard: every project, and all of their work in one ordered list
GET /api/health liveness and identity, outside the token check so a proxy can probe it
GET /api/projects what is being served
POST /api/projects {root} starts serving a repository, {id, remove:true} stops — this is what a second workmux calls
GET /api/session/list live sessions, each naming the project it belongs to
POST /api/session/kill {id}
WS /api/session/socket/{id} binary frames are raw PTY bytes both ways; text frames are JSON control ({"t":"size"}, {"t":"exit"})
POST /api/upload a pasted image; puts it on the host clipboard and returns the path
GET /api/log this process's own log
per project
GET /api/p/{id}/work one repository's own view
GET /api/p/{id}/config the resolved project shape
GET /api/p/{id}/changes ?path=&base= — file status with line counts, plus the commits this branch has that its base doesn't
GET /api/p/{id}/diff ?path=&file= for a file, ?path=&rev= for a commit
GET /api/p/{id}/stacks what is running and what it costs
POST /api/p/{id}/session/new {kind, cwd, cols, rows} — shell / agent / attach / logs / git, or resume for "this worktree's agent, whatever that is"
POST /api/p/{id}/new {prompt, name?, base?} — a worktree, its files, and an agent on the task
POST /api/p/{id}/stack {action, slot, path} — up / restart / stop, through the project's own commands
POST /api/p/{id}/update {path, base} — merge the base branch in, aborting on conflict
POST /api/p/{id}/pr {ref} — check a PR out into its own worktree
GET /api/p/{id}/mcp, POST …/mcp/add, …/mcp/remove the agent's server registry, with reachability

A project id is its name, lowercased and made path-safe, unique within the server — trip1, homelab. Two projects that would collide get -2.

Two rules the session API keeps, both server-side, because between them they're the difference between a dev tool and a remote shell for anyone who can reach the port: a session may only open in one of that project's worktrees — scoped to the project in the path, not to any repository the server happens to hold — and a WebSocket upgrade must come from an origin this instance is actually reachable at (CORS does not cover WebSockets).

Roadmap

  • Config resolution with defaults, optional stack, configurable agent
  • Worktrees, agents, PRs, stacks, ordering — GET /api/work
  • Token auth, origin allowlist, embedded UI
  • Terminal sessions: PTY + WebSocket, replay on reattach, size arbitration
  • Changes: status, per-file diffs, commits this branch has that its base doesn't
  • Actions: new work, start/stop a stack, merge the base in, check a PR out
  • MCP panel: reachability, scope, authenticate
  • The frontend as a proper TS build, embedded — shared with a mobile app
  • Release binaries, Homebrew, and an npm package that ships the binary
  • Paired device tokens and TLS, for the remote/mobile case

Using it on another project

Point it anywhere; nothing has to be installed in the project itself:

workmux --root ~/code/my-drupal-site

With no workmux.json it reads the repo: the name from the directory, a stack from whatever compose file it finds, and Claude Code as the agent. A stack the project already has running is recognised whether it's named after the directory (what docker compose up does) or numbered per worktree.

Two things are worth setting for a project that isn't structured around per-worktree stacks:

{
  "stack": { "url": "http://localhost:8080" },   // so there's an Open button
  "agent": null                                   // if you don't use one here
}

Developing workmux

Run it from source, against any project. No build, no install, no CI:

make dev ROOT=~/code/some-project            # :4316, beside the workmux you actually use

It defaults to 4316 so it doesn't land on the 4315 you actually use. Point it at a port something is already serving and it joins that server instead — adding the repo, and saying that --dev had no effect, because the frontend you're editing isn't the one being served. make dev PORT=… STANDALONE=1 is not a thing; add --standalone yourself if you want a second server on a busy port to be an error.

Against a monorepo with a stack and 40-odd worktrees, that looks like:

$ make dev ROOT=~/Projects/trivial-corp/trip1 PORT=4321
  workmux dev — trip1
  http://127.0.0.1:4321
  dev: frontend from …/internal/web/dist — edit and refresh
  root: /Users/tomas/Projects/trivial-corp/trip1

16:32:19  2.506s gh pr list --state all --limit 400 --json number,headRefName,…
16:32:19    79ms docker compose ls --all --format json
16:32:19    87ms docker compose -p trip1 -f …/compose.yaml ps --all --format json
16:32:19    16ms pgrep -f claude

— which tells you immediately that gh pr list is 2.5s of the first poll (cached for 20s after), rather than leaving you to guess.

That's go run, so a code change is a Ctrl-C and a re-run — about a second. Two things make it a debug loop rather than a guessing game:

  • --dev serves the frontend from internal/web/dist instead of the copy embedded in the binary, with caching off. Editing the UI is a refresh.

  • Every subprocess is logged with its duration and exit code. Every fact this tool reports comes from a git, docker, gh or agent-CLI call, so a wrong dashboard is nearly always a surprising command result, and this shows you which one:

    16:24:01    9ms git -C /repo symbolic-ref --quiet --short refs/remotes/origin/HEAD  exit 1:
    16:24:01   16ms git -C /repo worktree list --porcelain
    16:24:02  310ms docker compose ls --all --format json
    

    --verbose alone gives you that against the embedded frontend.

make test-race                # the concurrent read path, under the detector
make watch ROOT=~/code/app    # restart on every .go change (watchexec or entr)
make debug ROOT=~/code/app    # under delve, with breakpoints
make install                  # ~/go/bin/workmux — what `bin/dev serve` finds
make test                     # go test ./...
make lint                     # gofmt check + go vet

Nothing here needs a push, a tag or a CI run. CI exists to catch what a laptop misses (Linux, and the four cross-compiled binaries), not to stand between you and running the code.

Tests use real git repositories in temp directories rather than mocks: every claim this tool makes about worktrees, branches and commit counts comes from git's own output, so a fake would only test the fake.

CI

ci.yml on every push and PR: gofmt + go vet, tests, tests under the race detector, and a build — on both macOS and Linux, because this shells out to platform tools (pgrep, lsof) and that's where the difference bites. Then the four cross-compiled binaries, goreleaser check, and a Docker build that has to start and answer for itself.

release.yml on a v* tag: four static binaries with checksums, a Homebrew cask, and a multi-arch image to ghcr.

License

MIT

Directories

Path Synopsis
cmd
workmux command
Command workmux serves a dashboard over one or more repositories.
Command workmux serves a dashboard over one or more repositories.
internal
actions
Package actions is everything that changes something: new work, a stack up or down, merging a base branch in, checking a PR out.
Package actions is everything that changes something: new work, a stack up or down, merging a base branch in, checking a PR out.
agents
Package agents answers two questions the dashboard is built around: which agents live in which worktree, and which of them are working right now.
Package agents answers two questions the dashboard is built around: which agents live in which worktree, and which of them are working right now.
bg
Package bg tracks work that outlives the request that started it.
Package bg tracks work that outlives the request that started it.
changes
Package changes answers "what has this piece of work actually done".
Package changes answers "what has this piece of work actually done".
config
Package config resolves everything project-shaped from workmux.json — and, when it isn't there, from what the repository already says.
Package config resolves everything project-shaped from workmux.json — and, when it isn't there, from what the repository already says.
gitx
Package gitx reads the repository.
Package gitx reads the repository.
initcmd
Package initcmd is the bootstrap: it looks at a repository, says what workmux will do with it, and writes config only for the things it cannot infer.
Package initcmd is the bootstrap: it looks at a repository, says what workmux will do with it, and writes config only for the things it cannot infer.
instance
Package instance is what workmux knows between runs: where a server is listening, and which repositories it was serving.
Package instance is what workmux knows between runs: where a server is listening, and which repositories it was serving.
mcp
Package mcp surfaces the agent's MCP registry.
Package mcp surfaces the agent's MCP registry.
presets
Package presets turns "I want a session of this kind here" into something runnable, using only what config says.
Package presets turns "I want a session of this kind here" into something runnable, using only what config says.
project
Package project is one repository as the server holds it — its config and every reader and runner that works against it — and the set of them a single server serves.
Package project is one repository as the server holds it — its config and every reader and runner that works against it — and the set of them a single server serves.
prs
Package prs reads pull requests through the gh CLI.
Package prs reads pull requests through the gh CLI.
run
Package run is the one place this tool shells out.
Package run is the one place this tool shells out.
stack
Package stack reads the project's containers — one stack per piece of work.
Package stack reads the project's containers — one stack per piece of work.
term
Package term owns the terminal sessions.
Package term owns the terminal sessions.
testrepo
Package testrepo builds throwaway git repositories for tests.
Package testrepo builds throwaway git repositories for tests.
web
Package web is the HTTP surface: the API the browser and (later) the mobile app both read, plus the embedded frontend.
Package web is the HTTP surface: the API the browser and (later) the mobile app both read, plus the embedded frontend.
work
Package work assembles the one thing this tool is about.
Package work assembles the one thing this tool is about.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL