A control panel over your tmux sessions, built for orchestrating many Claude Code sessions at once.
It shows two things in one list, sorted by which one needs you most:
- every pane on a tmux server, with its state read off the screen and a tile of what it actually said;
- every Claude Code session
claude agents --jsonreports, whose state comes from Claude itself as a fact. Most of those have no tmux pane at all, so nothing else shows them.
Then it writes back: select panes, type a prompt, and it is pasted into each one
and submitted — guarded per target by that pane's own identity token, so a pane
that stopped being the agent you selected is refused rather than written to
(docs/design.md §7).
brew tap dawnbreather/tap
brew install --cask tmux-hub
That is the current release: a prebuilt binary, so no Go toolchain and no clone are
needed. tmux-hub --version prints the version and the commit it was built from.
The source in this repository is a snapshot, and it is older than the releases. The released binaries are built from a private tree — the release notes say so too — so building from here gives you the snapshot rather than the current program:
go build -o tmux-hub ./cmd/tmux-hub
./tmux-hub # the fleet as a filesystem
./tmux-hub --view=flat # the flat dashboard instead
./tmux-hub status # one poll cycle as JSON, for scripts and monitors
Each row in that JSON carries the claim its state is quoting — agent_word is the
listing's own word for the session, agent_status whether that session's worker is
presently occupied, and agent_pid the pid the reporting host gave for it. A pid
means that host can see the worker, so a state beside agent_word with no
agent_pid came from a machine that shares ~/.claude and nothing else — and a
pid alone is not work, since it routinely points at a pre-warmed process parked
between jobs. That is what agent_status separates: working with busy is work,
working with idle is a session waiting for you.
The hub opens on the FILESYSTEM view: hosts are volumes, directories are
directories, sessions are files, and what you pinned is a FAVOURITES band at the
top. The map is open and the folders are shut — every volume and every directory
is on the first screen, and a closed directory still says how much of it wants you
(▸ st-edgebox/ ⚑ 2 of 8), so thirteen waiting sessions across fifty-four are
one key away rather than a screenful of scrolling. enter opens and closes, a
goes straight to whatever has waited longest inside — opening every directory on
the way — n creates a session in the directory the cursor is on with the path
already filled in, and t toggles to the flat dashboard and back. The band at the
bottom describes whatever the cursor is on: a session's pane, or a directory's
address, counts and contents.
On the first run there is no hosts.toml, so the picker opens on it: every Host
in your ssh config, probed with tmux -V, each excluded one saying why and what
would fix it. Tick the ones you want and press enter: that writes the fleet to
~/.config/tmux-hub/hosts.toml, generated and hand-editable. Every run after that
starts straight into the fleet, and p reopens the picker.
A second, optional file groups your sessions by what you are working on.
P lists every project and how much of each one wants you; enter narrows the
dashboard to one. With no file at all the grouping is the last segment of each
pane's working directory, which is usually right — write
~/.config/tmux-hub/projects.toml when it is not:
[[project]]
name = "streams"
prefix = "/home/dev/lab/streams"
[[project]]
name = "just this host"
prefix = "/w"
host = "nuc"
The longest matching prefix wins, a prefix matches on a path boundary (so
/home/dev/lab/st does not swallow /home/dev/lab/streams), and host is
optional — without it the rule applies everywhere. Unlike hosts.toml, a
projects.toml that cannot be parsed does not stop the hub: it loses the names,
keeps the fleet, and says what was wrong.
Enabling a host is all there is to it: the hub spawns an ssh -N -M control master
per host and polls tmux over it, with no port forward anywhere. A master
deliberately outlives the hub — that is what makes the second run of the day free
(3.6 ms to adopt one against 1.55 s to spawn) — so there is a way out:
./tmux-hub --stop-masters # stop every master this hub may have left running
Unticking a host stops its master too, and a master whose host is no longer enabled is stopped at the next start.
--host is the escape hatch for a setup the file cannot describe, and it is added
to the file's hosts rather than replacing them. A second local server:
./tmux-hub --host sandbox=/tmp/hub-sandbox.sock,local
local marks the socket as a server on this machine, which is what turns the
identity walk on for it and therefore what makes it writable. Or an operator's own
forward, if you have one:
ssh -N -M -S /run/user/$UID/nuc.ctl \
-L /run/user/$UID/nuc.sock:/tmp/tmux-1000/default nuc &
./tmux-hub --host nuc=/run/user/$UID/nuc.sock,ssh=nuc,ctl=/run/user/$UID/nuc.ctl
A host given a socket is polled over that socket. ssh= and ctl= are then what
a needs: a forwarded socket can carry the polling but not an attach, so attaching
goes through the ssh master. The same master is what identifies a remote pane as an
agent — that walk needs a shell on the far side — so without ctl= a remote host
is read-only: it polls and shows its panes, but none of them can be identified, so
none is ever stamped and every send to it is refused for want of an identity token.
--hosts <path> moves the host list. --probe-timeout is how long one host gets to
answer the picker's probe; a host that runs out of time keeps its tick box and is
shown as slow rather than absent, because two of five real hosts here swing fourfold
between probes and no timeout is right for all of them.
To help settle the timing thresholds, log every state change:
./tmux-hub --log-states ~/.local/state/tmux-hub/states.jsonl
Every send is appended to $XDG_STATE_HOME/tmux-hub/history.jsonl, which the
history view reads. --history <path> moves it; --no-history turns both off.
Favourites are persisted to $XDG_STATE_HOME/tmux-hub/favourites.json; --favourites moves the
file and --no-favourites ignores it. A pin follows the SESSION, including across the moment it
gains a pane — press a on a pinned pane-less row and it stays pinned and stays at the top, because
the key is the Claude session's own id rather than the name, which the door changes. Pinning does not reorder the fleet, it SPLITS it: every
pinned row above every other one, with the attention order untouched inside each half — so a pinned
session that is waiting for you still leads the pinned band, and an unpinned one that is waiting
still leads the rest. Pinning a project pins every session in it, including ones that appear later.
Hidden panes are persisted to $XDG_STATE_HOME/tmux-hub/hidden.json. --hidden <path>
moves it; --no-hide shows every pane, ignoring the hidden set.
j / k
|
move the cursor |
space |
select the pane under the cursor |
A |
select every pane on screen — never one below the fold |
C |
clear the selection |
i |
open the input box; alt+enter for a newline, enter to send, esc keeps the draft |
! |
interrupt the selected panes with C-c
|
h |
the history of what was sent, r re-sends an entry to the CURRENT selection |
n |
open the launch form to create a new Claude Code session |
R |
restart the selected pane, resuming its Claude session with --resume
|
K |
kill the selected pane (always confirms, naming what is running) |
p |
the host picker: tick what to keep, r probes again, enter saves |
P |
the project list: what state each thing you are working on is in, enter narrows the dashboard to one, a narrows AND puts the cursor on the row that wants you, esc widens again |
tab |
the next project, without going back to the list; it cycles, and with no project selected it opens the first |
N |
name the session under the cursor, so it reads by your name everywhere. ctrl+u then enter removes the name; esc cancels. Names live in projects.toml beside the project rules |
on the project list, h, p and n work as they do on the dashboard — their subject is the fleet. A key that acts on a session (i, !, R, K, x, A, C) says so instead of doing nothing: open a project first |
|
N on the project list names the PROJECT, writing a [[project]] rule for you. It refuses if the rule would also capture rows outside that project, and says which ones |
|
x |
hide the pane under the cursor, or every selected pane if there is a selection. On a row that is WAITING it says when the mark takes effect, because such a row stays on screen until it stops asking |
X |
show the hidden panes too — each marked [x], and the footer says the screen is unfiltered |
f |
pin the session under the cursor, or the PROJECT under the cursor on the project list. Pinned rows carry ★ and sit above everything else |
v |
switch the inbox between per-host and per-project grouping; the header names the view while it is not the default |
a |
go to it — a jump on this server, a window for another (§20). On a session that has NO pane, it MAKES one: a tmux session running claude attach <id>, named after the row (§22) |
t |
switch between the filesystem view and the flat dashboard. --view=flat picks which one you start on |
on the filesystem view every key above that acts on a SESSION acts on the session under the cursor there, and every key that cannot act says what to press instead. enter and l open a directory, h closes it or goes out to its parent, and n on a directory opens the launch form with THAT directory already in it. h therefore does not open the history here — that stays a dashboard key, one esc away |
|
esc |
leave the screen you are on and go back to the dashboard, deciding nothing — and on the dashboard, clear a project filter |
q |
quit — on the filesystem view and on the dashboard. Inside a text box it types a q, so leave with esc first |
ctrl+c |
quit, from every screen, including the picker and the text boxes |
A send asks for confirmation whenever anything about a target changed since you
selected it — it stopped being an agent, it moved session or window, its server
restarted, its last send was not witnessed, the text came from the history view,
the pane would read a paste as keypresses, or there is more than one target. The
dialog names the act, the payload and the targets; enter goes ahead and any
other key cancels.
A session with no pane still has a door. Most background Claude sessions are not
running in any tmux pane — they are jobs the daemon holds — so the dashboard lists
them with no pane to attach to. a on one of those makes the pane it is missing: the
hub creates a detached tmux session on that row's host named <name>-<short id>,
running claude attach <short id> inside a shell that outlives it, and then goes
there. Press a on it again and you land in the same session rather than a second
one. A session of any other kind has no background id for the verb to take, and a
says so and names the fix (/background).
Waking a row that is finished or reaped asks first, on a screen that lists what it
costs: the turn a reaped session was running was abandoned and a killed turn can still
report success, a process its last tool call started may still be alive, and — measured
— most background sessions are set to reply when they resume, so a wake can pick the
work up again and spend tokens. enter wakes it, any other key leaves it alone. A row
that is merely blocked or working is woken immediately: waking a blocked session is how
you answer it.
a dispatches over four paths, chosen from what the row and the hub's own
situation are. On the hub's own tmux server it jumps directly (switch-client +
select-window), leaving the hub running; C-b L returns. On another server —
another machine, or a second socket on this one — it opens a new window holding
today's attach command; C-b there is consumed by the hub's own tmux, so leaving
that inner tmux takes C-b C-b d and a plain C-b d would detach you from the
hub instead. In that window the attach runs inside a shell that stays in the
foreground after it on failure, so an attach that fails — a dead ssh master is
the usual reason — leaves its own error on screen, adds the exit status, and waits
for you to press enter instead of closing the window and leaving you with a cheerful
"back from …". On success the window closes silently. When the hub
is not inside tmux, a takes the terminal — there is no client to switch and no
session to hold a window.
Both hints assume the default C-b. The hub never changes anyone's prefix: a
distinct one would only work for a session the hub itself created, and it normally
runs inside yours.
The hub leaves its own pane out of the list, so it does not render itself.
| ⚑ needs | waiting on you |
| ✱ quiet | silent for over 180s |
| ▸ idle | finished, prompt empty |
| · works | producing output |
| ✗ error | failed, or the pane is dead |
| ? unknown | a source reported the session but not its state |
| ✓ done | the job ended — nothing to type into, its output is there to read |
| ✝ gone | the pane vanished; its last screen is kept |
Rows are sorted by how much the pane wants you, in that order. A pane whose host
stops answering stays on the list marked stale, keeping its last screen.
docs/design.md is the spec, and §3 is worth reading before changing anything:
it records measurements against live tmux 3.7b and 3.2a where the obvious
implementation is wrong.