diff --git a/.gitignore b/.gitignore
index d2cfb3b..49c30d4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,3 +28,6 @@ Thumbs.db
.vscode/
*.swp
*.swo
+
+# Verification screenshots
+*.png
diff --git a/docs/superpowers/plans/2026-05-28-phase1-ghostty-web-swap.md b/docs/superpowers/plans/2026-05-28-phase1-ghostty-web-swap.md
new file mode 100644
index 0000000..dfb7934
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-28-phase1-ghostty-web-swap.md
@@ -0,0 +1,596 @@
+# Phase 1: ghostty-web Swap Implementation Plan
+
+> **Execution:** Use the subagent-driven-development workflow to implement this plan.
+
+**Goal:** Replace xterm.js with ghostty-web as the terminal emulator frontend — same backend, better VT parsing.
+
+**Architecture:** ghostty-web is a WASM-compiled port of Ghostty's VT100 parser with xterm.js API compatibility. It ships a UMD build (`ghostty-web.umd.cjs`) that exposes globals via `
+
+
+
+
+```
+
+**After:**
+```html
+
+
+
+
+```
+
+Note: We keep WebLinksAddon, SearchAddon, and ImageAddon for now. They use the xterm.js addon interface (`loadAddon`). Task 1 research will determine if they're compatible. If not, Task 4 will handle fallbacks. FitAddon is dropped because ghostty-web bundles it natively.
+
+Also update the CSS link if ghostty-web has its own CSS. If ghostty-web does NOT have a CSS file (it uses canvas rendering, not DOM), remove the xterm.css link:
+
+**Before:**
+```html
+
+```
+
+**After (if ghostty-web has CSS):**
+```html
+
+```
+
+**After (if ghostty-web has NO CSS — canvas renderer needs no CSS):**
+```html
+
+```
+
+**Step 4: Run tests to verify they pass**
+
+Run:
+```bash
+.venv/bin/python -m pytest muxplex/tests/test_frontend_js.py -k "ghostty" -xvs
+```
+Expected: PASS
+
+**Step 5: Commit**
+```bash
+git add muxplex/frontend/index.html muxplex/tests/test_frontend_js.py
+git commit -m "feat: update index.html to load ghostty-web instead of xterm.js"
+```
+
+---
+
+### Task 4: Update terminal.js for ghostty-web API
+
+**Files:**
+- Modify: `muxplex/frontend/terminal.js`
+
+**Depends on:** Task 1 (research results), Task 3 (script tags updated)
+
+**Context:** ghostty-web is API-compatible with xterm.js, but has two key differences:
+1. Requires `await init()` before creating Terminal — loads WASM asynchronously
+2. FitAddon is built-in (import path differs)
+3. The global name exposed by the UMD build may differ from `window.Terminal`
+
+The existing code uses:
+- `new window.Terminal({...})` (line 349)
+- `new window.FitAddon.FitAddon()` (line 369)
+- `_term.loadAddon(...)` for WebLinksAddon, SearchAddon, ImageAddon (lines 387-406)
+- `_term.write(string)` for terminal output
+- `_term.onData(callback)` for terminal input
+- `_term.onResize(callback)` for resize events
+- `_term.dispose()` for cleanup
+- `_term.parser.registerOscHandler()` for OSC 52 clipboard
+- `_term.attachCustomKeyEventHandler()` for key interception
+- `_term.getSelection()` and `_term.onSelectionChange()` for clipboard
+- `_term.focus()`, `_term.cols`, `_term.rows`
+
+**Step 1: Add WASM initialization at module load**
+
+Add a WASM initialization block near the top of `terminal.js` (after the module-level state declarations, around line 38). ghostty-web's UMD build exposes an `init()` function that must be called once before creating any Terminal instance:
+
+```javascript
+// --- ghostty-web WASM initialization ---
+// ghostty-web requires WASM to be loaded before creating Terminal instances.
+// The UMD build exposes init() on the global (e.g., window.GhosttyWeb.init()).
+// Call init() once at module load; createTerminal() awaits the result.
+var _ghosttyReady = null; // Promise that resolves when WASM is loaded
+
+(function _initGhosttyWasm() {
+ // The exact global depends on the UMD build — Task 1 research identifies this.
+ // Common patterns: window.GhosttyWeb.init(), window.ghosttyWeb.init()
+ // The init() call may accept a WASM URL: init('/vendor/ghostty-vt.wasm')
+ if (typeof window !== 'undefined' && window.GhosttyWeb && window.GhosttyWeb.init) {
+ _ghosttyReady = window.GhosttyWeb.init('/vendor/ghostty-vt.wasm');
+ } else if (typeof window !== 'undefined' && window.init) {
+ _ghosttyReady = window.init('/vendor/ghostty-vt.wasm');
+ } else {
+ _ghosttyReady = Promise.resolve(); // Fallback for test environments
+ }
+})();
+```
+
+**Important:** The exact global name and init() signature depend on Task 1 research findings. Adjust the code above based on what the UMD build actually exposes.
+
+**Step 2: Update `createTerminal()` — Terminal constructor**
+
+In the `createTerminal()` function (line 335), update the Terminal constructor reference. ghostty-web's UMD build may expose the Terminal class under a different global:
+
+**Before (line 349):**
+```javascript
+ _term = new window.Terminal({
+```
+
+**After (adjust global name based on Task 1 research):**
+```javascript
+ // ghostty-web UMD exposes Terminal on the same global or on GhosttyWeb
+ var TerminalClass = window.GhosttyWeb ? window.GhosttyWeb.Terminal : window.Terminal;
+ _term = new TerminalClass({
+```
+
+**Step 3: Update FitAddon instantiation**
+
+ghostty-web bundles FitAddon. The import path changes:
+
+**Before (line 369):**
+```javascript
+ _fitAddon = new window.FitAddon.FitAddon();
+```
+
+**After (adjust based on Task 1 research — FitAddon may be on GhosttyWeb or still on window.FitAddon):**
+```javascript
+ var FitAddonClass = (window.GhosttyWeb && window.GhosttyWeb.FitAddon)
+ ? window.GhosttyWeb.FitAddon
+ : (window.FitAddon && window.FitAddon.FitAddon);
+ _fitAddon = new FitAddonClass();
+```
+
+**Step 4: Guard addon loading for compatibility**
+
+The WebLinksAddon, SearchAddon, and ImageAddon use the xterm.js addon interface. If they fail to load with ghostty-web, wrap them in try/catch so the terminal still works:
+
+**Before (lines 387-406):**
+```javascript
+ var WebLinksAddon = window.WebLinksAddon && window.WebLinksAddon.WebLinksAddon;
+ if (WebLinksAddon) {
+ _term.loadAddon(new WebLinksAddon(function(event, uri) {
+ window.open(uri, '_blank');
+ }));
+ }
+
+ var SearchAddon = window.SearchAddon && window.SearchAddon.SearchAddon;
+ if (SearchAddon) {
+ _searchAddon = new SearchAddon();
+ _term.loadAddon(_searchAddon);
+ }
+
+ var ImageAddon = window.ImageAddon && window.ImageAddon.ImageAddon;
+ if (ImageAddon) {
+ _term.loadAddon(new ImageAddon());
+ }
+```
+
+**After:**
+```javascript
+ // WebLinksAddon — try loading xterm.js addon; ghostty-web may or may not support it
+ try {
+ var WebLinksAddon = window.WebLinksAddon && window.WebLinksAddon.WebLinksAddon;
+ if (WebLinksAddon) {
+ _term.loadAddon(new WebLinksAddon(function(event, uri) {
+ window.open(uri, '_blank');
+ }));
+ }
+ } catch (e) {
+ console.warn('WebLinksAddon not compatible with ghostty-web:', e.message);
+ }
+
+ // SearchAddon — try loading xterm.js addon
+ try {
+ var SearchAddon = window.SearchAddon && window.SearchAddon.SearchAddon;
+ if (SearchAddon) {
+ _searchAddon = new SearchAddon();
+ _term.loadAddon(_searchAddon);
+ }
+ } catch (e) {
+ console.warn('SearchAddon not compatible with ghostty-web:', e.message);
+ _searchAddon = null;
+ }
+
+ // ImageAddon — try loading xterm.js addon (Sixel, iTerm2 IIP, Kitty graphics)
+ try {
+ var ImageAddon = window.ImageAddon && window.ImageAddon.ImageAddon;
+ if (ImageAddon) {
+ _term.loadAddon(new ImageAddon());
+ }
+ } catch (e) {
+ console.warn('ImageAddon not compatible with ghostty-web:', e.message);
+ }
+```
+
+**Step 5: Handle the async init() in openTerminal()**
+
+The `openTerminal()` function (line 453) and `switchTerminal()` function (line 642) call `createTerminal()` synchronously. Since ghostty-web requires WASM to be loaded first, ensure `_ghosttyReady` is resolved before terminal creation. The simplest approach: `init()` fires at page load (module execution), so by the time the user clicks a session tile, WASM is loaded. But add a guard:
+
+In `openTerminal()`, before `createTerminal(fontSize)` (line 479), add:
+```javascript
+ // Ensure ghostty-web WASM is loaded before creating terminal
+ if (_ghosttyReady && typeof _ghosttyReady.then === 'function') {
+ _ghosttyReady.then(function() {
+ _ghosttyReady = null; // Only wait once
+ });
+ }
+```
+
+Note: Since `init()` is called at module load and terminal opening happens after user interaction (seconds later), the WASM will already be loaded. This guard is defensive only.
+
+**Step 6: Verify the terminal.js changes don't break the existing test suite**
+
+Run:
+```bash
+.venv/bin/python -m pytest muxplex/tests/test_frontend_js.py -xvs
+```
+Expected: All existing tests PASS (they test app.js structure, not terminal.js internals).
+
+**Step 7: Commit**
+```bash
+git add muxplex/frontend/terminal.js
+git commit -m "feat: update terminal.js for ghostty-web API (FitAddon built-in, addon guards)"
+```
+
+---
+
+### Task 5: Remove old xterm.js vendor files
+
+**Files:**
+- Delete: `muxplex/frontend/vendor/xterm.js`
+- Delete: `muxplex/frontend/vendor/xterm.css`
+- Delete: `muxplex/frontend/vendor/xterm-addon-fit.js`
+
+**Depends on:** Task 3, Task 4 (new vendor files are loaded, terminal.js updated)
+
+**Important:** Do NOT delete `xterm-addon-web-links.js`, `xterm-addon-search.js`, or `addon-image.js` yet. These addons are still loaded (with try/catch guards) and may work with ghostty-web's `loadAddon()` interface. They'll be removed later once ghostty-web has native equivalents or Phase 2 eliminates the need.
+
+**Step 1: Delete the replaced files**
+
+```bash
+rm muxplex/frontend/vendor/xterm.js
+rm muxplex/frontend/vendor/xterm.css
+rm muxplex/frontend/vendor/xterm-addon-fit.js
+```
+
+**Step 2: Verify no remaining references to deleted files**
+
+```bash
+grep -r "xterm\.js" muxplex/frontend/ --include="*.html" --include="*.js"
+grep -r "xterm\.css" muxplex/frontend/ --include="*.html"
+grep -r "xterm-addon-fit" muxplex/frontend/ --include="*.html" --include="*.js"
+```
+
+Expected: No references to `xterm.js`, `xterm.css`, or `xterm-addon-fit.js` in HTML or JS files. (Comments referencing "xterm.js" in code are fine — they're documentation, not imports.)
+
+**Step 3: Verify remaining vendor files**
+
+```bash
+ls -la muxplex/frontend/vendor/
+```
+
+Expected contents:
+- `ghostty-web.js` (new)
+- `ghostty-vt.wasm` (new)
+- `ghostty-web.css` (new, if applicable)
+- `xterm-addon-web-links.js` (kept)
+- `xterm-addon-search.js` (kept)
+- `addon-image.js` (kept)
+- `lit/` directory (unrelated, kept)
+
+**Step 4: Commit**
+```bash
+git add -u muxplex/frontend/vendor/
+git commit -m "chore: remove xterm.js, xterm.css, xterm-addon-fit.js (replaced by ghostty-web)"
+```
+
+---
+
+### Task 6: Manual smoke test
+
+**Files:** None (testing only)
+
+**Depends on:** Tasks 2-5 complete
+
+**Step 1: Restart the service**
+
+```bash
+systemctl --user restart muxplex
+```
+
+**Step 2: Test terminal rendering**
+
+Open the muxplex web UI in a browser. Click a session tile to open a terminal. Verify:
+- [ ] Terminal renders and shows a shell prompt
+- [ ] Typing works (keystrokes reach the shell)
+- [ ] Colors render correctly (run `ls --color` or `htop`)
+- [ ] Terminal resizes when the browser window resizes (FitAddon working)
+- [ ] Scrollback works (scroll up to see history)
+
+**Step 3: Test search (Ctrl+F)**
+
+Press Ctrl+F in the terminal. If the search bar appears and search works, SearchAddon loaded successfully. If not, note this as a known limitation.
+
+**Step 4: Test links**
+
+Run `echo "https://example.com"` in the terminal. Hover over the URL. If it becomes clickable, WebLinksAddon loaded successfully. Note: ghostty-web may handle OSC 8 hyperlinks natively even without WebLinksAddon.
+
+**Step 5: Test session switching**
+
+Open one session, then click a different session in the sidebar. Verify the terminal switches and renders the new session content.
+
+**Step 6: Document any issues**
+
+If anything doesn't work, note it. Common issues to watch for:
+- WASM not loading: check browser console for 404 on `ghostty-vt.wasm` or MIME type errors
+- Terminal blank: check if `init()` resolved before Terminal creation
+- Addons failing: check console for the try/catch warning messages
+- CSS issues: ghostty-web uses canvas rendering so xterm.css classes like `.xterm` may not exist
+
+---
+
+### Task 7: Run the full test suite
+
+**Files:** None (testing only)
+
+**Depends on:** Task 6 (smoke test passed or issues documented)
+
+**Step 1: Run all tests**
+
+Run:
+```bash
+.venv/bin/python -m pytest muxplex/tests/ -x -q --timeout=30
+```
+
+Expected: All 1306 tests pass. The frontend structural tests (`test_frontend_js.py`) verify app.js structure — they don't execute JavaScript, so they're unaffected by the terminal swap. The new tests from Task 3 verify index.html loads ghostty-web.
+
+**Step 2: If tests fail, fix before proceeding**
+
+Most likely failure: a test that greps for "xterm" in terminal.js comments. These are documentation tests (the test file checks for patterns in JS source). If a test assumes `xterm.js` is present in terminal.js comments, update the comment to reference ghostty-web instead.
+
+Check specifically:
+```bash
+grep -n "xterm" muxplex/tests/test_frontend_js.py | head -20
+```
+
+If any tests reference xterm.js presence in terminal.js, update them.
+
+---
+
+### Task 8: Final commit
+
+**Files:** Any remaining uncommitted changes
+
+**Depends on:** Task 7 (all tests pass)
+
+**Step 1: Check for uncommitted changes**
+
+```bash
+git status
+git diff --stat
+```
+
+**Step 2: Commit any remaining changes**
+
+```bash
+git add -A
+git commit -m "feat: Phase 1 complete — ghostty-web replaces xterm.js as terminal frontend"
+```
+
+**Step 3: Verify the commit history**
+
+```bash
+git log --oneline -10
+```
+
+Expected: Clean commit history showing the incremental swap:
+1. Research doc
+2. Vendor ghostty-web files
+3. Update index.html script tags
+4. Update terminal.js for API differences
+5. Remove old xterm.js files
+6. Final commit (if any remaining changes)
\ No newline at end of file
diff --git a/docs/superpowers/plans/2026-05-28-phase2-muxterm-go-binary.md b/docs/superpowers/plans/2026-05-28-phase2-muxterm-go-binary.md
new file mode 100644
index 0000000..35f65a3
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-28-phase2-muxterm-go-binary.md
@@ -0,0 +1,2213 @@
+# Phase 2: muxterm Go Binary — Implementation Plan
+
+> **Execution:** Use the subagent-driven-development workflow to implement this plan.
+
+**Goal:** Replace the ttyd process pool + Python WebSocket proxy with a single Go binary (`muxterm`) that owns the entire terminal data path — one process, one WebSocket hop, direct PTY ownership, server-side session switching.
+
+**Architecture:** muxterm is a Go binary that manages a map of tmux sessions → PTYs. The browser connects directly to muxterm's WebSocket after getting a short-lived HMAC token from Python. Binary frames carry raw terminal I/O; text frames carry JSON control messages (attach, resize, detach). Python's only roles are issuing auth tokens and supervising the muxterm process.
+
+**Tech Stack:** Go (creack/pty, gorilla/websocket), Python FastAPI, vanilla JS frontend.
+
+**Assumes:** Phase 1 (ghostty-web swap) is already done. The frontend uses ghostty-web, not xterm.js.
+
+---
+
+## Group A — Go Binary Core (`muxterm/`)
+
+### Task 1: Scaffold Go Module
+
+**Files:**
+- Create: `muxterm/go.mod`
+- Create: `muxterm/main.go`
+
+**Step 1: Create the Go module**
+
+Create `muxterm/go.mod`:
+
+```go
+module github.com/muxplex/muxterm
+
+go 1.22
+
+require (
+ github.com/creack/pty v1.1.24
+ github.com/gorilla/websocket v1.5.3
+)
+```
+
+**Step 2: Create the main.go entry point**
+
+Create `muxterm/main.go`:
+
+```go
+package main
+
+import (
+ "flag"
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+ "os/signal"
+ "syscall"
+)
+
+var (
+ listenAddr = flag.String("addr", "127.0.0.1:7682", "listen address")
+ secret = flag.String("secret", "", "HMAC shared secret for token validation")
+)
+
+func main() {
+ flag.Parse()
+
+ if *secret == "" {
+ // Also check env var
+ if s := os.Getenv("MUXTERM_SECRET"); s != "" {
+ *secret = s
+ } else {
+ fmt.Fprintln(os.Stderr, "muxterm: --secret or MUXTERM_SECRET required")
+ os.Exit(1)
+ }
+ }
+
+ pool := NewPool()
+
+ // Graceful shutdown on SIGTERM/SIGINT
+ sigCh := make(chan os.Signal, 1)
+ signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
+ go func() {
+ <-sigCh
+ log.Println("muxterm: shutting down")
+ pool.CloseAll()
+ os.Exit(0)
+ }()
+
+ http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
+ HandleWebSocket(pool, *secret, w, r)
+ })
+
+ // Health check endpoint
+ http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(200)
+ w.Write([]byte(`{"status":"ok"}`))
+ })
+
+ log.Printf("muxterm: listening on %s", *listenAddr)
+ if err := http.ListenAndServe(*listenAddr, nil); err != nil {
+ log.Fatalf("muxterm: %v", err)
+ }
+}
+```
+
+**Step 3: Download dependencies**
+
+Run:
+```bash
+cd muxterm && go mod tidy
+```
+Expected: `go.sum` created, dependencies resolved.
+
+**Step 4: Verify it compiles (will fail with missing symbols — that's expected)**
+
+Run:
+```bash
+cd muxterm && go build ./... 2>&1 || echo "Expected: fails on missing Pool/HandleWebSocket"
+```
+Expected: Compilation errors for undefined `NewPool` and `HandleWebSocket` — confirming the scaffold is structurally valid.
+
+**Step 5: Commit**
+```bash
+git add muxterm/ && git commit -m "feat(muxterm): scaffold Go module with main.go entry point"
+```
+
+---
+
+### Task 2: PTY Pool — Attach, Detach, Cleanup
+
+**Files:**
+- Create: `muxterm/pool.go`
+
+**Step 1: Write pool.go**
+
+Create `muxterm/pool.go`:
+
+```go
+package main
+
+import (
+ "log"
+ "os"
+ "os/exec"
+ "sync"
+ "syscall"
+
+ "github.com/creack/pty"
+)
+
+// Session holds a PTY file descriptor and the child process for one tmux attach.
+type Session struct {
+ PTY *os.File
+ Cmd *exec.Cmd
+ Cols uint16
+ Rows uint16
+}
+
+// Pool manages a map of session names to live PTY sessions.
+type Pool struct {
+ mu sync.Mutex
+ sessions map[string]*Session
+}
+
+// NewPool creates an empty session pool.
+func NewPool() *Pool {
+ return &Pool{sessions: make(map[string]*Session)}
+}
+
+// Attach returns the session for name, spawning `tmux attach -t name` if needed.
+// cols and rows set the initial PTY size.
+func (p *Pool) Attach(name string, cols, rows uint16) (*Session, error) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+
+ // Reuse existing session if process is still alive
+ if s, ok := p.sessions[name]; ok {
+ if s.Cmd.ProcessState == nil {
+ // Process hasn't exited — check if it's still running
+ if err := s.Cmd.Process.Signal(syscall.Signal(0)); err == nil {
+ return s, nil
+ }
+ }
+ // Dead session — clean up
+ s.PTY.Close()
+ delete(p.sessions, name)
+ }
+
+ cmd := exec.Command("tmux", "attach", "-t", name)
+ winSize := &pty.Winsize{Cols: cols, Rows: rows}
+ ptmx, err := pty.StartWithSize(cmd, winSize)
+ if err != nil {
+ return nil, err
+ }
+
+ s := &Session{
+ PTY: ptmx,
+ Cmd: cmd,
+ Cols: cols,
+ Rows: rows,
+ }
+ p.sessions[name] = s
+
+ // Background goroutine: wait for process exit and clean up
+ go func() {
+ _ = cmd.Wait()
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ // Only delete if this is still the same session (not replaced)
+ if current, ok := p.sessions[name]; ok && current == s {
+ log.Printf("muxterm: session %q exited", name)
+ ptmx.Close()
+ delete(p.sessions, name)
+ }
+ }()
+
+ return s, nil
+}
+
+// Resize changes the PTY window size for the named session.
+func (p *Pool) Resize(name string, cols, rows uint16) error {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+
+ s, ok := p.sessions[name]
+ if !ok {
+ return nil // no-op if session doesn't exist
+ }
+
+ s.Cols = cols
+ s.Rows = rows
+ return pty.Setsize(s.PTY, &pty.Winsize{Cols: cols, Rows: rows})
+}
+
+// Get returns the session for name, or nil if not attached.
+func (p *Pool) Get(name string) *Session {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ return p.sessions[name]
+}
+
+// IsAlive checks if a session's process is still running.
+func (p *Pool) IsAlive(name string) bool {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ s, ok := p.sessions[name]
+ if !ok {
+ return false
+ }
+ if s.Cmd.ProcessState != nil {
+ return false
+ }
+ return s.Cmd.Process.Signal(syscall.Signal(0)) == nil
+}
+
+// CloseAll terminates all sessions. Called on graceful shutdown.
+func (p *Pool) CloseAll() {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+
+ for name, s := range p.sessions {
+ log.Printf("muxterm: closing session %q", name)
+ _ = s.Cmd.Process.Signal(syscall.SIGTERM)
+ s.PTY.Close()
+ }
+ p.sessions = make(map[string]*Session)
+}
+```
+
+**Step 2: Verify it compiles**
+
+Run:
+```bash
+cd muxterm && go build ./... 2>&1 || echo "Expected: fails on missing HandleWebSocket only"
+```
+Expected: Only `HandleWebSocket` undefined — pool compiles.
+
+**Step 3: Commit**
+```bash
+git add muxterm/pool.go && git commit -m "feat(muxterm): PTY pool with attach, resize, cleanup"
+```
+
+---
+
+### Task 3: Wire Protocol — Binary I/O + JSON Control
+
+**Files:**
+- Create: `muxterm/protocol.go`
+
+**Step 1: Write protocol.go**
+
+Create `muxterm/protocol.go`:
+
+```go
+package main
+
+import "encoding/json"
+
+// Control message types — client to server
+
+// AttachMsg requests switching to a session.
+type AttachMsg struct {
+ Attach string `json:"attach"`
+}
+
+// ResizeMsg requests resizing the active PTY.
+type ResizeMsg struct {
+ Resize struct {
+ Cols int `json:"cols"`
+ Rows int `json:"rows"`
+ } `json:"resize"`
+}
+
+// DetachMsg requests detaching (keep PTY alive).
+type DetachMsg struct {
+ Detach bool `json:"detach"`
+}
+
+// Control message types — server to client
+
+// AttachedMsg confirms a session switch.
+type AttachedMsg struct {
+ Attached string `json:"attached"`
+}
+
+// ErrorMsg signals a failure.
+type ErrorMsg struct {
+ Error string `json:"error"`
+}
+
+// ExitedMsg signals a session death.
+type ExitedMsg struct {
+ Exited string `json:"exited"`
+}
+
+// ParseControlMessage parses a text frame JSON into one of the control types.
+// Returns the parsed type and value. Unknown messages return ("unknown", nil).
+func ParseControlMessage(data []byte) (string, interface{}) {
+ var raw map[string]json.RawMessage
+ if err := json.Unmarshal(data, &raw); err != nil {
+ return "invalid", nil
+ }
+
+ if _, ok := raw["attach"]; ok {
+ var msg AttachMsg
+ if err := json.Unmarshal(data, &msg); err == nil {
+ return "attach", msg
+ }
+ }
+
+ if _, ok := raw["resize"]; ok {
+ var msg ResizeMsg
+ if err := json.Unmarshal(data, &msg); err == nil {
+ return "resize", msg
+ }
+ }
+
+ if _, ok := raw["detach"]; ok {
+ var msg DetachMsg
+ if err := json.Unmarshal(data, &msg); err == nil {
+ return "detach", msg
+ }
+ }
+
+ return "unknown", nil
+}
+
+// MarshalAttached creates a JSON text frame for attach confirmation.
+func MarshalAttached(session string) []byte {
+ b, _ := json.Marshal(AttachedMsg{Attached: session})
+ return b
+}
+
+// MarshalError creates a JSON text frame for an error.
+func MarshalError(msg string) []byte {
+ b, _ := json.Marshal(ErrorMsg{Error: msg})
+ return b
+}
+
+// MarshalExited creates a JSON text frame for session exit.
+func MarshalExited(session string) []byte {
+ b, _ := json.Marshal(ExitedMsg{Exited: session})
+ return b
+}
+```
+
+**Step 2: Verify it compiles**
+
+Run:
+```bash
+cd muxterm && go build ./... 2>&1 || echo "Expected: fails on missing HandleWebSocket only"
+```
+Expected: Only `HandleWebSocket` undefined.
+
+**Step 3: Commit**
+```bash
+git add muxterm/protocol.go && git commit -m "feat(muxterm): wire protocol types and parser"
+```
+
+---
+
+### Task 4: WebSocket Server — Token Auth + Relay Goroutines
+
+**Files:**
+- Create: `muxterm/ws.go`
+
+**Step 1: Write ws.go**
+
+Create `muxterm/ws.go`:
+
+```go
+package main
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/gorilla/websocket"
+)
+
+var upgrader = websocket.Upgrader{
+ CheckOrigin: func(r *http.Request) bool { return true }, // localhost only
+}
+
+// ValidateToken checks an HMAC-SHA256 token: hex(hmac(secret, timestamp)) + "." + timestamp.
+// Returns true if the signature is valid and the token is within ttl seconds of now.
+func ValidateToken(token, secret string, ttl int64) bool {
+ parts := strings.SplitN(token, ".", 2)
+ if len(parts) != 2 {
+ return false
+ }
+ sig, tsStr := parts[0], parts[1]
+
+ ts, err := strconv.ParseInt(tsStr, 10, 64)
+ if err != nil {
+ return false
+ }
+
+ // Check TTL
+ now := time.Now().Unix()
+ if now-ts > ttl || ts-now > 5 { // 5s future tolerance for clock skew
+ return false
+ }
+
+ // Verify HMAC
+ mac := hmac.New(sha256.New, []byte(secret))
+ mac.Write([]byte(tsStr))
+ expected := hex.EncodeToString(mac.Sum(nil))
+
+ return hmac.Equal([]byte(sig), []byte(expected))
+}
+
+// HandleWebSocket upgrades the HTTP connection and manages the terminal relay.
+func HandleWebSocket(pool *Pool, secret string, w http.ResponseWriter, r *http.Request) {
+ // Validate auth token from query parameter
+ token := r.URL.Query().Get("token")
+ if token == "" || !ValidateToken(token, secret, 30) {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ conn, err := upgrader.Upgrade(w, r, nil)
+ if err != nil {
+ log.Printf("muxterm: upgrade failed: %v", err)
+ return
+ }
+ defer conn.Close()
+
+ log.Printf("muxterm: client connected from %s", r.RemoteAddr)
+
+ var (
+ activeSession string
+ activePTY *Session
+ ptyCancel chan struct{} // signals PTY→WS goroutine to stop
+ mu sync.Mutex // protects activeSession/activePTY/ptyCancel
+ )
+
+ // stopRelay cancels the current PTY→WS relay goroutine
+ stopRelay := func() {
+ if ptyCancel != nil {
+ close(ptyCancel)
+ ptyCancel = nil
+ }
+ }
+
+ // startRelay spawns a goroutine that reads from PTY and writes binary frames to WS
+ startRelay := func(s *Session, cancel chan struct{}) {
+ go func() {
+ buf := make([]byte, 32*1024)
+ for {
+ select {
+ case <-cancel:
+ return
+ default:
+ }
+ n, err := s.PTY.Read(buf)
+ if err != nil {
+ if err != io.EOF {
+ select {
+ case <-cancel:
+ return // expected — relay was stopped
+ default:
+ }
+ }
+ // PTY closed — session exited
+ mu.Lock()
+ sessionName := activeSession
+ mu.Unlock()
+ if sessionName != "" {
+ conn.WriteMessage(websocket.TextMessage, MarshalExited(sessionName))
+ }
+ return
+ }
+ if err := conn.WriteMessage(websocket.BinaryMessage, buf[:n]); err != nil {
+ return // WS write failed — client disconnected
+ }
+ }
+ }()
+ }
+
+ defer func() {
+ mu.Lock()
+ stopRelay()
+ mu.Unlock()
+ }()
+
+ // Main read loop — handles text (control) and binary (input) frames
+ for {
+ msgType, data, err := conn.ReadMessage()
+ if err != nil {
+ break // client disconnected
+ }
+
+ switch msgType {
+ case websocket.TextMessage:
+ kind, val := ParseControlMessage(data)
+ switch kind {
+ case "attach":
+ msg := val.(AttachMsg)
+ mu.Lock()
+ // Stop existing relay
+ stopRelay()
+
+ s, err := pool.Attach(msg.Attach, 80, 24) // default size, will resize
+ if err != nil {
+ mu.Unlock()
+ log.Printf("muxterm: attach %q failed: %v", msg.Attach, err)
+ conn.WriteMessage(websocket.TextMessage,
+ MarshalError(fmt.Sprintf("session not found: %s", msg.Attach)))
+ continue
+ }
+
+ activeSession = msg.Attach
+ activePTY = s
+ ptyCancel = make(chan struct{})
+
+ // SIGWINCH to trigger tmux repaint
+ sendSIGWINCH(s)
+
+ startRelay(s, ptyCancel)
+ mu.Unlock()
+
+ conn.WriteMessage(websocket.TextMessage, MarshalAttached(msg.Attach))
+ log.Printf("muxterm: attached to %q", msg.Attach)
+
+ case "resize":
+ msg := val.(ResizeMsg)
+ mu.Lock()
+ if activeSession != "" {
+ pool.Resize(activeSession, uint16(msg.Resize.Cols), uint16(msg.Resize.Rows))
+ }
+ mu.Unlock()
+
+ case "detach":
+ mu.Lock()
+ stopRelay()
+ activeSession = ""
+ activePTY = nil
+ mu.Unlock()
+
+ default:
+ log.Printf("muxterm: unknown control message: %s", string(data))
+ }
+
+ case websocket.BinaryMessage:
+ // Raw terminal input — write to active PTY
+ mu.Lock()
+ if activePTY != nil {
+ activePTY.PTY.Write(data)
+ }
+ mu.Unlock()
+ }
+ }
+}
+
+// sendSIGWINCH sends SIGWINCH to the tmux attach process to trigger a repaint.
+func sendSIGWINCH(s *Session) {
+ if s.Cmd.Process != nil {
+ _ = s.Cmd.Process.Signal(syscall.SIGWINCH)
+ }
+}
+```
+
+Note: Add the missing `syscall` import to the import block:
+
+```go
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "strconv"
+ "strings"
+ "sync"
+ "syscall"
+ "time"
+
+ "github.com/gorilla/websocket"
+)
+```
+
+**Step 2: Build the full binary**
+
+Run:
+```bash
+cd muxterm && go build -o muxterm .
+```
+Expected: Binary compiles successfully. `muxterm/muxterm` binary created.
+
+**Step 3: Quick smoke test**
+
+Run:
+```bash
+cd muxterm && MUXTERM_SECRET=test123 ./muxterm &
+sleep 1
+curl -s http://127.0.0.1:7682/health
+kill %1
+```
+Expected: `{"status":"ok"}`
+
+**Step 4: Add muxterm binary to .gitignore**
+
+Add `muxterm/muxterm` to the project `.gitignore` (if one exists) or create `muxterm/.gitignore`:
+```
+muxterm
+```
+
+**Step 5: Commit**
+```bash
+git add muxterm/ws.go muxterm/.gitignore && git commit -m "feat(muxterm): WebSocket server with HMAC token auth and PTY relay"
+```
+
+---
+
+### Task 5: Go Tests — Pool, Protocol, Token Validation
+
+**Files:**
+- Create: `muxterm/pool_test.go`
+- Create: `muxterm/protocol_test.go`
+- Create: `muxterm/ws_test.go`
+
+**Step 1: Write protocol tests**
+
+Create `muxterm/protocol_test.go`:
+
+```go
+package main
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+func TestParseAttachMessage(t *testing.T) {
+ data := []byte(`{"attach": "dev-server"}`)
+ kind, val := ParseControlMessage(data)
+ if kind != "attach" {
+ t.Fatalf("expected attach, got %s", kind)
+ }
+ msg := val.(AttachMsg)
+ if msg.Attach != "dev-server" {
+ t.Fatalf("expected dev-server, got %s", msg.Attach)
+ }
+}
+
+func TestParseResizeMessage(t *testing.T) {
+ data := []byte(`{"resize": {"cols": 120, "rows": 40}}`)
+ kind, val := ParseControlMessage(data)
+ if kind != "resize" {
+ t.Fatalf("expected resize, got %s", kind)
+ }
+ msg := val.(ResizeMsg)
+ if msg.Resize.Cols != 120 || msg.Resize.Rows != 40 {
+ t.Fatalf("unexpected size: %+v", msg.Resize)
+ }
+}
+
+func TestParseDetachMessage(t *testing.T) {
+ data := []byte(`{"detach": true}`)
+ kind, val := ParseControlMessage(data)
+ if kind != "detach" {
+ t.Fatalf("expected detach, got %s", kind)
+ }
+ msg := val.(DetachMsg)
+ if !msg.Detach {
+ t.Fatal("expected detach=true")
+ }
+}
+
+func TestParseInvalidJSON(t *testing.T) {
+ data := []byte(`not json`)
+ kind, _ := ParseControlMessage(data)
+ if kind != "invalid" {
+ t.Fatalf("expected invalid, got %s", kind)
+ }
+}
+
+func TestParseUnknownMessage(t *testing.T) {
+ data := []byte(`{"foo": "bar"}`)
+ kind, _ := ParseControlMessage(data)
+ if kind != "unknown" {
+ t.Fatalf("expected unknown, got %s", kind)
+ }
+}
+
+func TestMarshalAttached(t *testing.T) {
+ b := MarshalAttached("dev-server")
+ var msg AttachedMsg
+ json.Unmarshal(b, &msg)
+ if msg.Attached != "dev-server" {
+ t.Fatalf("expected dev-server, got %s", msg.Attached)
+ }
+}
+
+func TestMarshalError(t *testing.T) {
+ b := MarshalError("session not found")
+ var msg ErrorMsg
+ json.Unmarshal(b, &msg)
+ if msg.Error != "session not found" {
+ t.Fatalf("expected 'session not found', got %s", msg.Error)
+ }
+}
+
+func TestMarshalExited(t *testing.T) {
+ b := MarshalExited("dev-server")
+ var msg ExitedMsg
+ json.Unmarshal(b, &msg)
+ if msg.Exited != "dev-server" {
+ t.Fatalf("expected dev-server, got %s", msg.Exited)
+ }
+}
+```
+
+**Step 2: Write token validation tests**
+
+Create `muxterm/ws_test.go`:
+
+```go
+package main
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "testing"
+ "time"
+)
+
+func makeToken(secret string, ts int64) string {
+ mac := hmac.New(sha256.New, []byte(secret))
+ tsStr := fmt.Sprintf("%d", ts)
+ mac.Write([]byte(tsStr))
+ sig := hex.EncodeToString(mac.Sum(nil))
+ return sig + "." + tsStr
+}
+
+func TestValidateToken_Valid(t *testing.T) {
+ secret := "test-secret-key"
+ token := makeToken(secret, time.Now().Unix())
+ if !ValidateToken(token, secret, 30) {
+ t.Fatal("expected valid token")
+ }
+}
+
+func TestValidateToken_Expired(t *testing.T) {
+ secret := "test-secret-key"
+ token := makeToken(secret, time.Now().Unix()-60) // 60s ago
+ if ValidateToken(token, secret, 30) {
+ t.Fatal("expected expired token to be rejected")
+ }
+}
+
+func TestValidateToken_WrongSecret(t *testing.T) {
+ token := makeToken("correct-secret", time.Now().Unix())
+ if ValidateToken(token, "wrong-secret", 30) {
+ t.Fatal("expected wrong secret to be rejected")
+ }
+}
+
+func TestValidateToken_MalformedNoTimestamp(t *testing.T) {
+ if ValidateToken("justasignature", "secret", 30) {
+ t.Fatal("expected malformed token to be rejected")
+ }
+}
+
+func TestValidateToken_EmptyToken(t *testing.T) {
+ if ValidateToken("", "secret", 30) {
+ t.Fatal("expected empty token to be rejected")
+ }
+}
+```
+
+**Step 3: Write pool tests**
+
+Create `muxterm/pool_test.go`:
+
+```go
+package main
+
+import (
+ "testing"
+)
+
+func TestNewPool(t *testing.T) {
+ p := NewPool()
+ if p == nil {
+ t.Fatal("expected non-nil pool")
+ }
+}
+
+func TestPool_Get_NoSession(t *testing.T) {
+ p := NewPool()
+ s := p.Get("nonexistent")
+ if s != nil {
+ t.Fatal("expected nil for nonexistent session")
+ }
+}
+
+func TestPool_IsAlive_NoSession(t *testing.T) {
+ p := NewPool()
+ if p.IsAlive("nonexistent") {
+ t.Fatal("expected false for nonexistent session")
+ }
+}
+
+func TestPool_CloseAll_Empty(t *testing.T) {
+ p := NewPool()
+ p.CloseAll() // should not panic
+}
+
+// Integration test: requires tmux to be running with a session.
+// Skipped in CI — run manually with: go test -run TestPool_Attach_Real -count=1
+func TestPool_Attach_Real(t *testing.T) {
+ // Skip if no tmux sessions exist
+ if testing.Short() {
+ t.Skip("skipping integration test in short mode")
+ }
+
+ p := NewPool()
+ defer p.CloseAll()
+
+ // This test requires a tmux session named "test-muxterm" to exist.
+ // Create it with: tmux new-session -d -s test-muxterm
+ s, err := p.Attach("test-muxterm", 80, 24)
+ if err != nil {
+ t.Skipf("tmux session 'test-muxterm' not found (create with: tmux new-session -d -s test-muxterm): %v", err)
+ }
+
+ if s.PTY == nil {
+ t.Fatal("expected non-nil PTY")
+ }
+
+ if !p.IsAlive("test-muxterm") {
+ t.Fatal("expected session to be alive")
+ }
+
+ // Test resize
+ err = p.Resize("test-muxterm", 120, 40)
+ if err != nil {
+ t.Fatalf("resize failed: %v", err)
+ }
+}
+```
+
+**Step 4: Run the tests**
+
+Run:
+```bash
+cd muxterm && go test -short -v ./...
+```
+Expected: All protocol and token tests pass. Pool integration test skipped in short mode.
+
+**Step 5: Commit**
+```bash
+git add muxterm/*_test.go && git commit -m "test(muxterm): unit tests for protocol, token validation, and pool"
+```
+
+---
+
+## Group B — Python Integration
+
+### Task 6: Token Endpoint — `GET /api/terminal-token`
+
+**Files:**
+- Modify: `muxplex/main.py`
+
+**Step 1: Write the failing test**
+
+Add to `muxplex/tests/test_api.py` (at the end of the file, before any final newline):
+
+```python
+# ---------------------------------------------------------------------------
+# GET /api/terminal-token — muxterm auth token endpoint
+# ---------------------------------------------------------------------------
+
+
+def test_terminal_token_returns_200(client):
+ """GET /api/terminal-token returns 200 with a token string."""
+ response = client.get("/api/terminal-token")
+ assert response.status_code == 200
+ data = response.json()
+ assert "token" in data
+ assert isinstance(data["token"], str)
+ assert "." in data["token"] # format: hex_signature.timestamp
+
+
+def test_terminal_token_contains_port(client):
+ """GET /api/terminal-token returns the muxterm port."""
+ response = client.get("/api/terminal-token")
+ assert response.status_code == 200
+ data = response.json()
+ assert "port" in data
+ assert isinstance(data["port"], int)
+
+
+def test_terminal_token_is_unique_per_call(client):
+ """Each call to GET /api/terminal-token returns a different token (time-based)."""
+ r1 = client.get("/api/terminal-token")
+ # Tokens include a timestamp, so even rapid calls may produce the same ts.
+ # At minimum, the response must be valid JSON with a token field.
+ assert r1.status_code == 200
+ assert "token" in r1.json()
+```
+
+**Step 2: Run test to verify it fails**
+
+Run:
+```bash
+.venv/bin/python -m pytest muxplex/tests/test_api.py::test_terminal_token_returns_200 -x -q --timeout=30
+```
+Expected: FAIL — 404 (endpoint doesn't exist yet).
+
+**Step 3: Implement the endpoint**
+
+In `muxplex/main.py`, add the following. First, add a module-level constant for the muxterm shared secret and port near the other configuration constants (around line 96, after `SERVER_PORT`):
+
+```python
+MUXTERM_PORT: int = int(os.environ.get("MUXTERM_PORT", "7682"))
+_muxterm_secret: str = os.environ.get("MUXTERM_SECRET", "")
+```
+
+Then add a helper function and the endpoint. Place the endpoint near the other `/api/` endpoints (after `connect_session` around line 747):
+
+```python
+def _generate_muxterm_token() -> str:
+ """Generate a short-lived HMAC token for muxterm WebSocket auth.
+
+ Format: hex(hmac-sha256(secret, timestamp)) + "." + timestamp
+ Token is valid for 30 seconds.
+ """
+ import hashlib
+ ts = str(int(time.time()))
+ sig = hmac.new(
+ _muxterm_secret.encode(),
+ ts.encode(),
+ hashlib.sha256,
+ ).hexdigest()
+ return f"{sig}.{ts}"
+
+
+@app.get("/api/terminal-token")
+async def get_terminal_token() -> dict:
+ """Return a short-lived HMAC token for direct WebSocket auth with muxterm.
+
+ The browser uses this token to connect directly to muxterm's WebSocket.
+ Token is valid for 30 seconds. Auth is handled by the middleware —
+ if this endpoint is reached, the caller is already authenticated.
+
+ Returns {token: str, port: int}.
+ """
+ if not _muxterm_secret:
+ raise HTTPException(status_code=503, detail="muxterm not configured")
+ return {"token": _generate_muxterm_token(), "port": MUXTERM_PORT}
+```
+
+**Important:** The `_generate_muxterm_token` function uses `hmac.new` — but Python's module is `hmac` (already imported at line 13 of main.py). The function name is `hmac.new()`. Double-check: it's actually `hmac.new()` (lowercase) in Python's stdlib.
+
+**Step 4: Run test to verify it passes**
+
+Run:
+```bash
+MUXTERM_SECRET=test-secret-for-tests .venv/bin/python -m pytest muxplex/tests/test_api.py::test_terminal_token_returns_200 muxplex/tests/test_api.py::test_terminal_token_contains_port muxplex/tests/test_api.py::test_terminal_token_is_unique_per_call -x -q --timeout=30
+```
+Expected: All 3 pass.
+
+**Note:** You may need to set `MUXTERM_SECRET` in the test fixture or monkeypatch `_muxterm_secret` on `muxplex.main`. Check how the existing `patch_startup_and_state` autouse fixture works in `test_api.py` and add a `monkeypatch.setattr("muxplex.main._muxterm_secret", "test-secret")` there, or define a dedicated fixture for these tests.
+
+**Step 5: Commit**
+```bash
+git add muxplex/main.py muxplex/tests/test_api.py && git commit -m "feat: GET /api/terminal-token endpoint for muxterm HMAC auth"
+```
+
+---
+
+### Task 7: muxterm Process Supervision
+
+**Files:**
+- Create: `muxplex/muxterm.py`
+- Modify: `muxplex/main.py` (lifespan function, lines 371-422)
+
+**Step 1: Write the failing test**
+
+Create `muxplex/tests/test_muxterm.py`:
+
+```python
+"""Tests for muxplex/muxterm.py — muxterm process supervision."""
+
+import asyncio
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from muxplex.muxterm import start_muxterm, stop_muxterm, _muxterm_process
+
+
+@pytest.fixture(autouse=True)
+def reset_muxterm_state():
+ """Reset module state between tests."""
+ import muxplex.muxterm as mod
+ mod._muxterm_process = None
+ yield
+ mod._muxterm_process = None
+
+
+@pytest.mark.asyncio
+async def test_start_muxterm_spawns_process(monkeypatch):
+ """start_muxterm calls create_subprocess_exec with correct args."""
+ mock_proc = MagicMock()
+ mock_proc.pid = 99999
+ mock_proc.returncode = None
+
+ mock_create = AsyncMock(return_value=mock_proc)
+ monkeypatch.setattr("asyncio.create_subprocess_exec", mock_create)
+
+ await start_muxterm(secret="test-secret", port=7682, binary_path="/usr/local/bin/muxterm")
+
+ mock_create.assert_called_once()
+ args = mock_create.call_args
+ # First positional arg should be the binary path
+ assert args[0][0] == "/usr/local/bin/muxterm"
+
+
+@pytest.mark.asyncio
+async def test_stop_muxterm_terminates_process(monkeypatch):
+ """stop_muxterm sends SIGTERM and waits."""
+ import muxplex.muxterm as mod
+
+ mock_proc = MagicMock()
+ mock_proc.returncode = None
+ mock_proc.terminate = MagicMock()
+ mock_proc.wait = AsyncMock(return_value=0)
+ mod._muxterm_process = mock_proc
+
+ await stop_muxterm()
+
+ mock_proc.terminate.assert_called_once()
+ assert mod._muxterm_process is None
+```
+
+**Step 2: Run test to verify it fails**
+
+Run:
+```bash
+.venv/bin/python -m pytest muxplex/tests/test_muxterm.py -x -q --timeout=30
+```
+Expected: FAIL — `ModuleNotFoundError: No module named 'muxplex.muxterm'`
+
+**Step 3: Implement muxterm.py**
+
+Create `muxplex/muxterm.py`:
+
+```python
+"""muxterm process supervision — start, monitor, restart, stop.
+
+Python starts the muxterm Go binary as a managed subprocess.
+If muxterm crashes, Python restarts it automatically.
+On shutdown, Python sends SIGTERM for graceful cleanup.
+"""
+
+import asyncio
+import logging
+import shutil
+
+_log = logging.getLogger(__name__)
+
+_muxterm_process: asyncio.subprocess.Process | None = None
+_restart_task: asyncio.Task | None = None
+
+
+def _find_muxterm_binary(binary_path: str | None = None) -> str:
+ """Locate the muxterm binary.
+
+ Priority:
+ 1. Explicit binary_path argument
+ 2. ``muxterm`` on PATH (shutil.which)
+
+ Raises FileNotFoundError if not found.
+ """
+ if binary_path:
+ return binary_path
+ found = shutil.which("muxterm")
+ if found:
+ return found
+ raise FileNotFoundError(
+ "muxterm binary not found. Build it with: cd muxterm && go build -o muxterm ."
+ )
+
+
+async def start_muxterm(
+ secret: str,
+ port: int = 7682,
+ binary_path: str | None = None,
+ auto_restart: bool = True,
+) -> None:
+ """Start the muxterm process.
+
+ Args:
+ secret: HMAC shared secret for token validation.
+ port: TCP port for muxterm to listen on.
+ binary_path: Explicit path to the muxterm binary. If None, searches PATH.
+ auto_restart: If True, restart muxterm automatically on crash.
+ """
+ global _muxterm_process, _restart_task
+
+ binary = _find_muxterm_binary(binary_path)
+
+ _muxterm_process = await asyncio.create_subprocess_exec(
+ binary,
+ "--addr", f"127.0.0.1:{port}",
+ "--secret", secret,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ )
+ _log.info("muxterm started (pid=%d, port=%d)", _muxterm_process.pid, port)
+
+ if auto_restart:
+ _restart_task = asyncio.create_task(
+ _monitor_and_restart(secret, port, binary_path)
+ )
+
+
+async def _monitor_and_restart(
+ secret: str,
+ port: int,
+ binary_path: str | None,
+) -> None:
+ """Wait for muxterm to exit and restart it if it crashed."""
+ global _muxterm_process
+
+ while True:
+ if _muxterm_process is None:
+ return
+
+ returncode = await _muxterm_process.wait()
+
+ if returncode == 0:
+ _log.info("muxterm exited cleanly")
+ _muxterm_process = None
+ return
+
+ _log.warning("muxterm crashed (rc=%d), restarting in 1s", returncode)
+ await asyncio.sleep(1)
+
+ try:
+ await start_muxterm(
+ secret=secret,
+ port=port,
+ binary_path=binary_path,
+ auto_restart=False, # this task IS the restarter
+ )
+ except Exception as exc:
+ _log.error("muxterm restart failed: %s", exc)
+ await asyncio.sleep(5) # back off on repeated failures
+
+
+async def stop_muxterm() -> None:
+ """Stop the muxterm process gracefully."""
+ global _muxterm_process, _restart_task
+
+ if _restart_task is not None:
+ _restart_task.cancel()
+ try:
+ await _restart_task
+ except (asyncio.CancelledError, Exception):
+ pass
+ _restart_task = None
+
+ if _muxterm_process is not None:
+ _log.info("stopping muxterm (pid=%d)", _muxterm_process.pid)
+ try:
+ _muxterm_process.terminate()
+ try:
+ await asyncio.wait_for(_muxterm_process.wait(), timeout=5.0)
+ except asyncio.TimeoutError:
+ _muxterm_process.kill()
+ except ProcessLookupError:
+ pass
+ _muxterm_process = None
+```
+
+**Step 4: Run tests to verify they pass**
+
+Run:
+```bash
+.venv/bin/python -m pytest muxplex/tests/test_muxterm.py -x -q --timeout=30
+```
+Expected: All pass.
+
+**Step 5: Commit**
+```bash
+git add muxplex/muxterm.py muxplex/tests/test_muxterm.py && git commit -m "feat: muxterm process supervision (start, restart on crash, stop)"
+```
+
+---
+
+### Task 8: Wire muxterm into FastAPI Lifespan
+
+**Files:**
+- Modify: `muxplex/main.py` (lifespan function at line 372, imports at line 81)
+
+**Step 1: Update imports in main.py**
+
+Replace the ttyd import block (lines 81-90) with the muxterm import:
+
+```python
+from muxplex.muxterm import start_muxterm, stop_muxterm
+```
+
+Remove these lines entirely:
+```python
+from muxplex.ttyd import (
+ TTYD_PORT,
+ _ttyd_is_listening,
+ get_or_spawn,
+ kill_orphan_ttyd,
+ kill_session,
+ kill_ttyd, # noqa: F401 — backward compat re-export used by tests
+ pool_port,
+ spawn_ttyd, # noqa: F401 — backward compat re-export used by tests
+)
+```
+
+**Step 2: Update the lifespan function**
+
+Replace the startup section of the `lifespan` function (line 372). Change:
+```python
+ # Startup: kill any orphaned ttyd from a previous muxplex run, then
+ # start the background poll loop.
+ await kill_orphan_ttyd()
+```
+
+To:
+```python
+ # Startup: start muxterm Go binary, then start the background poll loop.
+ if _muxterm_secret:
+ try:
+ await start_muxterm(secret=_muxterm_secret, port=MUXTERM_PORT)
+ except FileNotFoundError:
+ _log.warning("muxterm binary not found — terminal features disabled")
+ except Exception as exc:
+ _log.warning("failed to start muxterm: %s", exc)
+```
+
+Add cleanup in the shutdown section (after the poll task cancellation, before the end of the `finally` block):
+```python
+ # Stop muxterm
+ await stop_muxterm()
+```
+
+**Step 3: Generate the muxterm secret on startup if not set**
+
+Update the `_muxterm_secret` initialization (from Task 6) to auto-generate a secret if not provided via env var:
+
+```python
+import secrets as _secrets_mod
+
+MUXTERM_PORT: int = int(os.environ.get("MUXTERM_PORT", "7682"))
+_muxterm_secret: str = os.environ.get("MUXTERM_SECRET", "") or _secrets_mod.token_hex(32)
+```
+
+This ensures muxterm always has a shared secret — either from the environment or auto-generated.
+
+**Step 4: Run the existing test suite to check for breakage**
+
+Run:
+```bash
+.venv/bin/python -m pytest muxplex/tests/test_api.py -x -q --timeout=30 -k "not connect_session and not terminal_ws and not ws_proxy"
+```
+Expected: Tests that don't depend on ttyd imports pass. Tests that reference the deleted `connect_session` or WS proxy will fail — that's expected and handled in Task 10.
+
+**Step 5: Commit**
+```bash
+git add muxplex/main.py && git commit -m "feat: wire muxterm into FastAPI lifespan (start on boot, stop on shutdown)"
+```
+
+---
+
+### Task 9: Delete Old Code — ttyd.py, WS Proxy, Connect Endpoint
+
+**Files:**
+- Delete: `muxplex/ttyd.py`
+- Delete: `muxplex/tests/test_ttyd.py`
+- Delete: `muxplex/tests/test_ws_proxy.py`
+- Modify: `muxplex/main.py` (remove `connect_session`, `terminal_ws_proxy`, `_ws_auth_check`, `delete_current_session`'s `kill_session` call, update `delete_session`'s cleanup call)
+
+**Step 1: Delete ttyd.py and its tests**
+
+Run:
+```bash
+git rm muxplex/ttyd.py muxplex/tests/test_ttyd.py muxplex/tests/test_ws_proxy.py
+```
+
+**Step 2: Remove the WS proxy from main.py**
+
+Delete the following sections from `muxplex/main.py`:
+
+1. The `_ws_auth_check` function (lines 1002-1025)
+2. The `terminal_ws_proxy` function and its `@app.websocket("/terminal/ws")` decorator (lines 1028-1112)
+3. The comment block above them: `# WebSocket proxy — bridges browser to ttyd` (line 995) and `# _ttyd_is_listening is imported from muxplex.ttyd` (line 999)
+4. The `websockets` import used by the proxy — find `import websockets` and `from websockets import Subprotocol` and remove them if they're only used by the proxy
+
+**Step 3: Remove the connect_session endpoint**
+
+Delete the `connect_session` function (lines 723-746):
+```python
+@app.post("/api/sessions/{name}/connect")
+async def connect_session(name: str) -> dict:
+ ...
+```
+
+**Step 4: Update delete_current_session**
+
+The `delete_current_session` endpoint (line 749) calls `await kill_session(active)` which comes from `ttyd.py`. Remove that call — muxterm handles PTY lifecycle. Change it to just clear the state:
+
+```python
+@app.delete("/api/sessions/current")
+async def delete_current_session() -> dict:
+ """Clear the active session in persistent state.
+
+ Returns {active_session: None}.
+ """
+ async with state_lock:
+ state = load_state()
+ state["active_session"] = None
+ save_state(state)
+
+ return {"active_session": None}
+```
+
+**Step 5: Update delete_session**
+
+The `delete_session` endpoint (around line 770) calls `await kill_session(name)` for pool cleanup. Remove that call — muxterm detects when tmux sessions die via process exit.
+
+Remove the line:
+```python
+ await kill_session(name)
+```
+
+And remove the comment above it:
+```python
+ # Clean up the pool entry (ttyd will die when tmux session dies,
+ # but clean up immediately so the port is freed).
+```
+
+**Step 6: Remove tests for deleted endpoints**
+
+In `muxplex/tests/test_api.py`, delete or comment out:
+- `test_connect_session_returns_200` (line 370)
+- `test_connect_session_sets_active_session` (line 386)
+- `test_connect_session_calls_get_or_spawn` (line 403)
+- `test_connect_nonexistent_session_returns_404` (line 420)
+- `test_terminal_ws_route_exists` (line 809)
+- `test_connect_session_logs_session_name` (line 2473)
+
+**Step 7: Run the test suite**
+
+Run:
+```bash
+.venv/bin/python -m pytest muxplex/tests/test_api.py -x -q --timeout=30
+```
+Expected: Passes (minus the deleted tests). Any remaining import errors for `ttyd` indicate missed references — fix them.
+
+**Step 8: Commit**
+```bash
+git add -A && git commit -m "refactor: delete ttyd.py, WS proxy, connect endpoint — muxterm owns terminal path"
+```
+
+---
+
+### Task 10: Fix Remaining ttyd References
+
+**Files:**
+- Modify: various files that may still reference ttyd imports
+
+**Step 1: Search for remaining ttyd references**
+
+Run:
+```bash
+grep -rn "ttyd\|kill_session\|get_or_spawn\|pool_port\|spawn_ttyd\|kill_ttyd\|TTYD_PORT\|_ttyd_is_listening" muxplex/ --include="*.py" | grep -v __pycache__ | grep -v test_ttyd | grep -v test_ws_proxy
+```
+
+**Step 2: Fix each remaining reference**
+
+For each hit:
+- If it's a comment referencing ttyd in documentation, update the comment to reference muxterm
+- If it's an import, remove it
+- If it's a function call, remove it or replace with the muxterm equivalent
+- The `delete_current_session` endpoint no longer needs `kill_session` (handled in Task 9)
+- The federation proxy at line ~1503 calls connect on remote instances — this still makes sense (remote instances may still use ttyd), so leave federation proxy code as-is if it's calling a remote `/connect` endpoint
+
+**Step 3: Run the full test suite**
+
+Run:
+```bash
+.venv/bin/python -m pytest muxplex/tests/ -x -q --timeout=30
+```
+Expected: All remaining tests pass. Fix any failures.
+
+**Step 4: Commit**
+```bash
+git add -A && git commit -m "fix: remove remaining ttyd references from Python codebase"
+```
+
+---
+
+## Group C — Frontend Rewrite
+
+### Task 11: Rewrite terminal.js — Single WebSocket, Control Protocol
+
+**Files:**
+- Rewrite: `muxplex/frontend/terminal.js`
+
+This is the biggest task. The entire file (~1091 lines) gets rewritten to ~300 lines. The new version:
+- Maintains ONE WebSocket connection to muxterm (not one per session)
+- Maintains ONE terminal instance (not cached per session)
+- Uses binary frames for raw I/O (no `0x30`/`0x31` prefix encoding)
+- Uses text frames for JSON control messages (attach, resize, detach)
+- No terminal cache, no `_terminalCache`, no `_cacheOrder`, no `switchTerminal`
+
+**Step 1: Write the new terminal.js**
+
+Rewrite `muxplex/frontend/terminal.js` with:
+
+```javascript
+// terminal.js — muxterm WebSocket client
+// Single WebSocket to muxterm, single terminal instance, binary/text frame protocol.
+
+// ——— Module-level state ———————————————————————————————————————
+let _term = null;
+let _fitAddon = null;
+let _ws = null;
+let _reconnectTimer = null;
+let _overlayTimer = null;
+let _currentSession = null;
+let _vpHandler = null;
+let _reconnectAttempts = 0;
+let _searchAddon = null;
+let _resizeObserver = null;
+let _ctrlActive = false;
+let _altActive = false;
+let _muxtermPort = null;
+let _muxtermToken = null;
+
+// ——— Encoding helpers ————————————————————————————————————————
+const _encoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null;
+const _decoder = typeof TextDecoder !== 'undefined' ? new TextDecoder() : null;
+
+// ——— Clipboard helpers ——————————————————————————————————————
+function _copyToClipboard(text) {
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard.writeText(text).catch(function() {});
+ } else {
+ var ta = document.createElement('textarea');
+ ta.value = text;
+ ta.style.position = 'fixed';
+ ta.style.left = '-9999px';
+ document.body.appendChild(ta);
+ ta.select();
+ try { document.execCommand('copy'); } catch(e) {}
+ document.body.removeChild(ta);
+ }
+}
+
+// ——— WebSocket connection ————————————————————————————————————
+
+function connectWebSocket() {
+ // Fetch auth token from Python, then connect directly to muxterm
+ fetch('/api/terminal-token')
+ .then(function(r) { return r.json(); })
+ .then(function(data) {
+ _muxtermPort = data.port;
+ _muxtermToken = data.token;
+ _openMuxtermSocket();
+ })
+ .catch(function(err) {
+ console.warn('tmux-web: failed to get terminal token:', err);
+ _scheduleReconnect();
+ });
+}
+
+function _openMuxtermSocket() {
+ var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
+ var host = location.hostname;
+ var url = proto + '//' + host + ':' + _muxtermPort + '/ws?token=' + encodeURIComponent(_muxtermToken);
+
+ var ws = new WebSocket(url);
+ _ws = ws;
+ ws.binaryType = 'arraybuffer';
+ var reconnectOverlay = document.getElementById('reconnect-overlay');
+
+ ws.addEventListener('open', function() {
+ if (ws !== _ws) return;
+ _reconnectAttempts = 0;
+ if (_overlayTimer) { clearTimeout(_overlayTimer); _overlayTimer = null; }
+ if (reconnectOverlay) reconnectOverlay.classList.add('hidden');
+
+ // If we have a session to attach to, send attach message
+ if (_currentSession) {
+ ws.send(JSON.stringify({ attach: _currentSession }));
+ }
+
+ if (_term) _term.focus();
+ });
+
+ ws.addEventListener('message', function(e) {
+ if (ws !== _ws) return;
+ if (!_term) return;
+
+ if (e.data instanceof ArrayBuffer) {
+ // Binary frame = raw terminal output from muxterm
+ var bytes = new Uint8Array(e.data);
+ if (bytes.length > 0) {
+ _term.write(_decoder ? _decoder.decode(bytes) : bytes);
+ }
+ } else if (typeof e.data === 'string') {
+ // Text frame = JSON control message from muxterm
+ try {
+ var msg = JSON.parse(e.data);
+ if (msg.attached) {
+ // Session switch confirmed
+ _currentSession = msg.attached;
+ } else if (msg.error) {
+ console.warn('muxterm error:', msg.error);
+ if (typeof showToast === 'function') showToast(msg.error);
+ } else if (msg.exited) {
+ console.info('muxterm: session exited:', msg.exited);
+ // Return to dashboard if the active session exited
+ if (msg.exited === _currentSession && typeof closeSession === 'function') {
+ closeSession();
+ }
+ }
+ } catch (err) {
+ // Not JSON — write as text (shouldn't happen)
+ _term.write(e.data);
+ }
+ }
+ });
+
+ ws.addEventListener('close', function() {
+ if (ws !== _ws) return;
+ if (!_currentSession) return; // intentional close
+ _scheduleReconnect();
+ });
+
+ ws.addEventListener('error', function() {
+ if (ws !== _ws) return;
+ console.warn('tmux-web: WebSocket error');
+ });
+}
+
+function _scheduleReconnect() {
+ var reconnectOverlay = document.getElementById('reconnect-overlay');
+ if (_overlayTimer) clearTimeout(_overlayTimer);
+ _overlayTimer = setTimeout(function() {
+ if (reconnectOverlay && _currentSession) reconnectOverlay.classList.remove('hidden');
+ _overlayTimer = null;
+ }, 1500);
+
+ _reconnectAttempts++;
+ var delay = Math.min(1000 * Math.pow(2, _reconnectAttempts - 1), 15000);
+ delay += Math.random() * 500;
+ _reconnectTimer = setTimeout(connectWebSocket, delay);
+}
+
+// ——— Visual viewport (mobile keyboard) ——————————————————————
+function initVisualViewport() {
+ if (!window.visualViewport) return;
+ if (_vpHandler) window.visualViewport.removeEventListener('resize', _vpHandler);
+
+ _vpHandler = function() {
+ if (!_term || !_fitAddon) return;
+ var container = document.getElementById('terminal-container');
+ if (!container) return;
+ var headerHeight = 44;
+ var toolbar = document.getElementById('mobile-toolbar');
+ var toolbarHeight = (toolbar && !toolbar.classList.contains('hidden')) ? toolbar.offsetHeight : 0;
+ var vvh = window.visualViewport.height;
+ var termHeight = Math.max(100, vvh - headerHeight - toolbarHeight);
+ container.style.height = termHeight + 'px';
+ window.scrollTo(0, 0);
+ try { _fitAddon.fit(); } catch (_) {}
+ };
+ window.visualViewport.addEventListener('resize', _vpHandler);
+}
+
+// ——— Terminal creation ——————————————————————————————————————
+
+function createTerminal(fontSize) {
+ if (_term) {
+ _term.dispose();
+ _term = null;
+ _fitAddon = null;
+ }
+
+ var storedFontSize = (typeof fontSize === 'number' && fontSize > 0) ? fontSize : 14;
+ const mobile = window.innerWidth < 600;
+ const effectiveFontSize = mobile ? Math.min(storedFontSize, 12) : storedFontSize;
+
+ _term = new window.Terminal({
+ cursorBlink: true,
+ fontSize: effectiveFontSize,
+ fontFamily: "'SF Mono', 'Fira Code', Consolas, monospace",
+ theme: { background: '#000000', foreground: '#c9d1d9', cursor: '#58a6ff' },
+ scrollback: mobile ? 500 : 5000,
+ allowProposedApi: true,
+ linkHandler: {
+ activate: function(event, uri) { window.open(uri, '_blank'); },
+ },
+ });
+
+ _fitAddon = new window.FitAddon.FitAddon();
+ _term.loadAddon(_fitAddon);
+
+ var WebLinksAddon = window.WebLinksAddon && window.WebLinksAddon.WebLinksAddon;
+ if (WebLinksAddon) {
+ _term.loadAddon(new WebLinksAddon(function(event, uri) { window.open(uri, '_blank'); }));
+ }
+
+ var SearchAddon = window.SearchAddon && window.SearchAddon.SearchAddon;
+ if (SearchAddon) {
+ _searchAddon = new SearchAddon();
+ _term.loadAddon(_searchAddon);
+ }
+
+ var ImageAddon = window.ImageAddon && window.ImageAddon.ImageAddon;
+ if (ImageAddon) {
+ _term.loadAddon(new ImageAddon());
+ }
+}
+
+// ——— Search helpers ——————————————————————————————————————————
+function _openSearch() {
+ var bar = document.getElementById('terminal-search-bar');
+ var input = document.getElementById('terminal-search-input');
+ if (bar) { bar.classList.remove('hidden'); if (input) { input.focus(); input.select(); } }
+}
+function _closeSearch() {
+ var bar = document.getElementById('terminal-search-bar');
+ if (bar) bar.classList.add('hidden');
+ if (_searchAddon) _searchAddon.clearDecorations();
+ if (_term) _term.focus();
+}
+function _searchNext() {
+ var input = document.getElementById('terminal-search-input');
+ if (input && input.value && _searchAddon) _searchAddon.findNext(input.value);
+}
+function _searchPrev() {
+ var input = document.getElementById('terminal-search-input');
+ if (input && input.value && _searchAddon) _searchAddon.findPrevious(input.value);
+}
+
+// ——— Open terminal ——————————————————————————————————————————
+
+function openTerminal(sessionName, remoteId, fontSize) {
+ // For remote sessions, fall back to old behavior (federation proxy).
+ // Phase 2 only handles local sessions via muxterm.
+ // TODO: remote session support via muxterm
+
+ _currentSession = null;
+ _reconnectAttempts = 0;
+
+ if (_reconnectTimer) { clearTimeout(_reconnectTimer); _reconnectTimer = null; }
+
+ // Don't close WS — reuse it. Just send attach for the new session.
+ _currentSession = sessionName;
+
+ const container = document.getElementById('terminal-container');
+ if (!container) { console.warn('[openTerminal] #terminal-container not found'); return; }
+
+ // Create terminal only if we don't have one
+ if (!_term) {
+ createTerminal(fontSize);
+ _term.open(container);
+
+ // Resize observer
+ if (_resizeObserver) { _resizeObserver.disconnect(); _resizeObserver = null; }
+ if (typeof ResizeObserver !== 'undefined') {
+ var _roTimer = null;
+ _resizeObserver = new ResizeObserver(function() {
+ clearTimeout(_roTimer);
+ _roTimer = setTimeout(function() {
+ if (_fitAddon) try { _fitAddon.fit(); } catch (_) {}
+ }, 50);
+ });
+ _resizeObserver.observe(container);
+ }
+
+ // Terminal input → binary frame to muxterm (raw bytes, no prefix)
+ _term.onData(function(data) {
+ if (_ws && _ws.readyState === WebSocket.OPEN) {
+ var outData = data;
+ if (_ctrlActive && data.length === 1) {
+ var code = data.toUpperCase().charCodeAt(0);
+ if (code >= 65 && code <= 90) outData = String.fromCharCode(code - 64);
+ _ctrlActive = false;
+ var cb = document.querySelector('[data-modifier="ctrl"]');
+ if (cb) cb.classList.remove('mobile-toolbar__key--active');
+ } else if (_altActive && data.length === 1) {
+ outData = '\x1b' + data;
+ _altActive = false;
+ var ab = document.querySelector('[data-modifier="alt"]');
+ if (ab) ab.classList.remove('mobile-toolbar__key--active');
+ }
+ // Send as raw binary — no ttyd prefix encoding
+ var bytes = _encoder ? _encoder.encode(outData) : new Uint8Array(Array.from(outData).map(function(c) { return c.charCodeAt(0); }));
+ _ws.send(bytes);
+ }
+ });
+
+ // Terminal resize → JSON control message
+ _term.onResize(function(size) {
+ if (_ws && _ws.readyState === WebSocket.OPEN) {
+ _ws.send(JSON.stringify({ resize: { cols: size.cols, rows: size.rows } }));
+ }
+ });
+
+ // Clipboard: Ctrl+Shift+C, Ctrl+F
+ _term.attachCustomKeyEventHandler(function(e) {
+ if (e.type !== 'keydown') return true;
+ if (e.ctrlKey && e.shiftKey && (e.key === 'C' || e.code === 'KeyC')) {
+ var sel = _term.getSelection(); if (sel) _copyToClipboard(sel); return false;
+ }
+ if (e.ctrlKey && !e.shiftKey && (e.key === 'f' || e.key === 'F' || e.code === 'KeyF')) {
+ _openSearch(); return false;
+ }
+ return true;
+ });
+
+ // Auto-copy on selection
+ _term.onSelectionChange(function() { var sel = _term.getSelection(); if (sel) _copyToClipboard(sel); });
+
+ // OSC 52 clipboard
+ _term.parser.registerOscHandler(52, function(data) {
+ var parts = data.split(';');
+ if (parts.length >= 2) { try { _copyToClipboard(atob(parts[1])); } catch(e) {} }
+ return true;
+ });
+
+ // Right-click context menu suppression
+ container.addEventListener('contextmenu', function(e) {
+ if (e.shiftKey || e.ctrlKey || e.metaKey) return; e.preventDefault();
+ });
+
+ // Search bar wiring
+ _wireSearchBar();
+ }
+
+ // Fit after layout
+ if (_fitAddon) {
+ var fitRef = _fitAddon;
+ var raf = typeof requestAnimationFrame !== 'undefined' ? requestAnimationFrame : function(fn) { fn(); };
+ raf(function() {
+ try { fitRef.fit(); } catch (_) {}
+ setTimeout(function() { if (_fitAddon) try { _fitAddon.fit(); } catch (_) {} }, 500);
+ });
+ }
+
+ // Connect or switch session
+ if (!_ws || _ws.readyState !== WebSocket.OPEN) {
+ connectWebSocket();
+ } else {
+ // WebSocket already open — just send attach for the new session
+ _ws.send(JSON.stringify({ attach: sessionName }));
+ }
+
+ initVisualViewport();
+ _initAndroidIMEFix(container);
+ _initMobileToolbar();
+}
+
+function _wireSearchBar() {
+ var searchInput = document.getElementById('terminal-search-input');
+ var searchClose = document.getElementById('terminal-search-close');
+ var searchNextBtn = document.getElementById('terminal-search-next');
+ var searchPrevBtn = document.getElementById('terminal-search-prev');
+ if (searchInput) {
+ var newInput = searchInput.cloneNode(true);
+ searchInput.parentNode.replaceChild(newInput, searchInput);
+ searchInput = newInput;
+ searchInput.addEventListener('input', function() {
+ if (_searchAddon && searchInput.value) _searchAddon.findNext(searchInput.value);
+ else if (_searchAddon) _searchAddon.clearDecorations();
+ });
+ searchInput.addEventListener('keydown', function(e) {
+ if (e.key === 'Enter') { e.preventDefault(); if (e.shiftKey) _searchPrev(); else _searchNext(); }
+ if (e.key === 'Escape') { e.preventDefault(); _closeSearch(); }
+ });
+ }
+ if (searchClose) { var nc = searchClose.cloneNode(true); searchClose.parentNode.replaceChild(nc, searchClose); nc.addEventListener('click', _closeSearch); }
+ if (searchNextBtn) { var nn = searchNextBtn.cloneNode(true); searchNextBtn.parentNode.replaceChild(nn, searchNextBtn); nn.addEventListener('click', _searchNext); }
+ if (searchPrevBtn) { var np = searchPrevBtn.cloneNode(true); searchPrevBtn.parentNode.replaceChild(np, searchPrevBtn); np.addEventListener('click', _searchPrev); }
+}
+
+// ——— Close terminal ——————————————————————————————————————————
+
+function closeTerminal() {
+ if (_vpHandler) {
+ if (window.visualViewport) window.visualViewport.removeEventListener('resize', _vpHandler);
+ _vpHandler = null;
+ }
+ if (_reconnectTimer) { clearTimeout(_reconnectTimer); _reconnectTimer = null; }
+ if (_overlayTimer) { clearTimeout(_overlayTimer); _overlayTimer = null; }
+
+ // Send detach so muxterm keeps the PTY alive
+ if (_ws && _ws.readyState === WebSocket.OPEN) {
+ _ws.send(JSON.stringify({ detach: true }));
+ }
+
+ if (_ws) { _ws.close(); _ws = null; }
+ if (_resizeObserver) { _resizeObserver.disconnect(); _resizeObserver = null; }
+
+ _ctrlActive = false;
+ _altActive = false;
+ var mobileToolbar = document.getElementById('mobile-toolbar');
+ if (mobileToolbar) mobileToolbar.classList.add('hidden');
+
+ if (_term) { _term.dispose(); _term = null; _fitAddon = null; _searchAddon = null; }
+ _closeSearch();
+ _currentSession = null;
+ _reconnectAttempts = 0;
+}
+
+// ——— Font size ————————————————————————————————————————————————
+function setTerminalFontSize(size) {
+ if (!_term) return;
+ _term.options.fontSize = size;
+ if (_fitAddon) try { _fitAddon.fit(); } catch (_) {}
+}
+
+// ——— Android IME fix —————————————————————————————————————————
+function _initAndroidIMEFix(container) {
+ if (!/Android/i.test(navigator.userAgent)) return;
+ setTimeout(function() {
+ var ta = container.querySelector('.xterm-helper-textarea');
+ if (!ta) return;
+ ta.addEventListener('beforeinput', function(e) {
+ if (e.inputType === 'insertReplacementText') {
+ e.preventDefault(); e.stopImmediatePropagation();
+ var data = e.data || '';
+ if (data && _ws && _ws.readyState === WebSocket.OPEN) {
+ var bytes = _encoder ? _encoder.encode('\x08' + data) : new Uint8Array(0);
+ _ws.send(bytes);
+ }
+ ta.value = '';
+ }
+ }, true);
+ }, 100);
+}
+
+// ——— Mobile toolbar ——————————————————————————————————————————
+function _initMobileToolbar() {
+ var isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
+ if (!isTouchDevice) return;
+ var toolbar = document.getElementById('mobile-toolbar');
+ if (!toolbar) return;
+ toolbar.classList.remove('hidden');
+ _ctrlActive = false; _altActive = false;
+ var ctrlBtn = toolbar.querySelector('[data-modifier="ctrl"]');
+ var altBtn = toolbar.querySelector('[data-modifier="alt"]');
+
+ toolbar.addEventListener('pointerdown', function(e) {
+ var btn = e.target.closest('.mobile-toolbar__key');
+ if (!btn) return;
+ var modifier = btn.dataset.modifier;
+ if (modifier === 'ctrl') {
+ e.preventDefault(); _ctrlActive = !_ctrlActive;
+ btn.classList.toggle('mobile-toolbar__key--active', _ctrlActive);
+ if (_altActive && altBtn) { _altActive = false; altBtn.classList.remove('mobile-toolbar__key--active'); }
+ if (_ctrlActive && _term) _term.focus(); return;
+ }
+ if (modifier === 'alt') {
+ e.preventDefault(); _altActive = !_altActive;
+ btn.classList.toggle('mobile-toolbar__key--active', _altActive);
+ if (_ctrlActive && ctrlBtn) { _ctrlActive = false; ctrlBtn.classList.remove('mobile-toolbar__key--active'); }
+ if (_altActive && _term) _term.focus(); return;
+ }
+ e.preventDefault();
+ var key = btn.dataset.key, input = btn.dataset.input, seq = '';
+ if (key) {
+ switch (key) {
+ case 'Escape': seq = '\x1b'; break; case 'Tab': seq = '\t'; break;
+ case 'ArrowUp': seq = '\x1b[A'; break; case 'ArrowDown': seq = '\x1b[B'; break;
+ case 'ArrowRight': seq = '\x1b[C'; break; case 'ArrowLeft': seq = '\x1b[D'; break;
+ }
+ } else if (input) { seq = input; }
+ if (seq && _ws && _ws.readyState === WebSocket.OPEN) {
+ var bytes = _encoder ? _encoder.encode(seq) : new Uint8Array(0);
+ _ws.send(bytes);
+ }
+ });
+}
+
+// ——— Expose to app.js ————————————————————————————————————————
+window._openTerminal = openTerminal;
+window._switchTerminal = openTerminal; // same function — server-side switching
+window._closeTerminal = closeTerminal;
+window._closeAllTerminals = closeTerminal; // no cache — same as close
+window._destroyCachedTerminal = function() {}; // no-op — no cache
+window._isTerminalCached = function() { return false; }; // no cache
+window._openSearch = _openSearch;
+window._closeSearch = _closeSearch;
+window._setTerminalFontSize = setTerminalFontSize;
+```
+
+**Step 2: Verify the file is syntactically valid**
+
+Run:
+```bash
+node -c muxplex/frontend/terminal.js
+```
+Expected: No syntax errors.
+
+**Step 3: Commit**
+```bash
+git add muxplex/frontend/terminal.js && git commit -m "feat: rewrite terminal.js for muxterm — single WS, control protocol, no cache"
+```
+
+---
+
+### Task 12: Update app.js openSession — Remove /connect POST
+
+**Files:**
+- Modify: `muxplex/frontend/app.js` (openSession at line 2840, closeSession at line 2963)
+
+**Step 1: Update openSession**
+
+In `openSession` (line 2840), replace the block that does the `/connect` POST and cache check (lines 2922-2940) with a simpler version that doesn't POST to `/connect`:
+
+Find this block (approximately lines 2922-2940):
+```javascript
+ // Skip /connect POST if the session is already cached with a live WebSocket —
+ // this is the biggest win, bypassing the entire backend round-trip.
+ var sessionKey = _deviceId ? (_deviceId + ':' + name) : name;
+ var isCached = window._isTerminalCached && window._isTerminalCached(sessionKey);
+
+ if (!isCached) {
+ // Spawn ttyd for this session — ensures correct session after service restart or page restore
+ try {
+ if (_deviceId !== '') {
+ // Remote session: route connect POST through same-origin federation proxy
+ await api('POST', '/api/federation/' + encodeURIComponent(_deviceId) + '/connect/' + encodeURIComponent(name));
+ } else {
+ await api('POST', '/api/sessions/' + encodeURIComponent(name) + '/connect');
+ }
+ } catch (err) {
+ showToast(err.message || 'Connection failed');
+ return closeSession();
+ }
+ }
+```
+
+Replace with:
+```javascript
+ // Remote sessions still need the federation connect POST (remote may use ttyd).
+ // Local sessions don't need /connect — muxterm handles attach via WebSocket.
+ if (_deviceId !== '') {
+ try {
+ await api('POST', '/api/federation/' + encodeURIComponent(_deviceId) + '/connect/' + encodeURIComponent(name));
+ } catch (err) {
+ showToast(err.message || 'Connection failed');
+ return closeSession();
+ }
+ }
+```
+
+**Step 2: Update closeSession**
+
+In `closeSession` (line 2963), remove the `DELETE /api/sessions/current` call since muxterm manages PTY lifecycle. Find:
+```javascript
+ // Fire-and-forget DELETE — skip for remote sessions (they don't need to know we stopped watching)
+ if (_viewingRemoteId === '') {
+ api('DELETE', '/api/sessions/current').catch(function() {});
+ }
+```
+
+Remove those lines entirely — the detach message is already sent by `closeTerminal()` in the new terminal.js.
+
+**Step 3: Verify syntax**
+
+Run:
+```bash
+node -c muxplex/frontend/app.js
+```
+Expected: No syntax errors.
+
+**Step 4: Commit**
+```bash
+git add muxplex/frontend/app.js && git commit -m "feat: update openSession/closeSession — no /connect POST for local sessions"
+```
+
+---
+
+### Task 13: Update Frontend Tests
+
+**Files:**
+- Modify: `muxplex/tests/test_frontend_js.py`
+
+**Step 1: Identify tests that reference deleted code**
+
+Run:
+```bash
+grep -n "_terminalCache\|switchTerminal\|_cacheOrder\|_encodePayload\|0x30\|0x31\|ttyd protocol\|tty.*subprotocol\|/connect" muxplex/tests/test_frontend_js.py
+```
+
+This will find tests that reference the old terminal cache, ttyd protocol encoding, or /connect POST.
+
+**Step 2: Update or remove each failing test**
+
+For each test that references deleted code:
+- If it tests terminal cache behavior (`_terminalCache`, `switchTerminal` cache path, `_cacheOrder`, `destroyCachedEntry`, `_MAX_CACHED`): **delete the test** — no cache exists anymore.
+- If it tests ttyd protocol encoding (`0x30`, `0x31`, `_encodePayload`): **delete the test** — no prefix encoding in muxterm protocol.
+- If it tests the `/connect` POST in openSession: **update** to verify it's NOT called for local sessions (only for remote/federation sessions).
+- If it tests `connectWebSocket` URL construction: **update** to verify the muxterm WebSocket URL pattern (direct to muxterm port, not `/terminal/ws` proxy path).
+
+**Step 3: Add new tests for the muxterm protocol**
+
+Add tests verifying the new terminal.js:
+
+```python
+# ——— terminal.js: muxterm protocol (no ttyd prefix encoding) ———————
+
+def test_terminal_js_no_ttyd_prefix_encoding() -> None:
+ """terminal.js must NOT use ttyd 0x30/0x31 prefix encoding."""
+ assert "0x30" not in _TERMINAL_JS, (
+ "terminal.js must not use 0x30 prefix — muxterm uses raw binary frames"
+ )
+ assert "0x31" not in _TERMINAL_JS, (
+ "terminal.js must not use 0x31 prefix — muxterm uses JSON text frames for resize"
+ )
+
+
+def test_terminal_js_no_terminal_cache() -> None:
+ """terminal.js must not have terminal instance cache."""
+ assert "_terminalCache" not in _TERMINAL_JS, (
+ "terminal.js must not have _terminalCache — muxterm does server-side switching"
+ )
+ assert "_cacheOrder" not in _TERMINAL_JS, (
+ "terminal.js must not have _cacheOrder — no client-side cache"
+ )
+
+
+def test_terminal_js_uses_json_attach() -> None:
+ """terminal.js must send JSON attach messages for session switching."""
+ assert '{ attach:' in _TERMINAL_JS or '"attach"' in _TERMINAL_JS or 'attach:' in _TERMINAL_JS, (
+ "terminal.js must send {attach: sessionName} for session switching"
+ )
+
+
+def test_terminal_js_uses_json_resize() -> None:
+ """terminal.js must send JSON resize messages."""
+ assert 'resize' in _TERMINAL_JS and 'cols' in _TERMINAL_JS and 'rows' in _TERMINAL_JS, (
+ "terminal.js must send {resize: {cols, rows}} for terminal resize"
+ )
+
+
+def test_terminal_js_fetches_terminal_token() -> None:
+ """terminal.js must fetch /api/terminal-token for muxterm auth."""
+ assert "/api/terminal-token" in _TERMINAL_JS, (
+ "terminal.js must fetch /api/terminal-token to get muxterm auth token"
+ )
+```
+
+**Step 4: Run the frontend tests**
+
+Run:
+```bash
+.venv/bin/python -m pytest muxplex/tests/test_frontend_js.py -x -q --timeout=30
+```
+Expected: All pass. Fix any failures from tests that still reference deleted code.
+
+**Step 5: Commit**
+```bash
+git add muxplex/tests/test_frontend_js.py && git commit -m "test: update frontend tests for muxterm protocol, remove cache/ttyd tests"
+```
+
+---
+
+### Task 14: Run Full Test Suite + Fix Remaining Issues
+
+**Files:**
+- Potentially any file with remaining breakage
+
+**Step 1: Run the complete test suite**
+
+Run:
+```bash
+.venv/bin/python -m pytest muxplex/tests/ -x -q --timeout=30
+```
+
+**Step 2: Fix any failures**
+
+Common issues to watch for:
+- Tests importing from `muxplex.ttyd` (deleted) — remove those tests or imports
+- Tests referencing `terminal_ws_proxy` (deleted) — remove
+- Tests referencing `connect_session` endpoint (deleted) — remove
+- Tests that check for `/terminal/ws` route existence — remove or update
+- Tests that mock `get_or_spawn` or `kill_session` — remove
+- Tests checking `delete_current_session` calls `kill_session` — update
+
+**Step 3: Run Go tests too**
+
+Run:
+```bash
+cd muxterm && go test -short -v ./...
+```
+Expected: All Go tests pass.
+
+**Step 4: Final commit**
+```bash
+git add -A && git commit -m "fix: resolve remaining test failures from muxterm migration"
+```
+
+---
+
+### Task 15: Build and Smoke Test
+
+**Files:**
+- No new files
+
+**Step 1: Build the muxterm binary**
+
+Run:
+```bash
+cd muxterm && go build -o muxterm .
+```
+Expected: Binary compiles successfully.
+
+**Step 2: Run the full test suite one more time**
+
+Run:
+```bash
+.venv/bin/python -m pytest muxplex/tests/ -x -q --timeout=30
+cd muxterm && go test -short -v ./...
+```
+Expected: All tests pass in both Python and Go.
+
+**Step 3: Manual smoke test (if tmux is available)**
+
+```bash
+# Create a test tmux session
+tmux new-session -d -s test-muxterm
+
+# Start muxterm
+cd muxterm && MUXTERM_SECRET=smoke-test ./muxterm &
+MUXTERM_PID=$!
+
+# Verify health
+curl -s http://127.0.0.1:7682/health
+# Expected: {"status":"ok"}
+
+# Clean up
+kill $MUXTERM_PID
+tmux kill-session -t test-muxterm
+```
+
+**Step 4: Final commit with summary**
+```bash
+git add -A && git commit -m "feat: Phase 2 complete — muxterm Go binary replaces ttyd + Python WS proxy
+
+- muxterm: Go binary with PTY pool, WebSocket server, HMAC token auth
+- Python: token endpoint, process supervision, ttyd.py deleted
+- Frontend: single WS connection, JSON control protocol, no terminal cache
+- Deleted: ttyd.py, WS proxy, /connect endpoint, terminal cache"
+```