Commit Graph

532 Commits

Author SHA1 Message Date
Ken ee999b0513 chore: remove replaced xterm.js vendor files
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).
2026-05-28 06:23:55 +00:00
Ken 27b631c89e feat: update terminal.js for ghostty-web API compatibility 2026-05-28 06:22:02 +00:00
Ken 1b807fc24a feat: GET /api/terminal-token endpoint for muxterm HMAC auth 2026-05-28 06:21:26 +00:00
Ken 11794c4e24 test(muxterm): unit tests for protocol, token validation, and pool 2026-05-28 06:17:50 +00:00
Ken f2d999973a feat: replace xterm.js script tags with ghostty-web in index.html 2026-05-28 06:16:06 +00:00
Ken 10b57c8f60 feat(muxterm): WebSocket server with HMAC token auth and PTY relay 2026-05-28 06:15:05 +00:00
Ken 29aabcfd4f feat: vendor ghostty-web UMD build and WASM binary
- Copy ghostty-web UMD build (638 KB) to muxplex/frontend/vendor/ghostty-web.js
- Copy ghostty-vt WASM binary (413 KB) to muxplex/frontend/vendor/ghostty-vt.wasm
- Add .wasm to _STATIC_EXTENSIONS in auth.py to bypass auth for WASM files
- Add test_vendor_ghostty.py with 5 tests for file existence, size, MIME type, and HTTP serving

Generated with Amplifier

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
2026-05-28 06:12:49 +00:00
Ken b72d82624d feat(muxterm): wire protocol types and parser 2026-05-28 06:11:35 +00:00
Ken 3f0e31007f feat(muxterm): PTY pool with attach, resize, cleanup 2026-05-28 06:09:39 +00:00
Ken ecc6c6979c docs: ghostty-web addon compatibility research
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
2026-05-28 06:06:42 +00:00
Ken 595d08ba5a feat(muxterm): scaffold Go module with main.go entry point 2026-05-28 06:06:36 +00:00
Ken 9db70593c2 docs: resolve auth/handoff design - token-based auth, no Python relay 2026-05-28 05:28:39 +00:00
Ken 1a34ce75b3 docs: terminal architecture redesign spec (muxterm + ghostty-web) 2026-05-28 05:10:41 +00:00
Ken f7ba0a29e5 fix: blank terminal when switching cached sessions
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.)
2026-05-28 01:42:55 +00:00
Ken e032d540b0 feat: ttyd process pool + terminal instance cache for instant session switching
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>
2026-05-28 00:48:21 +00:00
Ken 967b30f8e0 fix: prevent hover preview popover from intercepting sidebar tile clicks
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>
2026-05-28 00:15:59 +00:00
Ken f9d37984cb fix: xterm.js link handling - OSC 8 hyperlinks and plain-text URL click detection
CI / test (3.13) (push) Failing after 10m42s
CI / test (3.12) (push) Failing after 10m44s
CI / test (3.11) (push) Failing after 10m46s
- 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>
2026-05-27 22:36:06 +00:00
Ken 2a082ecd8d fix: view dropdown menu appears off-screen - add position: relative
CI / test (3.13) (push) Failing after 13m10s
CI / test (3.12) (push) Failing after 13m12s
CI / test (3.11) (push) Failing after 13m14s
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.
2026-05-27 22:23:39 +00:00
Ken 767e7fce79 feat: hide session pill when sidebar is open (redundant switcher)
CI / test (3.13) (push) Failing after 12m56s
CI / test (3.12) (push) Failing after 12m58s
CI / test (3.11) (push) Failing after 13m0s
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>
2026-05-27 21:08:53 +00:00
Ken 815c806da4 fix: stabilize frontend after Lit migration, fix new-session layout, graceful reconnect UX
CI / test (3.13) (push) Failing after 12m40s
CI / test (3.12) (push) Failing after 12m42s
CI / test (3.11) (push) Failing after 12m44s
**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>
2026-05-27 18:14:07 +00:00
Ken b1f4569608 feat: implement all P0 UX improvements - visual hierarchy, readable previews, search filter, default session
CI / test (3.13) (push) Failing after 11m36s
CI / test (3.12) (push) Failing after 11m38s
CI / test (3.11) (push) Failing after 11m40s
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>
2026-05-27 07:30:13 +00:00
Ken 9c86cf096d feat(frontend): phase 3 lit web component migration - view-dropdown & store components
CI / test (3.13) (push) Failing after 10m48s
CI / test (3.12) (push) Failing after 10m50s
CI / test (3.11) (push) Failing after 15m52s
New files:
- muxplex/frontend/components/store.js: Reactive AppStore (EventTarget-based
  state management). Provides get/set/batch/on methods with automatic change
  event dispatch. Convenience accessors for sessions, activeView, serverSettings.
- muxplex/frontend/components/view-dropdown.js: Unified <view-dropdown> component.
  Replaces TWO parallel dropdown implementations (header + sidebar) with a single
  component taking a variant prop. Eliminates ~300 lines of duplicated
  HTML-string-building code.

app.js changes:
- renderViewDropdown/renderSidebarViewDropdown: 100+ lines of HTML builders
  → 6-line property setters
- toggleViewDropdown/toggleSidebarViewDropdown: simplified to toggle .open property
- closeViewDropdown: simplified to set .open = false
- switchView: removed manual sidebar label update (component handles it)
- bindStaticEventListeners: replaced 80+ lines of header/sidebar dropdown event
  wiring + two click-outside handlers with component event listeners
- showNewViewInput/showSidebarNewViewInput: updated to find menu inside component
- Exposed filterVisible() globally for component use
- Removed dead global _previewPopover

index.html changes:
- Replaced 14-line header dropdown structure with single <view-dropdown>
- Replaced 8-line sidebar dropdown structure with single <view-dropdown variant="sidebar">
- Added store.js and view-dropdown.js imports

All 1306 tests pass.
2026-05-27 07:01:01 +00:00
Ken e0c540b367 feat(frontend): phase 2 lit web component migration
CI / test (3.13) (push) Failing after 14m34s
CI / test (3.12) (push) Failing after 14m36s
CI / test (3.11) (push) Failing after 14m38s
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
2026-05-27 06:12:15 +00:00
Ken 8687d72d0b feat: introduce Lit web components framework for frontend (Phase 1)
CI / test (3.13) (push) Failing after 11m50s
CI / test (3.12) (push) Failing after 11m52s
CI / test (3.11) (push) Failing after 11m54s
- Vendored Lit 3 (21KB ESM bundle, no build step required)
- New components: toast-notification, connection-status, session-pill, session-tile
- Extracted utilities: escapeHtml, formatTimestamp, sessionPriority, ansiToHtml
- SVG icons registry for reuse
- Updated app.js and index.html to use new components
- All 1306 tests pass (incremental migration, vanilla JS still supported)
2026-05-27 05:39:57 +00:00
Ken b9fe3d7131 docs: add AGENTS.md with project guide and UX improvement roadmap
CI / test (3.13) (push) Failing after 12m58s
CI / test (3.12) (push) Failing after 13m0s
CI / test (3.11) (push) Failing after 13m2s
2026-05-27 01:14:23 +00:00
Ken 2ec6f73843 feat: mobile UX polish, shell injection fix, and performance optimizations
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>
2026-05-27 01:01:55 +00:00
Brian Krabach 57a4c771a0 chore: bump version to 0.6.7
CI / test (3.12) (push) Failing after 1m4s
CI / test (3.13) (push) Failing after 1m7s
CI / test (3.11) (push) Failing after 15m12s
2026-05-17 18:29:37 -07:00
Brian Krabach 4abb5186e2 fix(service): emit launchd ProgramArguments as separate strings (not embedded spaces)
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.
2026-05-17 18:29:19 -07:00
Brian Krabach a80c6a76b5 fix(cli): verify service is actually active after start, daemon-reload first for stale units
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
2026-05-17 18:28:54 -07:00
Brian Krabach b75be60e7f chore: bump version to 0.6.6 2026-05-17 17:23:58 -07:00
Brian Krabach c5e146bcf7 feat(ui): version-busting query suffix on all served asset URLs
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
2026-05-17 17:22:25 -07:00
Brian Krabach b30891f3e5 chore: bump version to 0.6.5 2026-05-17 16:55:27 -07:00
Brian Krabach c3a059a7ee fix(ui): suppress empty device status tiles in all view modes (not just grouped)
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).
2026-05-17 16:55:12 -07:00
Brian Krabach 54772ba12b chore: bump version to 0.6.4 2026-05-17 16:37:21 -07:00
Brian Krabach 13e5cb2484 fix(cli): probe known uv/pip locations off PATH and propagate caught install failures
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).
2026-05-17 16:36:47 -07:00
Brian Krabach c5921eba65 fix(ui): suppress empty device blocks in renderGrid grouped mode
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
2026-05-17 16:36:24 -07:00
Brian Krabach d364829677 chore: bump version to 0.6.3 2026-05-17 16:10:03 -07:00
Brian Krabach 43c559f812 feat(ui): suppress empty device blocks in grouped grid view
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
2026-05-17 16:09:53 -07:00
Brian Krabach 5f2f8fb36b chore: bump version to 0.6.2 2026-05-17 14:46:06 -07:00
Brian Krabach 78950cbf02 fix(cli): propagate install failures, restart service on partial upgrade, prefer uv tool over pip
Bug 1 — upgrade returned exit 0 on partial failure (macOS pip ImportError case):
  When the install subprocess fails (non-zero exit), upgrade() now sets
  _install_failed=True and calls sys.exit(1) after the finally block instead
  of silently returning.  A clear error message is printed so the caller /
  script can detect the failure.
  Files: muxplex/cli.py upgrade() install dispatch blocks.

Bug 2 — macOS launchd agent left unloaded when install fails:
  (a) Added _have_launchctl() helper mirroring the _have_systemctl() guard
      introduced in v0.6.1.  Every launchctl subprocess call in upgrade() and
      doctor() is now guarded — if launchctl is absent a clear note is printed
      and the step is skipped.
  (b) Wrapped stop + install + start in try/finally so the service-start step
      ALWAYS executes regardless of install outcome (success or failure).
      Applied to both the launchctl path (macOS) and the systemctl path (Linux/
      WSL) — same structural issue existed in both, only the macOS variant was
      observed in the field.
  Files: muxplex/cli.py _have_launchctl(), upgrade() try/finally.

Bug 3 — upgrade chose system pip3 over uv even when install was uv-tool-managed:
  Added uv-tool-managed detection: resolves the muxplex script on PATH and
  checks whether the target lives under ~/.local/share/uv/tools/.  When
  detected, uses 'uv tool install --reinstall --force muxplex' (package name,
  not a git URL) so the correct uv tool-environment is upgraded.  Falls back to
  pip when uv is absent from PATH — unchanged.
  Files: muxplex/cli.py upgrade() uv-tool detection + install dispatch.

Refs: v0.6.1 added _have_systemctl() and the Linux no-systemctl guard;
this commit brings parity for macOS and closes the remaining failure modes
observed during the fleet update.
2026-05-17 14:45:44 -07:00
Brian Krabach 831292279f chore: bump version to 0.6.1 2026-05-17 12:26:34 -07:00
Brian Krabach d7d07ec07e fix(cli): tolerate systems without systemctl in upgrade/doctor flows
Bug: On systems without systemd (Unraid OS 7.2.4, BSD, macOS containers,
and other non-systemd Linux hosts), running `muxplex upgrade` or
`muxplex update` crashed immediately with:

  FileNotFoundError: [Errno 2] No such file or directory: 'systemctl'

The check ran unconditionally before any install step, so the upgrade
aborted without even attempting to fetch the new version. Introduced
in v0.6.0.

Fix: Add a module-level `_have_systemctl() -> bool` helper to cli.py
(and service.py) that gates every systemd-specific operation behind
`shutil.which("systemctl") is not None`.

Call sites guarded in cli.py (upgrade() function):
  1. systemctl --user is-active muxplex  (stop-before-upgrade check)
  2. systemctl --user stop muxplex       (pre-install service stop)
  3. Service file regeneration step      (service_install() call)
  4. systemctl --user is-enabled muxplex (post-install restart check)
  5. systemctl --user daemon-reload      (post-install daemon reload)
  6. systemctl --user start muxplex      (post-install service start)

Behaviour on no-systemd systems:
  - Skips the is-active check (treats as unmanaged; prints skip note).
  - Skips the stop step.
  - Still performs the uv/pip install.
  - Skips service-file regeneration (prints skip note).
  - Skips the daemon-reload / start steps.
  - Prints: '! systemd not detected — restart muxplex manually to pick
    up the new version' with the running PID if pgrep finds one.
  - Still runs `muxplex doctor` for verification (no systemd required).

doctor() change:
  - On non-darwin platforms with no systemctl, now prints:
    '! Service: systemd not available on this platform'
    instead of silently doing nothing or crashing.

service.py change:
  - Public API functions (service_install, service_uninstall,
    service_start, service_stop, service_restart, service_status,
    service_logs) now check _have_systemctl() on the Linux path.
  - When absent, print a clear, friendly error pointing the user to
    `muxplex serve` instead of letting subprocess raise FileNotFoundError.

Tests added (muxplex/tests/test_cli.py, +8 tests):
  - test_have_systemctl_helper_exists
  - test_have_systemctl_returns_bool
  - test_upgrade_no_systemctl_runs_to_completion (regression)
  - test_upgrade_no_systemctl_prints_skip_note
  - test_upgrade_no_systemctl_prints_manual_restart_note
  - test_upgrade_with_systemctl_runs_systemd_commands
  - test_doctor_no_systemctl_shows_graceful_message
  - test_doctor_no_systemctl_does_not_crash
2026-05-17 12:26:20 -07:00
Brian Krabach 794e329938 chore: bump version to 0.6.0
Hidden-state redesign: filter-at-read visibility helper, operation layer,
stale-key pruning, UI dim styling (Phases 0+1+2+4+5).
2026-05-17 11:38:53 -07:00
Brian Krabach c3a719ab5d chore(release): wire normalize_session_keys + repair stale JS tests
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.
2026-05-17 11:38:35 -07:00
Brian Krabach a202d827d1 feat(ui): dim hidden sessions in Manage View with CSS badge (Phase 5)
Hidden sessions in the Manage View panel now render at 55% opacity with a "(hidden)" pseudo-element badge driven by CSS.

renderManageViewList uses the isHidden() Phase 1 helper for the conditional class — no inline hidden-checks remain in the rendering path.

4 new tests verify the class is applied/removed correctly across renders.
2026-05-17 10:26:26 -07:00
Brian Krabach fe0a5e10b2 feat(views): prune stale session keys with local-only bookkeeping (Phase 4)
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.
2026-05-17 10:26:20 -07:00
Brian Krabach 03bf24e8d4 refactor(views): introduce operation layer with pure and user-intent ops (Phase 2)
Backend (muxplex/views.py):
- Added five pure data operations that mutate settings dict in place:
  add_membership, remove_membership, remove_from_all_views, hide, unhide.
  Each operation is isolated and affects only its named field.
- Added 16 new tests under 'Pure data operations (Phase 2)' in test_views.py.
  Isolation verified: each operation only modifies its target field.

Frontend (muxplex/frontend/app.js):
- Added five pure ops (_opAddMembership, _opRemoveMembership,
  _opRemoveFromAllViews, _opHide, _opUnhide) and _cloneOpState helper.
- Added four user-intent operations with intentional asymmetry:
  * hideSessionOp = hide + removeFromAllViews (federation-safe;
    matches current UX; see design doc for rationale)
  * unhideSessionOp = unhide (orthogonal; does NOT change view membership)
  * addSessionToViewOp = unhide + addMembership (auto-unhide on add;
    expressed as explicit composition)
  * removeSessionFromViewOp = removeMembership (orthogonal; does NOT hide)
- Refactored 7 call sites to use user-intent ops: _doHideSession,
  _doUnhideSession, _doRemoveFromView, flyout submenu toggle, 'New View'
  flow, mobile picker toggle, Manage View checkbox.
- All new operations exported in module.exports.

Frontend tests (muxplex/frontend/tests/test_app.mjs):
- Added 30 new tests under 'Phase 2 operation layer'.
- Cover pure-op isolation, idempotency, no-op behavior, user-intent op
  composition, and guarantee that _serverSettings is never mutated.

Verification:
- muxplex/tests/: 1239 passed (1223 prior + 16 new pure-op tests)
- test_app.mjs: 372 passed, 10 pre-existing failures (Phase 1 had 21;
  Phase 2 reduces to 10; none Phase-2-related; all test unrelated details
  of createNewSession, renderFilterBar, loadGridViewMode)

Intentional inline-PATCH constructions (NOT regressions):
- Creating empty views (~1263, 1347, 1595)
- Moving views up/down in Settings tab (~2520, 2577)
- _saveViewsAndRerender (full views array persistence)
- createNewSession auto-add-to-active-view (session-creation side effect)
These are view-management operations, not session-in-view management.

Builds on Phase 0+1 (commit 0f9623d).
No behavior change — purely structural refactor.

Generated with Amplifier

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
2026-05-17 10:10:05 -07:00
Brian Krabach 0f9623d2c2 refactor(views): introduce visibility helper and schema version (Phases 0+1)
## Phase 0 — Schema Version Field

- Added SCHEMA_VERSION = 2 constant and _schema_version field to DEFAULT_SETTINGS
  in muxplex/settings.py to support versioned federation.
- Added _schema_version to SYNCABLE_KEYS so peers see the version but
  apply_synced_settings never accepts an incoming version (one-way: send only).
- save_settings() always clamps _schema_version to current SCHEMA_VERSION.
- Added peer_supports_v2() helper for federation handshake.
- 6 new tests under "Schema version (Phase 0)" in test_settings.py.

## Phase 1 — Backend & Frontend Visibility Helpers

Backend (muxplex/views.py):
- Added is_hidden(key, settings), filter_visible(sessions, settings, view,
  *, include_hidden=False), visible_count(...), and normalize_session_keys()
  to provide read-time visibility filtering.
- Updated module docstring to describe v2 semantics (hidden is a property,
  not a placement; legacy enforce_mutual_exclusion retained as v1 backstop).
- 22 new tests covering the full filter matrix and normalization edge cases.

Frontend (muxplex/frontend/app.js):
- Added isHidden, filterVisible, visibleCount to app.js (pre-ES6 idioms).
- Replaced getVisibleSessions body with thin wrapper around filterVisible.
- Replaced 8 raw .length count sites in dropdown, settings, and Manage View
  to route through visibleCount.
- Settings panel shows "N sessions (M hidden)" when M > 0.
- Manage View "in this view" count uses includeHidden: true.
- 17 new tests in test_app.mjs covering the same matrix as backend.
- Updated 5 stale tests in test_frontend_js.py.

## Additional Updates

- Updated test_readme.py to exempt internal underscore-prefixed setting keys
  from README documentation requirement.
- Updated docs/plans/2026-05-17-hidden-state-redesign-design.md with COE
  corrections and design notes (federation truth, Phase 3 deferral, schema
  version semantics, local-only pruning state, federation tests).

All 1223 tests in muxplex/tests/ pass. All 17 new JS tests pass.
No raw .length count sites remain in counting code paths.

Generated with Amplifier

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
2026-05-17 09:33:46 -07:00
Brian Krabach 64915777be chore: bump version to 0.5.0 2026-05-06 12:37:34 -07:00
Brian Krabach 33ea016f7e feat(tls): add --method ca for persistent local CA-signed leaves
Adds a new TLS certificate method ('ca') that generates a persistent local
Certificate Authority and signs leaf certificates against it. This allows
users to install the CA once on client devices and have browser-trusted
HTTPS for plain LAN names (my-host, 192.168.1.5, my-host.local) without
requiring external services like Tailscale.

Key improvements over existing --method selfsigned:
- Persistent CA: the root cert is stored separately and never rotates,
  so leaf certificate renewal doesn't require re-trusting on clients
- Auto-detected SANs: includes LAN IP, tailnet name (if applicable),
  hostname, and localhost variants
- Per-platform install guide: comprehensive docs/TRUSTING_THE_LOCAL_CA.md
  with Windows PowerShell, macOS/Linux, iOS, and Android install steps

Solves PWA installation issues on Windows machines with corporate IT
policy blocking Tailscale: PWAs now persist in standalone mode when the
local CA is trusted in the Windows user cert store.

Changes:
- muxplex/tls.py: added _default_lan_ip(), _default_tailnet_name(),
  generate_local_ca(), generate_leaf_signed_by_ca()
- muxplex/cli.py: added 'ca' to --method choices, wired setup_tls()
  to generate CA + leaf with auto-detected SAN
- docs/TRUSTING_THE_LOCAL_CA.md: comprehensive per-platform install guide
- README.md: added --method ca documentation and cross-links
- CHANGELOG.md: documented v0.5.0 features

Generated with Amplifier

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
2026-05-06 12:37:34 -07:00