muxherd
Herd your AI coding agents — Claude Code, Codex, Grok Build — running in named tmux sessions on one or more machines, and jump between them from anywhere on your tailnet.
- One picker for every host. muxherd lists tmux sessions on this machine and on any host you can reach over ssh, polling every 2 seconds.
- Agents start in a login shell. The agent command is typed into a normal shell, so it gets your full environment, and quitting the agent leaves you at a prompt instead of losing the session.
- mosh for remote attach. A laptop on flaky Wi-Fi stays connected, and the tmux session survives either way.
- Closed sessions are remembered. Every session is recorded in a small SQLite registry on the host that runs it. When a session ends, it stays in the list marked ✕, and Enter reopens it with the same name and directory. For Claude Code, that resumes the same conversation.
- Nothing runs in the background. muxherd talks to
tmuxdirectly, or oversshfor remote hosts, reusing one ssh connection per host via ControlMaster.
Install
uv tool install muxherd # or: pipx install muxherd
# latest from GitHub:
uv tool install git+https://github.com/kgx/muxherd
# or from a clone (a standalone copy, not linked to the repo):
make install # `make update` = git pull + reinstall; plain `make` lists targets
This installs muxherd and the short alias mh.
Requirements
- Python 3.11+ and uv (or pipx) to install it.
- tmux 3.x on every machine that runs sessions.
- muxherd on every machine that runs sessions, not just where you use the picker.
Clients call
muxherd _host ...on remote hosts over ssh. - Non-interactive ssh from your laptop to each host (key auth or Tailscale SSH).
- mosh (optional, recommended) on both ends for roaming-friendly attach.
- VS Code with Remote - SSH (optional) for
ctrl+e. - Linux and macOS are supported. A private network such as Tailscale is strongly recommended (see Security).
Setup
Host machine (where the agents run, e.g. devbox):
sudo apt install tmux mosh # macOS: brew install tmux mosh
mh init # config with this machine as "local"
Client laptops:
brew install mosh # or apt install mosh
mh init --no-local -r devbox # only show the remote host's sessions
# or keep local sessions too: mh init -r devbox
mh hosts # check connectivity
-r NAME uses NAME as the ssh target (with MagicDNS that's the tailnet hostname).
Use -r NAME=user@target or an ~/.ssh/config alias when they differ.
Remote hosts need non-interactive ssh (key auth, or Tailscale SSH) because muxherd
polls them with ssh -o BatchMode=yes. Make sure ssh devbox true works without
a prompt.
Keeping it on the tailnet
muxherd only ever talks to the targets in your config, so the tailnet boundary comes from the host's firewall. On the host:
sudo ufw default deny incoming
sudo ufw allow in on tailscale0 # ssh + mosh only via tailnet
sudo ufw enable
mosh starts with an ssh login and then uses UDP 60000–61000, which the tailscale0 rule
covers. To keep sshd off other interfaces entirely, set
ListenAddress <tailscale-ip> in sshd_config, or turn on Tailscale SSH
(tailscale up --ssh) and close port 22.
Usage
mh # picker
mh a infra # attach (or reopen if closed): name, host:name, or unique substring
mh code infra # open the session's directory in VS Code (no name: picker)
mh sh infra # throwaway shell (own tmux session) in the session's project dir
mh new -a codex # new codex session in cwd, named codex-<dir>, then attach
mh new api -a claude -H devbox -d ~/src/api -D # create detached on a host
mh ls # list everything, closed sessions marked ✕ (--live to hide them)
mh kill devbox:api # kill (asks first; -y to skip); it stays listed as closed
mh forget api # remove a closed session from the registry
mh rename api api-v2 # rename (live or closed; host:name works too)
mh chdir api ~/src/api-v2 # change a session's project directory
mh hosts # reachability check
Picker keys
| key | action |
|---|---|
| type | filter (space-separated terms match host, name, agent, dir) |
| ↑ ↓ PgUp PgDn | move |
| ⏎ | attach, or reopen a closed session |
| ctrl+n | new session (agent, host, directory, name); see below |
| ctrl+r | rename the selected session (live or closed) |
| ctrl+d | change the session's project directory (with dir browser) |
| ctrl+x | kill a live session / forget a closed one (with confirm) |
| ctrl+t | show/hide closed sessions |
| ctrl+e | open the session's directory in your editor |
| ctrl+o | throwaway shell (own tmux session) in the project directory |
| ctrl+p | toggle preview pane |
| F5 | refresh now (it also refreshes every 2s) |
| esc | clear filter, then quit |
In the new-session dialog, the directory field browses the chosen host, over ssh for
remote hosts. It starts out listing directories you've recently used there. As you type,
it lists matching subdirectories. ↑↓ pick one, Tab (or → at the end of the
line) steps into it, Enter on a picked entry takes it, and Enter again creates the
session. Hidden folders appear once you type a leading ..
Each session has a project directory: where it was started, unless you change it
with ctrl+d / mh chdir. The list shows it, a closed session reopens there, and the
editor (ctrl+e) and throwaway shells (ctrl+o) open there. Changing it doesn't move a
running agent. Claude Code still resumes the same conversation from the new directory.
How attach works:
- Local session, run outside tmux:
tmux attach. - Local session, run inside tmux:
tmux switch-client, so tmux never nests. - Remote session:
mosh <host> -- tmux attach, falling back tossh -twhen mosh isn't installed or the config setsattach = "ssh".
Throwaway shells
ctrl+o in the picker, or mh sh [name], opens a shell in the session's project
directory (where it was started) as its own tmux session on the same host. It gets a
generated name next to its parent, e.g. claude-infra-brave-otter, and you're attached
to it right away.
- If you get disconnected, it's still there: reattach from the picker like any session.
exitends it, and the registry forgets it instead of listing it as closed, so shells don't pile up.- The agent's session is untouched (a window in the agent's own session would switch every attached client to it).
Editor
ctrl+e in the picker, or mh code [name], opens the session's project directory in
an editor on the machine you're using. For sessions on another host, it uses VS Code's
Remote - SSH
over the same tailnet ssh connection, with an explicit folder URI. The plainer
code --remote ssh-remote+host /path form makes VS Code guess whether the path is a file
and often opens the parent folder:
code -n --folder-uri vscode-remote://ssh-remote+<hex-encoded host>/home/me/src/infra
Each laptop needs the Remote - SSH extension and the code command on PATH (macOS: VS Code
command palette → "Shell Command: Install 'code' command in PATH"). The first time, VS Code
installs its server on the host automatically. To use another editor, change [editor]
in the config: {path} is the directory, {host} the ssh target and {uri} the VS Code
folder URI. For example, Zed: remote = "zed ssh://{host}{path}".
Versioning
Versions follow semver and come from git tags (vX.Y.Z) via hatch-vcs, with no version
string in the source. A build of a tagged commit is that version. Later commits build as
dev versions like 0.2.1.dev3+g1a2b3c4 (3 commits past v0.2.0), so mh --version
shows exactly what a machine is running.
make version # what this checkout builds as
make release VERSION=0.2.0 # tag, push and create a GitHub release (clean tree required)
Config
~/.config/muxherd/config.toml (override with $MUXHERD_CONFIG):
attach = "mosh" # or "ssh"
[hosts]
devbox = "local" # this machine
# laptop = "me@laptop" # any ssh target
[editor] # {path} = project dir, {host} = ssh target, {uri} = VS Code URI
local = "code -n {path}"
remote = "code -n --folder-uri {uri}"
[agents.claude]
start = "claude --session-id {id}" # typed into the new session's shell
resume = "claude --resume {id}" # typed when reopening a closed session
resumable = "ls ~/.claude/projects/*/{id}.jsonl >/dev/null 2>&1" # check run before resuming
[agents.codex]
start = "codex"
resume = "codex resume --last"
[agents.grok]
start = "grok" # no resume: reopening starts it fresh
[agents.shell]
start = ""
{id} becomes a fresh UUID when a session starts. muxherd stores it, and resume uses
it to pick up the same conversation. If an agent has no resume, or the session has no
stored ID, reopening runs start instead. resumable is an optional shell check that
runs on the session's host first. If it fails, reopening runs start with the same ID.
Claude Code only saves a conversation once you send a message, so this covers a session
where you never typed anything. A plain string (yolo = "claude --dangerously-skip-permissions")
is shorthand for start only.
Session registry
Each host keeps a registry at ~/.local/state/muxherd/registry.db (SQLite; override
with $MUXHERD_REGISTRY). It's updated whenever muxherd lists that host's sessions:
live sessions are recorded, and sessions that disappeared are marked closed.
Sessions started outside muxherd (plain tmux new -s) are recorded too.
Clients read a remote host's registry with ssh <host> muxherd _host sync, so
install muxherd on every host that runs sessions. Without it, muxherd falls back
to plain tmux on that host and only shows live sessions.
A session counts as closed from the last time muxherd saw it alive. If no picker was open when it ended, the close time is approximate.
The agent column shows the agent muxherd launched the session with, which it stores in
the @muxherd_agent tmux option. For sessions muxherd didn't create, it shows the pane's
current command instead.
Security
muxherd adds no network service of its own. It only runs tmux, ssh and mosh as
you, against the hosts in your config. A few things follow from that:
- Anyone who can ssh into a host as you can do everything muxherd does there. Protect the hosts the usual way: key-only ssh, and ideally reachable only on a private network. The tailnet setup above shows one way.
- The config file is a list of commands. Agent
start/resume/resumableand the[editor]templates are executed as written, so treat~/.config/muxherd/config.tomllike a shell script: don't run with a config you didn't write. - Remote hosts are trusted. The picker runs
muxherd _host ...on each host and displays what comes back, including pane previews. Only add hosts you control. - Agents run with your permissions inside tmux on the host. muxherd doesn't sandbox
them. If you use permission-skipping modes (e.g.
--dangerously-skip-permissions), the agent can do anything your user can on that host. - Session registries (
~/.local/state/muxherd/registry.db) store session names, directories and agent conversation IDs, but not conversation content.
Development
git clone https://github.com/kgx/muxherd && cd muxherd
uv tool install -e . # mh runs from your checkout; edits take effect immediately
make test # pytest
make lint # ruff check + format check
The tests run against a private tmux server (each test sets TMUX_TMPDIR to a temp
dir) and a temp registry and config, so they're safe to run on a machine with live
sessions. Do the same when trying changes by hand:
export TMUX_TMPDIR=$(mktemp -d) MUXHERD_REGISTRY=$(mktemp -d)/registry.db
unset TMUX
mh new -D -a shell scratch && mh ls
License
Metadata
Release files for muxherd 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| muxherd-0.2.0.tar.gz | 44.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| muxherd-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 74.8 kB
Release files / muxherd-0.2.0.tar.gz
| Download URL | muxherd-0.2.0.tar.gz |
|---|---|
| Size | 44.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
6e55a7ea6bd2ec32214d3e8b00c59e5dec72e91dfb4cf18b14978c72f8df2ade
|
|
BLAKE2b-256 checksum How to use checksums |
da274f56878abf280e187a0ad7ee842ed915a731b8117b3d0c40a2c67f785dd5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.22 {"installer":{"name":"uv","version":"0.12.22","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / muxherd-0.2.0-py3-none-any.whl
| Download URL | muxherd-0.2.0-py3-none-any.whl |
|---|---|
| Size | 30.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
80f4ccd6126ec0971313c92a62f3f1eba6768623ae9682fabdb7b658c9862413
|
|
BLAKE2b-256 checksum How to use checksums |
928cbc723560aa8848ebde66a0d40b184f53fedbcf795d5ae40d1ad9b0709de6
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.22 {"installer":{"name":"uv","version":"0.12.22","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|