Venom
RFC-0006venom-spec

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 Vt and into the pane ring, which retains the most recent ring_bytes of 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 PTY frames 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 capability venomx.pane_stream in HELLO; 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 ATTACH on 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 CLOSE on the stream. The server closes a stream with CLOSE (pane removed, server shutting down) or with core ERROR (refused attach, protocol violation on that stream). Both end the stream; the ID is not reused.
  • Verbs keep working on the same connection through CONTROL frames (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)
  • snap numbers the snapshots on this stream: 1 for the first, +1 after every RESYNC. offset is the pane offset the snapshot is as of: it reflects exactly the PTY bytes [0, offset). Every chunk of one snapshot carries the same snap and offset.
  • The server splits the snapshot into as many SNAPSHOT frames as max_frame requires. The client concatenates the chunks; it MUST NOT assume chunk boundaries mean anything.
  • SNAPSHOT frames carry only the part needed to paint. The rest (history) follows as SCROLLBACK after READY.

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 rows rows immediately older than end_row, i.e. absolute rows [end_row − rows, end_row) in epoch. The first chunk has end_row = top; each next chunk’s end_row is the previous chunk’s end_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 rows comes 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-byte tag, length, crc32c record 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; rows is their sum.
  • The server sends SCROLLBACK only when the stream has no pending PTY bytes, 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 SCROLLBACK with LAST|ABORTED. The client keeps what it has; it MAY ask for a fresh snapshot with RESYNC (6.3) if it wants full history again.
  • ATTACH.history = 0, or no history retained, means a single empty SCROLLBACK with LAST right after READY, 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
  • offset is the pane offset of the first byte. On a stream, consecutive PTY frames are contiguous: each starts where the previous one ended, and the first after READY starts at READY.offset (or at ATTACHED.offset when resumed). A client that sees a gap or overlap MUST send RESYNC (6.3) and discard PTY frames until the new READY.
  • 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_bytes long (default 1 MiB, section 12), plus the RESIZE and EXIT markers 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 × 8 bytes: ~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 unsent PTY bytes (a frame already partly written is completed first), send RESYNC with reason overflow, then a new snapshot.
  • The server MUST NOT slow down reading the PTY, or delay other clients, because of a slow client.
  • Snapshot and SCROLLBACK bytes 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:

  1. resume.source equals the daemon’s current source (the daemon has not restarted);
  2. resume.offset is 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 with claim: true make a writer active. A RESIZE (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, and INPUT to 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 sends SIGWINCH), records a resize marker at the current pane offset N, and sends every stream a RESIZE (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 PTY bytes 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.cell when it knows its host terminal’s cell size, and RESYNC with a new cell when it changes (for example after a font change).
  • The pane’s cell size is the cell of the client whose viewport is the pane size (the active writer under latest), falling back to the most recent writer that sent one. With none, the pixel size is unknown: the server sets ws_xpixel/ws_ypixel to 0 and answers CSI 14 t and CSI 16 t with zeros.
  • The PTY’s pixel size is cols × width by rows × height, set together with the cell size whenever either changes. cell alone never resizes the pane or records a resize marker: it does not change what any PTY byte means.

9. Input and roles

INPUT payload:  bytes to write to the PTY
  • INPUT needs the write grant 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 in ATTACHED.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 sends ERROR denied.write (exit 4) on that stream, closing it, and writes nothing.
  • The server is the single writer to the PTY. It applies each INPUT frame atomically, in arrival order across all writers: bytes of two frames are never interleaved. Terminal query replies produced by the authoritative Vt (write_pty, RFC-0003 section 7) go through the same serialized writer. A client SHOULD send a paste as one frame when it fits in max_frame.
  • INPUT bytes 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).
  • INPUT counts as activity for the latest size policy (section 8).
  • Input does not echo: the client sees the program’s response on PTY like 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.colors when 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 colors of the active writer (section 8), falling back to the most recent writer that sent them, then to the Vt’s configured defaults. Colours a program set itself (OSC 10/11/12 with a value) take precedence, as on any terminal.
  • colors only 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 colors after attach (for example when the host switches between light and dark). A client that needs to can RESYNC with new colors (6.3 allows any ATTACH member).

10. Client modes

10.1 Emulating

The client runs one venom-term Vt (libghostty-vt) per attached pane:

  1. On SNAPSHOT chunks for format ghostty/…: feed them to a snapshot decoder; on READY, take the live terminal from the decoder and paint. For format vt: create a fresh Vt of size READY.cols × READY.rows and feed it the vt bytes.
  2. On PTY: feed() the bytes into the Vt, after checking contiguity.
  3. On SCROLLBACK: pass the bytes to the decoder (ghostty) to prepend history; free the decoder on LAST.
  4. On RESIZE: resize() the Vt at 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:

  1. The vt snapshot is written to the host terminal after a reset sequence the client chooses (for example ESC c or CSI ! p plus clearing the screen). The server MUST build the vt snapshot so that offset is 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 first PTY frame). This is why vt needs no continuation.
  2. PTY bytes go to the host terminal as they arrive.
  3. On RESIZE to 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, new RESYNC reasons, new SCROLLBACK flag bits, new formats) need no new capability: receivers ignore unknown members and bits, and formats are negotiated per attach. A new frame type in 0x010A–0x01FF needs 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 to vt rather than misdecoding.
  • Clients MUST treat an unknown RESYNC.reason like requested and an unknown ATTACHED.policy like fixed.

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 bridge will carry them unchanged. Compression (zstd per stream for SNAPSHOT and SCROLLBACK, 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 a pane.read returning text from the authoritative Vt) and send input with a write-grant verb, both defined in venomx’s verb schema, not here. Under fixed, a pane.resize verb (venomx schema, write grant) sets the size. venomx attach <pane> [--raw] is a presentation client in the same binary, not a daemon verb.
  • --json and 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) and EXIT carry the same status. OSC 7501 / OSC 133 events come only from the server’s authoritative parser, never from clients.
  • skill, doctor, config: adds the pane_stream config section (section 12). doctor SHOULD report per pane: ring size and fill, attached clients with mode and role, resync count, and the server’s ghostty pin. skill SHOULD tell agents to watch panes through verbs and events, not by attaching.
  • Capability grants: attaching needs read on the pane; INPUT and influencing the size need write. 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 vt format 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 smallest as the default (tmux before 3.1; zellij per tab). Rejected: a small second window or phone shrinks everyone’s pane. latest follows whoever is typing, which is what a single human with several terminals expects, and watchers can’t affect it either way. tmux moved to latest in 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|ABORTED and RESYNC stay. Measured on venomx e16b21a, ReleaseFast, over the real socket, with a pane holding about 10,000 rows (1.9 MiB of history at 200×50):

    What Result
    READY to LAST, idle pane or a build log 0.6–1.1 ms
    READY to LAST, a flood, whose client can’t keep up 40–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 RESIZE sent as soon as READY arrives 15 of 15 aborted, with 5,082 of 9,575 rows delivered
    RESYNC after that abort READY in 0.4 ms, all 9,575 rows in 0.65 ms

    So 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 RESYNC both 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.

  1. 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).
  2. 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).
  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.
  4. 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).
  5. 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 PTY frame cap, which 6.1 already allows.

  • 2026-11-08: Decided to keep LAST|ABORTED and an unresumed backfill, with RESYNC as the way back (venom-spec#46). No behaviour changes; the numbers and reasons are under Alternatives. venomx’s own client doesn’t send that RESYNC yet: venom-org/venomx#59.

  • 2026-11-08: ring_bytes and client_backlog_bytes default 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_ms and the overflow snapshot limit were measured and kept. See venom-spec#48 and venomx#46.