Critical fix for JavaScript syntax error:
- terminal.js line 282/415: Missing closing }); for .then(function() { callback
caused SyntaxError: missing ) after argument list, breaking all terminal functionality
Important fixes for stale xterm.js references in terminal.js:
- Line 98: Stale comment 'write directly to xterm' → 'write directly to terminal'
- Line 164: Stale docstring 'xterm.js Terminal' → 'ghostty-web Terminal'
- Line 470: Stale CSS selector '.xterm-helper-textarea' → 'textarea' for ghostty-web
Test improvements:
- Added test_terminal_js_valid_javascript_syntax: validates terminal.js via node -c
(catches JavaScript syntax errors like this one in CI/CD)
- Added test_terminal_js_no_stale_xterm_comments: prevents future stale xterm references
- Updated test_app.mjs Node.js tests to validate ghostty-web.js instead of stale xterm
Verification:
- node -c terminal.js passes (exit 0)
- 1307/1307 Python tests pass
- Node.js test_app.mjs tests pass
Delete xterm.js, xterm.css, and xterm-addon-fit.js from vendor directory.
These are replaced by ghostty-web.js and ghostty-vt.wasm.
Kept: xterm-addon-web-links.js, xterm-addon-search.js, addon-image.js
(still loaded with try/catch guards for ghostty-web's loadAddon() interface).
Research findings from inspecting ghostty-web@0.4.0 npm package:
- UMD build exports 16 symbols (Terminal, FitAddon, Ghostty, init, etc.)
- FitAddon: native in ghostty-web, API-compatible, use directly
- WebLinksAddon: not needed, ghostty-web has built-in LinkDetector
- SearchAddon: incompatible (needs _core internals), reimplement via buffer API
- ImageAddon: incompatible (different render pipeline), drop for now
- init()/Ghostty.load(wasmPath): supports explicit WASM path for vendoring
- No CSS required (canvas-only rendering)
- Core Terminal API is highly compatible with xterm.js
Two bugs introduced by the terminal instance cache that caused blank terminals:
Bug 1 (primary — every second+ visit to a cached session):
switchTerminal()'s section 4 calls createTerminal(), which disposes the
existing _term before creating a new one. At the time section 4 runs,
_term still points to the PREVIOUSLY-ACTIVE session's terminal — the one
sitting live in its own cache entry. After disposal the cache entry holds
a dead xterm instance; the next visit to that session via the fast-path
calls fit()/focus() on the disposed terminal, both silently no-op, and the
terminal renders blank.
Fix: null out _term and _fitAddon in section 4's 'Reset module-level state'
block BEFORE createTerminal(), so the previously-active session's terminal
is left untouched in its cache entry.
Bug 2 (secondary — WS dies while session is in background):
When a cached session's WebSocket closes while hidden, the close handler
bails early via the stale-guard (ws !== _ws), because _ws now points to the
active session's socket. No reconnect is scheduled. On revisit:
isTerminalCached() returns false → openSession() POSTs /connect → but
switchTerminal() takes the cache-HIT branch (_terminalCache.has() is still
true), shows the container, and returns — without creating a new WebSocket.
Terminal container appears but has no live connection → blank.
Fix: in the cache-HIT branch, check WS readyState before taking the fast
path. If the socket is CLOSED or CLOSING, call destroyCachedEntry() and
fall through to section 4 so a fresh terminal + connection is created.
(openSession() has already POST'd /connect, so ttyd is ready.)
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>
Added pointer-events: none to .preview-popover CSS so clicks fall through to tiles below.
Removed e.stopPropagation() from capture-phase document click handler to prevent event
interception. Changed dispatched event from preview-click to preview-close (cleanup only).
Updated app.js event listener to match.
The hover preview overlay (position: fixed, z-index: 300) was intercepting clicks meant
for sidebar tiles. Its capture-phase click handler called stopPropagation(), killing the
event before the sidebar tile's click handler could fire, then dispatched openSession()
with the hovered session name instead of the clicked one.
Generated with Amplifier
Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
- OSC 8 hyperlinks (ESC]8;;URL sequences from ls --hyperlink, gcc, grep) now open directly in new tabs instead of showing confirm() dialog
- WebLinksAddon plain-text URL detection now opens on plain click instead of requiring Ctrl/Cmd+Click
- Matches Ghostty/iTerm2 behavior for terminal link handling in browser-based terminal
Generated with [Amplifier](https://github.com/microsoft/amplifier)
Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
The .view-dropdown__menu uses position: absolute; top: calc(100% + 4px)
which needs a positioned ancestor. The .view-dropdown CSS class provided
position: relative, but the <view-dropdown> custom element had no class —
so the menu resolved against the viewport and rendered 581px off-screen.
Fixed by adding position: relative to the .view-dropdown element selector.
Hide the floating session pill when the sidebar is visible, since the
sidebar already shows the complete session list. The pill reappears when
the sidebar is collapsed (via toggle or click-away on mobile).
Generated with Amplifier
Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
**Frontend stabilization:**
- Added CSS display: contents rules for custom element wrappers (session-grid, session-tile,
sidebar-item, view-dropdown) that were defaulting to display: inline and breaking grid/flex
layouts
- Restored 20-line snapshot previews in session-tile and sidebar-item (were silently reduced
to 12)
- Fixed view-dropdown New View button missing data-action attribute, breaking create-view flow
- Fixed sidebar dropdown close referencing non-existent #sidebar-view-dropdown-trigger element
- Fixed sidebar renderSidebar() clear+rebuild pattern that ran every 2s poll — now diffs
existing elements to avoid layout jank and scroll loss
- Removed dead store.js import from index.html
- Removed dead document-level .tile-options-btn delegation handler (superseded by Lit
custom event chain)
- Updated test for removed handler
**Bug fix: new session from sidebar covers entire screen:**
- Scoped zoom animation selector from unscoped [data-session] to .session-tile[data-session]
within #session-grid
- Added guard to skip zoom when overview grid not visible (creating from sidebar already
in fullscreen, no grid tile to zoom from)
**UX: graceful reconnect overlay:**
- Added 1.5s grace period before showing Reconnecting... overlay
- Brief disconnections (like service restarts) now invisible to user
- Timer properly cleaned up in onopen, closeTerminal, and self-clearing callback
**New documentation:**
- Created RECOVERY_PLAN.md documenting COE, state diagrams, storyboards, data model
Generated with Amplifier <https://github.com/microsoft/amplifier>
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>
Introduce 4 new Lit components for dashboard, sidebar, previews, and mobile UX:
- sidebar-item: <sidebar-item> for expanded-view sidebar entries
- hover-preview: <hover-preview> full-window snapshot popover
- bottom-sheet-switcher: <bottom-sheet-switcher> mobile session switcher
- session-grid: <session-grid> wrapper owning sort/group/repeat loop with keyed rendering
Refactor app.js to leverage components:
- renderSidebar() now uses <sidebar-item> elements with event delegation
- renderGrid() simplified to property-setting on <session-grid>, which owns tile loop
- showPreview/hidePreview simplified to property-setting on <hover-preview>
- openBottomSheet/closeBottomSheet/renderSheetList simplified to property-setting on <bottom-sheet-switcher>
- Removed dead code (renderGroupedGrid, renderFilterBar bodies)
- Preview click and sheet events wired via component event listeners
Update index.html:
- Replace bottom sheet div with <bottom-sheet-switcher> custom element
- Replace session grid div with <session-grid> custom element
- Add <hover-preview> element
- Add new component imports
All 1306 tests pass.
Generated with Amplifier
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>
On one MacBook the plist generated by 'muxplex service install' contained:
<key>ProgramArguments</key>
<array>
<string>/Users/brkrabac/.../python3 -m muxplex</string>
<string>serve</string>
</array>
launchd treats each <string> element as a literal, unsplit argv token. The
first element was a single string with embedded spaces, so launchd tried to
exec a binary literally named 'python3 -m muxplex' (including the spaces) —
which doesn't exist. Because KeepAlive=true, launchd respawned the failed
exec every few seconds, so 'pgrep' showed a PID and 'muxplex doctor' reported
'Service: launchd agent running' even though the daemon NEVER bound to port
8088. The user had no visible signal.
Root cause: _resolve_muxplex_bin() returned a fallback string of the form
"$sys.executable -m muxplex" (a single string with spaces), which was
placed verbatim into a single <string> tag.
Fix:
* Add _resolve_muxplex_bin_for_launchd() which returns a list[str] of tokens:
1. Prefer ~/.local/bin/muxplex (stable uv-tool console-script symlink,
survives 'uv tool reinstall' without changing path — Option A from spec).
2. Fall back to shutil.which('muxplex').
3. Last resort: [sys.executable, '-m', 'muxplex'] — correctly split.
* Update _LAUNCHD_PLIST_TEMPLATE to take a {program_arguments_xml} placeholder
instead of a single {muxplex_bin}.
* In _launchd_install(), build argv = bin_args + ['serve'] and render each
token as its own <string> element.
Now the generated plist reads:
<key>ProgramArguments</key>
<array>
<string>/home/user/.local/bin/muxplex</string>
<string>serve</string>
</array>
Users who have an existing malformed plist will pick up the fix the next time
they run 'muxplex service install' (or after the upgrade flow regenerates the
service file).
Test added (test_service.py, under 'v0.6.7 fixes'):
- test_launchd_plist_program_arguments_are_separate_strings: calls
_launchd_install(), parses the result with plistlib.loads, asserts
ProgramArguments is a list with >= 2 elements and that NO element
contains a space.
During the v0.6.5→v0.6.6 rollout on spark-1 (systemd Linux) the upgrade flow
printed 'Restarting systemd service...' and 'Service started' but the unit was
actually inactive (dead) for 14 minutes afterward. The PWA was dark and no
alert fired because the CLI reported success.
Root cause: we fired daemon-reload + start but never verified the result. If
the unit was left in a 'failed' state (e.g. stale unit-file mismatch after the
previous ExecStart was regenerated mid-upgrade), systemd silently ignored the
start.
Fix:
* Add _probe_service_port(port) — lightweight HTTP probe to
localhost:port/login that returns True on any HTTP response.
* Add _verify_service_started(timeout_s=10) — polls 'systemctl --user
is-active' once (synchronous path) or polls the port via HTTP (async
launchctl path) and returns True/False.
* In upgrade() finally block (systemd path): call daemon-reload BEFORE start
(already present but now documented), then call _verify_service_started().
If not active, do reset-failed + retry start once. If still not active,
print a clear error and set _service_restart_failed = True.
* Propagate _service_restart_failed as sys.exit(1) after the try/finally, so
callers and scripts can detect that the service is not running.
* Augment doctor() systemd check: probe is-active and downgrade to a warn '!'
marker when the unit file exists but the service is not active.
* Augment doctor() launchd check: probe the HTTP port after confirming the
agent is registered; report '! launchd agent registered but not serving on
port N' when the port is not responding (catches the silent-failure mode from
Fix 2 as well).
Tests added (test_cli.py, under 'v0.6.7 fixes'):
- _verify_service_started returns True when is-active exits 0
- _verify_service_started returns False when is-active exits 3 (inactive)
- upgrade() exits 1 when service fails to restart after install
- upgrade() calls daemon-reload before start (call-order assertion)
- doctor() reports 'registered but not serving' when launchd agent is up
but port is not bound
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
v0.6.4 added `&& _gridViewMode !== 'grouped'` to the status:empty branch in
renderGrid, suppressing the "No sessions" tile only in grouped grid view.
User reported the alienware-r13 "No sessions" tile still appearing in flat view
(gridViewMode: "flat"), which is the muxplex default. The original request was
unambiguous: "don't need a block for those that don't have anything to show" —
no view-mode qualifier was implied.
Changes:
- Both renderGrid call sites (early-return branch for visible.length === 0, and
the normal rendering branch) now simply omit the status:empty else-if entirely.
The status:empty sentinel may still arrive from the server; the renderer silently
ignores it in every view mode.
- Updated surrounding comments to reflect the new unconditional suppression.
- Updated/replaced tests that asserted the old (wrong) flat-mode behaviour:
- Replaced 'renderGrid shows "No sessions" status tile for status=empty devices'
with a v0.6.5 variant that asserts NO tile is emitted.
- Removed 'v0.6.4: status:empty block IS still rendered in flat grid mode'
(superseded; kept as a tombstone comment).
- Added three new tests under 'v0.6.5 empty tile suppressed in all view modes':
1. Flat view: empty sentinel → no source-tile--empty, no 'No sessions' text.
2. Grouped view: same (regression guard for the v0.6.4 suppression).
3. Mixed input: real session renders, empty sentinel is discarded, both modes.
No other rendering paths that produce a per-device block for empty devices were
found: renderSidebar and renderGroupedGrid both operate on getVisibleSessions()
output which already excludes status-bearing sentinels.
Test count: 402 (v0.6.4) → 404 (v0.6.5).
On three fleet devices muxplex update failed silently because
shutil.which('uv') returned None even though uv was installed:
- tower (Unraid/root): /root/.local/bin/uv
- macOS (user): ~/.local/bin/uv
- spark-1 (snap): /snap/bin/uv
The muxplex process running under systemd / launchd inherits a stripped
PATH that omits ~/.local/bin and /snap/bin. shutil.which gives up at
PATH exhaustion; _find_uv() doesn't.
Add _find_uv() and _find_pip() helpers that:
1. Try shutil.which first (PATH fast path).
2. If that returns None, probe a curated list of known install locations
checking os.path.exists + os.access(X_OK) for each candidate.
3. Return the first found path, or None.
Known locations covered:
uv: ~/.local/bin/uv, /opt/homebrew/bin/uv, /usr/local/bin/uv,
/snap/bin/uv, /root/.local/bin/uv
pip: ~/.local/bin/{pip,pip3}, /opt/homebrew/bin/pip3,
/usr/local/bin/pip3, /root/.local/bin/{pip,pip3}
shutil.which() calls for systemctl/launchctl in service.py are not
changed — those tools are reliably on PATH when present.
Exit-code propagation (sys.exit(1) on _install_failed) was already
implemented in v0.6.2; this commit adds 9 tests confirming the complete
behaviour including the uv/pip path-probing and exit-code paths.
Updated test_upgrade_falls_back_to_pip_when_uv_absent to monkeypatch
_find_uv directly (avoids false positives on dev systems where uv is
installed at a known non-PATH location).
Root cause: the v0.6.3 fix added a guard in renderGroupedGrid
(groupSessions.length === 0) that is unreachable — groups are built by
iterating the already-filtered session list, so every group that exists
always has ≥1 entry. The actual empty-device block was coming from a
separate code path in renderGrid that unconditionally appends a
source-tile--empty status tile for every federation remote whose server
returns {status: 'empty'}. In flat mode this is correct and intentional
("No sessions" badge); in grouped mode it produced a visible block
showing the device name, exactly what the user saw for alienware-r13.
The v0.6.3 tests only covered the hidden-sessions case (sessions exist
but are hidden); they didn't cover the zero-sessions case (remote device
online, zero tmux sessions → server emits status:empty sentinel). Both
status-tile generation paths in renderGrid (the visible.length===0 early
return and the main append-at-end path) were appending the empty tile
regardless of gridViewMode.
Fix: skip status:empty tiles when _gridViewMode === 'grouped'.
auth_failed and unreachable tiles are still shown in all modes because
they represent actionable error states.
Adds three regression tests in test_app.mjs:
- status:empty NOT rendered in grouped mode (would have caught the bug)
- status:empty IS still rendered in flat mode (backward compat)
- auth_failed/unreachable still appear in grouped mode
In grouped-by-device mode, devices whose sessions are all filtered out
of the current view (hidden, not in the active user view, or status-only
tiles) were still rendering a device-group header with an empty body —
visual clutter that made the dashboard feel broken.
Change: renderGroupedGrid() now skips a device entirely when its session
list is empty after partitioning the already-filtered visible session set.
The guard (devSessions.length === 0 → continue) is placed before the
<h3 class="device-group-header"> write so no HTML is emitted for the
empty device. The empty-state UI path (visible.length === 0 early-return
in renderGrid) is unaffected — it runs before renderGroupedGrid is ever
called, so the "all sessions hidden" case still shows the empty state.
Filtering source: renderGrid() already calls getVisibleSessions() →
filterVisible(_currentSessions, _serverSettings, _activeView) before
passing the ordered list to renderGroupedGrid(), so status tiles and
hidden sessions are never in the partition input.
Tests added (test_app.mjs — v0.6.3 section):
- grouped view skips device header when device has only hidden sessions
- grouped view still shows device header when ≥1 session is visible
- empty-state still appears when every device has zero visible sessions