23 KiB
muxplex Distribution Packaging Plan
Execution: Use the subagent-driven-development workflow to implement this plan.
Goal: Package muxplex as a distributable Python tool installable via uvx or uv tool install from git+https://github.com/bkrabach/muxplex. Single-port server (no Caddy required), CLI entry point, systemd service support.
Architecture: The coordinator/ package is renamed to muxplex/, the frontend/ directory moves inside it as package data, and a WebSocket proxy route replaces Caddy's only remaining job (proxying /terminal/ws to ttyd). A new cli.py provides the muxplex command with serve and install-service subcommands.
Tech Stack: Python 3.11+, FastAPI, uvicorn, websockets, hatchling (build backend)
Not in scope: GitHub repo creation, git submodule setup, PyPI publishing — handled separately after coding tasks complete.
Task 1: Rename coordinator/ → muxplex/ and move frontend/ inside
Files:
- Rename:
coordinator/→muxplex/(all files move) - Move:
frontend/→muxplex/frontend/(becomes package data) - Modify: every
.pyfile undermuxplex/(import rewrite) - Modify:
pyproject.toml(testpaths)
This is a purely mechanical rename — no behavior changes, no new features. Every other task depends on this layout being in place.
Step 1: Move the directories
cd /home/bkrabach/dev/web-tmux/muxplex
mv coordinator muxplex
mv frontend muxplex/frontend
Step 2: Rewrite all coordinator → muxplex in Python source files
There are exactly 6 import lines in source files that need updating:
In muxplex/bells.py:
# line 20 — old
from coordinator.sessions import run_tmux
# new
from muxplex.sessions import run_tmux
# line 21 — old
from coordinator.state import empty_bell
# new
from muxplex.state import empty_bell
In muxplex/main.py:
# line 22 — old
from coordinator.bells import apply_bell_clear_rule, process_bell_flags
# new
from muxplex.bells import apply_bell_clear_rule, process_bell_flags
# line 23 — old
from coordinator.sessions import (
# new
from muxplex.sessions import (
# line 31 — old
from coordinator.state import (
# new
from muxplex.state import (
# line 40 — old
from coordinator.ttyd import kill_orphan_ttyd, kill_ttyd, spawn_ttyd, TTYD_PORT
# new
from muxplex.ttyd import kill_orphan_ttyd, kill_ttyd, spawn_ttyd, TTYD_PORT
Step 3: Rewrite all coordinator → muxplex in test files
There are 25 import references and 35 monkeypatch.setattr("coordinator.*") calls across 7 test files. Use sed for bulk replacement in each file:
cd /home/bkrabach/dev/web-tmux/muxplex
# Replace in all Python files under muxplex/ (source + tests)
find muxplex -name '*.py' -exec sed -i 's/coordinator\./muxplex./g' {} +
find muxplex -name '*.py' -exec sed -i 's/from muxplex\.sessions/from muxplex.sessions/g' {} +
But verify these specific files have the correct replacements (every occurrence of coordinator. becomes muxplex.):
muxplex/tests/test_api.py— 27 monkeypatch + 7 import referencesmuxplex/tests/test_bells.py— 2 importsmuxplex/tests/test_integration.py— 4 imports + 4 monkeypatchmuxplex/tests/test_sessions.py— 2 importsmuxplex/tests/test_state.py— 3 imports + 2 monkeypatchmuxplex/tests/test_ttyd.py— 2 imports + 2 monkeypatch
Step 4: Update _FRONTEND_DIR path in muxplex/main.py
The static file path must change since frontend/ is now inside the package:
# OLD (line 344 of muxplex/main.py):
_FRONTEND_DIR = pathlib.Path(__file__).parent.parent / "frontend"
# NEW:
_FRONTEND_DIR = pathlib.Path(__file__).parent / "frontend"
Step 5: Update frontend path references in test files
Two test files compute the path to frontend/ relative to their own location. After the restructure, the traversal changes from .parent.parent.parent / "frontend" (3 levels up) to .parent.parent / "frontend" (2 levels up, since tests are now at muxplex/tests/ and frontend is at muxplex/frontend/):
In muxplex/tests/test_frontend_css.py (line 5):
# OLD:
CSS_PATH = pathlib.Path(__file__).parent.parent.parent / "frontend" / "style.css"
# NEW:
CSS_PATH = pathlib.Path(__file__).parent.parent / "frontend" / "style.css"
In muxplex/tests/test_frontend_html.py (line 7):
# OLD:
HTML_PATH = pathlib.Path(__file__).parent.parent.parent / "frontend" / "index.html"
# NEW:
HTML_PATH = pathlib.Path(__file__).parent.parent / "frontend" / "index.html"
Step 6: Move spike_bell_flag.py out of the package
The spike_bell_flag.py file is a one-off dev spike — it should not ship in the wheel:
mv muxplex/spike_bell_flag.py scripts/spike_bell_flag.py
Step 7: Update pyproject.toml testpaths
# OLD:
testpaths = ["coordinator/tests"]
# NEW:
testpaths = ["muxplex/tests"]
Step 8: Run all tests to verify nothing broke
cd /home/bkrabach/dev/web-tmux/muxplex
python -m pytest muxplex/tests/ --ignore=muxplex/tests/test_integration.py -q 2>&1 | tail -10
Expected: 169 passed (same count as before)
node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -3
Expected: pass 136, fail 0
node --test muxplex/frontend/tests/test_terminal.mjs 2>&1 | tail -3
Expected: pass, fail 0
Step 9: Commit
git add -A && git commit -m "refactor: rename coordinator → muxplex package, move frontend inside as package data"
Task 2: Add WebSocket proxy route to replace Caddy
Files:
- Modify:
muxplex/main.py(add WebSocket route + update bell hook port) - Modify:
muxplex/tests/test_api.py(add route-exists test) - Modify:
requirements.txt(add websockets)
This eliminates Caddy as a runtime dependency. The FastAPI app becomes the single-port server, proxying /terminal/ws to ttyd at ws://localhost:7682/ws.
Step 1: Write the failing test
Add to the end of muxplex/tests/test_api.py:
# ---------------------------------------------------------------------------
# WebSocket proxy route
# ---------------------------------------------------------------------------
def test_terminal_ws_route_exists():
"""The app must have a WebSocket route registered at /terminal/ws."""
from muxplex.main import app
ws_routes = [
r for r in app.routes
if hasattr(r, "path") and r.path == "/terminal/ws"
]
assert len(ws_routes) == 1, "Expected exactly one /terminal/ws route"
Step 2: Run test to verify it fails
cd /home/bkrabach/dev/web-tmux/muxplex
python -m pytest muxplex/tests/test_api.py::test_terminal_ws_route_exists -v
Expected: FAIL — no /terminal/ws route exists yet.
Step 3: Add websockets dependency to requirements.txt
websockets>=11.0
Install it:
pip install websockets>=11.0
Step 4: Add the WebSocket proxy route to muxplex/main.py
Add these imports near the top (after the existing imports):
from fastapi import WebSocket
import websockets
Note: asyncio is already imported. WebSocket goes on the same line as the existing FastAPI, HTTPException import:
# Change this line:
from fastapi import FastAPI, HTTPException
# To:
from fastapi import FastAPI, HTTPException, WebSocket
Add a SERVER_PORT config variable near POLL_INTERVAL (around line 46):
SERVER_PORT: int = int(os.environ.get("MUXPLEX_PORT", "8088"))
Add the WebSocket route after the last API route (setup_hooks) but before the static file mount:
# ---------------------------------------------------------------------------
# WebSocket proxy — relay browser ↔ ttyd (replaces Caddy reverse-proxy)
# ---------------------------------------------------------------------------
@app.websocket("/terminal/ws")
async def proxy_terminal_ws(websocket: WebSocket) -> None:
"""Proxy browser WebSocket to ttyd for terminal I/O.
Accepts with subprotocol 'tty' (required by ttyd), connects to the local
ttyd instance, and relays frames bidirectionally until either side closes.
"""
await websocket.accept(subprotocol="tty")
ttyd_url = f"ws://localhost:{TTYD_PORT}/ws"
try:
async with websockets.connect(ttyd_url, subprotocols=["tty"]) as ttyd_ws:
async def client_to_ttyd() -> None:
try:
while True:
msg = await websocket.receive()
if msg.get("type") == "websocket.disconnect":
break
if msg.get("bytes") is not None:
await ttyd_ws.send(msg["bytes"])
elif msg.get("text") is not None:
await ttyd_ws.send(msg["text"])
except Exception:
pass
async def ttyd_to_client() -> None:
try:
async for message in ttyd_ws:
if isinstance(message, bytes):
await websocket.send_bytes(message)
else:
await websocket.send_text(message)
except Exception:
pass
await asyncio.gather(client_to_ttyd(), ttyd_to_client())
except Exception:
pass
finally:
try:
await websocket.close()
except Exception:
pass
Step 5: Update the alert-bell hook URLs from port 8099 → SERVER_PORT
There are exactly two hardcoded 8099 references in muxplex/main.py. Both are in the run-shell curl command for the tmux alert-bell hook.
Line ~148 (in lifespan):
# OLD:
"run-shell 'curl -sfo /dev/null -X POST http://localhost:8099/api/sessions/#{session_name}/bell || true'",
# NEW:
f"run-shell 'curl -sfo /dev/null -X POST http://localhost:{SERVER_PORT}/api/sessions/#{{session_name}}/bell || true'",
Line ~333 (in setup_hooks):
# OLD:
"run-shell 'curl -sfo /dev/null -X POST http://localhost:8099/api/sessions/#{session_name}/bell || true'",
# NEW:
f"run-shell 'curl -sfo /dev/null -X POST http://localhost:{SERVER_PORT}/api/sessions/#{{session_name}}/bell || true'",
Note the double-braces #{{session_name}} — in an f-string, {{ produces a literal {, so the tmux variable #{session_name} is preserved.
Step 6: Run tests to verify pass
cd /home/bkrabach/dev/web-tmux/muxplex
python -m pytest muxplex/tests/test_api.py::test_terminal_ws_route_exists -v
Expected: PASS
Also run the full test suite to ensure nothing broke:
python -m pytest muxplex/tests/ --ignore=muxplex/tests/test_integration.py -q 2>&1 | tail -5
Expected: 170 passed (169 + 1 new)
Step 7: Commit
git add -A && git commit -m "feat: add WebSocket proxy route to replace Caddy, update bell hook port to configurable SERVER_PORT"
Task 3: Create muxplex/cli.py entry point and muxplex/__main__.py
Files:
- Create:
muxplex/cli.py - Create:
muxplex/__main__.py - Create:
muxplex/tests/test_cli.py
The CLI is the user-facing interface: muxplex runs the server, muxplex install-service writes a systemd unit file.
Step 1: Write the failing tests
Create muxplex/tests/test_cli.py:
"""Tests for muxplex/cli.py — CLI entry point."""
import os
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
def test_cli_module_importable():
"""muxplex.cli must be importable."""
from muxplex.cli import main # noqa: F401
def test_main_calls_serve_by_default():
"""Calling main() with no args must invoke serve()."""
from muxplex.cli import main
with patch("muxplex.cli.serve") as mock_serve:
with patch("sys.argv", ["muxplex"]):
main()
mock_serve.assert_called_once_with(host="0.0.0.0", port=8088)
def test_main_passes_custom_host_and_port():
"""main() with --host/--port must forward them to serve()."""
from muxplex.cli import main
with patch("muxplex.cli.serve") as mock_serve:
with patch("sys.argv", ["muxplex", "--host", "127.0.0.1", "--port", "9000"]):
main()
mock_serve.assert_called_once_with(host="127.0.0.1", port=9000)
def test_main_install_service_subcommand():
"""main() with 'install-service' must invoke install_service()."""
from muxplex.cli import main
with patch("muxplex.cli.install_service") as mock_install:
with patch("sys.argv", ["muxplex", "install-service"]):
main()
mock_install.assert_called_once_with(system=False)
def test_main_install_service_system_flag():
"""main() with 'install-service --system' passes system=True."""
from muxplex.cli import main
with patch("muxplex.cli.install_service") as mock_install:
with patch("sys.argv", ["muxplex", "install-service", "--system"]):
main()
mock_install.assert_called_once_with(system=True)
def test_install_service_user_mode_writes_unit_file(tmp_path, monkeypatch):
"""install_service(system=False) writes a unit file to ~/.config/systemd/user/."""
from muxplex.cli import install_service
fake_home = tmp_path / "home"
fake_home.mkdir()
monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home))
install_service(system=False)
unit_path = fake_home / ".config" / "systemd" / "user" / "muxplex.service"
assert unit_path.exists()
content = unit_path.read_text()
assert "[Unit]" in content
assert "[Service]" in content
assert "[Install]" in content
assert "muxplex" in content
assert "default.target" in content
def test_install_service_system_mode_target(tmp_path, monkeypatch):
"""install_service(system=True) targets multi-user.target in the unit file."""
from muxplex.cli import install_service
# Redirect the system path to tmp so we don't write to /etc
unit_path = tmp_path / "muxplex.service"
monkeypatch.setattr("muxplex.cli._system_service_path", unit_path)
install_service(system=True)
assert unit_path.exists()
content = unit_path.read_text()
assert "multi-user.target" in content
def test_dunder_main_calls_main():
"""python -m muxplex must call cli.main()."""
with patch("muxplex.cli.main") as mock_main:
# Simulate `python -m muxplex` by exec'ing __main__.py
import importlib
import muxplex.__main__ # noqa: F401
# The import itself calls main() at module level
# Re-exec to test:
mock_main.reset_mock()
exec(Path("muxplex/__main__.py").read_text())
mock_main.assert_called_once()
Step 2: Run tests to verify they fail
cd /home/bkrabach/dev/web-tmux/muxplex
python -m pytest muxplex/tests/test_cli.py -v 2>&1 | head -30
Expected: FAIL — muxplex.cli does not exist yet.
Step 3: Create muxplex/cli.py
"""muxplex CLI — web-based tmux session dashboard."""
import argparse
import os
import sys
from pathlib import Path
# Module-level path constants (overridable in tests via monkeypatch)
_system_service_path = Path("/etc/systemd/system/muxplex.service")
def serve(host: str = "0.0.0.0", port: int = 8088) -> None:
"""Start the muxplex server."""
import uvicorn # noqa: PLC0415
os.environ.setdefault("MUXPLEX_PORT", str(port))
from muxplex.main import app # noqa: PLC0415
print(f" muxplex → http://{host}:{port}")
uvicorn.run(app, host=host, port=port, log_level="warning")
def install_service(*, system: bool = False) -> None:
"""Install muxplex as a systemd service."""
executable = sys.executable
unit = f"""\
[Unit]
Description=muxplex — web-based tmux session dashboard
After=network.target
[Service]
Type=simple
ExecStart={executable} -m muxplex
Restart=on-failure
RestartSec=5s
Environment=PATH={os.environ.get('PATH', '/usr/local/bin:/usr/bin:/bin')}
[Install]
WantedBy={"multi-user.target" if system else "default.target"}
"""
if system:
path = _system_service_path
reload_cmd = "sudo systemctl daemon-reload && sudo systemctl enable --now muxplex"
else:
path = Path.home() / ".config" / "systemd" / "user" / "muxplex.service"
path.parent.mkdir(parents=True, exist_ok=True)
reload_cmd = "systemctl --user daemon-reload && systemctl --user enable --now muxplex"
path.write_text(unit)
print(f"Service file written to {path}")
print(f"Enable with:\n {reload_cmd}")
def main() -> None:
"""CLI entry point."""
parser = argparse.ArgumentParser(
prog="muxplex",
description="muxplex — web-based tmux session dashboard",
)
parser.add_argument("--host", default="0.0.0.0", help="Bind host (default: 0.0.0.0)")
parser.add_argument("--port", type=int, default=8088, help="Port (default: 8088)")
sub = parser.add_subparsers(dest="command")
sub.add_parser("serve", help="Start the server (default)")
svc = sub.add_parser("install-service", help="Install systemd service unit")
svc.add_argument("--system", action="store_true", help="System-wide (requires sudo)")
args = parser.parse_args()
if args.command == "install-service":
install_service(system=args.system)
else:
serve(host=args.host, port=args.port)
Step 4: Create muxplex/__main__.py
"""Allow running muxplex as: python -m muxplex"""
from muxplex.cli import main
main()
Step 5: Run tests to verify pass
cd /home/bkrabach/dev/web-tmux/muxplex
python -m pytest muxplex/tests/test_cli.py -v
Expected: PASS (all 8 tests)
Run full suite:
python -m pytest muxplex/tests/ --ignore=muxplex/tests/test_integration.py -q 2>&1 | tail -5
Expected: 178 passed (170 + 8 new)
Step 6: Commit
git add -A && git commit -m "feat: add CLI entry point (muxplex serve, muxplex install-service) and __main__.py"
Task 4: Update pyproject.toml for full distribution packaging
Files:
- Modify:
pyproject.toml(complete rewrite)
Step 1: No new tests needed
The existing test suite implicitly validates the package is importable. The verification step below confirms pip install -e . and the entry point work.
Step 2: Replace pyproject.toml contents entirely
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "muxplex"
version = "0.1.0"
description = "Web-based tmux session dashboard — access all your tmux sessions from any browser"
readme = "README.md"
license = { text = "MIT" }
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.30.0",
"aiofiles>=23.0",
"websockets>=11.0",
]
[project.optional-dependencies]
dev = [
"httpx>=0.27.0",
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"beautifulsoup4>=4.12",
]
[project.scripts]
muxplex = "muxplex.cli:main"
[project.urls]
Repository = "https://github.com/bkrabach/muxplex"
Issues = "https://github.com/bkrabach/muxplex/issues"
[tool.hatch.build.targets.wheel]
packages = ["muxplex"]
[tool.pytest.ini_options]
testpaths = ["muxplex/tests"]
asyncio_mode = "auto"
addopts = "--import-mode=importlib -m 'not integration'"
markers = [
"integration: marks tests as integration tests requiring real tmux (deselect with '-m not integration')",
]
Step 3: Verify installability
cd /home/bkrabach/dev/web-tmux/muxplex
pip install -e ".[dev]" --quiet
Then verify the entry point:
muxplex --help
Expected output should show:
usage: muxplex [-h] [--host HOST] [--port PORT] {serve,install-service} ...
Also verify python -m muxplex --help shows the same.
Step 4: Run full test suite one more time
cd /home/bkrabach/dev/web-tmux/muxplex
python -m pytest -q 2>&1 | tail -5
Expected: all tests pass.
Step 5: Commit
git add -A && git commit -m "chore: configure pyproject.toml for distribution (entry point, deps, hatchling build)"
Task 5: Rewrite README.md
Files:
- Modify:
README.md
Step 1: No tests needed — doc-only change
Step 2: Replace README.md contents
# muxplex
> Web-based tmux session dashboard — access and manage all your tmux sessions from any browser or mobile device.

## Features
- Live grid of all running tmux sessions with snapshot previews
- Full interactive terminal in the browser (via ttyd + xterm.js)
- Collapsible session sidebar — switch sessions without leaving the terminal
- Bell / activity notifications
- Mobile-friendly responsive layout, PWA-capable
- Works over Tailscale / private network
## Prerequisites
- **tmux** — must be installed and running sessions
- **ttyd** — WebSocket bridge for terminal access (`brew install ttyd` / `apt install ttyd`)
- **Python 3.11+**
## Quick start (uvx — no install)
```bash
uvx --from git+https://github.com/bkrabach/muxplex muxplex
Open http://localhost:8088 in your browser.
Install permanently
uv tool install git+https://github.com/bkrabach/muxplex
muxplex
Install as a service (systemd)
# User service (no sudo required)
muxplex install-service
systemctl --user daemon-reload
systemctl --user enable --now muxplex
# System service (runs on boot for all users)
muxplex install-service --system
sudo systemctl daemon-reload
sudo systemctl enable --now muxplex
Usage
muxplex [--host HOST] [--port PORT] [serve]
muxplex install-service [--system]
| Option | Default | Description |
|---|---|---|
--host |
0.0.0.0 |
Bind address |
--port |
8088 |
Port to serve on |
install-service |
— | Install systemd service unit |
--system |
false | System-wide service (requires sudo) |
Development
git clone https://github.com/bkrabach/muxplex
cd muxplex
pip install -e ".[dev]"
# Run server
muxplex
# Run tests
python -m pytest
node --test muxplex/frontend/tests/test_app.mjs
node --test muxplex/frontend/tests/test_terminal.mjs
Architecture
- muxplex/ — Python package (FastAPI coordinator + CLI)
main.py— FastAPI app: session API, static frontend, WebSocket proxy to ttydsessions.py— tmux session enumeration and snapshot capturebells.py— bell/activity detectionttyd.py— ttyd process lifecycle managementstate.py— persistent state management (JSON)cli.py— CLI entry point (muxplex serve,muxplex install-service)frontend/— static web app (HTML/CSS/JS + xterm.js)
- assets/branding/ — SVG sources, rendered PNGs, design tokens
- scripts/ —
render-brand-assets.py— regenerate brand PNGs from SVGs
Brand assets
Design language, color tokens, and brand assets in assets/branding/. Regenerate:
python scripts/render-brand-assets.py
**Step 3: Commit**
```bash
git add -A && git commit -m "docs: rewrite README with uvx/uv install instructions and full usage guide"
Post-plan: Git repo setup (manual, not automated)
After all 5 tasks are complete, the parent session will:
- Create the GitHub repo at
github.com/bkrabach/muxplex - Push the muxplex directory contents to it
- Convert the local
muxplex/directory into a git submodule of the parentweb-tmuxrepo
These steps are outside the scope of this coding plan.