Minimal terminal multiplexer written in Go. Zero third-party dependencies.
A port of MTM (Rob King) from C to Go, designed as a lightweight pane splitter for running multiple terminal applications side by side.
# Homebrew (macOS/Linux)
brew tap MadAppGang/tap && brew install magmux
# Go install
go install github.com/MadAppGang/magmux@latest
# From source
git clone https://github.com/MadAppGang/magmux
cd magmux && go build -o magmux .# Default: 3 panes with your shell
magmux
# Custom commands in each pane
magmux -e 'htop' -e 'vim' -e 'bash'
# Two coding agents side by side
magmux -e 'claude' -e 'opencode'
# A session driven from outside, with the control panel watching
magmux -c -e 'claude'
# Named panes, so a client can address them by name instead of by index
magmux -e 'bun test --watch' --label tests -e 'bun dev' --label server
# No terminal at all: CI, a benchmark harness, an agent. Reads `results`
# off the socket and exits with every pane's real exit status.
magmux --headless -w -e 'bun test' -e 'go test ./...'
magmux -w -e 'bun test' < /dev/null # the same thing; headless is automaticmagmux shows nothing of itself by default. magmux -e 'claude' is a bare
terminal running Claude Code: one pane, no border, the whole window. The
control panel is always there — it just starts hidden,
and Ctrl-G p reveals it without losing a row of its history. Ctrl-G s does
the same for the status bar.
| Flag | |
|---|---|
-e CMD |
run CMD in a pane (repeatable) |
--label NAME |
name the pane the preceding -e created |
-g FILE |
grid file, one command per line |
-w |
exit once every pane is done (needs at least one -e/-g) |
--headless |
run with no terminal; automatic when stdin is not a tty |
-c |
start with the control panel visible |
--no-status |
start with the status bar hidden |
--no-idle-done |
an idle pane is resting, not finished: no ✓ DONE, and -w waits for exit |
-x SECS |
close SECS after a driver finishes (default: wait for a keypress) |
--id NAME |
bind magmux-NAME.sock instead of the pid socket (not all digits) |
--sock-dir DIR |
bind the IPC socket in DIR instead of /tmp |
Subcommands: magmux mcp runs magmux as an MCP server.
| Key | Action |
|---|---|
Ctrl-G q |
Quit |
Ctrl-G Tab |
Switch focus to next pane |
Ctrl-G p |
Show / hide the control panel |
Ctrl-G s |
Show / hide the status bar |
Ctrl-G [ |
Scroll the focused pane back through its scrollback |
| Mouse click | Switch focus to clicked pane |
| Mouse drag | Select text (auto-copies to clipboard) |
| Mouse wheel | Scroll the pane under the cursor — the control panel, or an ordinary pane's scrollback. Forwarded to full-screen apps |
↑ ↓ PgUp PgDn Home End |
Scroll the control panel, when focused |
Ctrl-G [ scrolls back one page and puts the pane in scroll mode: k/j
and ↑/↓ move a line, PgUp/PgDn and b/space move a page, g and G
jump to the oldest line and back to live, and q, Enter or Esc leave. A
badge in the pane's top-right corner shows how far back you are and how to get
out. Nothing typed in scroll mode reaches the child; outside it, every one of
those keys goes to the child untouched, which is what a full-screen TUI needs.
With 3 commands, magmux creates this layout:
┌──────────────────┬──────────────────┐
│ Command 1 │ Command 2 │
│ (top-left) │ (top-right) │
├──────────────────┴──────────────────┤
│ Command 3 (bottom) │
├─────────────────────────────────────┤
│ Status bar │
└─────────────────────────────────────┘
Every pane needs at least 3 rows and 20 columns, and magmux refuses a
layout that cannot give every pane that much rather than producing panes too
small to show a prompt and a line of output. A column of k panes needs
4k - 1 rows (3 each plus a border between), and -e fills two columns, so the
practical ceiling is about 12 panes on an 80×24 terminal and 24 on 80×50. On
a terminal narrower than 41 columns only a single pane is possible, since
2 × 20 + 1 will not fit.
The refusal names the count, the geometry and the floor:
$ magmux --headless -e 'echo 1' … 40 times …
magmux: cannot lay out 40 panes in 80x23: every pane needs at least 3 rows and
20 columns (use fewer -e commands, a larger terminal, or set COLUMNS/LINES)
Two asymmetries worth knowing. -c costs a pane — the panel is appended to
the layout, so 12 panes succeed and 12 panes with -c refuse; without -c the
panel lives outside the tree and costs nothing. And the bare magmux layout
(no -e, no -g) is exempt: magmux chose that pane count itself, so there is
no caller to have made a mistake and three cramped shells beat refusing to
start.
The same floor governs Ctrl-G p and an agent's open_pane, deliberately —
"usable" cannot mean one thing for a flag and another for a socket verb.
--headless runs magmux with no terminal at all: no raw mode, no alternate
screen, no OSC 11 probe, and not one byte on stdout. The socket is the whole
interface. It is what makes magmux usable from CI, from a benchmark harness, or
from an agent that has no tty to give it — and it is the reason several magmuxes
can run at once, since a run no longer needs a pty each. Only the probe is
skipped: --theme, MAGMUX_THEME, TERM_THEME and COLORFGBG still pick the
palette children are told about over OSC 11, so a headless run can be told it
is on a light terminal.
It turns on automatically when stdin is not a terminal, so the shape that used to fail —
$ magmux -w -e 'echo hi' < /dev/null
magmux: raw mode: operation not supported by device
— now just works, with no flag. Pass --headless explicitly to force the mode
from inside a real terminal.
What does not change: the render loop still runs (it is what drives controller
polling, the snapshot broadcast and -w's auto-exit — only the write is
suppressed), every pane still spawns, results has exactly the shape it always
had, and the control panel still exists and still reports state: "panel".
Geometry comes from COLUMNS/LINES, else 80×24. That means a headless magmux
started inside a magmux pane inherits that pane's size, since those are the
two variables magmux exports to its own children — deliberate, so the nested
run's captures line up with the space a human is looking at.
-w needs a session to wait for. magmux --headless -w with no -e/-g
falls into the default shell layout and never auto-exits, and neither does
magmux --headless -w -c with no -e, because the control panel is not a
session. Both are legitimate — a controller-driven run should not exit on its
own — but they are indistinguishable from a hang, so end them with kill -TERM,
which magmux turns into its ordinary teardown (results, then shutdown, then
EOF for every subscriber).
- Pane splitting — horizontal and vertical with binary tree layout
- VT-100 terminal emulation — DEC ANSI state machine parser
- 256-color + truecolor — full SGR support including RGB
- Mouse support — click to focus, drag to select, auto-copy to clipboard
- Line drawing characters — G0/G1 charset switching for TUI borders
- Alt screen — proper handling for vim, htop, Claude Code, OpenCode
- SIGWINCH — automatic resize when terminal size changes
- Scrollback — 1000 lines per pane, primary screen only,
readable by a human (
Ctrl-G [, wheel) and by an agent (capture/read_panewith an offset) - Controlled sessions — drive a pane from the IPC socket, with a built-in
control panel (
-c) showing what was asked and what came back - Zero dependencies — only
golang.org/x/sysandgolang.org/x/term
Each pane keeps the last 1000 rows that scrolled off the top of its screen —
MAGMUX_SCROLLBACK changes the number, 0 turns it off. The ring fills lazily
and drops the oldest row when it is full, so a pane that never scrolls costs
nothing and a busy one costs a bounded amount (roughly rows × cols × 20 bytes,
about 4 MB for a full 1000-row ring at 200 columns).
Three ways in:
Ctrl-G [scrolls the focused pane back a page and enters scroll mode (see Controls).- The mouse wheel over an ordinary pane.
capture/read_panewith anoffset, for an agent — see Read verbs.
Only the primary screen records, and that is the rule to know. A
full-screen app — Claude Code, vim, htop, less — runs on the terminal's
alternate screen, which no terminal records into history; it is why quitting
vim does not leave its buffer in your scrollback. Switching to the alternate
screen and back leaves the primary's history exactly as it was.
The practical consequence is a division of labour rather than a gap:
| Pane | History lives in |
|---|---|
| shell, build, test run, dev server | scrollback — it has no transcript |
| Claude Code and other agent sessions | the transcript ({"type":"transcript"}, read_pane transcript:N) — it has little or no scrollback |
Host Terminal
└── magmux (raw mode + mouse tracking)
├── Pane 1 (PTY + VT parser + screen buffer)
├── Pane 2 (PTY + VT parser + screen buffer)
└── Pane 3 (PTY + VT parser + screen buffer)
Each pane runs a goroutine reading from its PTY, parsing VT escape sequences into a cell grid. The render loop checks dirty flags and only redraws when content changes.
Key design: child processes see TERM=screen-256color, which limits escape sequences to what the multiplexer supports — the same approach tmux and MTM use.
The palette is resolved once at startup, first answer wins: --theme,
MAGMUX_THEME, TERM_THEME, the OSC 11 probe (interactive tty only),
COLORFGBG, then dark. auto is "no opinion" at every level and falls through.
| Env Variable | Default | Description |
|---|---|---|
MAGMUX_THEME |
auto |
light, dark or auto. Second in the order: --theme wins over it, and it beats everything below. Set it when a terminal answers OSC 11 wrongly |
TERM_THEME |
(unset) | light or dark, as set by the terminal or shell. When it answers, magmux does not probe the terminal at all. Any other value (including auto) is ignored without a warning |
COLORFGBG |
(unset) | fg;bg or fg;x;bg colour indexes (rxvt, konsole, iTerm2). Consulted only when the probe was skipped or did not answer; background 0-6 or 8 is dark, 7 or 9-15 is light |
MAGMUX_SCROLLBACK |
1000 |
Lines of history kept per pane; 0 turns it off |
MAGMUX_SEL_FG |
0 (black) |
Selection foreground (256-color index) |
MAGMUX_SEL_BG |
220 (yellow) |
Selection background (256-color index) |
MAGMUX_SOCK_DIR |
/tmp |
Directory for the IPC socket. --sock-dir wins over it |
MAGMUX_DEBUG |
(unset) | Enable debug logging to /tmp/magmux-debug.log |
magmux exposes a Unix-domain socket that external tools can subscribe to for live pane state — this is the stable integration API (used by madbench).
Path scheme. On startup magmux binds /tmp/magmux-<pid>.sock, where <pid>
is the PID of the magmux process. The same path is exported to child processes
via the MAGMUX_SOCK environment variable. A parent that spawned magmux knows
its child PID and derives the path directly.
--id NAME binds /tmp/magmux-<name>.sock instead — the path is then known
before magmux starts, which is what lets a caller that did not fork magmux
find it. NAME is restricted to [A-Za-z0-9_-]{1,64} and may not be all
digits; an invalid one is ignored with a line on stderr and the pid socket
binds as usual. The pid default is unchanged, and --id replaces it rather
than adding to it.
The all-digits restriction is deliberate and worth the sentence: the startup
sweep below identifies a socket as reapable by its name being a pid, so a live
--id 1234 would be indistinguishable from the socket of a dead process 1234 —
and almost always removable.
Directory. --sock-dir DIR, or MAGMUX_SOCK_DIR, moves the socket out of
/tmp; the flag wins. A directory that is missing, is not a directory, or would
push the path past the OS limit on socket paths (~104 bytes on macOS) is ignored
with a line on stderr, and /tmp is used. --sock-dir also exports
MAGMUX_SOCK_DIR to children, so a magmux mcp started inside a pane finds the
right directory — but it cannot reach an MCP server the client launched in its
own process tree. For that, set MAGMUX_SOCK_DIR in the client's own env
block; it is the only channel that spans both process trees.
Startup sweep. magmux removes stale sockets in that directory as it starts:
the ordinary teardown unlinks the socket, but SIGKILL, a panic, an OOM kill
and a power loss all skip it, so without a sweep those files accumulate forever.
It only removes a socket named magmux-<pid>.sock whose <pid> it can prove is
gone (kill(pid, 0) reporting ESRCH, checked twice, and only on a real socket
inode). A running session, a socket owned by another user, and any --id socket
are all left alone — an --id name has no liveness oracle, so it is never
touched. SIGTERM, SIGINT and SIGHUP now run the normal teardown, so a
session ended that way removes its own socket and still delivers results; a
second signal exits immediately.
Framing. JSON Lines: one JSON object per line, terminated by \n (UTF-8).
The stream is bidirectional but subscribers typically only read. Unknown event
types and unknown fields should be ignored for forward compatibility.
Lifecycle & ordering guarantees:
- On connect the subscriber immediately receives one aggregate
snapshotevent carrying the current state of every pane. A subscriber that connects after some panes have already exited still gets full state from this event. - During the run magmux pushes
snapshot(per-pane, on meaningful change) andexit(per-pane, on completion) events as they happen. - On shutdown magmux broadcasts a final
resultsevent (authoritative final state of all panes), then ashutdownevent, then flushes and closes each connection. Theresultsevent is guaranteed to arrive before EOF, and beforeshutdown. Teardown is bounded (~2s) so a wedged subscriber cannot hang magmux's exit.
Events pushed to subscribers (type → fields):
type |
Shape | When |
|---|---|---|
snapshot (connect) |
panes: array of pane-state objects (see below) |
once, immediately on connect |
snapshot (per-pane) |
pane (int), controller, state, project, model, prompt, response, tool, startedAt, completedAt, and label when the pane has one |
on meaningful per-pane change |
exit |
pane (int), exitCode (int), duration, lastLine, response, prompt, tool, model |
when a pane's process exits |
results |
panes: array of pane-state objects, endedAt (RFC3339) |
once, at shutdown, before EOF |
shutdown |
(no extra fields) | once, after results, right before close |
control |
dir (out|note), pane, label, text, keys, at, or event/goal/summary for notes |
when a controlled session is driven (see below) |
pane_opened |
pane (int), cmd, and cwd / label when they were given |
when open_pane adds a pane |
pane_closed |
pane (int) |
when close_pane removes one |
reply |
id, ok, plus result or code+error |
answer to your message, if it carried an id (see below) |
reply is the one event that is not broadcast: it goes only to the
connection that asked for it. pane_opened/pane_closed are broadcasts like
the rest.
Guaranteed delivery. results is replayed to a connection that arrives
during teardown, so it always lands before EOF. exit, snapshot, control,
pane_opened and pane_closed are live broadcasts with no such guarantee — a
subscriber that connects late, or one still being served when magmux tears
down, can miss them. Anything you must not lose, read off results.
Disambiguate the two snapshot variants by field: the connect-time aggregate
carries a panes array; the per-pane live event carries a singular pane index.
Pane-state object (elements of the panes array in snapshot/results):
| Field | Type | Notes |
|---|---|---|
pane |
int | pane index, stable for the process lifetime; correlates to -e argument order |
state |
string | completed | failed | awaiting_input | running | closed | panel |
exitCode |
int | process exit code (0 until exit) |
dead |
bool | whether the pane's process has exited |
closed |
bool | present and true on a tombstone (see below) |
control |
bool | present and true on the control panel pane, which has no process |
hidden |
bool | control panel only: true when it is not currently on screen (Ctrl-G p). Its state is panel either way — hidden is a fact about magmux's chrome, not about a session |
controller |
string | controller name, if any (e.g. claude) |
model |
string | model name, if detected (omitted when empty) |
project |
string | project name, if detected (omitted when empty) |
prompt |
string | last user prompt, if any (omitted when empty) |
response |
string | last response, if any (omitted when empty) |
tool |
string | last tool used, if any (omitted when empty) |
startedAt |
string | RFC3339, if known (omitted when zero) |
completedAt |
string | RFC3339, if known (omitted when zero) |
focused |
bool | whether this pane currently has keyboard focus |
rows cols |
int | the pane's screen geometry — without the width there is no telling a wrapped line from a hard one |
altMode |
bool | whether the pane's app is on the alternate screen (vim, htop, a TUI agent) |
label |
string | the name given at open_pane or by -e CMD --label NAME (omitted when empty) |
cmd |
string | the pane's command line, if known (omitted otherwise) |
cwd |
string | the pane's working directory, if known (omitted otherwise) |
pid |
int | the pane process's pid, if running (omitted otherwise) |
inputSignal |
string | which heuristic called the pane idle — osc, 2004, title, idle, ctrl, perm (omitted when none) |
The last nine are additive; the forward-compat clause above already covers them, and every one is omitted rather than reported as a zero when it cannot be sourced.
Pane ids follow argument order. Panes created from -e receive ids
0..N-1 in the order the flags were given, and magmux's own panes — the control
panel — are appended after them, whether or not -c was passed. So -e A -e B gives you pane 0 and pane 1, and the first pane an agent opens is not
necessarily 1.
Pane ids are sparse. close_pane tombstones the slot and nothing ever
renumbers, so ids can have gaps and results can contain entries with
"state":"closed". A client treating the panes array as a dense positional
list will address the wrong session after the first close: the pane field was
always the authoritative index, and now it matters. Tombstones are reported
rather than omitted precisely so the hole is visible.
Or don't count at all. -e CMD --label NAME names a pane at startup, and
the name comes back on results, on the list verb and on every live
snapshot event — so a client can resolve "tests" once and never reason about
indices again. --label binds to the -e immediately before it; with no
preceding -e it is dropped with a line on stderr, and -g discards labels
along with the -e commands it overrides.
exitCode is only meaningful once the status has been collected. A pane
whose PTY has closed but whose cmd.Wait has not yet returned reports
"state":"running", not "completed" — magmux will not claim a success it
cannot back. The final results under -w always waits for the real status, so
exitCode there is the child's own.
A message carrying an "id" gets exactly one reply event, unicast to the
connection that sent it. A message with no id is dispatched exactly as it
always was, and answered exactly as it always was — which is to say not at all.
Existing clients never send an id, so they never receive a reply; nothing
about their stream changes, including for verbs they have never heard of, which
stay silently ignored.
The id round-trips verbatim: send 7 and you get 7, send "7" and you
get "7". ok:true carries result; ok:false carries code and error.
Branch on code — the error string beside it is for a human and may be
reworded at any time.
code |
Meaning |
|---|---|
bad_request |
the message is malformed, or a field is not what the verb needs |
no_such_pane |
no pane with that index (it may have been closed) |
pane_is_control |
that pane is the control panel, which has no process |
pane_dead |
the pane's process is gone, so the bytes had nowhere to go |
unknown_verb |
this magmux does not know that type |
unsupported |
known verb, not available in this mode |
busy |
the pane already has a request of this kind in flight |
timeout |
the verb's budget expired |
too_small |
open_pane: no room to split without going below the minimum pane size |
internal |
magmux's own fault |
reply is never replayed to a late connection, so it cannot land after
results; the results → shutdown → EOF ordering is unaffected.
For send, ok:true means the bytes reached the PTY — not that the TUI
accepted them. An app that was not ready for a paste can still drop it.
// what this magmux can do; also the version probe — an older magmux
// answers nothing at all, because an unknown verb without an id is silent
{"type":"capabilities","id":1}
// -> {"protocol":1,"version":"0.4.0","commit":"abc1234","pid":4212,
// "sock":"/tmp/magmux-4212.sock","rows":48,"cols":180,"gridMode":true,
// "scrollback":1000,"verbs":[...],"events":[...]}
// every pane, built by the same call snapshot and results are built from,
// so list and results can never disagree about the same pane
{"type":"list","id":2}
// -> {"panes":[ <pane-state objects, exactly as above> ]}
// a pane's screen as text; `lines` keeps the LAST N rows
{"type":"capture","id":3,"pane":0,"lines":40}
// -> {"pane":0,"rows":24,"cols":100,"alt":false,"truncated":false,
// "cursor":{"y":3,"x":0},"text":"line\nline\n…",
// "offset":0,"scrollback":312,"atTop":false}
// the same pane, one screenful further back in its history
{"type":"capture","id":4,"pane":0,"offset":24}
// -> {…,"offset":24,"scrollback":312,"atTop":false}capture reads the visible screen at offset: 0 and the pane's scrollback
above it. offset counts ROWS back from the bottom of the live screen, so
offset: rows is the screenful directly above what is on display and
successive reads at rows, 2*rows, 3*rows walk history backwards without
overlap. The reply always carries three fields to navigate by: scrollback is
how many rows of history that pane holds, offset is the one magmux settled
on (an over-large request clamps rather than failing), and atTop says the
oldest row it still has is in view. truncated is unrelated — it says only
that your lines cut something off.
Two bounds matter:
- History is bounded and drops the oldest first.
capabilitiesreports the ceiling — 1000 rows per pane by default,MAGMUX_SCROLLBACKto change it,0to turn it off. The ring fills lazily, so a pane that never scrolls costs nothing. - Only the primary screen records. A full-screen app runs on the terminal's
alternate screen, and no terminal records that into history — which is why
quitting
vimdoes not leave its buffer in your scrollback. So a pane running Claude Code,vim,htoporlessreports"scrollback": 0and every offset returns the live screen. For those, the session's own transcript ({"type":"transcript"}) is the history. Scrollback is for shell panes: builds, test runs, dev servers.
The control panel pane is capturable like any other.
// split the layout and run a command in the new half
{"type":"open_pane","id":4,"cmd":"claude","cwd":"/proj","label":"api",
"target":0,"split":"vertical","ratio":0.5,"focus":false,
"env":["FOO=bar"]}
// -> {"pane":3,"cmd":"claude","cwd":"/proj","label":"api"}
// close a pane and reap its process
{"type":"close_pane","id":5,"pane":3,"force":false}
// -> {"pane":3,"closed":true}
// move keyboard focus
{"type":"focus","id":6,"pane":0}
// -> {"pane":0,"focused":true}cmd runs through the user's login shell exactly as -e does, so the same
quoting, pipelines and cd x && y forms work. dir is accepted as a synonym
for cwd. target is the pane to split and defaults to the focused one; an
absent field takes that default, but a present one that is not a pane index
is refused rather than rounded — the wrong pane gets split and the layout is
silently wrong. split is auto (the default, which splits the longer axis),
horizontal or vertical. A split that would leave a pane below 3 rows or 20
columns is refused with too_small before anything is forked.
close_pane retains the index as a tombstone forever, and force escalates to
SIGKILL on the process group after 2s. focus exists because open_pane's
focus flag is one-shot.
Inbound messages (subscribers writing to magmux) also drive status-bar text,
pane tints, and agent hook events; see sockMsg in main.go. These are
optional and unrelated to the read-only subscribe flow above.
{"type":"tint","color":"green"} colours the pane's border (green, red or
yellow; "" clears it). It deliberately does not touch the pane's interior:
magmux does not know what foreground colours the program in the pane is using
and cannot recolour them, so any background it painted underneath them would be
illegible for somebody — a dark wash hides a dark-on-light program, a light one
hides a light-on-dark program like Claude Code. A pane with no split beside it
has no border to colour; use {"type":"overlay"} there, which sets both halves
of its own colour and is legible anywhere.
A subscriber can also drive a pane, not just watch it. That closes the loop: an external agent reads a session's state off the socket and pushes the next instruction back in, guiding an interactive coding tool through a multi-step task.
magmux -c \
-e 'claude' \
-e 'bun pilot/pilot.ts --pane 0 --goal "get the test suite green"'Every session has a control panel — a magmux pane like any other, except it has no process. magmux paints it itself, by writing ANSI through the pane's own VT parser exactly as a child would, so it inherits borders, selection and dirty-flag rendering and costs nothing while idle. It is built into the binary; there is nothing to install and nothing to run.
It starts hidden, so a session pane has the whole terminal. Ctrl-G p
shows it and Ctrl-G p hides it again; -c starts it already visible. Hiding
is not closing — the panel keeps every row of its ledger while it is away, and
results still reports it as state: "panel" either way (plus a hidden
flag). While it is hidden, the status bar carries a one-line digest of it:
* magmux │ 2/3 done │ 1 running │ ▶4 ◀3 │ p0 working · Bash 14s │ ▶ send "run the tests…" │ ^g p
▶ is what the controller asked for and ◀ is what magmux observed — the same
two numbers the panel shows, with the same provenance rule. The newest signal
is dropped as the terminal narrows; the counters are the last to go.
It is the instrument panel for a controlled session: what was asked, what came back, how long each turn took.
CONTROL PLANE 2m 40s
──────────────────────────────────────────────────────────
PILOT ══▶ SESSION [ WORKING ]
claude-sonnet-5 pane 0
4 sent │ 3 done │ 1 in flight ███████░░░
goal get the test suite green, then clear every warning
──────────────────────────────────────────────────────────
1 ✓ step 1/5 ████████░░░░░░░░ 32.0s Bash
2 ✓ step 2/5 ████████████████ 1m 14s Edit
3 ✓ verify █████░░░░░░░░░░░ 21.0s Bash
4 ◐ step 3/5 ██░░░░░░░░░░░░░░ 28.0s Read
─────────────────────────────────── ▲ 4 earlier ──────────
▶ now run go vet ./... and list every warning
◀ go vet reported 4 findings across 3 files. All four are
shadowed variables inside error branches — the inner err
is assigned but never checked…
Header. The badge is the live state of the driven session, not the
driver: amber WORKING, green AWAITING, red on an error or permission block.
Below it, the driver's model and the pane it is steering.
Counters. sent is instructions pushed in; done is turns magmux itself
observed complete. done is never taken from the driver's word for it, so if
a driver claims work it never got, the two diverge visibly. The meter appears
only when the driver declared a planned step count.
Ledger. One row per step: number, outcome (✓ done, ◐ in flight, ✗
error, ⚠ permission block), the driver's own label, a duration bar, the
elapsed time, and the last tool the session used. The bars are scaled against
the slowest turn, so the column is a comparison — a row twice as long took
twice as long. That is the thing a driver's own log cannot show you.
Exchange. The full conversation, oldest first — every instruction and every reply, in full, nothing truncated. It scrolls rather than cutting text off.
That is the one-pane layout. When a controller drives several panes — which is
what open_pane and the MCP server make ordinary — the panel switches to a
routed layout: one row per pane, and a single interleaved stream underneath.
CONTROLLER ══▶ 4 PANES claude-code/2.1 4m 2s
─────────────────────────────────────────────────────────────────────────
12 sent │ 11 done │ 1 in flight ████████████░
─────────────────────────────────────────────────────────────────────────
▸0 api ◐ WORKING 3/2 ‹3m 2s ▃█ Bash
1 web ✓ AWAITING 3/3 1m 36s ▄▁█ Read
2 infra ✓ AWAITING 4/4 1m 1s ▁█▁▅ Grep
3 scratch ✗ GONE 2/2 3.9s █▁ Edit
───────────────────────────────────────────────────── ▲ 24 earlier
01:03 3 ▶ send_and_wait scratch: make the tests green, then report back
⇦ ok 34 bytes + enter
01:03 3 ◀ AWAITING 42 passed, 0 failed 18.8s
01:04 1 ▶ capture read the screen
⇦ pane_dead pane 1 rejected the read
01:04 ▸0 ▶ send_and_wait now run go vet ./... and list every warning
Each route is a pane the controller has touched, keyed on the pane index, and
routes are never deleted — a pane the agent closed stays as ✗ GONE, because
the history of something closed because it had failed is exactly what you want
left to read. ▸ marks the focused pane. Counters are per route, so one pane
going idle can never close another pane's outstanding step.
The stream is a single chronological lane rather than per-pane columns:
causality across panes is the interleaving. The indented ⇦ line is magmux's
answer to the request directly above it — it never closes a turn, and only a
◀ row does. Narrow or short panes degrade rather than wrap: the route table
collapses to a one-line strip, and the stream drops its time column.
A single route keeps the one-pane layout above, so a -c pilot run looks
exactly as it always did.
| wheel over the panel | scroll, whichever pane has focus |
↑ ↓ PgUp PgDn |
scroll (panel focused — Ctrl-G Tab to focus it) |
Home / g |
jump to the start of the run |
End / G |
resume following the newest exchange |
1–9 |
show only that pane's traffic |
0 |
show every route again |
[ ] |
cycle through the open routes, via an "all" position |
q |
close, once the run has finished |
The panel is read-only. It cannot focus, send or close anything — the moment it could, "who closed pane 2" would become a question it has to answer about itself.
The header shows ▲ N earlier when there is history above the fold, and
▲ N back · End to follow once you have scrolled up, so you always know
whether you are looking at the live tail or the past.
When a driver declares the run over, the panel says so and how to leave:
FINISHED press q to close
test suite green and every go vet warning cleared
It waits for that keypress by default — a finished run that vanishes before you
have read it is worse than one that lingers. Pass -x SECS to close
automatically instead; the countdown is shown, and any keypress cancels it.
Inbound verbs:
// announce what you are driving, so the panel can show it. `client` is your
// own identity for the panel header ("claude-code/2.1"), and is optional
{"type":"pilot","event":"start","pane":0,"goal":"...","steps":3,"model":"...",
"client":"claude-code/2.1"}
// type an instruction into a pane and submit it
{"type":"send","pane":0,"text":"run the tests","label":"step 1/3"}
// press named keys instead (enter, escape, tab, up/down/left/right,
// ctrl-a..ctrl-w, home/end/pageup/pagedown, or any single character)
{"type":"send","pane":0,"keys":["escape"],"enter":false}
// close out the run
{"type":"pilot","event":"finish","summary":"all green"}send writes to a pane even when it is idle — steering a finished turn is
the entire point — and clears the pane's completion state exactly as a real
keystroke would. Multi-line text is wrapped in bracketed paste when the pane has
requested that mode, and Enter is sent after a short delay so the TUI has taken
the text before it is submitted. label is the short tag the panel shows in its
verb column; an MCP client puts its tool name there, which is that layer's
entire footprint on this protocol.
The panel's two directions come from different sources on purpose. ▶ rows
are the controller's request as it arrived on the socket; ◀ rows are recorded
only from what magmux itself observed the pane do. A driver can never fabricate
a completion, and the panel can show a session disagreeing with what the driver
believes it asked for.
The panel is driver-agnostic: anything that speaks these verbs on the socket
fills it in. pilot/pilot.ts below is one such driver, not a requirement — a
shell script piping JSON into nc -U $MAGMUX_SOCK gets the same panel, and so
does an MCP client, which reaches magmux through magmux mcp translating tool
calls into exactly these verbs.
pilot/pilot.ts is a reference driver built on pi.dev. Its
toolbox is replaced with exactly two tools — send_to_session and finish —
where send_to_session blocks until magmux observes the turn settle and returns
the session's own answer as the tool result. The multi-step loop is therefore
just pi's ordinary agent loop, with no orchestration code on top.
It finds its multiplexer through MAGMUX_SOCK, so running it as a magmux pane
needs no configuration.
task pilot:check # preflight: which models pi can use
task pilot:demo # watch a pilot build a file line by line (cheap, scratch dir)
GOAL="get the tests green" task pilot
task test:pilot # full e2e, asserts the artifact on diskCredentials. Only the pilot needs them — magmux needs none, and the Claude
Code pane it drives uses its own auth. pi reads provider keys from the
environment it is started in; how they get there is up to you.
task pilot:check reports what it found.
magmux mcp runs magmux as an MCP server on
stdio, so an AI agent can spawn and drive real panes — real PTYs running real
interactive tools, with a human watching every one of them. It is the same
socket protocol as everything above; the MCP server is a translator, not a
second API.
Session-scoped, and usually what you want. --mcp-config takes a JSON file
or an inline JSON string, and persists nothing:
claude --mcp-config '{"mcpServers":{"magmux":{"command":"/path/to/magmux","args":["mcp"]}}}'Add --strict-mcp-config to ignore every other MCP configuration for that
session — the right setting when what you are testing is magmux itself.
This fits the flagship case above, where the agent runs inside a magmux pane,
inherits MAGMUX_SOCK and attaches to its own host session: the server exists
for exactly as long as the window does. Nesting the inline form in -e is not
worth the quoting, though — that command goes through $SHELL -l -c, so the
single quotes collide. Put the same JSON in a file, as test/ui/case4.ts does,
and pass the path:
cat > /tmp/magmux-mcp.json <<'JSON'
{"mcpServers":{"magmux":{"command":"/path/to/magmux","args":["mcp"]}}}
JSON
magmux -c -e 'claude --mcp-config /tmp/magmux-mcp.json'Persistent, for when you want magmux available in every session:
claude mcp add magmux -- /path/to/magmux mcpIt speaks JSON-RPC 2.0 as newline-delimited JSON. stdout carries protocol only.
MAGMUX_MCP_LOG sends logs to a file instead of stderr.
Tools:
| Tool | |
|---|---|
list_sessions |
every magmux on this machine, with its panes and their states |
attach_session |
pick one by id, pid or socket path; it becomes the default |
request_session |
how to get a session when there is none — it never starts one |
list_panes |
index, label, state, command and pid for each pane |
open_pane |
split the layout and run a command in the new pane |
close_pane |
close a pane and reap its process |
read_pane |
the pane's state, its rendered screen (and its scrollback), its transcript |
send_keys |
type text and press named keys, without waiting |
send_and_wait |
give an agent one instruction and wait for the resulting turn |
send_and_wait is the workhorse: it waits for the turn to visibly start
before waiting for it to finish, so it can never hand back the previous turn's
answer. read_pane is the agent's eyes, and it reads from two independent
sources: transcript:N returns the last N turns from the session's own record
on disk and is authoritative, while screen is what the pane is painting —
plus, with offset:N, the rows that have scrolled off above it. The
alternate-screen rule above applies here too and decides which source to reach
for: an agent pane keeps no scrollback, so its transcript is its history; a
shell, build or dev-server pane has no transcript, so its scrollback is.
How it finds a session, in order:
- The host session. The agent is already running inside a magmux pane, so
MAGMUX_SOCKis set and that session is used. This is the flagship case: the agent drives its siblings in the window the human is already looking at, and the human watches the whole time. (An agent may never drive its own pane — it would be waiting for a turn it is itself inside. That is refused.) - A discovered session. One reachable
magmux-*.sockin the socket directory (/tmp, orMAGMUX_SOCK_DIR), or whateverattach_sessionis pointed at. If the magmux you want was started with--sock-dir, setMAGMUX_SOCK_DIRto the same directory in this client's ownenvblock —--sock-dirreaches magmux's children, but nothing magmux does at runtime can reach a server the client launched itself. - Inside tmux.
request_sessiontells the client to use its own tmux MCP tooling to split a pane and runmagmux --id NAME -c -e '<cmd>'there, then callattach_session. - Otherwise, it hands the human that same command to run, and waits to be
pointed at the result with
attach_session.
magmux never execs tmux itself, and never starts a magmux of its own. A
session nobody can see defeats the design: the point of driving a real pane
rather than a subprocess is that a human can read it, interrupt it, and take
the keyboard. -c in those instructions adds the control panel, so what the
agent asked for and what the session actually did are both on screen.
task mcp:demoThe flagship flow, live and in one window. It opens bare, the way magmux opens
by default: one full-screen Claude Code session, --mcp-config pointed at the
freshly built binary, nothing else on screen. That session then splits its
own multiplexer — open_pane — to start a second Claude Code session in a
scratch directory, and drives it with send_and_wait until it has produced
squares.txt holding 1 4 9 16 25.
Nothing is configured to make the attachment happen. magmux exports
MAGMUX_SOCK before any child starts and Claude Code hands its environment to
the MCP servers it spawns, so magmux mcp finds the window it is already
inside.
What to watch for:
- the window splitting itself, mid-run, while you are looking at it — a full-screen session becomes two panes with nobody at the keyboard. This is the moment, which is why the demo does not start with the panel up;
- the routing, once you ask for it.
Ctrl-G preveals the control panel —▶ open_pane,▶ send_and_wait, and◀rows for the turns magmux itself observed the new session take. The▶rows are the driver's request; the◀rows are not, so the panel can show the two sessions disagreeing.Ctrl-G phides it again, and the status line carries the same counters as a digest while it is away; - the file on disk, printed after the window exits. The panel is only as trustworthy as what each agent says it did; the file is the part neither of them can print into existence.
MCP_CLOSE_AFTER=20 closes the window twenty seconds after everything settles
instead of waiting for q, and MCP_MODEL=opus picks the driving model.
task test:mcp is the automated cousin: same artifact, but the driver is
denied Bash, Write and Edit so it cannot do the work itself.
Not yet run end to end. It spends real model budget and needs a TTY, so the flow above is written but unverified.
The original MTM is ~1,800 lines of C using ncurses. magmux is ~2,100 lines of Go with no ncurses dependency — raw ANSI escape codes to stdout. Go provides:
- Memory safety (no buffer overflows in escape sequence parsing)
- Goroutine-per-pane concurrency (simpler than C's
select()loop) - Static binary distribution (no ncurses/libc dependency)
- Accessible to teams that don't maintain C code
MIT
--> {"type":"capture","id":7,"pane":0,"lines":40} <-- {"type":"reply","id":7,"ok":true,"result":{"pane":0,"rows":24,"cols":100, "alt":false,"truncated":false,"cursor":{"y":3,"x":0},"text":"..."}} --> {"type":"send","id":8,"pane":9,"text":"hi"} <-- {"type":"reply","id":8,"ok":false,"code":"no_such_pane", "error":"no pane 9 (it may have been closed)"}