# muxdeck A single-binary web app for creating, managing, and interacting with tmux sessions from the browser. ![muxdeck](docs/screenshot.png) - **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 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 `. - 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 `, `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 `, 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)