Pane stream protocol (snapshot, ready, raw PTY fan-out, resync)
| Field | Value |
|---|---|
| Status | Accepted |
| Authors | commixon (commixon) |
| Created | 2026-11-06 |
| Accepted | 2026-11-06, approved by the maintainer |
| Linear | COM-221 |
| Supersedes | None |
| Superseded by | None |
| Amended | 2026-11-08: ring_bytes and client_backlog_bytes default to 1 MiB, from measurements. 2026-11-09: measured claims corrected, with no protocol change. 2026-11-11: optional ATTACH.cell (History) |
Summary
This RFC defines how venomx streams a pane to its clients over the binary
frames of RFC-0002. The server owns the PTY and one
authoritative terminal emulator (libghostty-vt, wrapped by venom-term). A
client attaches to a pane on a stream, receives a snapshot of the
terminal as of PTY byte offset N, a READY marker once it can paint,
and then the raw PTY bytes from offset N onward, not screen diffs.
Scrollback is backfilled after READY, newest first, addressed by absolute
row. A client that falls behind the pane’s ring buffer is dropped to
RESYNC and gets a fresh snapshot; the PTY is never paused for anyone.
A client that reconnects while its offset is still in the ring resumes
without a snapshot.
Two client modes are defined: emulating (the client runs its own libghostty-vt per pane, needed for splits inside one host terminal) and raw passthrough (one pane, the snapshot is replayed as VT sequences and the host terminal is the only emulator).
The server side is implemented in venomx (COM-222) on top of venom-term’s
Vt (COM-219); the client side in venomx’s attach client and venom-desk.
Motivation
venomx is a multiplexer that humans and agents share. A pane has to be visible from several clients at once (a TUI on the laptop, a second terminal, an agent watching), survive client disconnects, and stay fast when a program floods output. The usual designs each fail one of these:
- Server renders, clients get screen diffs (tmux): the server’s emulator becomes the only one, every client sees tmux’s interpretation of the bytes, and features the host terminal supports but the server doesn’t (kitty keyboard, graphics, new OSCs) are lost.
- Plain byte fan-out without snapshots: a late joiner sees garbage until the program redraws, and there is no recovery for a slow client.
- Pausing the PTY for slow clients: one stalled watcher freezes the program for everybody.
The model here is taken from Rex (Superlogical): the server keeps the
authoritative emulator only for snapshots, queries and events; clients get
the same raw bytes the PTY produced, with byte offsets so that “snapshot as
of N, then bytes from N” is exact. The COM-218 spike
(venom-term docs/spikes/COM-218-libghostty-vt.md)
found that libghostty-vt already has a snapshot format with exactly this
shape (… READY → history newest→oldest → FINISH), decodable
incrementally while live bytes are fed, so we don’t invent a grid format.
Waiting on this RFC: COM-222 (pane stream server: snapshot, raw PTY fan-out, per-client ring buffer, resync), and through it the venomx MVP and venom-desk (Agents in Panes). It also answers RFC-0002 Open question 5 (flow control for streams).
Design
1. Overview
PTY master ──read──► offset counter ──► pane ring (last R bytes, with markers)
│ │ │ │
▼ ▼ ▼ ▼
authoritative Vt client A client B client C
(snapshots, query (each has a send offset into the ring;
replies, OSC events) behind the ring tail ⇒ RESYNC)
- Every byte read from the pane’s PTY gets a pane offset: a u64 counting PTY output bytes since the pane was created, starting at 0.
- The bytes go, in order, into the authoritative
Vtand into the pane ring, which retains the most recentring_bytesof output together with in-band markers for resizes and exit (section 6). - Each attached client has a send offset. Whenever its connection is
writable, the server sends
PTYframes from the ring starting at that offset. Nothing is copied per client beyond the frame being written. - The server never stops reading the PTY because of a client.
Per stream, the server sends frames in this order:
ATTACHED SNAPSHOT* READY { PTY | RESIZE | SCROLLBACK }* [EXIT] { … }* CLOSE
▲ │
└────────── RESYNC SNAPSHOT* READY ◄──────────────┘ (any time after READY)
A resumed attach (section 7) skips SNAPSHOT* READY and continues with
PTY frames.
2. Connection, capability and streams
- Pane streams use a binary connection (RFC-0002 section 3, preamble
0xF5). The client MUST include capabilityvenomx.pane_streaminHELLO; the frames below MUST NOT be sent unless it is in the negotiated set (RFC-0002 section 6.3). - One stream per attached pane. The client opens a stream by sending
ATTACHon a new odd stream ID (RFC-0002 section 5.1). Several panes on one connection use several streams; this is how an emulating client with splits works. - The client detaches with core
CLOSEon the stream. The server closes a stream withCLOSE(pane removed, server shutting down) or with coreERROR(refused attach, protocol violation on that stream). Both end the stream; the ID is not reused. - Verbs keep working on the same connection through
CONTROLframes (RFC-0002 section 5.2), or on a separate NDJSON connection.
3. Frame catalog
venomx frame types, in the tool range RFC-0002 reserved for venomx
(0x0100–0x01FF). All integers are big-endian. “JSON” payloads are one
compact UTF-8 JSON object, with RFC-0002 rules (unknown members ignored,
integers within ±(2^53−1)).
| Type | Name | Dir | Payload | Section |
|---|---|---|---|---|
0x0100 |
ATTACH |
C→S | JSON | 4.1 |
0x0101 |
ATTACHED |
S→C | JSON | 4.2 |
0x0102 |
SNAPSHOT |
S→C | binary: snap u32, offset u64, bytes |
5.1 |
0x0103 |
READY |
S→C | JSON | 5.2 |
0x0104 |
PTY |
S→C | binary: offset u64, bytes |
6.1 |
0x0105 |
SCROLLBACK |
S→C | binary: snap u32, epoch u32, end_row u64, rows u32, flags u8, bytes |
5.3 |
0x0106 |
INPUT |
C→S | binary: bytes | 9 |
0x0107 |
RESIZE |
both | JSON | 8 |
0x0108 |
RESYNC |
both | JSON | 6.3 |
0x0109 |
EXIT |
S→C | JSON | 6.4 |
0x010A–0x01FF |
reserved | future venomx use, behind a new capability |
ATTACH / ATTACHED are the per-stream “hello/caps” exchange: the
connection’s HELLO negotiates what the connection can speak; ATTACH
negotiates mode, snapshot format and role for one pane.
A frame sent in the wrong direction, before ATTACHED, on a stream that is
not open, or out of the order in section 1 is protocol.bad_frame, sent as
ERROR on that stream (closing it). A stream ID that was never opened by
ATTACH gets ERROR on stream 0, which closes the connection.
4. Attach
4.1 ATTACH (C→S)
{"pane":"p3","mode":"emulate","formats":["ghostty/1@0fa3d577a561","vt"],"size":{"cols":80,"rows":24},"history":10000,"claim":true}
| Member | Required | Meaning |
|---|---|---|
pane |
yes | Pane ID, as in venomx’s verbs and RFC-0003 events |
mode |
yes | "emulate" or "raw" (section 10) |
formats |
yes | Snapshot formats the client can decode, in preference order (5.1). A raw client sends ["vt"]. |
size |
yes | The client’s viewport for this pane, cols and rows ≥ 1 |
history |
no | Maximum scrollback rows to backfill after READY. Default: all retained. 0 = none. Ignored in raw mode unless vt history is supported (5.4). |
claim |
no | Default true. Whether this attach counts as activity for the size policy (section 8). Ignored for watchers. |
resume |
no | {"source":"…","offset":N} to resume without a snapshot (section 7) |
colors |
no | The host terminal’s colours, {"fg":"#rrggbb","bg":"#rrggbb","cursor":"#rrggbb"}, each optional (section 9.1) |
cell |
no | The host terminal’s cell size in pixels, {"width":W,"height":H}, each ≥ 1 (section 8.1) |
4.2 ATTACHED (S→C)
{"source":"venomx/01234567-89ab-4cde-8fab-0123456789ab","pane":"p3","mode":"emulate","format":"ghostty/1@0fa3d577a561","resumed":false,"input":true,"size":{"cols":80,"rows":24},"policy":"latest"}
| Member | Meaning |
|---|---|
source |
The daemon instance, <tool>/<uuid> as in RFC-0003 section 2. Offsets are only meaningful within one source and one pane. |
pane |
Echo of the pane ID |
mode |
The mode in effect |
format |
The snapshot format chosen: the first entry of ATTACH.formats the server supports |
resumed |
true if the server resumed from resume.offset; then offset is present and no snapshot follows |
offset |
Only when resumed: the offset the first PTY frame starts at |
input |
true if this attachment is a writer (holds the write grant, section 9), false for a watcher |
size |
The pane’s current size |
policy |
The pane’s size policy: "latest", "smallest" or "fixed" (section 8) |
A pane ID MUST NOT be reused within one source, so (source, pane)
identifies one PTY’s byte sequence. venomx MUST allocate pane IDs from a
counter that only increases for the daemon’s lifetime, as tmux (%N) and
wezterm (PaneId) do. The counter restarts with the daemon, which is
harmless because source changes too.
4.3 Attach errors
Sent as ERROR on the stream, which closes it:
| Code | Exit | When |
|---|---|---|
not_found.pane |
3 | No such pane |
denied.read |
4 | The caller lacks read on the pane (RFC-0005) |
invalid_params |
2 | Missing or malformed member; size with a zero dimension |
unsupported.snapshot_format |
10 | No entry in formats is supported. data.supported lists the server’s formats. A server MUST support vt, so a client listing vt never gets this. |
unsupported.mode |
10 | Unknown mode |
A resume that cannot be honoured is not an error: the server falls
back to a snapshot (resumed: false).
5. Snapshot, READY and scrollback
5.1 SNAPSHOT frames and formats
SNAPSHOT payload: snap u32 | offset u64 | snapshot bytes (chunk)
snapnumbers the snapshots on this stream: 1 for the first, +1 after everyRESYNC.offsetis the pane offset the snapshot is as of: it reflects exactly the PTY bytes[0, offset). Every chunk of one snapshot carries the samesnapandoffset.- The server splits the snapshot into as many
SNAPSHOTframes asmax_framerequires. The client concatenates the chunks; it MUST NOT assume chunk boundaries mean anything. SNAPSHOTframes carry only the part needed to paint. The rest (history) follows asSCROLLBACKafterREADY.
Snapshot formats:
| Format | Payload | Used by |
|---|---|---|
ghostty/<v>@<pin> |
The libghostty-vt snapshot stream (GHOSTSNP envelope version <v>, produced by the ghostty commit whose first 12 hex digits are <pin>), opaque to this RFC. SNAPSHOT carries the records up to and including ghostty’s READY record. |
Emulating clients built with the same pin |
vt |
A VT byte sequence that, written to a freshly reset terminal of the pane’s size, reproduces the state in 5.2’s table (section 10.2) | Raw clients; emulating clients whose pin differs |
The ghostty snapshot format has no compatibility guarantee yet (COM-218
risks), so the pin is part of the format name and a client MUST NOT offer a
ghostty/… format it was not built with. Matching is exact string
equality. venom-term pins one ghostty commit for the whole ecosystem, so
clients and server of the same venom release always match; a mismatch
falls back to vt. This spec does not describe ghostty’s record types; to
venomx they are an opaque, versioned blob with READY and FINISH markers.
Snapshot contents. Whatever the format, the state at READY MUST include:
| State | ghostty/… |
vt |
|---|---|---|
| Dimensions | TERMINAL record | Implied (client terminal is the pane size, section 10) |
| Visible screen, both primary and alternate, with styles, wide chars, graphemes, hyperlinks | SCREEN + PAGE records | Cell dump with SGR, OSC 8; ?1049h if alternate is active |
| Cursor position, visibility, shape, pending wrap, saved cursor | SCREEN | CUP, ?25h/l, DECSCUSR, DECSC |
| Modes: DECCKM, DECAWM, origin, bracketed paste (2004), focus events (1004), synchronized output state | TERMINAL | DECSET/DECRST |
| Mouse tracking and encoding (1000/1002/1003, 1006, 1016) | TERMINAL | DECSET |
| Keyboard protocol: kitty keyboard flags, modifyOtherKeys | TERMINAL | CSI = flags u, CSI > 4 ; n m |
| Scrolling region, tab stops, palette overrides | TERMINAL | DECSTBM, HTS/TBC, OSC 4/10/11 |
| Title, cwd | TERMINAL | OSC 2, OSC 7 |
Unfinished escape sequence or UTF-8 at offset |
CONTINUATION record | None needed: offset is chosen at a ground state (10.2) |
Selection, viewport scroll position and dirty state are per-client and are
not part of a snapshot. Kitty graphics MAY be included by ghostty/…
(optional records) and are omitted by vt.
Cost. Encoding is cheap enough to do on demand per attach (COM-218:
2–40 µs for one screen, ≤ 1.2 ms with 10k lines of history at 250 cols), so
servers SHOULD NOT cache snapshots. The encode needs exclusive access to the
Vt, so it runs on the pane’s own thread between feeds; offset is the
pane offset at that moment.
5.2 READY (S→C)
{"snap":1,"offset":4096,"epoch":2,"oldest":0,"top":155,"active":185,"cols":80,"rows":24}
READY follows the last SNAPSHOT chunk. After it the client can paint,
and the next PTY frame starts at offset.
| Member | Meaning |
|---|---|
snap, offset |
Same as the SNAPSHOT frames it ends |
epoch |
Row-address epoch (5.3) the row numbers below belong to |
oldest |
Absolute row of the oldest row the server retains (primary screen) |
top |
Absolute row of the first row included in the snapshot prefix. Rows [max(oldest, top − history), top) will arrive as SCROLLBACK. |
active |
Absolute row of the first row of the active area |
cols, rows |
Pane size at offset |
5.3 Row addresses and SCROLLBACK
Row addresses follow venom-term’s Vt.rows() (COM-219): an absolute row
is a u64 index of a physical row on the primary screen that does not change
when older rows are pruned or newer rows are written, valid only within an
epoch (u32). The server’s Vt starts a new epoch on a reflowing resize,
on clearing scrollback (CSI 3 J), on full reset (RIS), on a switch between
primary and alternate screen, and when it loses its anchor. Absolute rows
from different epochs MUST NOT be compared.
SCROLLBACK payload: snap u32 | epoch u32 | end_row u64 | rows u32 | flags u8 | bytes
flags: bit 0 LAST — no more SCROLLBACK for this snap
bit 1 ABORTED — backfill stopped early (always together with LAST)
bits 2–7 — 0; receivers ignore them
- Each chunk covers the
rowsrows immediately older thanend_row, i.e. absolute rows[end_row − rows, end_row)inepoch. The first chunk hasend_row = top; each next chunk’send_rowis the previous chunk’send_row − rows. History is sent newest first, so the client fills the scrollbar from the screen upward. - Because addresses are absolute, live output arriving between chunks (which pushes rows into scrollback on the client too) never shifts what a chunk refers to.
- In
ghostty/…format the bytes are the next history records of the same ghostty snapshot stream, split at record boundaries; the client passes them to the same decoder (decoder_next). ghostty explicitly allows the terminal to be fed live bytes and resized between history records. - Where
rowscomes from. Each ghostty history page is one PAGE record, and its fixed, uncompressed header holds the page’s row count (pinned format: little-endian u16 at byte 2 of the record payload, after the 10-bytetag, length, crc32crecord header). The server can label chunks by walking record headers, without decoding cells. That knowledge belongs in venom-term: its snapshot API MUST hand venomx the history as chunks that already carry their row counts, so venomx never parses ghostty bytes and the format stays opaque outside venom-term. A chunk MAY contain several pages;rowsis their sum. - The server sends
SCROLLBACKonly when the stream has no pendingPTYbytes, so backfill never delays live output. - If the epoch changes before backfill completes (for example a resize that
reflows), the server MUST stop and send an empty
SCROLLBACKwithLAST|ABORTED. The client keeps what it has; it MAY ask for a fresh snapshot withRESYNC(6.3) if it wants full history again. ATTACH.history = 0, or no history retained, means a single emptySCROLLBACKwithLASTright afterREADY, so the client always learns that backfill is complete.
5.4 History in raw mode
A raw client has no decoder to prepend rows into; its scrollback is the
host terminal’s own. In vt format the server MAY therefore put up to
history rows of plain (styled) history at the start of the vt
snapshot, oldest first, before the screen dump, so that it scrolls into the
host terminal’s scrollback naturally. A raw stream never receives
SCROLLBACK frames other than the single empty LAST one.
6. Live bytes, backpressure and resync
6.1 PTY (S→C)
PTY payload: offset u64 | bytes
offsetis the pane offset of the first byte. On a stream, consecutivePTYframes are contiguous: each starts where the previous one ended, and the first afterREADYstarts atREADY.offset(or atATTACHED.offsetwhen resumed). A client that sees a gap or overlap MUST sendRESYNC(6.3) and discardPTYframes until the newREADY.- The bytes are exactly what the PTY produced. Frame boundaries are
arbitrary: they need not match PTY reads, escape sequences or UTF-8
characters. The server MAY coalesce or split as it likes, up to
max_frame. A large frame has a cost for a slow reader: overflow (6.2) is only checked between frames, and the connection’s other streams wait behind it. venomx stops at 256 KiB for this reason (section 12).
6.2 Pane ring and per-client backlog
- Each pane keeps a ring of its most recent PTY output,
ring_byteslong (default 1 MiB, section 12), plus theRESIZEandEXITmarkers at their offsets. Its tail is the oldest offset still retained. - Each client stream has a send offset: the next pane offset to send. This is the “per-client ring buffer”: a window into the pane ring that starts at the send offset. Implementations SHOULD share the pane ring rather than copy bytes per client; the observable behaviour is the same.
- A server MAY also cap each client’s window with
client_backlog_bytes(default =ring_bytes). - Why this size. A snapshot costs about 1.5 ms of CPU (section 12; COM-218
measured at most 1.2 ms for the encode alone), so for a client that is far behind, resyncing early is cheaper
and less stale than replaying a large backlog. But the ring must outlast a
healthy client’s short scheduling delays, or a client that keeps up gets
dropped too: a program flooding 200-byte lines writes about 300 MiB/s, which
crosses 256 KiB in under 1 ms and 1 MiB in about 3.5 ms. tuios uses a 64 KB
ring per pane; tmux stops feeding a client once about 8 screens of output
are queued (
sx × sy × 8bytes: ~15 KB at 80×24, ~160 KB at 250×80) and redraws it later. 1 MiB is the smallest size at which no real client that was keeping up resynced in the measurements of section 12. It adds at most 768 KiB per busy pane. In a ReleaseFast build it adds nothing for a pane that has written little, since the ring’s pages are only touched as output arrives. Debug and ReleaseSafe builds fill every allocation, so there each pane pays for its whole ring from the start (section 12). - When a client’s send offset falls below the ring tail, or its backlog
exceeds
client_backlog_bytes, the client has fallen behind. The server MUST drop it to resync: discard its unsentPTYbytes (a frame already partly written is completed first), sendRESYNCwith reasonoverflow, then a new snapshot. - The server MUST NOT slow down reading the PTY, or delay other clients, because of a slow client.
- Snapshot and
SCROLLBACKbytes are produced on demand and are not counted in the ring. A server SHOULD bound how many snapshot encodes per second it does for a single stream that keeps overflowing (RECOMMENDED: at most one per second, sending nothing on that stream in between).
This answers RFC-0002 Open question 5: there is no credit-based flow
control in the envelope. A connection’s unsent output stays bounded
because PTY frames are produced lazily from the ring when the socket is
writable, and a stream that can’t keep up is resynced instead of buffered.
6.3 RESYNC (both directions)
S→C: the server is replacing the client’s state.
{"reason":"overflow","snap":2,"offset":9000012}
| Member | Meaning |
|---|---|
reason |
"overflow" (fell behind, 6.2), "requested" (client asked), "format" (the server can no longer produce the old format) |
snap |
The snap number of the snapshot that follows |
offset |
The pane offset the stream had delivered up to; bytes from here on are dropped |
It is followed by SNAPSHOT* and READY for the new snap, then PTY
from the new READY.offset. Any unfinished SCROLLBACK of the old snapshot
is abandoned (no LAST is sent for it). The client SHOULD keep showing its
old screen until the new READY, to avoid a blank flash, then replace its
whole emulator state.
C→S: the client asks for a new snapshot: after a gap (6.1), to switch mode (10.3), or to get full history again.
{"mode":"emulate","formats":["ghostty/1@0fa3d577a561","vt"],"history":10000}
Any ATTACH member except pane and resume may be given, including
size, claim, colors and cell; omitted ones keep their current value. The
server answers with S→C RESYNC (reason: "requested") and a new snapshot.
While a resync is in progress, further client RESYNC frames are ignored.
6.4 EXIT (S→C)
{"offset":6020,"exit_code":0}
The process attached to the pane’s PTY exited. offset is the final pane
offset: all PTY bytes before it have been sent, and no PTY frame
follows. exit_code (0–255) is present for a normal exit; signal
("SIGKILL", …) for death by signal. This matches RFC-0003 pane.exited.
The stream stays open after EXIT, because the pane is retained until it
is removed (RFC-0003 section 6, pane.exited): the client can still RESYNC to get a
final snapshot and history. When the pane is removed, the server sends
CLOSE with {"reason":"pane_removed"}. Attaching to an exited pane gives
ATTACHED, a snapshot, READY, then EXIT at once.
7. Reconnect with offset
A client that keeps its emulator state across a disconnect (an emulating client, or a raw client whose host terminal still shows the pane) resumes with:
{"pane":"p3","mode":"emulate","formats":["ghostty/1@0fa3d577a561","vt"],"size":{"cols":80,"rows":24},"resume":{"source":"venomx/01234567-89ab-4cde-8fab-0123456789ab","offset":4113}}
resume.offset is the end of the last PTY byte the client applied.
The server resumes, answering ATTACHED with resumed: true and the same
offset, if and only if both of these hold:
resume.sourceequals the daemon’s currentsource(the daemon has not restarted);resume.offsetis within[tail, head]of the pane ring.
Otherwise the server sends ATTACHED with resumed: false and a normal
snapshot. A client MUST send resume only with the mode and format it
had when it disconnected; changing either needs a snapshot.
On resume the server replays, in offset order, every PTY byte and every
RESIZE and EXIT marker from resume.offset on, so a client that missed
a resize while disconnected applies it at the right byte. Backfill that was
unfinished at disconnect is not resumed; the client MAY RESYNC to get it.
A resume does not reset the size policy; like any attach, it counts as
activity only if claim is true (section 8).
8. Size
A raw byte stream is only correct for one PTY size: programs format their output for the size the PTY reports. So a pane has exactly one size at a time, chosen by the server with a per-pane size policy:
| Policy | Rule |
|---|---|
latest (default) |
The pane takes the viewport of the active writer: the writer that most recently sent INPUT or attached with claim: true. It follows that writer’s viewport, including when that writer’s window is resized. |
smallest |
The smallest cols and smallest rows over all attached writers |
fixed |
A size set in config or by venomx’s pane.resize verb; clients never change it |
latest is the tmux default since 3.1 and the tuios default. Its details
follow tuios, which runs the same raw-bytes model:
- Activity is input, not geometry. Only
INPUT(keys, paste, mouse) and an attach withclaim: truemake a writer active. ARESIZE(C→S) or a focus change never does. - Hold. The active writer keeps the size for
latest_hold_ms(default 1000 ms) after its last activity. Activity by another writer inside that window does not move the size; the first activity after it does. Two people typing in one pane therefore don’t make it flip between sizes on every keystroke.
Rules common to every policy:
- Watchers never affect the size. An agent watching a pane never reflows a human’s terminal. With only watchers attached, the size stays as it is (tuios lets viewers set it in that case; venomx deliberately does not).
- With no writers attached, the size does not change. Detaching does not resize.
- Each writer reports its viewport with
RESIZE(C→S) whenever it changes:{"cols":120,"rows":40}. This only records the viewport. It changes the pane size if this writer is the active writer (latest), or if it changes the minimum (smallest). - Burst coalescing. Window managers send many sizes during a drag. A
size change that arrives within
resize_coalesce_ms(default 30 ms) of the previous one applied to that pane is held, and only the last held size is applied when the window ends. The first change after a quiet period is applied at once, andINPUTto the pane applies any held size first, so input always reaches the program at the size it was typed at. - When the size changes, the server resizes its
Vt, sets the PTY window size (TIOCSWINSZ, which sendsSIGWINCH), records a resize marker at the current pane offsetN, and sends every stream aRESIZE(S→C) at that point in the byte order:
{"offset":5002,"cols":120,"rows":40,"epoch":3,"oldest":0,"active":160}
offset is N: every PTY byte before N was produced for the old
size, every byte from N for the new one. A client applies the resize to
its emulator after applying bytes up to N and before applying byte N.
epoch, oldest and active are the row addresses after the resize.
What a client does when the pane size differs from its viewport:
- Emulating clients render the pane’s real size into their viewport: crop when the pane is larger (the client’s own view scrolls to keep the cursor visible) and letterbox when it is smaller (pad with empty cells). They never resize their emulator to anything but the pane size.
- Raw clients can’t crop: the host terminal is the emulator. A raw
client whose terminal size differs from the pane size MUST stop writing
PTYbytes to the host terminal and fall back to emulation (10.3).
8.1 Cell size
Programs that draw images (kitty graphics, sixel, image previews) size them
from the PTY’s pixel size (TIOCGWINSZ ws_xpixel/ws_ypixel) or from
XTWINOPS queries (CSI 14 t, CSI 16 t). A pane has one pixel size, chosen
the way its size in cells is:
- A client SHOULD send
ATTACH.cellwhen it knows its host terminal’s cell size, andRESYNCwith a newcellwhen it changes (for example after a font change). - The pane’s cell size is the
cellof the client whose viewport is the pane size (the active writer underlatest), falling back to the most recent writer that sent one. With none, the pixel size is unknown: the server setsws_xpixel/ws_ypixelto 0 and answersCSI 14 tandCSI 16 twith zeros. - The PTY’s pixel size is
cols × widthbyrows × height, set together with the cell size whenever either changes.cellalone never resizes the pane or records a resize marker: it does not change what anyPTYbyte means.
9. Input and roles
INPUT payload: bytes to write to the PTY
INPUTneeds thewritegrant on the pane (RFC-0004 grant names; identity and scopes are RFC-0005, COM-204). The server decides the role at attach and reports it inATTACHED.input: writer (true) or watcher (false). Until RFC-0005 lands, the default is the RFC-0001 rule: unknown callers get at most read-only rights, so they are watchers.- A watcher MUST NOT send
INPUT. If it does, the server sendsERRORdenied.write(exit 4) on that stream, closing it, and writes nothing. - The server is the single writer to the PTY. It applies each
INPUTframe atomically, in arrival order across all writers: bytes of two frames are never interleaved. Terminal query replies produced by the authoritativeVt(write_pty, RFC-0003 section 7) go through the same serialized writer. A client SHOULD send a paste as one frame when it fits inmax_frame. INPUTbytes are already encoded for the pane. An emulating client encodes keys, mouse and paste with its own emulator’s view of the pane’s modes (kitty keyboard, DECCKM, SGR mouse, bracketed paste), which it got from the snapshot and the byte stream. A raw client forwards what its host terminal sends (10.2 explains why that contains no query replies).INPUTcounts as activity for thelatestsize policy (section 8).- Input does not echo: the client sees the program’s response on
PTYlike everyone else.
9.1 Colour queries
Programs ask the terminal for its colours (OSC 10/11/12 with ?) to pick
light or dark themes. The server’s Vt answers every query (RFC-0003
section 7), so it needs to know what the real terminal looks like; tuios
and zellij likewise answer from what the attached clients report.
- A client SHOULD send
ATTACH.colorswhen it knows them: an emulating client from its own renderer’s theme, a raw client from its startup probe of the host terminal. - The server answers OSC 10/11/12 queries with the
colorsof the active writer (section 8), falling back to the most recent writer that sent them, then to theVt’s configured defaults. Colours a program set itself (OSC 10/11/12 with a value) take precedence, as on any terminal. colorsonly changes query answers. It does not recolour the snapshot or other clients’ rendering; each client renders default colours its own way.- v1 has no way to update
colorsafter attach (for example when the host switches between light and dark). A client that needs to canRESYNCwith newcolors(6.3 allows anyATTACHmember).
10. Client modes
10.1 Emulating
The client runs one venom-term Vt (libghostty-vt) per attached pane:
- On
SNAPSHOTchunks for formatghostty/…: feed them to a snapshot decoder; onREADY, take the live terminal from the decoder and paint. For formatvt: create a freshVtof sizeREADY.cols × READY.rowsand feed it thevtbytes. - On
PTY:feed()the bytes into theVt, after checking contiguity. - On
SCROLLBACK: pass the bytes to the decoder (ghostty) to prepend history; free the decoder onLAST. - On
RESIZE:resize()theVtat the given offset.
The client’s Vt MUST NOT answer terminal queries (no write_pty
callback): the server’s authoritative Vt already answers each query once
(RFC-0003 section 7), and a second reply would reach the program twice.
OSC side effects (clipboard, notifications) are not registered either; the
client learns about them through events. This is the mode for splits, tabs
and venom-desk.
10.2 Raw passthrough
The client attaches to a single pane, makes its host terminal the pane’s size, and writes bytes to it unchanged:
- The
vtsnapshot is written to the host terminal after a reset sequence the client chooses (for exampleESC corCSI ! pplus clearing the screen). The server MUST build thevtsnapshot so thatoffsetis at a ground state: if the parser is inside an escape sequence or a UTF-8 character at the current offset, the server labels the snapshot as of the offset where that sequence started (the bytes since are still in the ring and arrive in the firstPTYframe). This is whyvtneeds no continuation. PTYbytes go to the host terminal as they arrive.- On
RESIZEto a size that differs from the host terminal: fall back (10.3).
Raw mode trades isolation for fidelity, so the client MUST pass PTY
bytes through an output filter before the host terminal sees them. The
filter is a VT tokenizer (not an emulator) that tolerates sequences split
across frames. It handles at least these sequences; a client MAY remove
more, never fewer:
| Sequence | Action | Why |
|---|---|---|
Queries: DA1/DA2/DA3 (CSI c, CSI > c, CSI = c), DSR and CPR (CSI n, CSI ? n), DECRQM (CSI $ p, CSI ? $ p), DECRQSS (DCS $ q), XTVERSION (CSI > q), XTGETTCAP (DCS + q), kitty keyboard query (CSI ? u), window reports (CSI t with a report code), colour queries (OSC 4/10/11/12/104 with ?) |
Remove | The server’s authoritative Vt already answered them (RFC-0003 section 7). If the host saw them, it would reply too and the program would get two answers. tmux and zellij likewise answer queries themselves instead of forwarding them. |
OSC 52 clipboard read (? payload) |
Remove | Would hand the host clipboard to the pane’s program. tmux never reads the host clipboard by default (get-clipboard buffer). |
| OSC 52 clipboard write | Writers: pass when config raw_clipboard = .external (default), remove when .off. Watchers: always remove. |
Matches tmux’s default set-clipboard external. A program in a pane you only watch must not overwrite your clipboard. |
| Everything else (screen, modes, title, cwd, notifications, kitty graphics) | Pass | Fidelity is the point of raw mode |
Because the queries never reach the host, the host never sends replies
for them, and the client needs no input-side filtering of query replies.
Replies to the client’s own startup probes are consumed by venom-term
Input as usual.
10.3 Switching modes
A raw client that must fall back sends RESYNC with {"mode":"emulate"}
(and its formats), discards PTY bytes until the new READY, then
renders the pane as an emulating client would, cropped or letterboxed. When
it later becomes the active writer and the pane takes its size again, it
MAY RESYNC with {"mode":"raw","formats":["vt"]} to return to
passthrough. The new mode takes effect with the snapshot that follows the
server’s RESYNC.
11. Byte traces
All traces are complete frames as written to the socket (RFC-0002 envelope:
type u16, stream u32, length u32, payload). Where a payload is an
opaque snapshot blob, only its first bytes are shown and the rest is
elided; header lengths are the real ones. The GHOSTSNP bytes stand in for
a real ghostty snapshot.
11.1 Attach (emulating client, then typing)
The client opens a binary connection; the first byte is the envelope
preamble 0xF5 (RFC-0002 section 3), then HELLO asking for
venomx.pane_stream:
# C→S preamble
00000000 f5 |.|
# C→S HELLO (type 0x0001, stream 0, length 97)
00000000 00 01 00 00 00 00 00 00 00 61 7b 22 70 72 6f 74 |.........a{"prot|
00000010 6f 22 3a 5b 31 5d 2c 22 63 6c 69 65 6e 74 22 3a |o":[1],"client":|
00000020 7b 22 6e 61 6d 65 22 3a 22 76 65 6e 6f 6d 78 22 |{"name":"venomx"|
00000030 2c 22 76 65 72 73 69 6f 6e 22 3a 22 30 2e 31 2e |,"version":"0.1.|
00000040 30 22 7d 2c 22 63 61 70 73 22 3a 5b 22 63 61 6e |0"},"caps":["can|
00000050 63 65 6c 22 2c 22 76 65 6e 6f 6d 78 2e 70 61 6e |cel","venomx.pan|
00000060 65 5f 73 74 72 65 61 6d 22 5d 7d |e_stream"]}|
# S→C HELLO_OK (type 0x0002, stream 0, length 115)
00000000 00 02 00 00 00 00 00 00 00 73 7b 22 70 72 6f 74 |.........s{"prot|
00000010 6f 22 3a 31 2c 22 73 65 72 76 65 72 22 3a 7b 22 |o":1,"server":{"|
00000020 6e 61 6d 65 22 3a 22 76 65 6e 6f 6d 78 22 2c 22 |name":"venomx","|
00000030 76 65 72 73 69 6f 6e 22 3a 22 30 2e 31 2e 30 22 |version":"0.1.0"|
00000040 7d 2c 22 63 61 70 73 22 3a 5b 22 63 61 6e 63 65 |},"caps":["cance|
00000050 6c 22 2c 22 76 65 6e 6f 6d 78 2e 70 61 6e 65 5f |l","venomx.pane_|
00000060 73 74 72 65 61 6d 22 5d 2c 22 6d 61 78 5f 66 72 |stream"],"max_fr|
00000070 61 6d 65 22 3a 31 30 34 38 35 37 36 7d |ame":1048576}|
# C→S ATTACH (type 0x0100, stream 1, length 117)
00000000 01 00 00 00 00 01 00 00 00 75 7b 22 70 61 6e 65 |.........u{"pane|
00000010 22 3a 22 70 33 22 2c 22 6d 6f 64 65 22 3a 22 65 |":"p3","mode":"e|
00000020 6d 75 6c 61 74 65 22 2c 22 66 6f 72 6d 61 74 73 |mulate","formats|
00000030 22 3a 5b 22 67 68 6f 73 74 74 79 2f 31 40 30 66 |":["ghostty/1@0f|
00000040 61 33 64 35 37 37 61 35 36 31 22 2c 22 76 74 22 |a3d577a561","vt"|
00000050 5d 2c 22 73 69 7a 65 22 3a 7b 22 63 6f 6c 73 22 |],"size":{"cols"|
00000060 3a 38 30 2c 22 72 6f 77 73 22 3a 32 34 7d 2c 22 |:80,"rows":24},"|
00000070 68 69 73 74 6f 72 79 22 3a 31 30 30 30 30 7d |history":10000}|
# S→C ATTACHED (type 0x0101, stream 1, length 195)
00000000 01 01 00 00 00 01 00 00 00 c3 7b 22 73 6f 75 72 |..........{"sour|
00000010 63 65 22 3a 22 76 65 6e 6f 6d 78 2f 30 31 32 33 |ce":"venomx/0123|
00000020 34 35 36 37 2d 38 39 61 62 2d 34 63 64 65 2d 38 |4567-89ab-4cde-8|
00000030 66 61 62 2d 30 31 32 33 34 35 36 37 38 39 61 62 |fab-0123456789ab|
00000040 22 2c 22 70 61 6e 65 22 3a 22 70 33 22 2c 22 6d |","pane":"p3","m|
00000050 6f 64 65 22 3a 22 65 6d 75 6c 61 74 65 22 2c 22 |ode":"emulate","|
00000060 66 6f 72 6d 61 74 22 3a 22 67 68 6f 73 74 74 79 |format":"ghostty|
00000070 2f 31 40 30 66 61 33 64 35 37 37 61 35 36 31 22 |/1@0fa3d577a561"|
00000080 2c 22 72 65 73 75 6d 65 64 22 3a 66 61 6c 73 65 |,"resumed":false|
00000090 2c 22 69 6e 70 75 74 22 3a 74 72 75 65 2c 22 73 |,"input":true,"s|
000000a0 69 7a 65 22 3a 7b 22 63 6f 6c 73 22 3a 38 30 2c |ize":{"cols":80,|
000000b0 22 72 6f 77 73 22 3a 32 34 7d 2c 22 70 6f 6c 69 |"rows":24},"poli|
000000c0 63 79 22 3a 22 6c 61 74 65 73 74 22 7d |cy":"latest"}|
# S→C SNAPSHOT (type 0x0102, stream 1, length 6820, snap 1, as of offset 4096)
00000000 01 02 00 00 00 01 00 00 1a a4 00 00 00 01 00 00 |................|
00000010 00 00 00 00 10 00 47 48 4f 53 54 53 4e 50 00 01 |......GHOSTSNP..|
00000020 00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f |................|
… 6782 more payload bytes (opaque) …
# S→C READY (type 0x0103, stream 1, length 88)
00000000 01 03 00 00 00 01 00 00 00 58 7b 22 73 6e 61 70 |.........X{"snap|
00000010 22 3a 31 2c 22 6f 66 66 73 65 74 22 3a 34 30 39 |":1,"offset":409|
00000020 36 2c 22 65 70 6f 63 68 22 3a 32 2c 22 6f 6c 64 |6,"epoch":2,"old|
00000030 65 73 74 22 3a 30 2c 22 74 6f 70 22 3a 31 35 35 |est":0,"top":155|
00000040 2c 22 61 63 74 69 76 65 22 3a 31 38 35 2c 22 63 |,"active":185,"c|
00000050 6f 6c 73 22 3a 38 30 2c 22 72 6f 77 73 22 3a 32 |ols":80,"rows":2|
00000060 34 7d |4}|
# S→C PTY (type 0x0104, stream 1, length 21, offset 4096)
00000000 01 04 00 00 00 01 00 00 00 15 00 00 00 00 00 00 |................|
00000010 10 00 1b 5b 33 32 6d 6f 6b 1b 5b 30 6d 0d 0a |...[32mok.[0m..|
# S→C SCROLLBACK (type 0x0105, stream 1, length 9421, rows [0,155), LAST)
00000000 01 05 00 00 00 01 00 00 24 cd 00 00 00 01 00 00 |........$.......|
00000010 00 02 00 00 00 00 00 00 00 9b 00 00 00 9b 01 00 |................|
00000020 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f 10 |................|
… 9383 more payload bytes (opaque) …
# C→S INPUT (type 0x0106, stream 1, length 3)
00000000 01 06 00 00 00 01 00 00 00 03 6c 73 0d |..........ls.|
# S→C PTY (type 0x0104, stream 1, length 12, offset 4109)
00000000 01 04 00 00 00 01 00 00 00 0c 00 00 00 00 00 00 |................|
00000010 10 0d 6c 73 0d 0a |..ls..|
Reading it: the snapshot is as of offset 0x1000 = 4096 and covers rows
from top = 155 down to the bottom of the active area (rows 185–208). The
first PTY frame starts at exactly 4096 (00 00 00 00 00 00 10 00). The
single SCROLLBACK chunk has epoch 2, end_row 0x9b = 155, rows
155, flags 0x01 (LAST), so it covers rows [0,155). The typed ls\r
comes back as the program’s echo at offset 4109 = 4096 + 13.
11.2 Reconnect with offset
The client’s connection dropped after it applied bytes up to 4113. It
reconnects (preamble and HELLO as in 11.1, omitted) and resumes; no
snapshot is sent:
# C→S ATTACH (type 0x0100, stream 1, length 181)
00000000 01 00 00 00 00 01 00 00 00 b5 7b 22 70 61 6e 65 |..........{"pane|
00000010 22 3a 22 70 33 22 2c 22 6d 6f 64 65 22 3a 22 65 |":"p3","mode":"e|
00000020 6d 75 6c 61 74 65 22 2c 22 66 6f 72 6d 61 74 73 |mulate","formats|
00000030 22 3a 5b 22 67 68 6f 73 74 74 79 2f 31 40 30 66 |":["ghostty/1@0f|
00000040 61 33 64 35 37 37 61 35 36 31 22 2c 22 76 74 22 |a3d577a561","vt"|
00000050 5d 2c 22 73 69 7a 65 22 3a 7b 22 63 6f 6c 73 22 |],"size":{"cols"|
00000060 3a 38 30 2c 22 72 6f 77 73 22 3a 32 34 7d 2c 22 |:80,"rows":24},"|
00000070 72 65 73 75 6d 65 22 3a 7b 22 73 6f 75 72 63 65 |resume":{"source|
00000080 22 3a 22 76 65 6e 6f 6d 78 2f 30 31 32 33 34 35 |":"venomx/012345|
00000090 36 37 2d 38 39 61 62 2d 34 63 64 65 2d 38 66 61 |67-89ab-4cde-8fa|
000000a0 62 2d 30 31 32 33 34 35 36 37 38 39 61 62 22 2c |b-0123456789ab",|
000000b0 22 6f 66 66 73 65 74 22 3a 34 31 31 33 7d 7d |"offset":4113}}|
# S→C ATTACHED (type 0x0101, stream 1, length 208)
00000000 01 01 00 00 00 01 00 00 00 d0 7b 22 73 6f 75 72 |..........{"sour|
00000010 63 65 22 3a 22 76 65 6e 6f 6d 78 2f 30 31 32 33 |ce":"venomx/0123|
00000020 34 35 36 37 2d 38 39 61 62 2d 34 63 64 65 2d 38 |4567-89ab-4cde-8|
00000030 66 61 62 2d 30 31 32 33 34 35 36 37 38 39 61 62 |fab-0123456789ab|
00000040 22 2c 22 70 61 6e 65 22 3a 22 70 33 22 2c 22 6d |","pane":"p3","m|
00000050 6f 64 65 22 3a 22 65 6d 75 6c 61 74 65 22 2c 22 |ode":"emulate","|
00000060 66 6f 72 6d 61 74 22 3a 22 67 68 6f 73 74 74 79 |format":"ghostty|
00000070 2f 31 40 30 66 61 33 64 35 37 37 61 35 36 31 22 |/1@0fa3d577a561"|
00000080 2c 22 72 65 73 75 6d 65 64 22 3a 74 72 75 65 2c |,"resumed":true,|
00000090 22 6f 66 66 73 65 74 22 3a 34 31 31 33 2c 22 69 |"offset":4113,"i|
000000a0 6e 70 75 74 22 3a 74 72 75 65 2c 22 73 69 7a 65 |nput":true,"size|
000000b0 22 3a 7b 22 63 6f 6c 73 22 3a 38 30 2c 22 72 6f |":{"cols":80,"ro|
000000c0 77 73 22 3a 32 34 7d 2c 22 70 6f 6c 69 63 79 22 |ws":24},"policy"|
000000d0 3a 22 6c 61 74 65 73 74 22 7d |:"latest"}|
# S→C PTY (type 0x0104, stream 1, length 26, offset 4113)
00000000 01 04 00 00 00 01 00 00 00 1a 00 00 00 00 00 00 |................|
00000010 10 11 52 45 41 44 4d 45 2e 6d 64 20 20 73 72 63 |..README.md src|
00000020 0d 0a 24 20 |..$ |
Had the daemon restarted (new source) or had 4113 left the ring, the reply
would be ATTACHED with resumed: false and the stream would continue as
in 11.1:
# S→C ATTACHED (type 0x0101, stream 1, length 195)
00000000 01 01 00 00 00 01 00 00 00 c3 7b 22 73 6f 75 72 |..........{"sour|
00000010 63 65 22 3a 22 76 65 6e 6f 6d 78 2f 30 31 32 33 |ce":"venomx/0123|
00000020 34 35 36 37 2d 38 39 61 62 2d 34 63 64 65 2d 38 |4567-89ab-4cde-8|
00000030 66 61 62 2d 30 31 32 33 34 35 36 37 38 39 61 62 |fab-0123456789ab|
00000040 22 2c 22 70 61 6e 65 22 3a 22 70 33 22 2c 22 6d |","pane":"p3","m|
00000050 6f 64 65 22 3a 22 65 6d 75 6c 61 74 65 22 2c 22 |ode":"emulate","|
00000060 66 6f 72 6d 61 74 22 3a 22 67 68 6f 73 74 74 79 |format":"ghostty|
00000070 2f 31 40 30 66 61 33 64 35 37 37 61 35 36 31 22 |/1@0fa3d577a561"|
00000080 2c 22 72 65 73 75 6d 65 64 22 3a 66 61 6c 73 65 |,"resumed":false|
00000090 2c 22 69 6e 70 75 74 22 3a 74 72 75 65 2c 22 73 |,"input":true,"s|
000000a0 69 7a 65 22 3a 7b 22 63 6f 6c 73 22 3a 38 30 2c |ize":{"cols":80,|
000000b0 22 72 6f 77 73 22 3a 32 34 7d 2c 22 70 6f 6c 69 |"rows":24},"poli|
000000c0 63 79 22 3a 22 6c 61 74 65 73 74 22 7d |cy":"latest"}|
11.3 Resync after overflow
A watcher stalls while the program writes about 1.5 MB. The pane ring is 1 MiB, so when the head reaches 10485760 the tail is 9437184, past the client’s send offset 9000012. The server drops it to resync:
# S→C PTY (type 0x0104, stream 1, length 20, offset 9000000)
00000000 01 04 00 00 00 01 00 00 00 14 00 00 00 00 00 89 |................|
00000010 54 40 6c 69 6e 65 20 38 31 32 33 34 0d 0a |T@line 81234..|
# S→C RESYNC (type 0x0108, stream 1, length 47)
00000000 01 08 00 00 00 01 00 00 00 2f 7b 22 72 65 61 73 |........./{"reas|
00000010 6f 6e 22 3a 22 6f 76 65 72 66 6c 6f 77 22 2c 22 |on":"overflow","|
00000020 73 6e 61 70 22 3a 32 2c 22 6f 66 66 73 65 74 22 |snap":2,"offset"|
00000030 3a 39 30 30 30 30 31 32 7d |:9000012}|
# S→C SNAPSHOT (type 0x0102, stream 1, length 3132, snap 2, as of offset 10485760)
00000000 01 02 00 00 00 01 00 00 0c 3c 00 00 00 02 00 00 |.........<......|
00000010 00 00 00 a0 00 00 47 48 4f 53 54 53 4e 50 00 01 |......GHOSTSNP..|
00000020 00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f |................|
… 3094 more payload bytes (opaque) …
# S→C READY (type 0x0103, stream 1, length 103)
00000000 01 03 00 00 00 01 00 00 00 67 7b 22 73 6e 61 70 |.........g{"snap|
00000010 22 3a 32 2c 22 6f 66 66 73 65 74 22 3a 31 30 34 |":2,"offset":104|
00000020 38 35 37 36 30 2c 22 65 70 6f 63 68 22 3a 32 2c |85760,"epoch":2,|
00000030 22 6f 6c 64 65 73 74 22 3a 38 31 32 33 34 35 2c |"oldest":812345,|
00000040 22 74 6f 70 22 3a 38 32 31 37 30 30 2c 22 61 63 |"top":821700,"ac|
00000050 74 69 76 65 22 3a 38 32 31 37 34 35 2c 22 63 6f |tive":821745,"co|
00000060 6c 73 22 3a 38 30 2c 22 72 6f 77 73 22 3a 32 34 |ls":80,"rows":24|
00000070 7d |}|
# S→C PTY (type 0x0104, stream 1, length 20, offset 10485760)
00000000 01 04 00 00 00 01 00 00 00 14 00 00 00 00 00 a0 |................|
00000010 00 00 6c 69 6e 65 20 39 39 30 30 31 0d 0a |..line 99001..|
The RESYNC says the stream had delivered up to 9000012; bytes from there
to 10485760 are never sent to this client. The new snapshot (snap 2) is
as of 10485760, and PTY continues from that offset. The rows were
addressed in the same epoch (2) throughout, because nothing reflowed;
backfill for snap 2 follows as in 11.1.
11.4 Size change
Policy latest. Writer A (stream 1) is the active writer: it typed last.
Its window is resized to 120×40, so it reports the new viewport, and
because it is the active writer the pane follows. The shell’s prompt $ was the last output for the old size, so the
resize marker sits at offset 5002, and the shell’s redraw after SIGWINCH
follows it:
# C→S RESIZE (type 0x0107, stream 1, length 22)
00000000 01 07 00 00 00 01 00 00 00 16 7b 22 63 6f 6c 73 |..........{"cols|
00000010 22 3a 31 32 30 2c 22 72 6f 77 73 22 3a 34 30 7d |":120,"rows":40}|
# S→C PTY (type 0x0104, stream 1, length 10, offset 5000)
00000000 01 04 00 00 00 01 00 00 00 0a 00 00 00 00 00 00 |................|
00000010 13 88 24 20 |..$ |
# S→C RESIZE (type 0x0107, stream 1, length 70)
00000000 01 07 00 00 00 01 00 00 00 46 7b 22 6f 66 66 73 |.........F{"offs|
00000010 65 74 22 3a 35 30 30 32 2c 22 63 6f 6c 73 22 3a |et":5002,"cols":|
00000020 31 32 30 2c 22 72 6f 77 73 22 3a 34 30 2c 22 65 |120,"rows":40,"e|
00000030 70 6f 63 68 22 3a 33 2c 22 6f 6c 64 65 73 74 22 |poch":3,"oldest"|
00000040 3a 30 2c 22 61 63 74 69 76 65 22 3a 31 36 30 7d |:0,"active":160}|
# S→C PTY (type 0x0104, stream 1, length 15, offset 5002)
00000000 01 04 00 00 00 01 00 00 00 0f 00 00 00 00 00 00 |................|
00000010 13 8a 1b 5b 32 4a 1b 5b 48 |...[2J.[H|
# on stream 3 (a raw client sized 80×24 on another connection's stream 3)
# S→C RESIZE (type 0x0107, stream 3, length 70)
00000000 01 07 00 00 00 03 00 00 00 46 7b 22 6f 66 66 73 |.........F{"offs|
00000010 65 74 22 3a 35 30 30 32 2c 22 63 6f 6c 73 22 3a |et":5002,"cols":|
00000020 31 32 30 2c 22 72 6f 77 73 22 3a 34 30 2c 22 65 |120,"rows":40,"e|
00000030 70 6f 63 68 22 3a 33 2c 22 6f 6c 64 65 73 74 22 |poch":3,"oldest"|
00000040 3a 30 2c 22 61 63 74 69 76 65 22 3a 31 36 30 7d |:0,"active":160}|
# C→S RESYNC (type 0x0108, stream 3, length 60)
00000000 01 08 00 00 00 03 00 00 00 3c 7b 22 6d 6f 64 65 |.........<{"mode|
00000010 22 3a 22 65 6d 75 6c 61 74 65 22 2c 22 66 6f 72 |":"emulate","for|
00000020 6d 61 74 73 22 3a 5b 22 67 68 6f 73 74 74 79 2f |mats":["ghostty/|
00000030 31 40 30 66 61 33 64 35 37 37 61 35 36 31 22 2c |1@0fa3d577a561",|
00000040 22 76 74 22 5d 7d |"vt"]}|
# S→C RESYNC (type 0x0108, stream 3, length 45)
00000000 01 08 00 00 00 03 00 00 00 2d 7b 22 72 65 61 73 |.........-{"reas|
00000010 6f 6e 22 3a 22 72 65 71 75 65 73 74 65 64 22 2c |on":"requested",|
00000020 22 73 6e 61 70 22 3a 32 2c 22 6f 66 66 73 65 74 |"snap":2,"offset|
00000030 22 3a 35 30 31 30 7d |":5010}|
The raw client on stream 3 of another connection is 80×24, so after the
RESIZE it stops writing to its host terminal, asks to emulate, and gets a
new snapshot (as of 5010, the server’s position when it handled the
request) that it renders cropped into its 80×24 window (section 8).
11.5 Exit and removal; watcher input
# S→C EXIT (type 0x0109, stream 1, length 29)
00000000 01 09 00 00 00 01 00 00 00 1d 7b 22 6f 66 66 73 |..........{"offs|
00000010 65 74 22 3a 36 30 32 30 2c 22 65 78 69 74 5f 63 |et":6020,"exit_c|
00000020 6f 64 65 22 3a 30 7d |ode":0}|
# S→C CLOSE (type 0x0006, stream 1, length 25)
00000000 00 06 00 00 00 01 00 00 00 19 7b 22 72 65 61 73 |..........{"reas|
00000010 6f 6e 22 3a 22 70 61 6e 65 5f 72 65 6d 6f 76 65 |on":"pane_remove|
00000020 64 22 7d |d"}|
A watcher (stream 5, ATTACHED.input was false) sends INPUT; the
server refuses it and closes the stream, writing nothing to the PTY:
# C→S INPUT (type 0x0106, stream 5, length 9)
00000000 01 06 00 00 00 05 00 00 00 09 72 6d 20 2d 72 66 |..........rm -rf|
00000010 20 2f 0d | /.|
# S→C ERROR (type 0x0003, stream 5, length 101)
00000000 00 03 00 00 00 05 00 00 00 65 7b 22 63 6f 64 65 |.........e{"code|
00000010 22 3a 22 64 65 6e 69 65 64 2e 77 72 69 74 65 22 |":"denied.write"|
00000020 2c 22 6d 65 73 73 61 67 65 22 3a 22 74 68 69 73 |,"message":"this|
00000030 20 61 74 74 61 63 68 6d 65 6e 74 20 69 73 20 61 | attachment is a|
00000040 20 77 61 74 63 68 65 72 22 2c 22 64 61 74 61 22 | watcher","data"|
00000050 3a 7b 22 70 61 6e 65 22 3a 22 70 33 22 2c 22 67 |:{"pane":"p3","g|
00000060 72 61 6e 74 22 3a 22 77 72 69 74 65 22 7d 7d |rant":"write"}}|
12. Configuration
venomx config (ZON, changed with config get|set|check|reload|schema,
RFC-0001):
.{
.pane_stream = .{
.ring_bytes = 1048576, // per pane; resume window and backlog limit
.client_backlog_bytes = 1048576, // per client stream; ≤ ring_bytes
.size_policy = .latest, // default for new panes: .latest | .smallest | .fixed
.fixed_size = .{ .cols = 120, .rows = 40 }, // only used with .fixed
.latest_hold_ms = 1000, // section 8
.resize_coalesce_ms = 30, // section 8
.max_history_rows = 10000, // cap on ATTACH.history
},
.attach = .{ // the attach client in the same binary
.raw_clipboard = .external, // .external | .off (10.2)
},
}
size_policy is the default for new panes. The verb that creates a pane
(venomx schema) MAY take a policy and size that override it for that pane,
as tuios allows per session; ATTACHED.policy always reports the pane’s
own policy.
config check MUST reject client_backlog_bytes > ring_bytes,
ring_bytes < 65536, zero dimensions in fixed_size, and
latest_hold_ms or resize_coalesce_ms above 10000. A reload that
shrinks ring_bytes takes effect on the next write and may drop slow
clients to resync. A changed size_policy applies to existing panes at the
next activity.
Measured defaults. On 2026-11-08 each default was measured under an idle
shell, a build log, a full-screen TUI, synthetic floods and slow clients. Method and
full tables are in venomx
docs/pane-stream-defaults.md.
The machine was shared and the clients wrote to a PTY sink, not a real terminal.
| Default | Verdict | Evidence |
|---|---|---|
ring_bytes, client_backlog_bytes |
Changed from 256 KiB to 1 MiB | Runs of a real venomx attach on a flood with at least one resync: 10 of 20 at 256 KiB, 2 of 20 at 512 KiB, 0 of 20 at 1 MiB and at 2 MiB. Costs 24 MiB more over 32 busy panes (212 to 236 MiB). 32 idle panes cost 8.7 MiB at either size in ReleaseFast, but pay the difference in Debug and ReleaseSafe (+25 MiB). Resume window: about 7 s of a build log, 1 s of a 60 fps full repaint. |
latest_hold_ms = 1000 |
Confirmed | With two scripted writers typing at once, 12 size flips/min against 260 with no hold. Every turn in a turn-taking trace still moves the size (2000 ms loses some). The next writer waits for the rest of the 1000 ms hold (905 ms measured after a 100 ms pause). How sticky this feels was not measured. |
resize_coalesce_ms = 30 |
Confirmed | Applied sizes on a drag: 32/s at both 60 and 125 Hz, against 62 and 125 with no window. The last size lands within 31 ms. |
| One overflow snapshot per second (6.2) | Confirmed | About 1.5 ms of CPU per snapshot with 10,000 lines of history. 16 slow clients left a sibling pane’s echo p99 at 0.9 ms. |
venomx’s PTY frames (6.1) |
Capped at 256 KiB | Without a cap a frame reached 724 KiB on a client that reads in bursts. 64 clients that lag and then stop reading each held about 0.83 MiB until they disconnected, and about 0.38 MiB with the cap, the same as with a 256 KiB ring. On a 256 KiB/s reader, the longest wait between frames fell from 1842 ms to 1002 ms. |
13. Versioning
- The frame set is gated by capability
venomx.pane_stream. Compatible additions (new optional JSON members, newRESYNCreasons, newSCROLLBACKflag bits, new formats) need no new capability: receivers ignore unknown members and bits, and formats are negotiated per attach. A new frame type in0x010A–0x01FFneeds a new capability (venomx.<feature>), because an unknown tool frame type is fatal (RFC-0002 section 5.2). - An incompatible change to these frames gets a new capability name
(
venomx.pane_stream2) and a superseding RFC; a server MAY offer both. - Snapshot formats are versioned by name (5.1). Bumping the ghostty pin
changes the
ghostty/…name, so mixed versions fall back tovtrather than misdecoding. - Clients MUST treat an unknown
RESYNC.reasonlikerequestedand an unknownATTACHED.policylikefixed.
14. Out of scope
- Remote transport, as in RFC-0002 section 8. The frames carry no host
byte order or local paths, so
ssh host venomx bridgewill carry them unchanged. Compression (zstd per stream forSNAPSHOTandSCROLLBACK, which COM-218 found compress 10–180×) belongs to that RFC; local sockets send raw bytes. - Logical-line (unwrapped) addressing for agents; that is a text-level index, not part of the pane stream (COM-218 4.2).
- Selection, copy mode and per-client viewport scrolling: client-side.
Agent-contract impact
- Verbs: no new NDJSON verbs. The pane stream is a binary-frame
protocol on the daemon’s existing socket, gated by
venomx.pane_stream. Agents read panes through venomx’s own verbs (for example apane.readreturning text from the authoritativeVt) and send input with awrite-grant verb, both defined in venomx’s verb schema, not here. Underfixed, apane.resizeverb (venomx schema,writegrant) sets the size.venomx attach <pane> [--raw]is a presentation client in the same binary, not a daemon verb. --jsonand exit codes: no change. Stream errors use RFC-0002 error objects and classes:not_found.pane(3),denied.read/denied.write(4),invalid_params(2),unsupported.snapshot_format/.mode(10),protocol.bad_frame(9).- Events: no new event types.
pane.exited(RFC-0003) andEXITcarry the same status. OSC 7501 / OSC 133 events come only from the server’s authoritative parser, never from clients. skill,doctor,config: adds thepane_streamconfig section (section 12).doctorSHOULD report per pane: ring size and fill, attached clients with mode and role, resync count, and the server’s ghostty pin.skillSHOULD tell agents to watch panes through verbs and events, not by attaching.- Capability grants: attaching needs
readon the pane;INPUTand influencing the size needwrite. Unknown callers are watchers. - Headless: panes run, record offsets and keep their ring with no client attached; detaching every client changes nothing.
- OSC 7501 / OSC 133: parsed once, before fan-out, by the server (RFC-0003 section 7). Raw clients filter host-affecting sequences (10.2).
Alternatives
-
Screen diffs from the server (tmux, zellij). Rejected: the server’s emulator would cap what every client can display, every client pays for the server’s rendering, and agents watching raw output would see a re-rendering rather than the program’s bytes.
-
Pause the PTY until the slowest client catches up. Rejected: one stalled watcher would freeze a build for everybody. A snapshot is cheap (≤ 1.2 ms), so dropping a laggard to resync costs less than waiting.
-
Credit-based flow control per stream (HTTP/2 style), which RFC-0002 left open. Rejected for v1: credits only bound memory if the sender may stop producing, and the PTY must not stop. Lazily producing frames from the ring gives the same memory bound without new core frames.
-
Our own snapshot grid format. Rejected: libghostty-vt’s snapshot already carries everything in 5.1 including the parser continuation, with a READY marker and history after it, and decodes in ≤ 0.2 ms to first paint. Our own format would duplicate it and lag behind ghostty’s features. The
vtformat covers peers with a different pin. -
Sequence numbers per frame instead of byte offsets. Rejected: a byte offset lets the snapshot say exactly which bytes it contains, works whatever the frame boundaries, and lets the server re-chunk on resume.
-
History addressed by “rows above the screen” or by snapshot order. Rejected: live output shifts those during backfill. Absolute rows within an epoch, as venom-term already tracks, do not shift.
-
Size policy
smallestas the default (tmux before 3.1; zellij per tab). Rejected: a small second window or phone shrinks everyone’s pane.latestfollows whoever is typing, which is what a single human with several terminals expects, and watchers can’t affect it either way. tmux moved tolatestin 3.1, and tuios uses it with the same hold. -
largest(wezterm, tuios option). Rejected: every smaller client crops all the time, and raw clients would almost never match. -
Counting window resizes as activity. Rejected, following tuios: rearranging windows on one screen would steal the size from the person typing on another.
-
Forwarding queries to the host terminal in raw mode and filtering the replies on input. Rejected: the server already answers, so the host reply is a duplicate that races with real keystrokes; removing the query on output means no reply exists to filter.
-
Raw mode only, with the host terminal as the sole emulator. Rejected: splits need several panes in one host terminal, which needs per-pane emulation on the client.
-
The server restarts backfill by itself after an epoch change or a resume (venom-spec#46). Rejected for now;
LAST|ABORTEDandRESYNCstay. Measured on venomxe16b21a, ReleaseFast, over the real socket, with a pane holding about 10,000 rows (1.9 MiB of history at 200×50):What Result READYtoLAST, idle pane or a build log0.6–1.1 ms READYtoLAST, a flood, whose client can’t keep up40–400 ms median per run, 2.5 s worst A writer attaching at another size 0 of 15 aborted: the reflow comes before its snapshot A reflowing RESIZEsent as soon asREADYarrives15 of 15 aborted, with 5,082 of 9,575 rows delivered RESYNCafter that abortREADYin 0.4 ms, all 9,575 rows in 0.65 msSo the loss needs a resize or a disconnect inside about a millisecond, except under a flood, and the existing recovery costs less than a frame, without a blank flash (6.3). “Without a new snapshot” isn’t available either: history records belong to one ghostty snapshot stream and its one decoder, venom-term has no way to prepend another stream’s history to a live terminal, and rows don’t map one to one across a reflow, so the server can’t tell which rows of the new epoch a client lacks. A restart would be a new snapshot per epoch change, chosen by the server, up to about 33 per second per stream during a drag. Only the client knows whether it wants the history. Revisit this with the remote transport (venom-spec#51), where the window and the price of a
RESYNCboth grow with the link. -
Do nothing (each client polls
pane.read). Rejected: no fidelity, no real-time output, and quadratic cost for full-screen programs.
Open questions
All five questions from the first draft were answered on 2026-11-06 by the maintainer in review of PR #6, after comparing tmux, tuios, zellij, wezterm and Rex. Rex’s public material covers the attach handshake and backfill order but not sizing, backpressure, clipboard or IDs.
- Size policy default. Resolved:
latest(tmux since 3.1, tuios), with tuios’s refinements: only input and claiming attaches count as activity, a 1 s hold, and 30 ms resize coalescing (section 8). The policy is per pane; config sets the default and pane creation MAY override it (section 12). - Rows per history chunk. Resolved: the row count is in the plain header of each ghostty PAGE record, readable without decoding. venom-term exposes history chunks with their row counts; venomx stays format-blind (5.3).
- Raw-mode filtering scope. Resolved: a fixed minimum in this RFC,
applied on output only: queries and OSC 52 reads are removed, OSC 52
writes pass to writers unless
raw_clipboard = .off(10.2). This follows tmux’s defaults (set-clipboard external,get-clipboard buffer, queries answered by the multiplexer). Clients MAY remove more. - Pane ID reuse. Resolved: venomx allocates pane IDs from a counter that only increases, as tmux and wezterm do; no extra resume token (4.2).
- Ring default. Resolved: 256 KiB per pane and per client backlog (6.2, 12), between tuios’s 64 KB and tmux’s screen-sized limit, since an early resync is cheaper than replaying a big backlog. Changed 2026-11-08 to 1 MiB after measurement; see History.
History
-
2026-11-11: Compatible addition: optional
ATTACH.cell, the host’s cell size in pixels, which sets the pane PTY’s pixel size and answers XTWINOPS size queries (4.1, 6.3, 8.1). Servers that ignore it report a pixel size of 0, as before (venom-org/venomx#186). -
2026-11-09: venomx’s own client now sends that
RESYNC, once the size has settled and the program has left the alternate screen (venom-org/venomx#74). No protocol change. -
2026-11-09: Measured claims corrected, with no protocol change (venomx#55): an idle pane’s free ring holds only in ReleaseFast (6.2, 12), a snapshot costs about 1.5 ms (6.2), the next writer’s wait is the 1000 ms hold (12), and the floods were synthetic. Section 12 also records venomx’s 256 KiB
PTYframe cap, which 6.1 already allows. -
2026-11-08: Decided to keep
LAST|ABORTEDand an unresumed backfill, withRESYNCas the way back (venom-spec#46). No behaviour changes; the numbers and reasons are under Alternatives. venomx’s own client doesn’t send thatRESYNCyet: venom-org/venomx#59. -
2026-11-08:
ring_bytesandclient_backlog_bytesdefault to 1 MiB instead of 256 KiB (6.2, 12), and the trace in 11.3 follows. The change is compatible: both are config values, and no frame changes.latest_hold_ms,resize_coalesce_msand the overflow snapshot limit were measured and kept. See venom-spec#48 and venomx#46.