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" +```