Two independent optimizations that eliminate the ~700ms-1.2s lag when switching sessions:
### Backend: ttyd process pool (ttyd.py + main.py)
Instead of killing and respawning a single ttyd process on every session switch, maintain a pool of ttyd processes - one per connected session, each on its own port (7682-7701). Revisiting a session reuses the existing process instantly.
- New pool API: get_or_spawn(), pool_port(), kill_session(), kill_all(), kill_orphans()
- Backward-compat aliases preserved: kill_ttyd(), spawn_ttyd(), kill_orphan_ttyd(), TTYD_PORT
- /connect endpoint now returns in ~0ms for already-connected sessions vs 700ms+ before
- WS proxy reads ?session= query param to route to correct ttyd port
- Per-port PID files for orphan recovery across restarts
### Frontend: terminal instance cache (terminal.js + app.js)
Keep up to 5 xterm.js Terminal instances alive in hidden container divs. Switching to an already-visited session is a CSS display swap + fit() call - no xterm.js teardown/recreation, no WebSocket reconnection, no addon reloading.
- New switchTerminal() entry point with LRU cache (max 5)
- Each cached entry has its own container div, term, WebSocket, addons
- Cache-aware /connect skip: if terminal is cached with live WS, bypass backend entirely
- Animation gate fixed: 0ms for session-to-session switches (was always 260ms)
- Cache cleanup on session delete
### Test changes (minimal)
- test_ttyd.py: Added pool cleanup in fixture for isolation
- test_api.py: Updated 4 tests to mock new pool API
- test_ws_proxy.py: Updated auto-spawn test for pool API
All 1,306 tests pass.
Generated with Amplifier
Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
P0.1: Visual Hierarchy for Tiles
- Backend: Added last_activity_at to GET /api/sessions by querying tmux list-sessions -F '#{session_activity}'
Updated sessions.py with tuple return from enumerate_sessions and activity cache
Updated main.py poll loop to populate both session endpoints with activity data
- Frontend: sessionPriority() now returns 'active' (< 5 min) in addition to 'bell'/'idle'
Added .session-tile--active (accent left border) and .session-tile--stale (dimmed styling) CSS classes
Updated session-tile.js and sidebar-item.js to apply priority-based classes
- Fixed P4 hover fade bug: removed opacity:0 rule on .session-tile:hover .tile-meta
P0.2: Readable Tile Previews
- Wired --preview-font-size CSS variable (was set by JS but never consumed by CSS)
Changed default from 11px to 12px with line-height 1.2 (was 1.0)
- Reduced preview line count from 20 to 12 lines for better readability at larger size
P0.3: Search/Filter Bar
- New <search-filter> Lit component with live input, match count, and clear button
Replaces empty filter-bar div in index.html with functional search component
Integrated into renderGrid() for live filtering and bindStaticEventListeners() for re-render on input
P0.4: Default Session / Quick-Resume
- Updated restoreState() to fall back to default_session setting when no active_session in server state
Added existence check before opening to prevent errors on non-existent sessions
Files changed: sessions.py, main.py, app.js, style.css, session-tile.js, sidebar-item.js, utils.js, index.html
New files: search-filter.js (Lit component)
Test updates: test_api.py, test_frontend_css.py, test_integration.py, test_sessions.py
All 1306 tests pass.
Generated with Amplifier
Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
This commit combines multiple improvements for mobile support, reliability, and performance:
Mobile & Accessibility Improvements:
- Fix touch scrolling on mobile by removing CSS overflow-y:hidden that blocked xterm.js native scroll
- Add beforeinput handler to prevent Android IME double-space-to-period duplication
- Implement mobile control character toolbar (Esc, Tab, Ctrl/Alt toggles, arrows, special chars)
- One-shot modifier toggles bring soft keyboard on demand without showing it for other keys
- Replace 25+ unicode/HTML entity icons with inline SVGs across app for better rendering
Security & Reliability:
- Fix shell injection vulnerability: session names with spaces/special chars now properly quoted
- Use shlex.quote() in create_session and delete_session endpoints
- URL-encode session names in bell hook to prevent malformed curl URLs
- Also hardens against command injection through crafted session names
Performance Optimizations:
- Eliminate 2.4-5.6 second session creation delay (was 3 compounding bottlenecks)
- Frontend: check for new session immediately (was waiting 2s for interval tick)
- Backend: eagerly refresh session cache after tmux creation (was waiting for next poll)
- Connection: poll for ttyd readiness instead of blind 0.8s sleep
- Typical new session now appears in UI in 200-400ms vs previous 4-5 seconds
Infrastructure:
- Improve ttyd port detection with multi-tool fallback (lsof → fuser → ss)
- Add Cloudflare tunnel setup documentation
Test Updates:
- Update frontend tests to match new setTimeout recursion pattern (replaces setInterval)
All 1306 tests pass.
Generated with Amplifier
Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
Browser-tester confirmed on spark-1 that the 7 <script src> tags and all
<link href> tags in the served HTML had no cache-busting suffix. Because
there is no service worker, the standard HTTP cache would keep serving stale
JS/CSS to browsers that had already loaded a previous release — the root
cause of yesterday's 'is the user seeing stale JS?' investigation.
Fix: index_page() now appends ?v=<muxplex_version> to every static-asset
URL (src= and href= attributes whose path starts with / and does not begin
with /api/) before returning the HTML response. The version is read once at
module load from importlib.metadata.version('muxplex') — the same source
used by the doctor command — so it is always in sync with the installed
package. Starlette's StaticFiles handler ignores query parameters when
serving files from disk, so the versioned URLs continue to resolve to the
same bytes; they just carry a new cache key on every release.
Affected assets (7 <script> + 6 <link> + 1 <img>):
/vendor/xterm.js, /vendor/xterm-addon-fit.js,
/vendor/xterm-addon-web-links.js, /vendor/xterm-addon-search.js,
/vendor/addon-image.js, /app.js, /terminal.js
/manifest.json, /favicon.ico, /favicon-32.png, /apple-touch-icon.png,
/vendor/xterm.css, /style.css
/wordmark-on-dark.svg
New tests in muxplex/tests/test_main.py:
- test_index_all_asset_urls_have_version_suffix — every <script src> and
<link href> in GET / carries ?v=<version>
- test_index_vendor_scripts_each_versioned — all 7 expected script URLs
are present with the version suffix
- test_versioned_asset_url_resolves_to_static_file — static handler
serves the file correctly when the query string is present
Wire normalize_session_keys import and call into _run_poll_cycle step 13b, running
before the prune step. Use local device id from muxplex.identity.load_device_id().
Best-effort: errors are logged, poll cycle continues.
Fix 10 previously-failing JS tests:
- 8 were stale: regex search windows too narrow, refactored implementations, removed
UI elements (settings dialog: 4→5 tabs; sessions panel: moved hidden_sessions to Views)
- 2 were DOM drift from feature additions
Each fix has a code comment explaining the change.
Add 3 new end-to-end tests in test_views.py verifying normalize→prune pipeline.
Final test counts: pytest 1266 passing (1263 + 3 new), node --test 396 passing.
New pruning.py sidecar module with load_pruning_state / save_pruning_state. The sidecar lives at ~/.config/muxplex/pruning.json and is NEVER in SYNCABLE_KEYS — bookkeeping is per-device, not federated.
prune_stale_keys(settings, live_keys, *, pruning_state, grace_seconds, now) drops keys from hidden_sessions and view.sessions after they have been missing past the configurable grace period (default 24h, syncable via stale_key_grace_hours).
Wired into _run_poll_cycle as step 14 with try/except so failures never abort the cycle.
The prune ACTION (settings change) syncs normally via existing LWW; the bookkeeping does not.
24 new tests pass; total muxplex/tests/ = 1263 passed.
- Add optional active_view field to StatePatch Pydantic model
- Add active_view handling in patch_state to persist the field
- Add test_patch_state_sets_active_view: verifies PATCH persists active_view
- Add test_patch_state_active_view_defaults_to_all: verifies GET returns 'all' by default
Closes task-1: Add active_view to StatePatch Model
- BUG 1: Replace isinstance(active_remote_id, int) guard with _lookup_remote_by_device_id()
so bell-clear fires correctly when active_remote_id is a UUID string (new format).
Old code silently skipped the POST for all non-integer active_remote_id values.
Regression test: test_poll_cycle_fires_federation_bell_clear_for_remote_session_with_uuid
- GAP 4: Add _resolveActiveView(activeView, views) helper in app.js.
Falls back to 'all' when active_view references a deleted/unknown view name.
Ready for Phase 2 view-switching code to call at read time.
- ISSUE 6: _createDeviceSelect now uses remotes[i].device_id || String(i) for option
values instead of always String(i), so session creation routes receive correct device_id.
- ISSUE 9: identity.json written with indent=2 + trailing newline, consistent with
settings.json and state.json formatting conventions.
- ISSUE 11: Fix state.py module docstring — 'tmux-web muxplex' → 'muxplex'.
- ISSUE 12: Settings panel hidden_sessions checkboxes now use s.sessionKey || s.name
as the checkbox value (and checked state) so remote sessions are stored in
device_id:name format, consistent with getVisibleSessions() lookups.
Ruff was reformatting the ternary header constructions to multi-line form:
headers={"Authorization": f"Bearer {remote_key}"}\n if remote_key\n else {},
This caused test_federation_auth_headers_guard_empty_key to fail because
the source-inspection test requires 'if' to appear on the same line as
the Authorization header dict.
Fix: assign to a local variable before use so ruff has no reason to split:
auth_headers = {"Authorization": f"Bearer {key}"} if key else {}
Applied at two sites:
- poll-cycle bell-clear (line ~232)
- federation WS proxy endpoint (line ~1094)
Change all 5 federation proxy URL patterns from {remote_id:int} to
{device_id} (str) and use _lookup_remote_by_device_id() for lookup.
Affected endpoints:
- federation_connect: /api/federation/{device_id}/connect/{session_name}
- federation_bell_clear: /api/federation/{device_id}/sessions/{session_name}/bell/clear
- federation_create_session: /api/federation/{device_id}/sessions
- federation_delete_session: /api/federation/{device_id}/sessions/{session_name}
- federation_terminal_ws_proxy: /federation/{device_id}/terminal/ws
All endpoints now use _lookup_remote_by_device_id(device_id) which
provides integer index fallback for backward compatibility.
Also update existing test to expect 404 instead of 422 for non-integer
device_id (since str type accepts any string, lookup returns None->404).
Tests:
- test_federation_connect_by_device_id: POST with device_id='aaa-111-bbb' returns 200
- test_federation_connect_device_id_not_found: unknown device_id returns 404
Implements _lookup_remote_by_device_id(device_id: str) -> dict | None
in muxplex/main.py, inserted before the federation WebSocket proxy section.
Logic:
- Load settings and get remote_instances list
- Primary: iterate remotes, return first where remote.get('device_id') == device_id
- Fallback: if device_id parses as integer, use index-based lookup for
transition compatibility (0 <= idx < len(remotes))
- Return None if not found
Tests added:
- test_lookup_remote_by_device_id_found: two remotes with device_ids
'aaa-111' and 'bbb-222'; lookup 'bbb-222' returns Desktop remote
- test_lookup_remote_by_device_id_not_found: one remote with device_id
'aaa-111'; lookup 'zzz-999' returns None
Task: task-8
Add load_device_id import from muxplex.identity and include device_id
in the instance-info response dict. Update endpoint docstring to
mention device identity.
Test: test_instance_info_includes_device_id verifies device_id is
present as a non-empty string when IDENTITY_PATH is redirected to
tmp_path.
Two federation UX fixes:
1. Zero-session devices: when remote returns empty sessions list, return
{status: 'empty'} entry. Frontend renders 'No sessions' status tile
instead of making the device invisible.
2. Flapping prevention: server-side cache of last-known-good federation
results per remote. On transient failure, return cached sessions for
up to 3 consecutive failures before marking unreachable. Eliminates
the visible on/off/on/off pattern caused by occasional 5-second
timeouts during 2-second poll cycles.
Tests added:
- test_fetch_remote_returns_empty_status_for_zero_sessions (Python)
- test_fetch_remote_uses_cache_on_transient_failure (Python)
- test_fetch_remote_marks_unreachable_after_grace_period (Python)
- renderGrid shows 'No sessions' status tile for status=empty devices (JS)
Also adds reset_federation_cache autouse fixture to prevent cross-test
contamination from the new module-level _federation_cache dict.
Bug 1 (auth.py): AuthMiddleware.dispatch() was using self.federation_key
(set once at startup) for Bearer token validation. If the key was generated
or rotated via POST /api/federation/generate-key after startup, the old
(often empty) value caused all federation auth to silently return 401.
Fix: import load_federation_key from muxplex.settings and call it fresh on
every non-exempt, non-cookie request. Also adds a warning log when a Bearer
token is received but no key is configured on this server.
Bug 2 (main.py): _sync_settings_with_remotes() discarded the PUT response,
silently swallowing 401/500 errors from the remote sync endpoint.
Fix: capture the PUT response as put_resp. Handle 409 (Conflict = remote is
newer) with a debug log; raise_for_status() for any other non-2xx so errors
propagate to the outer except and are logged as warnings.
Tests:
- test_dispatch_calls_load_federation_key_live: pattern test confirming
load_federation_key() is called inside dispatch()
- test_dispatch_does_not_use_stale_self_federation_key_for_bearer: pattern
test confirming self.federation_key is gone from the live bearer check
- test_sync_put_response_calls_raise_for_status: pattern test confirming
raise_for_status() is called on the PUT response in the sync function
- Updated existing bearer tests to monkeypatch load_federation_key so they
are isolated from any real key file on disk
- Add SETTINGS_SYNC_INTERVAL = 15 constant (15 poll cycles × 2s ≈ 30s)
- Add _settings_sync_counter module-level counter
- Add _sync_settings_with_remotes() async function that:
- GETs /api/settings/sync from each remote instance
- Adopts remote settings if remote timestamp is newer
- Pushes local settings via PUT if local timestamp is newer
- Silently skips 404/405 (older muxplex without sync endpoints)
- Catches all per-remote errors and logs as warnings
- Wire sync call into _run_poll_cycle() step 13 (runs outside state_lock)
- Add test_settings_sync_poll.py with 10 tests covering all sync behaviours
Captures the exception as 'exc' in the except clause and includes it
as the third argument to the warning log message. This ensures that
the failure reason is observable in logs, improving diagnostics for
federation heartbeat failures.
Generated with Amplifier
Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
- Add module-level _federation_client reference for use in background poll task
- Assign _federation_client in lifespan startup; clear on shutdown
- Add step 12 to _run_poll_cycle: iterate devices viewing a remote session
in fullscreen with recent interaction (<60s), fire POST bell/clear to
the active remote instance with Bearer auth (fire-and-forget, errors logged)
- Add test_poll_cycle_fires_federation_bell_clear_for_remote_session test
Closes task-3 of federation-state-propagation-plan
- Add DELETE /api/federation/{remote_id}/sessions/{session_name} endpoint
to proxy session deletion to remote instances with Bearer auth
(follows same pattern as existing create/connect/bell-clear proxies)
- Update killSession() to accept optional remoteId parameter and route
through federation delete proxy when present
- Update delegated click handler to extract data-remote-id from parent
tile/sidebar element and pass to killSession() for remote routing
- Now users can delete sessions on remote devices from their local client
Fixes: https://github.com/bkrabach/muxplex/issues (delete remote sessions)
Generated with Amplifier
Seven bug fixes across five files:
1. auth.py: Add .json to static extension allowlist so /manifest.json
bypasses auth. Previously, unauthenticated requests got redirected to
the login page HTML which the browser tried to parse as JSON.
2. sessions.py: Catch FileNotFoundError in enumerate_sessions() alongside
RuntimeError. If the session command binary is missing from PATH,
asyncio.create_subprocess_exec raises FileNotFoundError, which was
unhandled and broke the poll loop.
3. settings.py: Fix federation key erasure when remote instance URLs are
edited. The key-preservation logic only restored keys by exact URL
match. Editing a URL (e.g. http:// to https://) changed the lookup
key, permanently erasing the real federation key. Added position-based
fallback for same-slot URL edits.
4. main.py: Replace fire-and-forget subprocess.Popen in create_session
with asyncio.create_subprocess_shell that checks the return code and
returns proper HTTP 500 errors. Added shutil.which() pre-flight check.
Commands that exit non-zero but still create the session (e.g.
amplifier-workspace which tries to attach after create) are detected
by checking enumerate_sessions() and treated as success.
5. main.py: Redact sensitive keys in PATCH /api/settings response,
matching the existing GET endpoint behavior.
6. main.py: Include exception type and message in federation 503 error
responses (connect, bell-clear, create-session).
7. app.js: Fix auto_open vs auto_open_created key mismatch. The settings
toggle read/wrote 'auto_open' but the backend key is
'auto_open_created'. The PATCH was silently dropped and the toggle
was completely non-functional.
🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)
Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
The source-inspection test checks each line individually for an 'if'
guard. The multi-line formatting of the 'if remote_key else {}' guard
split it across 3 lines, making the test see an unguarded header.
Consolidated to match the single-line pattern of the other 4 endpoints.
Remote sessions failed to reconnect after page refresh because
restoreState() and renderSheetList() didn't pass remoteId to
openSession(). Fix:
- state.py: add active_remote_id field to empty_state()
- main.py: extend StatePatch model to accept active_session and
active_remote_id; update PATCH /api/state to selectively update
only fields explicitly provided (using model_fields_set)
- app.js restoreState(): read state.active_remote_id and pass it
as remoteId option so page-refresh reconnects remote sessions
- app.js openSession(): fire-and-forget PATCH /api/state to persist
active_session + active_remote_id after successful connect
- app.js closeSession(): fire-and-forget PATCH /api/state to clear
both fields so refresh doesn't try to reopen a closed session
- app.js renderSheetList(): add data-remote-id to sheet items and
pass remoteId in click handler (previously called openSession(name)
with no remoteId, routing remote sessions to local 404 endpoint)
Fixes: POST /api/sessions/paperclip-adapter/connect 404 when clicking
a remote session from the mobile bottom sheet or after page refresh.
websockets.connect() used default SSL verification, rejecting self-signed
certs on remote instances. Same issue as the httpx fix (2c21dfe) but for
the websockets library. Fix: pass ssl.SSLContext with CERT_NONE when the
remote URL is wss://.
The source-inspection test correctly caught one remaining unguarded
Authorization header in the WS proxy additional_headers. Applied the
same 'if remote_key else {}' pattern as the other 4 federation endpoints.
Empty remote_instances[].key produced 'Bearer ' (illegal header value)
causing httpx to reject the request entirely. Fix: only include the
Authorization header when the key is non-empty. Remotes without keys
still work if their auth allows it (e.g., localhost bypass).
Fixes all 5 sites: fetch_remote GET, connect POST, bell/clear POST,
create-session POST, and WebSocket proxy additional_headers.
httpx.AsyncClient used default SSL verification, rejecting self-signed
certs from muxplex setup-tls. Remote HTTPS instances (e.g., setup-tls
with self-signed fallback) were unreachable — all federation requests
failed with CERTIFICATE_VERIFY_FAILED. Fix: verify=False on the
federation client. Bearer token auth still protects authorization.
Adds federation_create_session endpoint following the same pattern as
federation_connect and federation_bell_clear. The endpoint:
- Looks up remote by integer index into remote_instances in settings
- Sends POST {remote_url}/api/sessions with Bearer auth header and
JSON body {name: ...}
- Returns remote's JSON response
- Raises 404 for invalid remote_id, 503 when unreachable, 502 on HTTP error
Adds 4 tests covering:
- Proxy behavior with correct URL, auth header, and JSON body forwarding
- 404 for out-of-range remote_id
- 503 when ConnectError raised
- 502 when remote returns HTTP error status
- Remote sessions now include sessionKey field formatted as 'remoteId:name'
- Local sessions are unchanged (no sessionKey) as they never collide
- Prevents name collisions between local and remote sessions with identical names
- Added tests: test_federation_sessions_local_sessions_have_no_session_key
test_federation_sessions_remote_sessions_have_session_key
- fetch_remote now accepts index i from enumerate() and sets remote_id: int = i
instead of remote.get("id", url), which fell back to the URL string
- asyncio.gather uses enumerate(remote_instances) to pass the integer index
- federation_connect route: remote_id type changed from str to int; FastAPI
now validates path param at framework level, simplifying the handler body
- Update test_federation_sessions_includes_remote_failure_status to expect
integer 0 (not the legacy 'remote-1' id field string)
- Update test_federation_connect_returns_404_for_non_integer_remote_id: now
expects 422 (FastAPI schema validation) instead of 404
- Add test_federation_sessions_remote_id_is_integer_index verifying remoteId
is 0 for first remote and 1 for second
- Remove unused 'import websockets.exceptions' from main.py (no reference
to websockets.exceptions.* anywhere in the file; all WS handlers use
broad Exception catches)
- Add .gitignore with __pycache__/, *.pyc, .venv/, and other standard
Python excludes so bytecode no longer shows as modified in git status
- Untrack all pre-existing __pycache__/*.pyc files via git rm --cached
Remove unused JSONResponse import from starlette.responses in main.py.
All other stale references (CORSMiddleware, federation_tokens, X-Muxplex-Token,
window.opener, etc.) verified absent. Federation proxy endpoints confirmed present.
- Delete storeFederationToken function from app.js
- Delete window.addEventListener('message',...) handler from app.js
- Delete buildAuthTileHTML function from app.js
- Delete formatLastSeen function from app.js
- Delete buildOfflineTileHTML function from app.js
- Delete openLoginPopup function from app.js
- Simplify api() to same-origin only (no baseUrl/credentials/X-Muxplex-Token)
- Remove federation auth relay <script> block from index.html
- Remove /api/auth/token endpoint from main.py
- Remove CORSMiddleware from main.py
- Remove X-Muxplex-Token header check from auth.py
- Delete corresponding tests and add absence verification tests
Negative Python list indices bypass the intended 404 guard — e.g.
remote_id=-1 on a non-empty list silently proxied to the last remote
instead of returning 404.
Add an explicit 'idx < 0' check before the list lookup, consistent
with the sibling federation_terminal_ws_proxy endpoint (line 728).
Also adds test_federation_connect_returns_404_for_negative_remote_id
to document the expected behavior and prevent regression.
- Add _log.warning() to bare except clause in fetch_remote so unexpected
errors (JSON parse failures, AttributeError, etc.) leave a diagnostic
trail instead of silently surfacing as 'unreachable'
- Map HTTPStatusError (non-401/403 HTTP errors like 500, 503, 429) to
'unreachable' instead of 'auth_failed' — semantically correct since
these are connectivity/server errors, not authentication failures
- Fix stale task comment in test_api.py: task-8 → task-5
- Use getattr(app.state, 'federation_client', None) in shutdown to guard
against the theoretical case where AsyncClient constructor raised before
setting app.state.federation_client
- Rename 'Shutdown:' comment to 'Cleanup:' in the finally block to better
describe that the block covers both the poll-task cancellation and the
overall cleanup guarantee