Skip to main content

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.

muxherd picker: live and closed agent sessions on a host, with a preview of the selected session

  • 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 tmux directly, or over ssh for 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 to ssh -t when mosh isn't installed or the config sets attach = "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.
  • exit ends 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/resumable and the [editor] templates are executed as written, so treat ~/.config/muxherd/config.toml like 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

MIT

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)

Source distribution for muxherd 0.2.0
File Size Uploaded
muxherd-0.2.0.tar.gz 44.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for muxherd 0.2.0
File Interpreter ABI Platform
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}

Release history Release notifications | RSS feed

0.5.0

2 release files

0.4.0

2 release files

0.3.0

2 release files

This release

0.2.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page