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(removeconnect_session,terminal_ws_proxy,_ws_auth_check,delete_current_session'skill_sessioncall, updatedelete_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:
- The
_ws_auth_checkfunction (lines 1002-1025) - The
terminal_ws_proxyfunction and its@app.websocket("/terminal/ws")decorator (lines 1028-1112) - The comment block above them:
# WebSocket proxy — bridges browser to ttyd(line 995) and# _ttyd_is_listening is imported from muxplex.ttyd(line 999) - The
websocketsimport used by the proxy — findimport websocketsandfrom websockets import Subprotocoland 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_sessionendpoint no longer needskill_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
/connectendpoint
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/0x31prefix encoding) - Uses text frames for JSON control messages (attach, resize, detach)
- No terminal cache, no
_terminalCache, no_cacheOrder, noswitchTerminal
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,switchTerminalcache 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
/connectPOST in openSession: update to verify it's NOT called for local sessions (only for remote/federation sessions). - If it tests
connectWebSocketURL construction: update to verify the muxterm WebSocket URL pattern (direct to muxterm port, not/terminal/wsproxy 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_sessionendpoint (deleted) — remove - Tests that check for
/terminal/wsroute existence — remove or update - Tests that mock
get_or_spawnorkill_session— remove - Tests checking
delete_current_sessioncallskill_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"