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