Terminal session persistence for Windows. Start a session, detach, close the window, reattach later — the shell and everything running in it keep going.
wmux new -s build # create a session and attach
# ... start a long build ...
# press Ctrl-B then d -> detached, build keeps running
wmux ls # see it running
wmux attach build # pick up exactly where you left offWindows has never had a terminal multiplexer, for a reason that stopped being true in 2018. The classic console was not a byte stream — programs poked characters into a screen buffer through the Console API, so there was no stream for a multiplexer to sit in the middle of. ConPTY (Windows 10 1809) changed that, and everything wmux needs has been shipping since.
wmux uses it directly:
wmux new ──spawns──> wmux server (DETACHED_PROCESS: no console at all)
│
├── ConPTY ──────> pwsh.exe
├── vt100 model (so a later client can be repainted)
└── named pipe ──< wmux attach (any terminal)
The server holds no console, so when you close the window that started it, the
CTRL_CLOSE_EVENT conhost sends to that console never reaches the server. That
is the entire trick.
No panes, no splits, no status bar, no copy mode, no config language.
Windows Terminal already does tabs, splits, search, and settings, and does them well. On Unix tmux grew those features because terminals of the era did not have them; that history is not a reason to reimplement them here. wmux does the one thing Windows actually lacks — a session that outlives its window — and leaves the rest to your terminal. Split a Windows Terminal pane and attach a different wmux session in each.
winget install HomeOps.wmuxNative builds exist for x64 and ARM64 Windows; winget picks the right one.
They are also attached to each GitHub release
as wmux-<version>-x64.zip and wmux-<version>-arm64.zip.
Or build from source (requires Rust 1.77+):
cargo build --release
# ARM64 from an x64 machine (needs the VS "MSVC ARM64 build tools"):
rustup target add aarch64-pc-windows-msvc
cargo build --release --target aarch64-pc-windows-msvc| Command | What it does |
|---|---|
wmux new |
Create a session (auto-named) and attach |
wmux new -s NAME |
Create a named session and attach |
wmux new -d -s NAME |
Create it but stay detached (a 200x60 screen unless -x/-y say otherwise) |
wmux new -s NAME -x 132 -y 43 |
Create it at an explicit size |
wmux new -s NAME -- pwsh -NoProfile |
Run a specific program |
wmux new -d -s NAME --legacy -- prog.exe |
Host a classic console program in a real, hidden console |
wmux ls |
List running sessions |
wmux attach [NAME] |
Attach; NAME is optional if only one exists |
wmux run -t NAME <cmd> |
Run a PowerShell command, get its result as CLIXML/JSON/text |
wmux send -t NAME <text> |
Type raw keystrokes into a session without attaching |
wmux key -t NAME F1 Alt-F10 Down |
Press named keys without attaching |
wmux capture -t NAME |
Print the session's visible screen as plain text |
wmux capture -t NAME --format json -S 500 |
The screen with per-cell colours, plus scrollback |
wmux resize -t NAME -x 160 -y 50 |
Resize a session without attaching |
wmux kill NAME |
Terminate a session and its child |
tmux bindings, so muscle memory carries over:
| Keys | Action |
|---|---|
Ctrl-B d |
Detach |
Ctrl-B Ctrl-B |
Send a literal Ctrl-B to the session |
Or, without touching the keyboard at all:
wmux detach # from inside the session
wmux detach build # from any other terminalPSReadLine binds Ctrl-B to backward-char, which is the same tradeoff tmux
users already live with. To rebind:
$env:WMUX_PREFIX = '^A' # or any ^X form, or a literal control bytewmux send types text; wmux key presses keys that text cannot express:
wmux key -t ztw F1 # function keys F1-F24
wmux key -t ztw Alt-F10 C-x # modifiers: Ctrl-/C-, Alt-/M-, Shift-/S-
wmux key -t ztw Down Down PgDn Home Enter Esc Tab BSpace Ins Del
wmux key -t ztw q # a single character is pressed literallyA ConPTY session receives the xterm escape sequence for each key; a
--legacy session receives a real key record (see below). Either way the
command returns once the session has consumed the keys.
Some programs cannot run under a pseudoconsole. They were written against the
classic console API, and typically ask for their console window with
GetConsoleWindow(); a ConPTY has no real window, so they fail or draw nothing.
ZTreeWin is one, reporting "cannot get window handle".
--legacy gives such a program a real conhost, created with its window
hidden. wmux reads the screen straight from the console buffer and injects
input as key records, so everything the program does with the classic API
works exactly as it would on a desktop:
wmux new -d -s ztw --legacy -- ZTW64.EXE /AUTO X:\src
wmux capture -t ztw --wait-for COMMANDS
wmux key -t ztw Down Down F1
wmux capture -t ztw --format json # characters and coloursThe console is sized before the program starts, since many legacy programs
read the size once at startup. A console window cannot be larger than the
monitor and font allow, so a requested size is clamped to that limit. Killing
the session kills the program's whole process tree. wmux attach is not
supported for legacy sessions yet.
capture --format json returns each row twice: as text, and as one console
attribute per cell, two hex digits each (background then foreground, in the
classic console palette). 1E is bright yellow on blue; 70 is black on grey.
{"cols":200,"rows":49,"history":0,"cursor":{"x":0,"y":48},
"lines":[" X:\\src ...", ...], "attrs":["1E1E1E...", ...]}To look at a session instead, --format ansi redraws it in colour. It
starts with CSI 8;rows;cols t to ask the terminal for the session's size
(conhost honours it; Windows Terminal ignores it, so widen the window first):
wmux capture -t ztw --format ansi | Write-Host
wmux capture -t ztw --format ansi > ztw.ans # Get-Content -Raw ztw.ans | Write-HostConPTY sessions are mapped to the same 16 colours from the terminal model,
with 256-colour and RGB values rounded to the nearest. -S N prepends up to
N rows of scrollback; history says how many of the rows are scrollback.
| Variable | Effect |
|---|---|
WMUX_PREFIX |
Prefix key, e.g. ^A. Defaults to ^B. |
WMUX_SHELL |
Program wmux new runs. Defaults to pwsh.exe, then powershell.exe. |
WMUX_LOG |
Set to anything to log to %LOCALAPPDATA%\wmux\wmux.log. |
WMUX_SESSION |
Set inside a session to its name. |
The case nothing else covers. sshd on Windows creates a ConPTY per session,
so a dropped connection kills whatever was running. With wmux:
ssh you@windows-box
wmux new -s build
# start the build, press Ctrl-B d, close the laptop
# later, from anywhere:
ssh you@windows-box
wmux attach buildwmux spawns its server with CREATE_BREAKAWAY_FROM_JOB so it escapes any job
object the SSH session is holding. If breakaway is refused it says so on stderr
rather than silently leaving you with a session that will die.
None of run, send, or capture needs a console, so a script — or an AI
agent, or anything else without a terminal — can drive a session and keep shell
state between invocations that would otherwise each start cold:
wmux new -d -s work pwsh.exe -NoLogo -NoProfile
# expensive work happens once, and the result stays in the session
wmux run -t work -- '$inv = Get-ChildItem C:\big -Recurse -File | Select FullName, Length'
# ...later, from a completely separate process
wmux run -t work --format json -- '$inv | Measure-Object Length -Sum'The payoff is that the dataset never crosses the process boundary. Only the answer you asked for does.
None of these register as an attached client, so wmux ls still reports the
session as detached while a script is driving it, and several callers can use
one session concurrently.
capture scrapes the rendered terminal, which is the wrong tool for getting a
value out of PowerShell. The screen is wrapped to the terminal width, drops
anything that scrolled away, flattens the object pipeline to whatever
Out-String produced, and interleaves your echoed command with its own output.
So run routes the payload around the terminal entirely. It types a wrapper
that serialises the pipeline to a temp file and then touches a completion
marker; wmux polls for the marker and reads the payload off disk. The terminal
only ever carries the command.
That means no wrapping, no size limit, and real types:
wmux run -t work --format clixml -- 'Get-Process pwsh' > procs.xml
$procs = Import-Clixml procs.xml # real objects, not textEvery result is wrapped in an envelope so failures are data rather than a parsing problem:
{"Success":true,"Error":null,"ExitCode":null,"Output":[{"Name":"main.rs","Length":12795}]}A command that throws comes back with "Success":false and the message in
Error, and the session stays usable.
run is the one PowerShell-specific part of wmux. Sessions running anything
else still work with send and capture.
Only relevant if you use capture directly. A terminal echoes what you type,
so --wait-for 'count=' matches the moment the command is echoed, before it
has produced anything. Wait on a sentinel the shell builds at runtime:
wmux send -t work -- 'Write-Output ("DONE"+"MARK"+" rows=$($data.Count)")'
wmux capture -t work --wait-for 'DONEMARK 'The echo shows ("DONE"+"MARK"+...); only the output contains DONEMARK .
run does not have this problem, which is the main reason to prefer it.
ConPTY only ever hands out a stream. Once bytes have gone past, there is no API to ask Windows what the screen currently looks like — which is exactly what a client attaching ten minutes later needs.
So the server replays everything the pty emits into a vt100 terminal model and
keeps it current. On attach it serialises that model back into escape sequences
and sends it as a single repaint. This is the same reason tmux contains a
terminal emulator.
Each session is a named pipe at \\.\pipe\wmux.<your-SID>.<name>, created with
an explicit DACL granting access to SYSTEM, Administrators, and you — nobody
else. The first instance is created with FILE_FLAG_FIRST_PIPE_INSTANCE so a
squatter holding the name causes a hard failure instead of silently intercepting
keystrokes. Clients connect with SECURITY_IDENTIFICATION so a compromised
server cannot impersonate the attaching user.
cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings
cargo test # unit tests
cargo test --test e2e # spawns real servers, ConPTYs, and shellsThe end-to-end tests drive the wire protocol directly rather than going through
wmux attach, because attaching needs a real console and CI does not have one.
Everything below the console layer is exercised for real.
MIT