Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 37 additions & 3 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,9 +149,43 @@ Body:
### Web TTY stream (WebSocket)
`GET /api/instances/tty/ws?id=<instanceId>`

- 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":<number>,"rows":<number>}`

*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=<instanceId>`

**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`
Expand Down
3 changes: 3 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
6 changes: 5 additions & 1 deletion docs/PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 删除被拒绝)。
Expand Down
5 changes: 5 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
Expand Down
62 changes: 48 additions & 14 deletions internal/ui/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1126,34 +1126,63 @@ <h3>Start Instance</h3>
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);
}
Expand All @@ -1163,6 +1192,11 @@ <h3>Start Instance</h3>
};

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