diff --git a/docs/API.md b/docs/API.md index 2b5bf43..c32b6f4 100644 --- a/docs/API.md +++ b/docs/API.md @@ -149,9 +149,43 @@ Body: ### Web TTY stream (WebSocket) `GET /api/instances/tty/ws?id=` -- Bi-directional stream for terminal output/input. -- Client sends typed bytes as WebSocket text/binary frames. -- Server pushes terminal output chunks as **binary frames**. +Bi-directional stream for terminal output/input with PTY support. + +**Handshake Protocol:** +1. Server sends `{"type":"ready"}` immediately after connection +2. Client should wait for this message before sending resize +3. Client sends `{"type":"resize","cols":80,"rows":24}` to start data flow +4. Server sends initial log + real-time output as binary frames + +**Message Types:** + +*Client → Server:* +- Input: text/binary frames (raw bytes) +- Resize: `{"type":"resize","cols":,"rows":}` + +*Server → Client:* +- Ready: `{"type":"ready"}` (text frame) +- Output: binary frames (terminal output chunks) + +**Timeout & Fallback:** +- Client should implement handshake timeout (recommended: 5s) +- On timeout, close WebSocket and fallback to SSE: `GET /api/instances/log/stream?id=` + +**Example Flow:** +``` +Client Server + | | + |--- Connect ------------>| + |<-- {"type":"ready"} ----| Handshake + | | + |-- {"type":"resize", --->| Notify terminal size + | "cols":80,"rows":24} | + | | + |<-- binary output -------| Initial log + realtime + | | + |--- input bytes -------->| User input + |<-- binary output -------| Process output +``` ### Archive `POST /api/instances/archive` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 552c93c..f9baf18 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -38,6 +38,9 @@ It does **not** analyze project code or prevent concurrent write conflicts insid ## 4. Instance lifecycle & reconnect semantics - An instance is a server-managed process; UI windows are merely views. - Default interactive path is WebSocket TTY: `GET /api/instances/tty/ws?id=...` (bi-directional terminal stream). + - **Handshake Protocol**: Server sends `{"type":"ready"}` on connect; client must wait for this before sending resize to start data flow. + - **Timeout Handling**: Client should implement 5s handshake timeout with SSE fallback. + - **Resize Support**: Client sends `{"type":"resize","cols":80,"rows":24}` to update PTY size, triggering TUI programs to redraw. - Fallback path remains available: HTTP input `POST /api/instances/input` + replay/SSE logs (`GET /api/instances/log`, `GET /api/instances/log/stream`). - UI shows transport state (`websocket/sse/polling`) and supports manual WS reconnect. - Backlog is stored on disk with a size cap (rolling truncate). diff --git a/docs/PRD.md b/docs/PRD.md index 7e52bfd..5c7892e 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -47,7 +47,11 @@ ## 7. 当前实现状态(与愿景差异) - 已实现:worktree/instance 管理、Web UI、API、输出回放、脱敏、认证与可选 HTTPS、MCP tools 列表接口。 -- 规划增强:将 instance 升级为真正 PTY 并通过 WebSocket 实现交互式 Web TTY(xterm.js)。 +- **已实现 PTY + Web TTY**:instance 通过 PTY 启动,支持真正的交互式终端(vim/htop/less 等 TUI 程序)。 + - WebSocket 握手协议:服务端发送 `{"type":"ready"}`,客户端等待后发送 resize 开始数据流。 + - 窗口尺寸传递:前端监听窗口 resize 并通知后端 PTY,确保 TUI 程序正确重绘。 + - 超时降级:5 秒握手超时后自动降级到 SSE 方案。 +- 规划增强:无(PTY + Web TTY 已完成)。 ## 8. 验收标准(MVP) - 可创建/列出/删除 worktree(dirty 删除被拒绝)。 diff --git a/internal/app/app.go b/internal/app/app.go index c1482f2..a7354f9 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -518,6 +518,11 @@ func (s *Server) handleInstanceTTYWS(w http.ResponseWriter, r *http.Request) { } defer conn.Close() + readyMsg := []byte(`{"type":"ready"}`) + if err := conn.WriteText(readyMsg); err != nil { + return + } + initial, err := s.instanceMgr.Tail(id, 64*1024) if err != nil { _ = conn.WriteBinary([]byte(err.Error())) diff --git a/internal/ui/static/index.html b/internal/ui/static/index.html index 5dbda88..9bad108 100644 --- a/internal/ui/static/index.html +++ b/internal/ui/static/index.html @@ -1126,34 +1126,63 @@

Start Instance

const ws = new WebSocket(url); ws.binaryType = "arraybuffer"; - let isFirstMessage = true; + let isReady = false; + let isFirstData = true; + let handshakeTimeout = null; ws.onopen = () => { - if (ttySocket === ws) { - updateStatus("websocket live"); - focusTerminalIfPossible(); - } - // Send initial resize on connect - if (term && term.cols > 0 && term.rows > 0) { - sendResize(term.cols, term.rows); - } + // Set handshake timeout (5 seconds) + handshakeTimeout = setTimeout(() => { + if (!isReady && ws === ttySocket) { + console.error('WebSocket handshake timeout: no "ready" message received'); + updateStatus("handshake timeout"); + ws.close(); + // Fallback to SSE after timeout + setTimeout(() => startSSE(), 500); + } + }, 5000); }; ws.onmessage = (ev) => { + // Handle handshake: wait for "ready" message first + if (!isReady) { + try { + const msg = JSON.parse(ev.data); + if (msg.type === 'ready') { + isReady = true; + // Clear handshake timeout + if (handshakeTimeout) { + clearTimeout(handshakeTimeout); + handshakeTimeout = null; + } + if (ttySocket === ws) { + updateStatus("websocket live"); + focusTerminalIfPossible(); + } + // Send initial resize to backend + if (term && term.cols > 0 && term.rows > 0) { + sendResize(term.cols, term.rows); + } + return; + } + } catch (e) { + // Not JSON, treat as data + } + } + + // Handle binary/text data if (ev.data instanceof ArrayBuffer) { const u8 = new Uint8Array(ev.data); if (u8.length && term) term.write(u8); } else if (term) term.write(ev.data); - // After receiving first message (initial log), trigger resize - // This helps TUI programs redraw their interface - if (isFirstMessage && term) { - isFirstMessage = false; + // After receiving first data (after ready), trigger resize for TUI redraw + if (isFirstData && isReady && term) { + isFirstData = false; setTimeout(() => { if (term && ws === ttySocket && ws.readyState === WebSocket.OPEN) { const cols = term.cols; const rows = term.rows; if (cols > 0 && rows > 0) { - // Send resize to backend and trigger local redraw sendResize(cols, rows); term.resize(cols, rows); } @@ -1163,6 +1192,11 @@

Start Instance

}; ws.onclose = () => { + // Clear handshake timeout on close + if (handshakeTimeout) { + clearTimeout(handshakeTimeout); + handshakeTimeout = null; + } if (ttySocket === ws) { updateStatus("ws closed, retrying..."); ttyReconnectTimer = setTimeout(connectTTY, 1000);