Search: xterm-addon-search (0.13.0) with slide-down search bar. Ctrl+F
opens bar; type to search (live highlight); Enter/Shift+Enter for
next/prev; Escape to close. Bar sits above terminal-container inside
a new .terminal-wrapper flex column.
Image: xterm-addon-image (0.5.0) for inline graphics. Enables Sixel,
iTerm2 IIP, and Kitty graphic protocols — needed for tools like yazi
file manager. Auto-detects escape sequences, no configuration needed.
Loads xterm-addon-web-links (0.9.0) from CDN. URLs in terminal output
are auto-detected and underlined on hover. Ctrl+Click (Linux/Windows)
or Cmd+Click (macOS) opens the URL in a new browser tab. Plain click
preserved for normal terminal text selection.
subprocess.run with check=True raised CalledProcessError then
KeyboardInterrupt on Ctrl+C, printing an ugly traceback. Fix: remove
check=True, catch KeyboardInterrupt, exit silently.
Bug 1: killSession() now checks if _viewingSession === name and calls
closeSession() before pollSessions(). Previously the user was stuck in
the expanded terminal view for a dead session after deleting it.
Bug 2: Sidebar item click handler now ignores clicks on .sidebar-delete
buttons. Tile click handler now ignores clicks on .tile-delete buttons.
Previously stopPropagation() in the document-level delete handler fired
too late (after the sidebar-item/tile handlers had already called
openSession), causing the session to be navigated to before being killed.
Tests added:
- killSession closes active session and returns to dashboard
- sidebar click handler ignores clicks on delete button
- tile click handler ignores clicks on tile-delete button
321 JS tests pass, 760 Python tests pass.
Bug 1: amplifier-dev --destroy prompts 'Are you sure? [y/N]'. subprocess.run
had no stdin input, so the prompt hung until timeout. Fix: input='y\n' auto-
confirms interactive prompts in delete_session().
Bug 2: No request-level logging — only startup messages visible in journalctl.
Added INFO-level log lines for session create, delete (command + success/fail),
and connect operations. Set uvicorn log_level='info' so application logs
appear in service logs (journalctl / launchd).
Tests: 5 new tests covering stdin confirmation kwarg, INFO-level logs for
delete/create/connect, and uvicorn log level assertion.
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