README.md
Rama main · réplica de lectura del repositorio original.
# muxdeck
A single-binary web app for creating, managing, and interacting with tmux
sessions from the browser.

- **Full terminal in the browser** — xterm.js attached to any tmux session
over WebSocket; multiple viewers per session, just like multiple `tmux
attach` clients
- **Split view** — two sessions side by side on wide screens; the deep-link
hash carries both (`/#build,logs`)
- **Session management** — create, kill, rename, drag to reorder, unseen-output
dots, and a command palette (`⌘K` / `Ctrl+Shift+K`) with fuzzy switching
and tab completion
- **Sessions you can tell apart** — each row shows its working directory, the
command running there, the git branch it sits on (marked when the tree is
dirty), and any TCP ports its processes are listening on, so a sidebar full
of agents reads at a glance
- **Clipboard that works** — selection copies on release, tmux copy-mode
yanks land on the system clipboard via OSC 52, and touch devices get a
paste key; URLs in output are clickable
- **Resilient connections** — dropped WebSockets reconnect automatically
with backoff (phones background tabs constantly; muxdeck just comes back)
- **Scrollback search** — find in the current session (`⌘F` /
`Ctrl+Shift+F`), plus adjustable font size
- **A CLI in the same binary** — `muxdeck ls`, `status set`, `notify`, `send`
drive the running daemon over its HTTP API, so a script on your laptop can
report progress into a session, or type into one, on a box across a tunnel
- **One static binary** — frontend embedded, no runtime dependencies beyond
`tmux` itself; Linux and macOS, amd64 and arm64
- **Installable PWA** — home-screen app on iPad/phone, with a touch key bar
(esc / tab / sticky-ctrl / arrows / paste) for what software keyboards lack
- **Secure by default** — loopback binds are open; anything wider
auto-generates an access code unless you configure a token or opt out;
optional built-in TLS
## Install
```sh
curl -fsSL https://raw.githubusercontent.com/josecarlosrivas/muxdeck/main/install.sh | sh
```
The installer detects your platform, checks for tmux, downloads the latest
release binary, and offers three modes: try it in the foreground, install it
as a service (systemd/launchd, token-protected, starts at boot), or just put
the binary on your PATH. Non-interactive use:
`MUXDECK_MODE=service sh install.sh` (also `MUXDECK_BIN_DIR`, `MUXDECK_PORT`).
Any muxdeck binary can also make itself the machine's always-on daemon:
`muxdeck service install` writes a launchd agent (macOS) or a systemd user
unit (Linux) that runs *this* binary on loopback, tokenless, and claims it
with muxdeck cloud so the relay's gate does the authenticating — print the
claim code, enter it on the account page, `muxdeck relay on`. `-addr`,
`-service-token`, `-bin`, `-cloud` and `-no-claim` change the shape; `muxdeck
service` reports it and `muxdeck service uninstall` removes it. The desktop
app offers this on first launch when no daemon is running: saying yes points
the service at the daemon inside the app bundle, so it follows app updates
and the app attaches to it from then on instead of spawning its own.
## Build
```sh
go build -o muxdeck .
```
The web frontend (including vendored xterm.js) is embedded in the binary —
deploying is copying one file.
Prebuilt binaries for linux/darwin × amd64/arm64 are attached to each
[release](../../releases).
## Run
```sh
./muxdeck # http://127.0.0.1:8300, no auth (loopback)
./muxdeck -addr 0.0.0.0:8300 # all interfaces — access code auto-generated
./muxdeck -addr 0.0.0.0:8300 -token s3cret # fixed token instead
./muxdeck -addr 0.0.0.0:8300 -no-auth # explicitly disable auth
./muxdeck -tls-cert cert.pem -tls-key key.pem # serve HTTPS directly
```
**Auth is secure by default.** Binding beyond loopback without a `-token`
generates a 6-character access code, printed at startup:
```
access code: XK7M2P (set your own with -token, disable auth with -no-auth)
```
Enter it on the unlock screen (case-insensitive) — GitHub-device-auth style.
A new code is generated each start; use `-token` for a stable secret, or
`-token auto` to force a code even on loopback. Startup also prints the URLs
the server is reachable at, one per interface on wildcard binds.
Every flag has an env-var default: `MUXDECK_ADDR`, `MUXDECK_TOKEN`,
`MUXDECK_NO_AUTH`, `MUXDECK_TLS_CERT`, `MUXDECK_TLS_KEY`.
muxdeck talks to the default tmux server of the user it runs as. Sessions
created and killed in the UI are real tmux sessions — anything you can do in
`tmux attach` works in the browser terminal, including multiple simultaneous
viewers of one session.
**Scrolling:** browsers only route wheel/touch scrolling into the terminal
when tmux mouse mode is on, so muxdeck enables `mouse on` for sessions it
creates, and the per-pane `mouse` button toggles it per session (it's the
real tmux option, so it affects terminal clients of that session too).
**Copy/paste:** selecting text copies it to the clipboard on release (with
mouse mode on, hold Shift — Option on macOS — to select in the browser
instead of tmux). tmux copy-mode yanks reach the system clipboard too:
muxdeck sets the server's `set-clipboard` option and the `Ms` terminfo
override, and the frontend turns the resulting OSC 52 writes into browser
clipboard writes. Paste with `⌘V`/`Ctrl+V`, or the `paste` key on touch
devices. Clipboard access needs a secure context (HTTPS or localhost).
**Keyboard shortcuts:** `⌘K` (`Ctrl+Shift+K`) command palette,
`⌘F` (`Ctrl+Shift+F`) find in scrollback. Plain Ctrl combos always go to
the terminal.
## Deployment
muxdeck is a single plain HTTP(S) server with no external dependencies, so it
composes with whatever infrastructure you already have. Common shapes:
- **Localhost only** (default): `./muxdeck`, open http://127.0.0.1:8300.
- **Private network / VPN / overlay network** (WireGuard, Tailscale, Nebula,
…): bind to the private interface and rely on network access control;
add `-token` for defense in depth. Mesh tools with a TLS-fronting feature
(e.g. `tailscale serve`) can provide HTTPS without touching muxdeck.
- **Built-in TLS**: pass `-tls-cert`/`-tls-key` (any PEM pair — your own CA,
Let's Encrypt via certbot, mkcert for LAN). No proxy required.
- **Reverse proxy**: put muxdeck behind Caddy/nginx/Traefik for automatic
certificates and hostname routing. WebSockets pass through standard proxy
configs; e.g. Caddy needs only:
```
mux.example.com {
reverse_proxy 127.0.0.1:8300
}
```
- **Supervision**: any process manager works. systemd example:
```ini
[Unit]
Description=muxdeck
After=network.target
[Service]
User=youruser
Environment=MUXDECK_ADDR=0.0.0.0:8300 MUXDECK_TOKEN=changeme
ExecStart=/usr/local/bin/muxdeck
Restart=on-failure
# Without this, restarting/stopping muxdeck kills the whole cgroup —
# including the tmux server it spawned, and every session in it.
KillMode=process
[Install]
WantedBy=default.target
```
On macOS, a `launchd` plist pointing at the binary does the same job.
## iPad / phone (PWA)
muxdeck installs as a Progressive Web App: serve it over HTTPS (any option
above), open it in the browser, and use "Add to Home Screen" — you get a
full-screen terminal with the muxdeck icon. On touch devices a key bar
(esc / tab / sticky-ctrl / arrows / common symbols) appears above the
terminal to cover what software keyboards lack; hardware keyboards work
as-is.
Note: service workers (and therefore installation) require a secure context —
HTTPS or localhost. Plain-HTTP deployments still work in a normal browser
tab, including the key bar.
## iPad app (native, sideloaded)
`desktop/` also builds a native iPad client: a Tauri shell with a server
picker that frames the daemon's web UI full-screen. It is not on the App
Store; build and install it yourself with Xcode and a paid or free Apple
developer team:
```sh
rustup target add aarch64-apple-ios
cd desktop && npm ci
npx tauri ios build --export-method debugging # signs with your team
xcrun devicectl device install app --device <udid> src-tauri/gen/apple/build/arm64/muxdeck.ipa
```
`xcrun devicectl list devices` shows the udid of a paired iPad (enable
Developer Mode on the iPad first). Drive builds from the Tauri CLI —
`tauri ios build`, or `tauri ios dev --open` when you want Xcode — rather
than pressing Run in a hand-opened Xcode project: the Xcode script phase
talks back to the running CLI and fails without it.
**Auth:** the app frames the server cross-origin, and WebKit drops the
login cookie there, so a token-protected daemon bounces back to the token
prompt. Put the daemon behind an identity-aware HTTPS proxy instead — a
loopback bind plus `tailscale serve`, for example — and add that URL in the
picker.
## Security model
A browser terminal is remote code execution by design. Deploy accordingly:
- **Loopback binds** run without auth. **Any other bind** gets a generated
access code unless you set `-token` or explicitly pass `-no-auth`.
- **Trusted/private network:** network access control may be enough — use
`-no-auth` if you accept that, or keep the code/token as defense in depth.
- **Anything less trusted:** use a `-token` and serve over TLS (built-in
flags or a proxy). The token gates all `/api` routes; the login form sets
an HttpOnly cookie, and non-browser clients can send
`Authorization: Bearer <token>`.
- WebSocket upgrades enforce a same-host `Origin` check to block cross-site
hijacking.
Never expose muxdeck directly to the public internet without both TLS and a
token.
## API
| Method | Path | Description |
|--------|-------------------------------|---------------------------------|
| POST | `/api/login` | `{"token"}` → HttpOnly cookie |
| GET | `/api/sessions` | list sessions |
| POST | `/api/sessions` | `{"name"}` → create (detached) |
| DELETE | `/api/sessions/{name}` | kill session |
| POST | `/api/sessions/{name}/rename` | `{"name"}` → rename |
| GET | `/api/sessions/{name}/mouse` | `{"enabled"}` — tmux mouse mode |
| POST | `/api/sessions/{name}/mouse` | `{"enabled"}` → set mouse mode |
| GET | `/api/sessions/{name}/attach` | WebSocket terminal bridge |
| POST | `/api/sessions/{name}/send` | `{"text","enter"}` → type into it |
| GET | `/api/sessions/{name}/diff` | git diff (worktree vs HEAD) |
| GET | `/api/sessions/{name}/files` | markdown files under cwd |
| GET | `/api/sessions/{name}/file` | `?path=` → file content |
| GET | `/api/doctor` | macOS folder-privacy state; `?probe` reads the folders |
| GET | `/api/power` | keep-awake state (see below) |
| POST | `/api/power` | `{"keep_awake_while_viewing"}` → set |
| GET | `/api/remotes` | list remotes with liveness |
| POST | `/api/remotes` | add/update a remote |
| DELETE | `/api/remotes/{name}` | remove a remote |
| ANY | `/api/remotes/{name}/proxy/…` | reverse proxy to the remote API |
A session in the list carries `name`, `windows`, `created`, `attached` and
`activity` from tmux; `command` and `path` from its active pane; `branch` and
`dirty` when that path is inside a git repository; `ports`, the TCP ports its
process tree is listening on; and `agent`, the last self-reported agent status
(see [integrations](integrations/)). Everything past the tmux fields is
decoration: it is resolved out of band on a short TTL and omitted rather than
waited for, so the list never blocks on git, procfs, or `lsof`.
Attach protocol: client sends JSON text frames `{"type":"input","data":"…"}`
and `{"type":"resize","cols":N,"rows":N}`; server sends raw terminal output as
binary frames.
Session names are restricted to `[A-Za-z0-9_-]{1,64}`.
## CLI
The daemon binary is also the client. With no arguments it serves; with a
command it talks to a running daemon over the same HTTP API the browser uses.
```sh
muxdeck ls # a table of sessions
muxdeck ls -json # the API response, verbatim
muxdeck status set build working -progress 0.4 -label tests -chip 'flask:tests=6/12'
muxdeck status get build
muxdeck notify build "needs your permission"
muxdeck send build 'make test' # types it and presses Enter
muxdeck send -no-enter build 'make test' # types it and waits
muxdeck send build # a bare Enter
muxdeck doctor # check macOS folder-privacy access
```
The daemon to talk to comes from `-url` (default `$MUXDECK_URL`, else
`http://127.0.0.1:8300`) and `-token` (default `$MUXDECK_TOKEN`).
HTTP rather than a unix socket, which is the usual answer for a local daemon:
a socket is reachable only from the machine the daemon runs on, and muxdeck's
premise is that the machine you are looking at is often not the one you are
on. A session already reachable through a tunnel or an ssh remote is one the
CLI can drive with no extra plumbing.
`notify` posts `state: "waiting"` — the transition the UI raises a
notification on — adopting whatever agent name is already reporting for that
session, so it does not blank the model and spend a statusline has been
keeping current. `send` is remote code execution into a shell, deliberately:
it is the power the attach WebSocket has always had, behind the same auth.
`notify` and `send` take their flags before the session name, so a message or
a payload that starts with `-` stays a message.
`doctor` asks the daemon whether macOS folder privacy (TCC) is keeping it out
of Desktop, Documents or Downloads, and prints how to grant access. It has to
ask the daemon: grants attach to the process doing the reading, so a read that
works from your shell proves nothing about the service. The daemon also
remembers folders it was denied along the way — the web UI shows a banner for
those, since from an iPad the only other symptom is an `ls` that hangs. It
never reads protected folders unprompted: an undecided folder makes macOS
raise a consent prompt on the Mac's screen and park the read behind it, which
is exactly the hang the banner exists to explain.
## Viewers
The split layout doubles as a review surface. With a session focused, open
the command palette (⌘K):
- **`:diff`** — the session's repo diff (working tree vs `HEAD`) rendered in
the opposite pane, anchored to the pane's current directory. Refreshes
every few seconds and on the ↻ button.
- **`:md`** — pick a markdown file (tracked or untracked, relative to the
session's directory) and preview it rendered. Output is sanitized with
DOMPurify, so a cloned repo's README can't run script in the UI.
Both work on remote sessions too — the endpoints are session-scoped and ride
the remote proxy (the remote must run a version that has them).
## Remotes
Any machine running muxdeck can appear in the sidebar as a collapsible group
— sessions there list, attach, create, rename, and kill exactly like local
ones, addressed as `host:name` everywhere (panes, the command palette, the
URL hash). Two transports:
- **`url`** — the remote is directly reachable (LAN, VPN, tailnet). Its
token, if set, is stored locally and injected by the proxy; the browser
only ever authenticates against the local instance.
- **`ssh`** — the remote binds loopback only. The local daemon maintains an
`ssh -N -L` tunnel using *your* ssh binary, so `~/.ssh/config`, agents,
and `ProxyJump` all apply and ssh itself is the authentication. Tunnels
start lazily, restart with backoff, and die with the daemon.
Manage remotes with `:remote` in the command palette — the prompt
tab-completes each token and hints the syntax ahead (`add jack ssh jack`,
`add lab url http://lab:8300 TOKEN`, `rm jack`, `off`/`on` to toggle) — or
the `/api/remotes` endpoints. The registry lives at `remotes.json` under the user config dir
(override with `MUXDECK_REMOTES`), mode `0600` since it can hold remote
tokens. Unreachable remotes show a broken-link icon with the error in the
tooltip.
## muxdeck cloud in the sidebar
Sign the daemon in to a [muxdeck cloud](https://cloud.muxdeck.app) account
and every machine claimed there appears under a **muxdeck cloud** group,
next to the local sessions and any hand-registered remotes — one deck for
all your boxes, in the browser, the PWA, and the desktop app alike. The
daemon syncs the account's machine list every few minutes and registers
each one (except itself) as a `url` remote reached through its relay
name, presenting the account's device token as the bearer the relay
admits. Nothing about the relay or the account changes: the browser still
authenticates only against the local daemon.
- **Desktop app:** `:cloud signin` opens the account page in your browser;
after signing in, "open the muxdeck app" returns the token through the
app's `muxdeck://` link and the group fills in.
- **Browser / PWA deck:** `:cloud signin` opens the account page; mint an
app token there and paste it back as `:cloud signin mdd_…`.
- **Shell:** `muxdeck cloud signin <token>`, `muxdeck cloud` for status,
`muxdeck cloud signout` to revoke the token at the account and drop the
group, `muxdeck cloud sync` to refresh now.
Cloud machines cannot be edited with `:remote` (they would come back on
the next sync); `:remote off`/`on` still works and is remembered. A
machine whose name a hand-registered remote already holds is skipped and
listed as such in `muxdeck cloud`. The token lives in `cloud.json` under
the user config dir (override with `MUXDECK_CLOUD_CONFIG`), mode `0600`;
`MUXDECK_CLOUD_URL` points a fresh sign-in at a self-hosted control plane.
## Keep a Mac awake while viewing
A sleeping Mac wakes for the network, then dozes off again before a
terminal is any use. Opt in per machine and the daemon keeps that Mac
awake while someone is actually looking at it — a terminal pane or a live
mush run on screen, idle or not — and only while it is plugged in. The
display may still sleep; nothing touches `pmset`, no root service, and an
explicit Sleep is still respected.
- **Deck:** `:awake on|off|status` acts on the focused pane's machine (or
this one) and names it first. **Shell:** `muxdeck awake on|off|status`.
- Viewers renew a presence lease every 15s over the socket they already
hold; a lease lapses a minute after its last renewal, so a locked phone
or a dead network lets the machine sleep on its own. The last viewer's
departure is held for 15s in case it is a reconnect. Session lists,
status polls, relay traffic and detached sessions earn no lease; clients
older than this feature keep working and earn none either.
- One assertion per daemon, however many panes, tabs or phones are
looking: on macOS an owned `caffeinate -s -w <daemon pid>`, which only
applies on AC power and dies with the daemon. Status reports intent,
viewers, backend and effective power separately — `on_battery` means
viewers are present but nothing is being held. Off macOS the feature
reports `unsupported` and cannot be enabled. The preference is
`power.json` in the daemon's config dir.
## How it works
muxdeck shells out to the `tmux` CLI of the user it runs as — it holds no
session state of its own. Each WebSocket attach spawns a real `tmux
attach-session` client on a PTY and pipes bytes both ways, so everything
tmux can do (splits, scrollback, copy mode, status line) just works. If you
start muxdeck from inside a tmux session it manages that same tmux server;
otherwise the user's default server.
## Development
```sh
go run . # serve with live web/ from the embedded FS
go run . ls # drive a daemon that is already running
go run ./tools/genicons web/icons # regenerate PWA icons (stdlib only)
```
Plain `go build` produces the deployable binary. CI runs `go vet`, a build,
`go test ./...`, and a `gofmt` check on every push and PR.
## License
[MIT](LICENSE)