Skip to content

Commit befa6cc

Browse files
authored
Merge pull request #12 from linletian/fix/refresh-prob-1
feat(instance): add WebSocket TTY handshake protocol
2 parents 1496293 + c830b9f commit befa6cc

5 files changed

Lines changed: 98 additions & 18 deletions

File tree

‎docs/API.md‎

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,9 +149,43 @@ Body:
149149
### Web TTY stream (WebSocket)
150150
`GET /api/instances/tty/ws?id=<instanceId>`
151151

152-
- Bi-directional stream for terminal output/input.
153-
- Client sends typed bytes as WebSocket text/binary frames.
154-
- Server pushes terminal output chunks as **binary frames**.
152+
Bi-directional stream for terminal output/input with PTY support.
153+
154+
**Handshake Protocol:**
155+
1. Server sends `{"type":"ready"}` immediately after connection
156+
2. Client should wait for this message before sending resize
157+
3. Client sends `{"type":"resize","cols":80,"rows":24}` to start data flow
158+
4. Server sends initial log + real-time output as binary frames
159+
160+
**Message Types:**
161+
162+
*Client → Server:*
163+
- Input: text/binary frames (raw bytes)
164+
- Resize: `{"type":"resize","cols":<number>,"rows":<number>}`
165+
166+
*Server → Client:*
167+
- Ready: `{"type":"ready"}` (text frame)
168+
- Output: binary frames (terminal output chunks)
169+
170+
**Timeout & Fallback:**
171+
- Client should implement handshake timeout (recommended: 5s)
172+
- On timeout, close WebSocket and fallback to SSE: `GET /api/instances/log/stream?id=<instanceId>`
173+
174+
**Example Flow:**
175+
```
176+
Client Server
177+
| |
178+
|--- Connect ------------>|
179+
|<-- {"type":"ready"} ----| Handshake
180+
| |
181+
|-- {"type":"resize", --->| Notify terminal size
182+
| "cols":80,"rows":24} |
183+
| |
184+
|<-- binary output -------| Initial log + realtime
185+
| |
186+
|--- input bytes -------->| User input
187+
|<-- binary output -------| Process output
188+
```
155189

156190
### Archive
157191
`POST /api/instances/archive`

‎docs/ARCHITECTURE.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ It does **not** analyze project code or prevent concurrent write conflicts insid
3838
## 4. Instance lifecycle & reconnect semantics
3939
- An instance is a server-managed process; UI windows are merely views.
4040
- Default interactive path is WebSocket TTY: `GET /api/instances/tty/ws?id=...` (bi-directional terminal stream).
41+
- **Handshake Protocol**: Server sends `{"type":"ready"}` on connect; client must wait for this before sending resize to start data flow.
42+
- **Timeout Handling**: Client should implement 5s handshake timeout with SSE fallback.
43+
- **Resize Support**: Client sends `{"type":"resize","cols":80,"rows":24}` to update PTY size, triggering TUI programs to redraw.
4144
- Fallback path remains available: HTTP input `POST /api/instances/input` + replay/SSE logs (`GET /api/instances/log`, `GET /api/instances/log/stream`).
4245
- UI shows transport state (`websocket/sse/polling`) and supports manual WS reconnect.
4346
- Backlog is stored on disk with a size cap (rolling truncate).

‎docs/PRD.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,11 @@
4747

4848
## 7. 当前实现状态(与愿景差异)
4949
- 已实现:worktree/instance 管理、Web UI、API、输出回放、脱敏、认证与可选 HTTPS、MCP tools 列表接口。
50-
- 规划增强:将 instance 升级为真正 PTY 并通过 WebSocket 实现交互式 Web TTY(xterm.js)。
50+
- **已实现 PTY + Web TTY**:instance 通过 PTY 启动,支持真正的交互式终端(vim/htop/less 等 TUI 程序)。
51+
- WebSocket 握手协议:服务端发送 `{"type":"ready"}`,客户端等待后发送 resize 开始数据流。
52+
- 窗口尺寸传递:前端监听窗口 resize 并通知后端 PTY,确保 TUI 程序正确重绘。
53+
- 超时降级:5 秒握手超时后自动降级到 SSE 方案。
54+
- 规划增强:无(PTY + Web TTY 已完成)。
5155

5256
## 8. 验收标准(MVP)
5357
- 可创建/列出/删除 worktree(dirty 删除被拒绝)。

‎internal/app/app.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,11 @@ func (s *Server) handleInstanceTTYWS(w http.ResponseWriter, r *http.Request) {
518518
}
519519
defer conn.Close()
520520

521+
readyMsg := []byte(`{"type":"ready"}`)
522+
if err := conn.WriteText(readyMsg); err != nil {
523+
return
524+
}
525+
521526
initial, err := s.instanceMgr.Tail(id, 64*1024)
522527
if err != nil {
523528
_ = conn.WriteBinary([]byte(err.Error()))

‎internal/ui/static/index.html‎

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1126,34 +1126,63 @@ <h3>Start Instance</h3>
11261126
const ws = new WebSocket(url);
11271127
ws.binaryType = "arraybuffer";
11281128

1129-
let isFirstMessage = true;
1129+
let isReady = false;
1130+
let isFirstData = true;
1131+
let handshakeTimeout = null;
11301132

11311133
ws.onopen = () => {
1132-
if (ttySocket === ws) {
1133-
updateStatus("websocket live");
1134-
focusTerminalIfPossible();
1135-
}
1136-
// Send initial resize on connect
1137-
if (term && term.cols > 0 && term.rows > 0) {
1138-
sendResize(term.cols, term.rows);
1139-
}
1134+
// Set handshake timeout (5 seconds)
1135+
handshakeTimeout = setTimeout(() => {
1136+
if (!isReady && ws === ttySocket) {
1137+
console.error('WebSocket handshake timeout: no "ready" message received');
1138+
updateStatus("handshake timeout");
1139+
ws.close();
1140+
// Fallback to SSE after timeout
1141+
setTimeout(() => startSSE(), 500);
1142+
}
1143+
}, 5000);
11401144
};
11411145
ws.onmessage = (ev) => {
1146+
// Handle handshake: wait for "ready" message first
1147+
if (!isReady) {
1148+
try {
1149+
const msg = JSON.parse(ev.data);
1150+
if (msg.type === 'ready') {
1151+
isReady = true;
1152+
// Clear handshake timeout
1153+
if (handshakeTimeout) {
1154+
clearTimeout(handshakeTimeout);
1155+
handshakeTimeout = null;
1156+
}
1157+
if (ttySocket === ws) {
1158+
updateStatus("websocket live");
1159+
focusTerminalIfPossible();
1160+
}
1161+
// Send initial resize to backend
1162+
if (term && term.cols > 0 && term.rows > 0) {
1163+
sendResize(term.cols, term.rows);
1164+
}
1165+
return;
1166+
}
1167+
} catch (e) {
1168+
// Not JSON, treat as data
1169+
}
1170+
}
1171+
1172+
// Handle binary/text data
11421173
if (ev.data instanceof ArrayBuffer) {
11431174
const u8 = new Uint8Array(ev.data);
11441175
if (u8.length && term) term.write(u8);
11451176
} else if (term) term.write(ev.data);
11461177

1147-
// After receiving first message (initial log), trigger resize
1148-
// This helps TUI programs redraw their interface
1149-
if (isFirstMessage && term) {
1150-
isFirstMessage = false;
1178+
// After receiving first data (after ready), trigger resize for TUI redraw
1179+
if (isFirstData && isReady && term) {
1180+
isFirstData = false;
11511181
setTimeout(() => {
11521182
if (term && ws === ttySocket && ws.readyState === WebSocket.OPEN) {
11531183
const cols = term.cols;
11541184
const rows = term.rows;
11551185
if (cols > 0 && rows > 0) {
1156-
// Send resize to backend and trigger local redraw
11571186
sendResize(cols, rows);
11581187
term.resize(cols, rows);
11591188
}
@@ -1163,6 +1192,11 @@ <h3>Start Instance</h3>
11631192
};
11641193

11651194
ws.onclose = () => {
1195+
// Clear handshake timeout on close
1196+
if (handshakeTimeout) {
1197+
clearTimeout(handshakeTimeout);
1198+
handshakeTimeout = null;
1199+
}
11661200
if (ttySocket === ws) {
11671201
updateStatus("ws closed, retrying...");
11681202
ttyReconnectTimer = setTimeout(connectTTY, 1000);

0 commit comments

Comments
 (0)