Files
muxplex/docs/superpowers/plans/2026-05-28-phase2-muxterm-go-binary.md

64 KiB

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:

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:

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:

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:

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

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:

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:

cd muxterm && go build ./... 2>&1 || echo "Expected: fails on missing HandleWebSocket only"

Expected: Only HandleWebSocket undefined — pool compiles.

Step 3: Commit

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:

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:

cd muxterm && go build ./... 2>&1 || echo "Expected: fails on missing HandleWebSocket only"

Expected: Only HandleWebSocket undefined.

Step 3: Commit

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:

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:

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:

cd muxterm && go build -o muxterm .

Expected: Binary compiles successfully. muxterm/muxterm binary created.

Step 3: Quick smoke test

Run:

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

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:

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:

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:

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:

cd muxterm && go test -short -v ./...

Expected: All protocol and token tests pass. Pool integration test skipped in short mode.

Step 5: Commit

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):

# ---------------------------------------------------------------------------
# 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:

.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):

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):

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:

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

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:

"""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:

.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:

"""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:

.venv/bin/python -m pytest muxplex/tests/test_muxterm.py -x -q --timeout=30

Expected: All pass.

Step 5: Commit

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:

from muxplex.muxterm import start_muxterm, stop_muxterm

Remove these lines entirely:

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:

    # Startup: kill any orphaned ttyd from a previous muxplex run, then
    # start the background poll loop.
    await kill_orphan_ttyd()

To:

    # 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):

        # 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:

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:

.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

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:

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):

@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:

@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:

    await kill_session(name)

And remove the comment above it:

    # 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:

.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

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:

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:

.venv/bin/python -m pytest muxplex/tests/ -x -q --timeout=30

Expected: All remaining tests pass. Fix any failures.

Step 4: Commit

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:

// 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:

node -c muxplex/frontend/terminal.js

Expected: No syntax errors.

Step 3: Commit

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):

  // 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:

  // 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:

  // 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:

node -c muxplex/frontend/app.js

Expected: No syntax errors.

Step 4: Commit

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:

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:

# ——— 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:

.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

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:

.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:

cd muxterm && go test -short -v ./...

Expected: All Go tests pass.

Step 4: Final commit

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:

cd muxterm && go build -o muxterm .

Expected: Binary compiles successfully.

Step 2: Run the full test suite one more time

Run:

.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)

# 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

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"