Terminal interactions in the browser have several issues:
- No input echo - Characters typed don't appear until command execution
- Cursor positioning - Arrow keys, backspace don't work properly
- Line editing - No readline-style editing (Ctrl+A, Ctrl+E, etc.)
- Signal handling - Ctrl+C sometimes doesn't work as expected
Browser → WebSocket → Control Plane → WebSocket → Agent → FIFO → tmux
↓
send-keys (input)
pipe-pane (output)
Key files:
agents/agentd/internal/tmux/terminal.go- FIFO-based paneBridgeagents/agentd/internal/tmux/pipe_mux.go- Output capture viastdbuf -o0 tee
Problems:
send-keysbypasses PTY line discipline - no echo, no cursor handling- FIFO is unidirectional - can't provide proper terminal semantics
- No controlling terminal for the tmux session from browser's perspective
- Alternate screen + cursor addressing + full-screen apps are broken without a real TTY
Browser → WebSocket → Go Backend → PTY Master FD → tmux attach
↓
PTY Slave FD (terminal)
Key insight from webtmux (session.go):
cmd := exec.Command("tmux", "attach", "-t", sessionID)
ptmx, err := pty.Start(cmd) // Creates PTY, starts cmd with PTY as controlling terminal
// Read from ptmx → send to WebSocket
// Write from WebSocket → write to ptmxThe PTY provides:
- Line discipline - Echo, buffering, special character handling
- Terminal emulation - Cursor positioning, line editing
- Signal generation - Ctrl+C → SIGINT, Ctrl+Z → SIGTSTP
Replace FIFO-based paneBridge with PTY-based ptyBridge that:
- Spawns
tmux attach -t sessionvia PTY - Routes WebSocket input to PTY master fd
- Reads PTY master fd for output broadcast
- Uses PTY resize so tmux updates the client size (fixes cut-off + no-fit)
Before (FIFO):
paneBridge {
fifoPath: "/tmp/ac-tmux-pane-123"
readLoop() → reads from FIFO
Write() → tmux send-keys
}
After (PTY):
ptyBridge {
ptmx: *os.File // PTY master
cmd: *exec.Cmd // tmux attach process
readLoop() → reads from ptmx
Write() → writes to ptmx
}
package tmux
import (
"os"
"os/exec"
"sync"
"github.com/creack/pty"
)
type ptyBridge struct {
sessionID string
paneID string
ptmx *os.File
cmd *exec.Cmd
channels map[string]*ptyChannel
channelsMu sync.RWMutex
closeOnce sync.Once
closed chan struct{}
}
func newPtyBridge(sessionName, paneID string, readonly bool) (*ptyBridge, error) {
// Start tmux attach with PTY (select the exact pane)
args := []string{"attach-session", "-t", sessionName, ";", "select-pane", "-t", paneID}
if readonly {
args = []string{"attach-session", "-r", "-t", sessionName, ";", "select-pane", "-t", paneID}
}
cmd := exec.Command("tmux", args...)
cmd.Env = append(os.Environ(),
"TERM=xterm-256color",
"COLORTERM=truecolor",
"LANG=en_US.UTF-8",
"LC_CTYPE=en_US.UTF-8",
)
ptmx, err := pty.Start(cmd)
if err != nil {
return nil, err
}
// Set terminal size
pty.Setsize(ptmx, &pty.Winsize{Rows: 24, Cols: 80})
bridge := &ptyBridge{
sessionID: sessionName,
paneID: paneID,
ptmx: ptmx,
cmd: cmd,
channels: make(map[string]*ptyChannel),
closed: make(chan struct{}),
}
go bridge.readLoop()
go bridge.waitForExit()
return bridge, nil
}
func (b *ptyBridge) Write(data []byte) error {
_, err := b.ptmx.Write(data) // Direct write to PTY master
return err
}
func (b *ptyBridge) Resize(rows, cols uint16) error {
return pty.Setsize(b.ptmx, &pty.Winsize{Rows: rows, Cols: cols})
}
func (b *ptyBridge) readLoop() {
buf := make([]byte, 4096)
for {
select {
case <-b.closed:
return
default:
n, err := b.ptmx.Read(buf)
if err != nil {
return
}
if n > 0 {
b.broadcast(buf[:n])
}
}
}
}- Add
ptyBridges map[string]*ptyBridgealongside existingpaneBridges - Add option to choose PTY vs FIFO mode (for backward compatibility)
- Default to PTY mode for browser-connected sessions
type TerminalManager struct {
// ... existing fields
ptyBridges map[string]*ptyBridge
usePTYMode bool // Default true for browser sessions
}
func (tm *TerminalManager) ConnectSession(sessionID string, usePTY bool, readonly bool) (*TerminalChannel, error) {
if usePTY {
return tm.connectViaPTY(sessionID, readonly)
}
return tm.connectViaFIFO(sessionID) // Legacy mode
}- Determine tmux session name from pane ID before attaching:
tmux display-message -p -t <pane> "#{session_name}"- Attach to that session name, then
select-pane -t <pane>
- This avoids attaching to the wrong session and ensures the correct window is focused.
Add resize + mode flags (read-only vs interactive). Prefer binary frames for IO data:
// Message types
const (
MsgTypeIO = '1' // Input/Output data
MsgTypeResize = '2' // Terminal resize
MsgTypeMode = '3' // Mode toggle (read-only vs control)
)
// In websocket handler
switch msg[0] {
case MsgTypeIO:
bridge.Write(msg[1:])
case MsgTypeResize:
rows, cols := parseResize(msg[1:])
bridge.Resize(rows, cols)
case MsgTypeMode:
// toggle read-only / request control
}Recommendation: send IO over WS binary frames to avoid UTF-8 corruption.
If JSON-only, base64-encode IO payloads and include an encoding field.
Add resize handling:
useEffect(() => {
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
fitAddon.fit();
// Send resize to backend
const { rows, cols } = terminal;
sendMessage(`2${rows},${cols}`);
// Handle window resize
const resizeObserver = new ResizeObserver(() => {
fitAddon.fit();
sendMessage(`2${terminal.rows},${terminal.cols}`);
});
resizeObserver.observe(containerRef.current);
}, []);We need multiple viewers (read-only) without breaking interactivity:
- Default: allow multiple read-only PTY clients (
tmux attach -r). - Interactive: at most one writable client per session pane. Use a lock in agentd:
- If a writer is attached, other attachments are read-only unless the user explicitly steals control.
- UI: show who has control and add "Request Control" + "Take Control" actions.
This mirrors tmux’s own multi-client behavior and avoids input conflicts.
- Use bounded channels +
io.CopyBufferto avoid memory growth under high output. - If WS client is slow, drop output and show a warning banner (or disconnect after N drops).
- On disconnect:
- Close PTY → tmux client detaches automatically.
- Kill the attach process if still running.
- Periodic cleanup: if no attached viewers for a session, close the PTY bridge.
- Enforce read-only unless the user explicitly attaches in interactive mode.
- Require host/role checks before opening a PTY (terminal attach is shell access).
- Log audit events for attach/detach and control transfers.
tmux attachrequires a real session name; derive it from pane ID.- PTY mode only on Linux/macOS; keep FIFO fallback for environments without PTY support.
- Override
TERMtoxterm-256colorto fix color and cursor rendering.
- Add
creack/ptydependency to Go module - Create
pty_bridge.gowith PTY-based terminal handling - Keep FIFO bridge as fallback
- Add feature flag to toggle between modes
- Update
TerminalManager.ConnectSession()to use PTY mode - Add resize message handling in control plane WebSocket
- Update dashboard to send resize messages
- Test with existing sessions
- Make PTY mode the default
- Deprecate FIFO mode (keep for edge cases)
- Remove pipe-pane output capture for PTY sessions
- Update documentation
agents/agentd/go.mod- Addgithub.com/creack/ptydependencyagents/agentd/internal/tmux/pty_bridge.go- NEW: PTY-based bridgeagents/agentd/internal/tmux/terminal.go- Add PTY mode optionagents/agentd/cmd/agentd/main.go- Wire PTY bridge to WebSocket handler
services/control-plane/src/ws/agent.ts- Handle resize messagesservices/control-plane/src/ws/dashboard.ts- Forward resize to agentservices/control-plane/src/routes/terminal.ts- Passreadonly/controlflags, enforce auth
apps/dashboard/src/components/TerminalView.tsx- Send resize, handle fitapps/dashboard/src/components/TerminalView.tsx- Control/readonly toggle + control state UI
packages/ac-schema/src/terminal.ts- Add resize message types
-
Basic PTY test:
- Start tmux session
- Connect via PTY bridge
- Type characters → verify immediate echo
- Use arrow keys → verify cursor movement
- Use Ctrl+C → verify SIGINT works
-
Resize test:
- Connect to session
- Resize browser window
- Verify terminal reflows properly
- Run
stty size→ verify correct dimensions
-
Multi-client test:
- Connect two browser tabs to same session
- One tab read-only, one tab control
- Type in control → verify appears in both
- Request control from read-only → verify lock handoff
-
Backward compatibility:
- Verify FIFO mode still works when PTY fails
- Test CLI-only sessions (no browser)
Low Risk:
- PTY is well-established technology
- webtmux proves the approach works
- Fallback to FIFO available
Medium Risk:
- Resource usage: Each browser connection needs PTY
- tmux attach limit: May need detach/attach cycling
- Platform compatibility: PTY behavior on macOS vs Linux
- Multiple writers: conflicting input if control not gated
Mitigation:
- Connection pooling for PTY bridges
- Graceful degradation to FIFO
- Platform-specific testing in CI
- Enforce single writer + explicit control handoff
tmux has a -C control mode that provides structured output:
tmux -C attach -t sessionThis was considered but rejected because:
- Still requires parsing tmux protocol
- Doesn't provide standard terminal emulation
- webtmux doesn't use it - PTY approach is proven
- More complexity for similar result
github.com/creack/ptyv1.1.21+ (Go PTY library)- No new npm dependencies (xterm.js already handles resize)