README now documents all features, 14 config keys, full CLI reference,
keyboard shortcuts, platform support, and project structure. Previous
README covered ~40% of actual functionality. Added docs/plans/README.md
noting these are historical ADRs from the initial build.
Added 9 new README tests verifying all settings keys are documented,
clipboard features, keyboard shortcuts, auth modes, platform support,
ANSI previews, hover preview, and view modes.
Tests were asserting elements that don't exist in the current HTML
(notifications panel, device-name input, view-scope select). Updated
tests to match the actual settings dialog structure.
- Notification settings (bell-sound, status-text, request-btn) live in
the sessions panel, not a dedicated notifications panel
- Settings dialog has 4 tabs (display, sessions, new-session, devices),
not 5 — no notifications tab
- #setting-device-name is in the display panel, not the devices panel
- #setting-view-scope was never implemented; removed stale test
- .tile-bell-count was removed from CSS; dropped that single assertion
Mouse selection: onSelectionChange auto-copies to system clipboard on
every selection change (last fire on mouse-up captures final text).
Matches iTerm2/WezTerm/ttyd native select-to-copy behavior.
OSC 52: Custom parser.registerOscHandler(52, ...) decodes base64
clipboard data from tmux. When tmux copies text (set-clipboard on
in .tmux.conf), it sends OSC 52 to the terminal. We intercept and
write to system clipboard via _copyToClipboard (with execCommand
fallback for HTTP/LAN contexts).
Together: mouse-select auto-copies, and tmux Ctrl+B [ -> select ->
Enter copies to system clipboard via OSC 52.
Uses xterm.js attachCustomKeyEventHandler to intercept Ctrl+Shift+C/V.
Copy: getSelection() → navigator.clipboard.writeText (with execCommand
fallback for non-HTTPS contexts). Paste: navigator.clipboard.readText()
→ send to ttyd via WebSocket binary protocol (0x30 prefix).
Ctrl+C/V continue to work as normal terminal keys (SIGINT / literal paste).
The Shift modifier distinguishes clipboard from terminal operations.
Also hoists encodePayload + TextEncoder to module level (_encodePayload,
_encoder) so the clipboard handler can reuse them without duplication.
Sessions with cursor near the top (e.g. a fresh tunnel/ssh session)
have content at rows 1-2 and rows 3-40 blank in a 40-row terminal.
The previous fix trimmed AFTER slice(-20), but slice(-20) of a 40-line
snapshot grabs the last 20 rows — all blank. Trimming after slice then
removed everything, leaving an empty <pre>.
Fix: trim trailing blank lines from the full snapshot first, then
slice the last N lines of what remains. This way a session with 2
content lines at the top is reduced to those 2 lines before slicing,
and both appear in the preview.
Fixes both buildTileHTML (grid tiles) and buildSidebarHTML (sidebar
cards). Tests updated to cover the 40-row terminal scenario.
Sessions with cursor near the top (e.g. just a prompt) have 18+ empty
lines below. slice(-20) grabbed all blanks, making previews appear
empty. Fix: pop trailing blank lines after slicing so the pre element
contains only meaningful content. CSS bottom:0 anchors it correctly.
Tests: 4 new tests cover content-at-top trimming and all-blank edge case.
- Backend: AuthMiddleware.dispatch() checks X-Muxplex-Token header
after cookie auth, allowing cross-origin federation requests to
authenticate using a session token sent as a header instead of a
SameSite=Strict cookie.
- Backend: GET /api/auth/token endpoint returns the current session
token for authenticated requests. Used by login popups to relay
the token back to the opener window via postMessage.
- Frontend (app.js): api() injects X-Muxplex-Token header for
cross-origin requests when a token is stored in localStorage under
muxplex.federation_tokens keyed by origin.
- Frontend (app.js): storeFederationToken() helper stores tokens.
window.addEventListener('message') handler catches muxplex-auth-token
postMessages and stores the token, then retriggers a poll so the
auth_required source transitions to authenticated immediately.
- Frontend (app.js): _saveRemoteInstances() prunes stale tokens for
URLs no longer in the remote instances list.
- Frontend (index.html): Popup relay script fetches /api/auth/token
and sends the token to window.opener via postMessage when loaded
as a login popup (window.opener present).
Tests added:
test_auth.py: X-Muxplex-Token header authenticates / invalid rejected
test_api.py: GET /api/auth/token returns token / 401 when unauthed
test_app.mjs: api() header injection, storeFederationToken storage,
index.html popup script presence
Root cause (4th iteration): The WS proxy called websocket.accept() before
verifying ttyd was alive. The browser 'open' event fired immediately, resetting
_reconnectAttempts to 0. Counter bounced 0→1→0→1 forever — the client-side
/connect POST at >= 2 attempts never fired.
Server fix: _ttyd_is_listening() TCP probe (socket.create_connection, <1ms)
before websocket.accept(). If ttyd is dead, auto-spawns it from active_session
in state (kill_ttyd → spawn_ttyd → sleep 0.8s) THEN accepts. Browser 'open'
only fires when ttyd is confirmed reachable.
Client fix: _reconnectAttempts reset moved from 'open' handler to 'message'
handler. First data message proves ttyd is alive and relaying — not just that
the proxy accepted. Belt-and-suspenders: client-side /connect fetch still fires
at attempt >= 2 as a fallback.
Tests added:
- test_ttyd_is_listening_function_exists: function importable from main
- test_ws_proxy_checks_ttyd_before_accepting: source inspection ensures
_ttyd_is_listening() call appears before await websocket.accept()
- test_ws_proxy_auto_spawns_ttyd_when_dead: verifies spawn_ttyd called with
active_session when _ttyd_is_listening returns False
- terminal.mjs: _reconnectAttempts = 0 NOT in open handler, IS in message
The /connect POST that respawns ttyd was fire-and-forget — fetch fired
and the next line immediately created a new WebSocket. The WS raced
ttyd's startup and lost every time, staying stuck on 'Reconnecting...'.
Fix: extract WS creation into _connectWebSocket(). When
_reconnectAttempts >= 2, chain via .then() after the /connect fetch
+ 800ms settle delay for ttyd to bind its port, then return early to
prevent fallthrough. When < 2, call _connectWebSocket() directly
(normal reconnect — ttyd is still alive).
Exposes ~/.config/muxplex/settings.json management without hand-editing:
config list — show all settings with (modified) markers
config get <key> — print one value
config set <key> <value> — set with auto type coercion (bool/int/str/list)
config reset [key] — reset one or all to defaults
Example: muxplex config set host 0.0.0.0 && muxplex service restart
updateFaviconBadge() was creating new Image() on every call (every 2s via
poll cycle). Setting img.src triggers a network fetch of favicon-32.png
each time, causing favicon-32.png to appear in network traffic alongside
every heartbeat + sessions poll.
Fix: extract _drawFaviconBadge() helper that owns the Image lifecycle.
It lazily creates _faviconImage once (module-level var), caches it, and
reuses on all subsequent calls. If the image isn't loaded yet it registers
an onload callback to retry. updateFaviconBadge() simply calls
_drawFaviconBadge() — no Image construction in the hot path.
After a service restart ttyd dies. The reconnect loop was retrying the
WebSocket every 2s but never calling POST /api/sessions/{name}/connect
to respawn ttyd. The proxy had nothing to connect to so it looped forever
showing 'Reconnecting...'.
Fix:
- Add _reconnectAttempts counter at module level
- Close handler now uses exponential backoff: 1s, 2s, 4s, 8s, cap 15s + jitter
- After 2 failed WS attempts, fire-and-forget POST to /api/sessions/{name}/connect
to respawn ttyd before the next WebSocket retry
- Reset _reconnectAttempts to 0 on successful open, openTerminal, closeTerminal
Root cause: open event fires and reconnect loop retried the WS address but
ttyd was dead so the proxy rejected it — POST /connect was only called from
openSession() on page load/restore, never during reconnect.
Removed install-service from argparse subparsers so it doesn't appear
in the usage line or help listing. Intercepted before parse_args() for
backward compatibility — prints deprecation warning and forwards to
service install.
Without start_new_session=True, ttyd inherits the parent's process group
and gets cleaned up when the asyncio transport is garbage collected after
the HTTP handler completes. start_new_session=True detaches ttyd into
its own process group so it survives independently of the parent.
C1: _systemd_status, _systemd_stop, _systemd_uninstall (stop+disable),
_launchd_status, _launchd_stop, _launchd_uninstall (bootout) no longer
pass check=True. systemctl status exits 3 on stopped service; bootout
exits non-zero on unloaded service — both are informational, not errors.
C2: _prompt_host_if_localhost wraps input() in try/except (EOFError,
KeyboardInterrupt) so service install doesn't crash in CI or piped
stdin environments. Also fixes settings["host"] → settings.get("host")
to prevent KeyError on partial/missing settings files.
Added 9 regression tests in test_service.py:
- test_systemd_status_no_check_true
- test_systemd_stop_no_check_true
- test_systemd_uninstall_stop_and_disable_no_check_true
- test_launchd_status_no_check_true
- test_launchd_stop_no_check_true
- test_launchd_uninstall_no_check_true
- test_prompt_host_eoferror_defaults_to_no_change
- test_prompt_host_keyboard_interrupt_defaults_to_no_change
- test_prompt_host_missing_host_key_no_keyerror
Co-authored-by: Amplifier <amplifier@sourcegraph.com>
- Remove inner 'import sys' inside install-service branch (sys already
imported at module level on line 8; was triggering PLC0415 smell)
- Update two fallback messages in upgrade() from 'muxplex install-service'
to 'muxplex service install' — consistent with deprecation warning and
doctor() messages updated in Task 5
The multi-device enable checkbox is the gating control; device name
only matters when multi-device is on. Place the checkbox first to
reinforce the logical dependency between the two settings.
No behavioral change — no tests enforce element ordering within a panel.
- Add serve config display in doctor() showing host, port, auth, and
session_ttl from settings.json (inserted after Settings file block)
- Update two 'not installed' messages from 'muxplex install-service'
to 'muxplex service install' (macOS launchd and Linux systemd sections)
- Add test_doctor_shows_serve_config verifying custom settings values
(0.0.0.0, 9999, password) appear in doctor() output
- Add test_install_service_subcommand_prints_deprecation_warning to
verify that 'muxplex install-service' prints a deprecation warning
to stderr containing 'deprecated' and 'muxplex service install'
- Update the deprecation warning message in main() to include
'muxplex service install' as the recommended replacement command
Refs: task-4
- Add _add_serve_flags() helper to set --host/--port/--auth/--session-ttl
all with default=None so serve() can distinguish 'not passed' from
'passed the default value'
- Apply _add_serve_flags() to both root parser and 'serve' subparser so
'muxplex serve --host X --port Y' is accepted
- Consolidate upgrade/update via aliases=['update'] instead of two
separate subparsers — help now shows 'upgrade (update)'
- Print deprecation warning to stderr when 'install-service' is used
- Update 5 existing tests to expect None instead of hardcoded defaults
- Add 4 new tests: test_main_passes_none_for_unset_flags,
test_main_passes_explicit_host_only,
test_main_serve_subcommand_accepts_flags,
test_help_shows_single_upgrade_line
All 55 tests pass.
- Change serve() signature: all params now default to None (sentinel for 'not passed by CLI')
- Add load_settings() call: resolution order is CLI flag > settings.json > hardcoded default
- Switch from os.environ.setdefault() to os.environ[] hard assignment so settings.json values take effect
- Add 6 new tests: host/port/session_ttl from settings, CLI override, fallback to defaults, session_ttl=0 as valid value
Add four new server configuration keys to DEFAULT_SETTINGS in settings.py:
- host: '127.0.0.1'
- port: 8088
- auth: 'pam'
- session_ttl: 604800
Serve keys are placed first in the dict as primary server configuration.
Existing keys (default_session, sort_order, etc.) follow unchanged.
Add four tests covering the new keys:
- test_default_settings_include_serve_keys
- test_load_settings_returns_serve_keys_when_file_missing
- test_serve_keys_patchable
- test_old_settings_file_without_serve_keys_loads_correctly
- Config file (settings.json) becomes source of truth for serve options
- Replace install-service with muxplex service <command> subgroup
- Clean up CLI structure (upgrade/update alias, serve flag precedence)
- Thin wrappers over systemctl/launchctl for install/uninstall/start/stop/restart/status/logs
- Backward compat: install-service remains as deprecated alias
Root cause of repeated fit view failures: applyFitLayout() measured
clientHeight/clientWidth/getComputedStyle and set inline tile heights. This
failed in multiple ways:
- clientHeight = 0 when container is display:none (returning from session)
- inline tile.style.height destroyed every 2s by innerHTML rebuild in pollSessions
- getComputedStyle forces style recalc with potentially stale values
- rAF wrappers couldn't reliably fix timing issues
Fix: pure arithmetic applyFitLayout — no DOM measurement, no inline heights.
The grid already has a definite height from CSS (flex:1 inside height:100dvh).
grid-template-rows: repeat(rows, 1fr) divides that space without JS measurement.
CSS changes:
- .session-grid--fit { align-content: stretch }
- .session-grid--fit .session-tile { height: auto } — CSS grid cell controls sizing
JS changes:
- applyFitLayout() removes all clientHeight/clientWidth/getComputedStyle/style.height
- applyFitLayout() is now pure arithmetic: count tiles, compute cols×rows, set 1fr templates
- Removed rAF wrappers from all call sites (renderGrid, closeSession, resize handler,
applyDisplaySettings) — safe to call synchronously since no layout measurement needed
- applyDisplaySettings() no longer clears tile heights (never set them anymore)
Tests updated:
- Added test_fit_view_session_tile_has_height_auto (CSS)
- Added 'applyFitLayout does NOT measure DOM dimensions' (JS)
- Updated 'applyFitLayout clears stale...' → 'sets gridTemplateColumns/Rows via arithmetic'
- Updated rAF-wrapper test to match new direct-call behavior
kill_ttyd() now uses two strategies:
1. PID file (existing behavior — kept intact)
2. Port-based fallback: lsof -ti :{TTYD_PORT} finds and kills any orphaned
ttyd process that wasn't tracked in the PID file
spawn_ttyd() also does a pre-spawn port-free guard: if any process is still
occupying TTYD_PORT after kill_ttyd() returns, it sends SIGKILL to force-free
the port before the new ttyd tries to bind.
Root cause: PID file became desynced (pointed to dead process). kill_ttyd()
thought it succeeded but the REAL ttyd (PID not in file) kept running on
port 7682. New spawn_ttyd() failed to bind, died silently. Old ttyd kept
serving the old session — so every session switch showed the same session.
Tests: 2 new tests in test_ttyd.py (RED → GREEN confirmed)
Bug 1: applyFitLayout now clears grid-template-rows and tile heights
before recalculating. Prevents stale layout from empty-grid calls on
page reload from interfering with subsequent calculations.
Bug 2: Removed flex justify-content:flex-end approach — didn't work
because pre filled 100% of parent (flex-end had no effect). Reverted
to base CSS position:absolute + bottom:0 which anchors content to the
bottom. With 80 lines in fit mode, content always overflows the tile
and excess is clipped at the top by tile-body overflow:hidden.