This skill documents the WebSocket binary protocol and buffer replay system used between the browser, server (WS bridge), and Rust pty-host. Use this skill when modifying WS message handling, buffer replay, reconnection logic, the RESUME/SYNC handshake, IndexedDB caching, or the OutputBuffer ring buffer in Rust. It prevents breaking the protocol contract between the three layers.
Scanned 9/13/2026
Install to Claude Code
npx -y skills add ddrscott/relay-tty --skill ws-protocol --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Ws Protocol?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/ddrscott-ws-protocol)More formats (shields.io, HTML) on the badges page.
---
name: WS Protocol & Buffer Replay
description: >
This skill documents the WebSocket binary protocol and buffer replay system
used between the browser, server (WS bridge), and Rust pty-host. Use this
skill when modifying WS message handling, buffer replay, reconnection logic,
the RESUME/SYNC handshake, IndexedDB caching, or the OutputBuffer ring
buffer in Rust. It prevents breaking the protocol contract between the three
layers.
---
# WS Binary Protocol & Buffer Replay
## Key Source Files
| File | Role |
|------|------|
| `shared/types.ts` | `WS_MSG` constants (single source of truth for JS/TS) |
| `crates/pty-host/src/main.rs` | Rust constants (`WS_MSG_*`), `OutputBuffer`, `AltScreenScanner`, client handler |
| `crates/pty-host/src/agent_state.rs` | Agent state classifier (`agentState` in metadata) |
| `shared/client/session-stream.ts` | The one client: RESUME/SYNC, offsets, replay classification, reconnect, heartbeat, typed events |
| `shared/client/messages.ts` | Every encode/decode helper (`encodeResume`, `decodeSync`, ...) |
| `shared/client/transport-*.ts` | Unix socket (length-prefixed) and WebSocket (raw) transports |
| `app/hooks/use-terminal-core.ts` | Browser xterm side: consumes `SessionStream` events, owns replay writes and the cache |
| `app/lib/browser-stream.ts` | Browser `SessionStream` factory (reconnect and heartbeat policy) |
| `server/ws-handler.ts` | Node.js WS-to-Unix-socket bridge, backpressure, clipboard broadcast |
| `app/lib/buffer-cache.ts` | IndexedDB cache for instant local replay before WS connects |
| `docs/content/reference/protocol.mdx` | Canonical protocol documentation |
See `references/message-types.md` for a complete message type table.
## Binary Framing
Every message (WS or Unix socket) has the structure:
```
[1 byte: message type][N bytes: payload]
```
- **WebSocket** (browser/CLI <-> server): raw binary frames, no length prefix.
- **Unix socket** (server/CLI <-> pty-host): length-prefixed: `[4B uint32 BE length][payload]`.
The first byte of the payload is always the message type from `WS_MSG`.
## Adding or Changing Message Types
Keep these three locations in sync:
1. `shared/types.ts` -- `WS_MSG` object (JS/TS source of truth)
2. `crates/pty-host/src/main.rs` -- `WS_MSG_*` constants (Rust, must match exactly)
3. `docs/content/reference/protocol.mdx` -- protocol documentation table
When adding a new message type, assign the next available byte value and document direction (client->server, server->client, or bidirectional) in all three locations.
## Connection & RESUME/SYNC Handshake
### First Connect (no cached offset)
1. Browser opens WS to `/ws/sessions/<id>`.
2. Server opens a per-client Unix socket to the pty-host process.
3. Browser sends `RESUME(0)` immediately on `ws.onopen` (offset=0 signals first connect).
4. pty-host receives RESUME within 100ms, sees offset<=0, performs full replay.
5. pty-host sends: `RESIZE(cols, rows)` -> `BUFFER_REPLAY` or `BUFFER_REPLAY_GZ` -> `SYNC(totalWritten)` -> `TITLE` -> `SESSION_STATE`.
6. Browser calls `term.reset()`, writes replay data in 64KB chunks with `setTimeout` yields, calls `syncAndScroll()` at the end.
### Reconnect (delta resume)
1. Browser already has `byteOffset > 0` from a previous SYNC.
2. Sends `RESUME(byteOffset)` on WS open.
3. pty-host calls `read_from(offset)`:
- Returns delta bytes if offset is still in the ring buffer.
- Returns `None` if offset is too old (data overwritten) -- triggers full replay with `send_cache_reset()` (SYNC(0.0) to invalidate client cache).
4. pty-host sends: `RESIZE` -> `BUFFER_REPLAY(delta)` -> `SYNC(totalWritten)`.
5. Browser appends delta to xterm **without** `term.reset()` or `syncAndScroll()`. Preserves the user's scroll position.
### Tail-Limited Replay (16-byte RESUME)
RESUME has an optional 16-byte form: `[offset f64 BE][maxReplayBytes f64 BE]`. When `maxReplayBytes > 0` and the server would send a FULL replay (offset <= 0 or overwritten), pty-host clamps the replay to the last `maxReplayBytes` bytes via `clamp_replay_tail()` -- the tail starts after the first `\n` so it never begins mid-escape-sequence (same idea as `sanitize_start()`).
- **Delta replays are NEVER clamped** -- truncating a delta would corrupt the byte-offset contract.
- **SYNC semantics unchanged** -- SYNC always carries the authoritative `total_written`, so delta resume works after a clamped replay.
- The 8-byte form and the 100ms-timeout path (CLI) behave exactly as before.
- Client opt-in: `maxReplayBytes` in `TerminalCoreOpts` (`use-terminal-core.ts`); `GridTerminal` passes `256 * 1024` so 50 gallery cells don't each pull a full 10MB buffer.
- The bridge (`ws-handler.ts`) forwards RESUME opaquely in both normal and read-only paths -- no server change needed for the longer payload.
### 100ms RESUME Window
pty-host waits up to `RESUME_TIMEOUT_MS` (100ms) for the first message. If no RESUME arrives (CLI clients that predate the protocol), it falls back to full replay. Do not increase this timeout -- it adds latency to every new connection.
A `SPARKLINE_REQUEST` (0x18) as the first frame is answered directly with `SPARKLINE_HISTORY` and **no replay** -- the server's `fetchSparkline()` opens a throwaway socket and asks immediately. Any other non-RESUME first frame still triggers the legacy full replay before the message is processed. Do not route new query-only requests through the replay fallback: before this special case, every sparkline probe made the pty-host gzip its whole ring buffer for a client that discarded it.
### Cache Reset Signal
When `read_from(offset)` returns `None` (offset expired), pty-host sends `SYNC(0.0)` before the full replay. The browser detects `serverOffset === 0 && byteOffset > 0`, discards its IndexedDB cache, and resets `byteOffset` to 0.
## Buffer Replay Behaviors
### Full Replay (first connect)
- Call `term.reset()` before writing.
- Write in 64KB chunks with `setTimeout(writeNextChunk, 0)` between chunks to yield to the UI thread.
- Report progress via `onReplayProgress(0..1)`, clear with `onReplayProgress(null)`.
- After the last chunk, call `syncAndScroll()`: `viewport.syncScrollArea(true)` + `term.scrollToBottom()`.
- Set `replayingRef.current = true` before writing, clear it 200ms after the write callback fires.
### Delta Replay (reconnect)
- Do NOT call `term.reset()`.
- Do NOT call `syncAndScroll()` -- this would yank users who scrolled up away from the bottom.
- Write the delta payload directly. If the user was at the bottom, scroll to bottom after write; otherwise preserve position.
- `replayingRef` is still set during delta replay to suppress CPR/DA responses.
### The `replayingRef` Flag
During buffer replay, xterm processes DSR (Device Status Report) and DA (Device Attributes) queries embedded in the replayed data and emits responses via `onData`. Without `replayingRef`, these responses would be forwarded to the PTY as stdin, corrupting the session.
- Set `replayingRef.current = true` before any replay write.
- Clear it 200ms after the write callback (not immediately -- xterm emits responses asynchronously).
- 5-second safety timeout force-clears it if the write callback never fires (complex TUI alt-screen state).
### Gzip Compression
pty-host compresses replay data with gzip (fast level) when the buffer exceeds `GZIP_THRESHOLD` (4096 bytes) and the compressed size is smaller. The browser decompresses using `DecompressionStream("gzip")`.
### Terminal Query Stripping
pty-host runs `strip_terminal_queries()` on replay data to remove DSR (`ESC[6n`, `ESC[?6n`) and DA (`ESC[c`, `ESC[>c`, `ESC[=c`, `ESC[0c`) sequences. This is a Rust-side defense that reduces (but does not eliminate) spurious responses -- `replayingRef` is still required as the browser-side guard.
## IndexedDB Buffer Cache
The browser caches terminal output in IndexedDB (`relay-tty-buffer-cache`) for instant display before the WS connects.
- **Load on mount**: read cached buffer + `byteOffset` from IndexedDB, write to xterm, then connect WS with `RESUME(cachedOffset)`.
- **Continuous updates**: `BufferCacheWriter` batches incoming DATA payloads (flush every 1s or 64KB) and writes to IndexedDB.
- **10MB cap**: matches server-side ring buffer. Oldest bytes are truncated.
- **24h TTL**: stale entries are evicted on database open.
- **Cache invalidation**: deleted on session EXIT or when pty-host sends `SYNC(0.0)` (cache reset signal).
## OutputBuffer (Rust Ring Buffer)
Located in `crates/pty-host/src/main.rs`. Key properties:
- **10MB ring buffer** for normal-screen content (`BUFFER_SIZE`).
- **2MB alt-screen buffer** (`ALT_BUFFER_CAP`) -- separate Vec, not part of the ring.
- **`total_written: f64`** -- monotonic byte counter (float64 because it can exceed 2^32 for long sessions).
- **`AltScreenScanner`** -- byte-level scanner for CSI `?1049h/l`, `?47h/l`, `?1047h/l`. Detects alt-screen enter/exit at write time.
- On alt-screen enter: alt content goes to `alt_buf`, main buffer stops growing.
- On alt-screen exit: `alt_buf` is discarded (dead content), main buffer resumes.
- **`read()`** (full replay): linearizes ring buffer, appends alt_buf if in alt screen, truncates at last `ESC[2J` (screen clear) to skip dead TUI frames.
- **`read_from(offset)`** (delta): returns bytes from offset to current position, or `None` if offset is before buffer start (data overwritten by ring wrap).
- **`sanitize_start()`**: when the ring wraps, skips to the first `\n` to avoid partial escape sequences or multi-byte UTF-8 at the buffer boundary.
## Backpressure
The server-side WS handler (`ws-handler.ts`) implements backpressure on the Unix socket:
- `WS_HIGH_WATER_MARK` = 1MB. When `ws.bufferedAmount` exceeds this, pause reads from the pty socket.
- Resume when buffered amount drops below the threshold.
- Prevents memory exhaustion when a slow client cannot keep up with fast PTY output.
## Server WS Bridge Architecture
The server (`ws-handler.ts`) is a transparent bridge -- it does not interpret most message types:
- Each WS client gets its own Unix socket connection to pty-host.
- Messages from WS are length-prefixed and forwarded to the Unix socket.
- Messages from the Unix socket are de-framed and forwarded as WS binary.
- **Exceptions** handled at the bridge level:
- `PING`/`PONG` (application-level heartbeat, not forwarded to pty-host)
- `CLIPBOARD` (broadcast to other WS clients on the same session, not forwarded)
- `IMAGE` (broadcast to all clients, not forwarded as unicast)
- Read-only connections: only forward `RESUME`, drop `DATA` and `RESIZE`.
## Invariants -- Do Not Break
1. **Message type bytes must match** across `shared/types.ts`, Rust constants, and `docs/content/reference/protocol.mdx`.
2. **RESUME must be the first message** sent by the browser after WS open, before RESIZE.
3. **`byteOffset` is monotonic** -- never decrease it except on cache reset (`SYNC(0.0)`).
4. **Delta replay must not call `syncAndScroll()`** -- it yanks scrolled-up users to the bottom.
5. **Full replay must call `syncAndScroll()`** -- without it, the viewport scroll area is stale and the scrollbar is broken.
6. **`replayingRef` must be set during all replay writes** -- without it, xterm's CPR/DA responses leak to the PTY as keyboard input.
7. **200ms delay before clearing `replayingRef`** -- xterm emits responses asynchronously after the write callback.
8. **The 100ms RESUME window must not be increased** -- it adds latency to every CLI connection.
9. **RESIZE is sent before BUFFER_REPLAY** in `send_replay()` so the client renders at the correct dimensions.
10. **float64 for byte offsets** -- `totalWritten` can exceed 2^32; do not use integer types.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!