- Call enforce_mutual_exclusion(current) in apply_synced_settings() after
applying SYNCABLE_KEYS and before save_settings()
- Import enforce_mutual_exclusion from muxplex.views inside the function
to avoid circular import issues
- Update docstring to describe the mutual exclusion invariant repair step
- Add test_apply_synced_settings_enforces_mutual_exclusion to verify that
sessions present in both hidden_sessions and a view's sessions are
removed from hidden_sessions after sync (task-7)
Add reset_device_id_command() function to cli.py that:
- loads the current device_id via load_device_id()
- generates a new device_id via reset_device_id()
- prints new device_id, identity file path, previous device_id
- warns that existing session keys are now orphaned
Register 'reset-device-id' subparser with appropriate help text
and add dispatch branch in main().
Tests added:
- test_reset_device_id_writes_new_id: verifies command writes new ID
and prints required output including orphan warning
- test_main_dispatches_to_reset_device_id: verifies CLI routing
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.
- Change _default_state_dir from 'tmux-web' to 'muxplex'
- Update STATE_DIR to prefer MUXPLEX_STATE_DIR env var with TMUX_WEB_STATE_DIR fallback
- Add 'active_view': 'all' to empty_state() between active_remote_id and session_order
- Update module docstring schema to document active_view field
- Add tests: test_empty_state_has_active_view_key, test_empty_state_active_view_defaults_to_all, test_state_dir_uses_muxplex_name
- Add muxplex/identity.py with IDENTITY_PATH, load_device_id(), reset_device_id()
- load_device_id() creates identity.json when absent, regenerates on corrupt JSON
or missing device_id key, creates parent directories as needed
- reset_device_id() generates a new UUID v4, overwrites identity.json, returns it
- Add muxplex/tests/test_identity.py with 8 tests covering all spec requirements
Adds design specification for user-defined Views — curated session
collections that span devices and replace the filtered gridViewMode.
Covers:
- Stable device identity prerequisite (device_id UUID in identity.json)
- Data model: views array in settings, active_view in state
- Three view tiers: All (virtual), user-created, Hidden (virtual)
- Mutual exclusion invariant between hidden and view membership
- UI: header dropdown view switcher, tile flyout menu, add sessions panel
- Migration strategy for session key format change
- Known limitations: rename breakage, atomic sync, shortcut cap
setInterval fires regardless of whether the previous async call has
completed. When federation requests time out (5s) during 2s poll cycles,
multiple requests stack up. Chrome's 6-connection-per-origin limit is
quickly exhausted → ERR_INSUFFICIENT_RESOURCES → death spiral.
Fix: self-scheduling setTimeout pattern — poll completes → wait → next
poll. At most one in-flight request at a time. Applied to both
pollSessions and sendHeartbeat loops.
A sentinel (true) is set on _pollingTimer/_heartbeatTimer immediately in
startPolling/startHeartbeat so the double-start guard works even during
the first async call before the real timer ID is assigned.
The first remote instance (index 0) couldn't be opened from the mobile
bottom sheet because s.remoteId ? treated 0 as falsy. Changed to
s.remoteId != null to match buildTileHTML and buildSidebarHTML.
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.
buildStatusTileHTML received session.name (undefined for status entries)
instead of session.deviceName. Offline/unreachable tiles showed blank.
Fixed all 4 call sites in renderGrid to pass session.deviceName.
Updated two existing tests to use deviceName (not name) in status entry
objects — reflecting the real federation data shape.
Unreachable/auth_failed status entries from the federation response
were rendered twice: once as a blank session tile (no name) by
buildTileHTML, and again as an 'Offline' status tile by the separate
status rendering loop. Fix: getVisibleSessions() now filters out
entries with a status field. Status tiles are still rendered separately
from the full sessions array.
The touch scroll handler was only enabled for Android devices,
leaving iOS/iPad users unable to scroll through terminal history.
Changes:
- Extend UA detection to include iPhone, iPad, and iPod
- Add iPadOS 13+ detection (reports as MacIntel with touch points)
- Rename function to initMobileTerminalScroll for clarity
Fixes#3
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
Fixes falsy-0 bug where sessions with remoteId=0 (first remote instance) were
incorrectly hidden because !s.remoteId treats 0 as falsy. The null check
s.remoteId == null correctly handles:
- remoteId=0 (first remote instance) → truthy, not hidden
- remoteId=null or undefined (local sessions) → falsy, hidden if in hidden list
Changes:
- muxplex/frontend/app.js: Line 533 condition in getVisibleSessions()
- muxplex/frontend/tests/test_app.mjs: Added 2 tests for remoteId=0 behavior
- muxplex/tests/test_frontend_js.py: Added pattern test verifying source code
All 330 JS tests pass, all 200 Python tests pass.
Generated with Amplifier (https://github.com/microsoft/amplifier)
Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
Issue 1 (CRITICAL): terminal.js createTerminal() read fontSize from
localStorage.getItem('muxplex.display'), which always returns null after
the server-settings migration. This caused terminal font size to permanently
default to 14 regardless of the server-side fontSize setting.
Fix: Pass getDisplaySettings().fontSize from app.js to window._openTerminal
as a third argument. Update openTerminal(name, remoteId, fontSize) and
createTerminal(fontSize) to use the parameter, removing all localStorage
reads from terminal.js.
Issue 2: Fix 9 stale test references in test_app.mjs that set
_localStorageStore['muxplex.display'] instead of app._setServerSettings().
Tests now correctly exercise the server-settings path.
Issue 3: Fix stale initSidebar test that used
delete _localStorageStore['muxplex.sidebarOpen'] — replaced with
app._setServerSettings(null) since initSidebar reads from _serverSettings.
Also update 5 Python tests in test_frontend_js.py that were checking the
old localStorage-based createTerminal() behavior:
- Renamed test_create_terminal_reads_font_size_from_localstorage to
test_create_terminal_accepts_font_size_parameter
- Renamed test_create_terminal_parses_json_for_font_size to
test_create_terminal_does_not_parse_json_from_localstorage
- Updated 3 remaining tests to use regex that matches createTerminal(fontSize)
instead of createTerminal()
Verification:
- grep -rn 'localStorage' app.js: only tmux-web-device-id (3 lines)
- grep -rn 'muxplex\.display|muxplex\.sidebarOpen' frontend/: zero matches
- All 372 JS tests pass (44 terminal + 328 app)
- All 199 Python frontend_js tests pass
Part A: DOMContentLoaded now async with await loadServerSettings() before
first render. Removes lazy loadServerSettings() from inside .then() block.
Part B: Fix 18 failing tests in test_app.mjs:
- Sidebar tests: replace _localStorageStore['muxplex.sidebarOpen'] with
app._setServerSettings({sidebarOpen: ...}); update toggleSidebar mocks
to include classList.contains(); assert _serverSettings.sidebarOpen
instead of localStorage values
- loadGridViewMode/saveGridViewMode tests: replace localStorage setup with
_setServerSettings({gridViewMode: ...}); assert _serverSettings results
- cycleViewMode test: replace loadDisplaySettings/saveDisplaySettings with
_setServerSettings/getDisplaySettings
- activityIndicator tests (7): replace _localStorageStore['muxplex.display']
JSON.stringify() with app._setServerSettings({activityIndicator: ...})
- killSession test: increase substring limit from 500 to 600 chars
Part C: Add _getServerSettings test helper to app.js exports so tests can
read back _serverSettings state after sidebar/display mutations.
- Remove SIDEBAR_KEY constant (was an undefined variable bug)
- Rewrite initSidebar() to read sidebarOpen from _serverSettings
- Rewrite toggleSidebar() to derive state from DOM class and persist
to server via patchServerSetting
- Rewrite bindSidebarClickAway() to persist collapsed state via
patchServerSetting instead of localStorage
- Add 7 tests verifying SIDEBAR_KEY removal and _serverSettings usage
Move all display/UX settings from browser localStorage to server-side
settings.json. Flat keys approach (Approach A) - adds 10 new keys to
DEFAULT_SETTINGS, requiring zero changes to existing load/save/patch
functions or API endpoints.
Key decisions:
- No federation settings sync (each server owns its own settings)
- Drop notificationPermission (browser API is source of truth)
- sidebarOpen defaults to None for auto-detect on first load
- No migration needed - users reset preferences
- Fix auto_open vs auto_open_created naming alignment
Verified compatible with muxplex v0.2.0 (1b5207b).
- 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>
Bump version 0.1.0 → 0.1.1. Add CHANGELOG.md covering all changes
since initial release. Update README clipboard section to reflect
native xterm.js paste handling.