The blit wire protocol is a custom binary format defined in crates/remote/. There is no protobuf, JSON, or external schema. The protocol is symmetric in framing but asymmetric in message types: clients send C2S_* messages, servers send S2C_* messages. It is intentionally version-stable: new message types are added with new opcodes; existing opcodes never change layout.
Every non-WebSocket transport wraps messages in a 4-byte little-endian length prefix followed by the payload:
[len:4 LE][payload:len]
WebSocket provides its own framing, so the length prefix is omitted over WebSocket — each binary WebSocket frame is exactly one blit message. This framing convention is shared by:
blit-server(crates/server/src/lib.rs)blit-cli(crates/cli/src/transport.rs)blit-gateway(crates/gateway/src/lib.rs)blit-proxy(crates/proxy/src/lib.rs)- Browser WebTransport/WebRTC (
js/core/src/transports/)
Maximum frame size: 16 MiB.
Every message begins with a 1-byte opcode. All multi-byte fields are little-endian. Fields are tightly packed with no padding or alignment. PTY identifiers are 2-byte unsigned integers.
Any per-request reply guarantee is conditional on the logical connection remaining live through that reply. A transport failure or a documented fatal framing, protocol, or endpoint-resource violation closes the connection and cancels its outstanding requests without synthesizing replies. Clients resolve every pending operation as a connection error in that case.
| Opcode | Name | Layout |
|---|---|---|
0x00 |
INPUT |
[pty_id:2][data:N] |
0x01 |
RESIZE |
[pty_id:2][rows:2][cols:2]… (batch, repeating triplets) |
0x02 |
SCROLL |
[pty_id:2][offset:4] |
0x03 |
ACK |
(no payload) |
0x04 |
DISPLAY_RATE |
[fps:2] |
0x05 |
CLIENT_METRICS |
[backlog:2][ack_ahead:2][apply_ms_x10:2] |
0x06 |
MOUSE |
[pty_id:2][type:1][button:1][col:2][row:2] |
0x07 |
RESTART |
[pty_id:2] |
0x08 |
PING |
(empty) — application-level keepalive |
0x09 |
CLIENT_LIST |
[nonce:2] or [nonce:2][flags:1] — enumerate connected clients; flags bit 0 (WANT_ORIGIN) asks for S2C_CLIENT_LIST2 |
0x0A |
KICK |
[nonce:2][client_id:8][reason:N] — disconnect another client with a UTF-8 reason |
0x0B |
CLIENT_WATCH |
[nonce:2] or [nonce:2][flags:1] — subscribe to live client-catalog snapshots, under the same flags as CLIENT_LIST |
0x0C |
CLIENT_UNWATCH |
[nonce:2] — stop the client-catalog subscription using this nonce |
0x0F |
QUIT |
(empty) — request server shutdown |
0x10 |
CREATE |
[rows:2][cols:2][tag_len:2][tag:N] |
0x11 |
FOCUS |
[pty_id:2] |
0x12 |
CLOSE |
[pty_id:2] |
0x13 |
SUBSCRIBE |
[pty_id:2] |
0x14 |
UNSUBSCRIBE |
[pty_id:2] |
0x15 |
SEARCH |
[request_id:2][query:N] |
0x16 |
CREATE_AT |
[rows:2][cols:2][tag_len:2][tag:N][src_pty_id:2] |
0x17 |
CREATE_N |
[nonce:2][rows:2][cols:2][tag_len:2][tag:N] |
0x18 |
CREATE2 |
[nonce:2][rows:2][cols:2][features:1][tag_len:2][tag:N][optional…] |
0x19 |
READ |
[nonce:2][pty_id:2][offset:4][limit:4][flags:1] |
0x1A |
KILL |
[pty_id:2][signal:4][flags:1] — send signal to a PTY's process group; flags optional |
0x1B |
COPY_RANGE |
[nonce:2][pty_id:2][start_tail:4][start_col:2][end_tail:4][end_col:2][flags:1] |
0x1C |
TERM_CWD |
[nonce:2][pty_id:2] — request a PTY's live working directory (see Working directory tracking) |
0x1D |
DEADLINE |
[pty_id:2][ms:4] — arm or refresh a server-enforced deadline; ms = 0 clears it |
0x1E |
SCROLL_BY |
[pty_id:2][delta:4 i32] — move a scrolled view relative to where the server holds it (see Scrollback) |
0x20 |
SURFACE_INPUT |
[surface_id:2][keycode:4][pressed:1][time_ms:4] — time_ms is the browser KeyboardEvent.timeStamp, 0 when the sender synthesised the key |
0x21 |
SURFACE_POINTER |
[surface_id:2][type:1][button:1][x:2][y:2] |
0x22 |
SURFACE_POINTER_AXIS |
[surface_id:2][axis:1][value:4] — legacy scroll, superseded by 0x32 |
0x23 |
SURFACE_RESIZE |
[surface_id:2][width:2][height:2][scale_120:2] |
0x24 |
SURFACE_FOCUS |
[surface_id:2] |
0x25 |
CLIPBOARD_SET |
[mime_len:2][mime:N][data_len:4][data:M] |
0x26 |
SURFACE_LIST |
(empty) — request list of compositor surfaces |
0x27 |
SURFACE_CAPTURE |
[surface_id:2][format:1][quality:1] — screenshot (0=PNG, 1=AVIF) |
0x28 |
SURFACE_SUBSCRIBE |
[surface_id:2][codec:1][bandwidth:1][speed:1][width:2][height:2][max_fps:2] |
0x29 |
SURFACE_UNSUBSCRIBE |
[surface_id:2] |
0x2A |
SURFACE_ACK |
[surface_id:2] — acknowledge receipt of video frame |
0x2B |
SURFACE_CLOSE |
[surface_id:2] — request close of Wayland surface |
0x2C |
CLIPBOARD_LIST |
(no payload) |
0x2D |
CLIENT_FEATURES |
[codec_support:1] — client capability advertisement |
0x2E |
CLIPBOARD_GET |
[mime_len:2][mime:N] |
0x2F |
SURFACE_TEXT |
[surface_id:2][text:N] — composed text input (UTF-8) |
0x30 |
AUDIO_SUBSCRIBE |
[bitrate_kbps:2] |
0x31 |
AUDIO_UNSUBSCRIBE |
(no payload) |
0x32 |
SURFACE_POINTER_AXIS2 |
[surface_id:2][flags:1][dx_x100:4][dy_x100:4][v120_x:2][v120_y:2] — see Scroll |
0x33 |
PRIMARY_SET |
[mime_len:2][mime:N][data_len:4][data:M] — take PRIMARY, see Primary selection |
0x34 |
SURFACE_PREEDIT |
[surface_id:2][cursor:2][text:N] — composition in progress (UTF-8); cursor is a byte offset, empty text withdraws it |
0x35 |
SURFACE_DRAG_ENTER |
[surface_id:2][x:2][y:2][mime_count:2][mime entries][optional item trailer] — begin/retarget a drag; each entry is [mime_len:2][mime:N]; the append-only trailer is [item_count:2][item MIME entries], see Drag and drop |
0x36 |
SURFACE_DRAG_MOTION |
[surface_id:2][x:2][y:2] — move the drag |
0x37 |
SURFACE_DRAG_LEAVE |
[surface_id:2] — the drag left the surface |
0x38 |
SURFACE_DRAG_DROP |
[surface_id:2][x:2][y:2][item_count:2][items] — complete the drop; item is [mime_len:2][mime][name_len:2][name][data_len:4][data], see Drag and drop |
0x39 |
SURFACE_DRAG_CANCEL |
(empty) — abort the drag (Escape / drag left the window) |
0x3A |
SURFACE_TOUCH |
[surface_id:2][phase:1][contact_count:1][time_ms:4][contacts…]; contact is [identifier:4 i32][x_x100:4 i32][y_x100:4 i32]; time_ms is the browser TouchEvent.timeStamp, see Direct touch |
0x3B |
DESKTOP_SUBSCRIBE |
[flags:1]; bit 0 tray, bit 1 notifications, 0 unsubscribes; see tray/notification design |
0x3C |
TRAY_EVENT |
[tray_id:4][kind:1][menu_revision:4][value:4 i32][flags:1]; see tray/notification design |
0x3D |
NOTIFICATION_EVENT |
[notification_id:4][revision:4][kind:1][key_len:2][key:N]; see tray/notification design |
0x3E |
MEDIA_CONTROL |
[subtype:1][payload:N]; viewer media leases, portal replies/stops, and MPRIS subscriptions/actions, see media devices and portals |
0x3F |
MEDIA_DATA |
[lease_id:4][sequence:4][capture_us:8][kind:1][codec:1][flags:1][fragment_index:2][fragment_count:2][frame_len:4][data:N], see media devices and portals |
0x40 |
FS_SYNC |
[nonce:2][flags:2][latency_ms:2][inline_max:4][path_len:2][path:N] + [exclude_len:2][exclude:M] if EXCLUDE + [src_pty_id:2] if FROM_PTY; STAGING roots the sync at the drag staging dir, see Drag and drop |
0x41 |
FS_STOP |
[sync_id:2] |
0x42 |
FS_ACK |
[sync_id:2][update_id:4] — cumulative |
0x43 |
FS_FETCH |
[nonce:2][sync_id:2][path_len:2][path:N] |
0x44 |
FS_WRITE |
[nonce:2][sync_id:2][flags:1][base:16][mode:4][content_kind:1][path_len:2][path:N][content:LZ4] — CAS content upsert (design/fs-write.md) |
0x45 |
FS_OP |
[nonce:2][sync_id:2][op:1][flags:1][base:16][mode:4][a_len:2][a:N][b_len:2][b:N] — mkdir/remove/rename/symlink/hardlink (design/fs-write.md) |
0x46 |
FS_SEARCH |
[nonce:2][limit:2][root_len:2][root:N][query_len:2][query:M] — server-side fuzzy file search (design/fs-search.md) |
0x47 |
FS_INDEX |
[nonce:2][flags:1][root_len:2][root:N] — candidate list for client-side search; flags is DIRS_ONLY/FOLLOW_LINKS (design/fs-search.md, design/fs-read.md) |
0x49 |
FS_UPLOAD_BEGIN |
[nonce:2][sync_id:2][flags:1][base:16][mode:4][size:8][path_len:2][path:N] — begin a chunked upload; base is the FS_WRITE CAS precondition |
0x4A |
FS_UPLOAD_CHUNK |
[upload_id:2][offset:8][data:LZ4] — sequential append; offset must equal the bytes accepted so far |
0x4B |
FS_UPLOAD_FINISH |
[nonce:2][upload_id:2] — land the upload (terminates it either way) |
0x4C |
FS_UPLOAD_CANCEL |
[upload_id:2] — abort the upload; no reply |
0x4D |
FS_READ |
[nonce:2][flags:1][max_bytes:4][group_count:2] repeated{ [path_count:2] repeated{ [path_len:2][path:N] } } — one-shot read, no sync; a group is one question, and NO_CONTENT names files instead of carrying them (design/fs-read.md) |
0x50 |
TERM_JOURNAL |
[nonce:2][pty_id:2][from_index:8][limit:2][flags:1] — list OSC 133 command records; flags bit 0 (JOURNAL_TAIL) counts from_index back from the newest (design/term-journal.md) |
0x51 |
TERM_OUTPUT |
[nonce:2][pty_id:2][index:8][max_bytes:4][flags:1] — one command's output; index = u64::MAX is the newest (design/term-journal.md) |
0x52 |
TERM_SINCE |
[nonce:2][pty_id:2][from_seq:8][from_col:2][max_bytes:4][flags:1] — output since a sequence cursor; flags bit 0 (SINCE_PROBE) reports the cursor and returns no text (design/term-journal.md) |
0x53 |
TERM_JOURNAL_WAIT |
[nonce:2][pty_id:2][index:8][timeout_ms:4] — block until that command finishes; index = u64::MAX waits on whatever is running, or the next one to start (design/term-journal.md) |
0xD0 |
EVENTS |
[version:1][operation:1][nonce:2][body:N] — blit.events.v1 config, dump, and stream control (design/events.md) |
Notes:
SURFACE_RESIZE.scale_120 is the viewer's requested presentation scale in
1/120th units: 60 = 0.5×, 120 = 1×, 240 = 2×, and 0 means unspecified
(1×). It may carry the display's DPI-derived scale or an exact scale chosen
independently of display DPI. Sub-1× values enlarge the surface's logical
window while the compositor stays at Wayland's minimum 1× output scale; the
viewer receives a downscaled stream at its requested physical size.
CREATE2 extends CREATE with a nonce for response correlation and optional fields gated by feature bits in the features byte:
- Bit 0 (
HAS_SRC_PTY): followed by[src_pty_id:2]— create the new PTY in the same working directory assrc_pty_id. - Bit 1 (
HAS_COMMAND): remaining bytes after tag (andsrc_pty_idif present) are the UTF-8 command string (no length prefix) — spawn this command instead of the default shell. - Bit 2 (
HAS_CWD): followed by[cwd_len:2][cwd:N](before any command bytes) — spawn in this working directory. - Bit 3 (
WANT_STATUS): valid only whenHELLOadvertisesCREATE_STATUS; requests one correlatedCREATED_NorCREATE_FAILEDoutcome. It adds no trailing field. - Bit 4 (
HAS_DEADLINE): followed by[ms:4], after any cwd and before any command bytes — arm a deadline at creation. Valid only whenHELLOadvertisesPTY_DEADLINE. - Bit 5 (
HAS_ENV): followed by[count:2]thencountrecords of[key_len:2][key:N][value_len:4][value:N]— environment overrides for the child. Valid only whenHELLOadvertisesCREATE_EXEC. - Bit 6 (
HAS_ARGV): followed by[argc:2]thenargcrecords of[len:4][arg:N]— exec this argv directly, no shell. Mutually exclusive withHAS_COMMAND; a message setting both isINVALID. Valid only whenHELLOadvertisesCREATE_EXEC. - Bit 7 (
NO_SUBSCRIBE): do not automatically subscribe the creating client to terminal frame updates. It adds no trailing field and leaves lifecycle/control messages unchanged. Valid only whenHELLOadvertisesCREATE_NO_SUBSCRIBE.
Optional fields appear in flag-bit order — src_pty_id, cwd, deadline, env, argv — and the command, which has no length prefix, is always last.
Every field-bearing bit past HAS_CWD is unsafe against a server that does not advertise it. An older server does not refuse an unknown features bit; it ignores the bit, does not skip the field, and reads those bytes as the start of the command. HAS_ARGV fails differently and just as quietly: with no HAS_COMMAND set, the server spawns the default interactive shell. Negotiate, do not probe.
By default every create subscribes its requesting client, preserving the legacy
behavior. NO_SUBSCRIBE is also negotiation-only: an older server ignores the
bit and subscribes the creator.
Environment entries are applied last, after everything the server derives for a terminal — the inherited environment, TERM, an exported BLIT_SOCK, and the session variables — so a client entry always wins. Keys may not be empty, hold a NUL or an =, or repeat; values may not hold a NUL. Limits match the process family: 1024 arguments and 1 MiB of argument bytes, 256 variables and 1 MiB of key and value bytes, 64 KiB for any single argument or value, 255 bytes for a key.
Legacy argv. Before HAS_ARGV, a HAS_COMMAND payload containing a NUL was split on NUL and exec'd directly, and every server still accepts that spelling. It is lossy — empty arguments are dropped and the payload is trimmed — so it exists only to reach a server without CREATE_EXEC. A one-argument command needs a trailing NUL to be distinguishable from a shell string.
The COMMAND column of S2C_LIST shows an argv terminal a shell-quoted rendering of its argv, elided if it runs long. That rendering is for display: RESTART replays what the terminal was actually created with, argv and environment included.
READ requests text from a PTY's scrollback + viewport:
offset: lines to skip (from top, or from end whenREAD_TAILis set).limit: max lines to return (0 = all).flags: bit 0 (READ_ANSI) includes ANSI escape sequences; bit 1 (READ_TAIL) counts from the end.- Server responds with
S2C_TEXTechoing the same nonce.
COPY_RANGE returns S2C_COPY_FAILED with the request nonce and common
NOT_FOUND status if the PTY disappeared before the server handled it.
RESIZE is batched: after the opcode, the payload contains one or more [pty_id:2][rows:2][cols:2] triplets. Requires the RESIZE_BATCH feature bit in S2C_HELLO.
Client control is gated by FEATURE_CLIENT_CONTROL. C2S_CLIENT_LIST returns
the requester's own server-assigned u64 ID plus every live client, including
the requester, in ascending ID order. Each client is encoded as
[client_id:8][age_secs:8][outbound_bytes_per_sec:8]
[inbound_bytes_per_sec:8][terminal_count:2]
[surface_count:2][subscription_count:2], followed by terminal records
([pty_id:2][rows:2][cols:2]), surface records
([surface_id:2][width:2][height:2][scale_120:2]), and auxiliary subscription
records ([kind:1][id:2]). age_secs is the whole-second connection age;
outbound_bytes_per_sec is the latest one-second sample of successfully
written, length-prefixed server-to-client bytes — not bits — and
inbound_bytes_per_sec is the same sample of length-prefixed bytes read from
the client. Both are the server's own accounting of the socket, taken in one
tick so the pair covers a single interval. No client of any kind reports its
own bandwidth, which is what makes a command-line client's figures directly
comparable to a browser's. Zero size fields mean that the client subscribed
without reporting a view size.
Auxiliary subscription kinds are 1 audio (ID is zero), 2 filesystem sync,
3 Git repo, 4 LSP attachment, 5 KV watch, and 6 network flow. IDs are
local to each family. C2S_CLIENT_WATCH immediately returns the same
S2C_CLIENT_LIST shape and sends changed snapshots under the same nonce until
C2S_CLIENT_UNWATCH. Age and bandwidth are sampled once a second, so a watch
can produce an update that often even when topology is unchanged; between
samples only a real topology change publishes. Multiple watch nonces per client
are valid.
A CLIENT_LIST or CLIENT_WATCH may carry one trailing flags byte once
FEATURE_CLIENT_ORIGIN is advertised. Its bit 0 (CLIENT_LIST_WANT_ORIGIN)
asks for S2C_CLIENT_LIST2, which is the same message with
[origin_kind:1][origin_len:2][origin:N] appended to every entry.
origin_kind is 0 for an ordinary client — a browser, a CLI, a forwarder,
with an empty payload — or 1 for the connection belonging to a running
extension attempt, whose payload is
[extension_id:8][definition_revision:8][attempt:8][task_id:4][name:N]. The
name is the durable name of a persistent definition or the label a transient
ext run carried, empty when it has neither, and is captured when the
connection opens, so it names the definition the attempt started from.
origin_len is what a reader skips past a kind it does not know, which is how
a later kind can carry a payload without a third opcode.
The wider entry needs its own opcode because both shipped parsers reject a
catalog with bytes left over — it has to be unreadable to them by construction.
For the same reason, a flags byte is only safe once the feature bit is seen:
every server answers a client-control request with unexpected trailing bytes
with INVALID, and so does this one for a flag it does not recognise. Any
unknown flag bit is refused rather than ignored, so a client asking for a shape
the server cannot produce hears about it instead of misreading the reply.
CLIENT_UNWATCH takes no flags: a watch keeps the shape it was opened with for
its whole life.
S2C_KICK_RESULT is the status reply for the whole client-control family, not
only for C2S_KICK: a malformed CLIENT_LIST / CLIENT_WATCH /
CLIENT_UNWATCH is answered with INVALID under the sender's nonce rather
than dropped. Clients must therefore settle a pending list or watch on a
KICK_RESULT carrying its nonce, or the request hangs until they time out.
A request too short to carry a nonce (fewer than three bytes) is the one case
the server drops, because the nonce would be a guess.
The blit client list CLI filters its own short-lived connection from its
output, and prints an ORIGIN column (network, ext:<name>, ext:id:<hex>)
whenever the server offers origins — unknown when it does not. Persistent
clients can use self_id to identify their own live record. Client IDs are a per-process counter, not a capability or a stable
identity: they are only meaningful for the life of the server process,
HELLO.boot_generation identifies that lifetime, and a client can infer from
its own ID how many connections preceded it.
C2S_KICK cannot target its sender. Its correlated S2C_KICK_RESULT uses the
common status registry (OK, NOT_FOUND, INVALID, or TOO_LARGE) and may
append diagnostic text. OK means the target's connection was told to close,
not that the reason was acknowledged — a target that is already disconnecting
still reports OK. On OK, the target receives S2C_KICKED with the reason
and is then forcibly disconnected. A browser suppresses automatic reconnect
after KICKED, but permits a user-requested reconnect; command-line clients
report the reason as an error.
Reasons are UTF-8 and capped at KICK_REASON_MAX (1024) bytes. Requesters are
expected to validate and refuse an over-long reason rather than send one: the
CLI and the browser both do, the server answers TOO_LARGE, and the message
builders truncate at a UTF-8 scalar boundary only as a last-resort backstop so
a bug cannot put an invalid tail on the wire.
Authorization. There is none beyond reaching the socket. blit-server has
no read-only mode of its own, so any connection that completes the handshake
can enumerate and kick any other. The read-only capability enforced for
blit share consumers lives in the WebRTC forwarder's allowlist, which denies
the entire client-control family — including CLIENT_LIST, because telling an
untrusted viewer which ptys and surfaces the other viewers hold discloses
resources that viewer was never offered. Treat "can open a connection" as
"can kick".
SURFACE_SUBSCRIBE has optional trailing bytes for per-surface codec, bandwidth, speed, fixed encode size, and cadence control:
-
codec(byte 3):CODEC_SUPPORT_*bitmask restricting which codecs the server may use for this surface.0= use the connection-level default (fromC2S_CLIENT_FEATURES). -
bandwidth(byte 4): the most bits the surface may spend.0= server default (fromBLIT_SURFACE_BANDWIDTH),1= low,2= medium,3= high,4= ultra,5–9reserved,10–255= an AV1 quantizer used as the floor. The server adapts below this ceiling on its own — there is noautovalue to ask for and no way to switch adaptation off. What you pick is the best quality the encoder is allowed to produce; congestion moves it cheaper and recovery moves it back. -
speed(byte 5): how much encoder time a frame may cost, independent of bandwidth.0= server default (fromBLIT_SURFACE_SPEED),1= slow,2= medium,3= fast,4= realtime,5–9reserved,10–255= custom (10slowest,255fastest). -
width/height(bytes 6–9, LE u16): a fixed encode size in pixels for this client alone. Both nonzero makes the subscription scaled: the server encodes a downscale of the surface for this client and excludes it from surface-size mediation, so it never pulls the compositor surface smaller for anyone else. Both zero — or absent — means the client participates in mediation viaSURFACE_RESIZElike any other viewer. The size is a bounding box, not an aspect: the server inscribes the surface's own aspect ratio inside it and never upscales past native.A viewer that is handed a box rather than sizing one — a side-panel thumbnail — is what this is for. Without it such a viewer has only two options, and both are wrong: report its box and shrink the window for every other viewer, or report nothing and decode full-resolution video into a card.
-
max_fps(bytes 10–11, LE u16): a ceiling for this subscription's source and delivery cadence.0or absent means the client's declared display rate. This is independent of the encode size, so a scaled recording can still request full cadence while a live thumbnail can request a cheaper rate.
All the trailing bytes are optional — a 3-byte message uses connection/server defaults — but they are positional, so asking for a size or cadence means sending the earlier fields too (as zeros, if you have no preference). Re-subscribing to an already-subscribed surface updates the supplied values. Codec, bandwidth, speed, and size changes force encoder recreation; a cadence-only change does not.
The base wire protocol does not carry an access bit. A client connected
directly to blit-server is therefore not made read-only by the protocol.
Read-only share tokens (.ro) are enforced by the producer-side blit share
forwarder, after signaling authenticates the token and before a client message
reaches the server. The same filter applies to legacy "blit" data channels
and virtual streams on a "mux" channel.
The profile is deny-by-default. It forwards only these client operations:
- Connection and delivery accounting:
ACK,PING,CLIENT_FEATURES, andCLIENT_METRICS. The client-control family is blocked in full:KICKbecause it is a write, andCLIENT_LIST/CLIENT_WATCH/CLIENT_UNWATCHbecause enumerating the other viewers discloses pty and surface ids this consumer was never offered, plus a once-a-second sample of another viewer's bandwidth in both directions — upstream especially, which tracks how fast that viewer is typing or uploading. - Terminal viewing:
SCROLL,FOCUS,SUBSCRIBE,UNSUBSCRIBE,SEARCH,READ, andCOPY_RANGE. - Surface viewing:
SURFACE_LIST,SURFACE_CAPTURE,SURFACE_SUBSCRIBE,SURFACE_UNSUBSCRIBE, andSURFACE_ACK. - Clipboard reads:
CLIPBOARD_LISTandCLIPBOARD_GET. - Audio listening:
AUDIO_SUBSCRIBEandAUDIO_UNSUBSCRIBE. - Media observation: only
MEDIA_CONTROL(MPRIS_SUBSCRIBE)with an exact enabled byte of0or1. Media capabilities, leases/data, portal replies, ScreenCast revocation, and MPRIS actions remain blocked.
Every other opcode, including unknown future opcodes, is silently dropped. Consequently read-only peers cannot send input, create/restart/kill/close a PTY, focus or control a Wayland surface, write either clipboard selection, shut down the server, or access the filesystem, Git, LSP, KV, and network families.
Read-only peers never participate in shared sizing. RESIZE and all create
opcodes are blocked, so they cannot add a PTY view-size constraint.
SURFACE_RESIZE is blocked, so they cannot add a surface-size constraint or
raise the compositor output scale. The optional width and height on the
allowed SURFACE_SUBSCRIBE are a fixed encode box for that peer alone; such a
scaled subscription is explicitly excluded from surface-size mediation.
Likewise, CLIENT_FEATURES can cap that peer's decoder/encoder path but has no
shared sizing input without a SURFACE_RESIZE entry.
| Opcode | Name | Layout |
|---|---|---|
0x00 |
UPDATE |
[pty_id:2][lz4-compressed-frame] |
0x01 |
CREATED |
[pty_id:2][tag:N] |
0x02 |
CLOSED |
[pty_id:2] |
0x03 |
LIST |
[count:2][entries…] |
0x04 |
TITLE |
[pty_id:2][title:N] |
0x05 |
SEARCH_RESULTS |
[request_id:2][results…] |
0x06 |
CREATED_N |
[nonce:2][pty_id:2][tag:N] |
0x07 |
HELLO |
[version:2][features:4][boot_generation:8][server_version_len:2][server_version:N] |
0x08 |
EXITED |
[pty_id:2][exit_status:4][reason:1] — reason appended; older servers omit it |
0x09 |
READY |
(no payload) |
0x0A |
TEXT |
[nonce:2][pty_id:2][total_lines:4][offset:4][text:N] |
0x0B |
PING |
(empty) — server keepalive |
0x0C |
QUIT |
(empty) — server shutting down |
0x0D |
USED_ROWS |
[pty_id:2][used_rows:2] |
0x0E |
TERM_CWD |
[nonce:2][cwd_len:2][cwd:N] — reply to C2S_TERM_CWD; empty = unknown |
0x0F |
TERM_CWD_EVENT |
[pty_id:2][cwd:N] — unsolicited push when the OSC 7-reported cwd changes |
0x10 |
CREATE_FAILED |
[nonce:2][status:1][detail:N] — refusal of a CREATE2(WANT_STATUS) |
0x11 |
SCROLL_OFFSET |
[pty_id:2][offset:4] — this client's scrolled-back view was re-anchored (see Scrollback) |
0x12 |
CLIENT_LIST |
[nonce:2][self_id:8][count:4][client:N]… — sorted connection records, including the requester |
0x13 |
KICK_RESULT |
[nonce:2][status:1][detail:N] — correlated result of C2S_KICK |
0x14 |
KICKED |
[reason:N] — another client kicked this connection; the server closes it after delivery |
0x15 |
CLIENT_LIST2 |
CLIENT_LIST with [origin_kind:1][origin_len:2][origin:N] on every record — answer to a request carrying WANT_ORIGIN |
0x16 |
COPY_FAILED |
[nonce:2][status:1][detail:N] — correlated COPY_RANGE refusal; a missing PTY is NOT_FOUND |
0x20 |
SURFACE_CREATED |
[surface_id:2][parent_id:2][w:2][h:2][title_len:2][title:N][app_id_len:2][app_id:M] |
0x21 |
SURFACE_DESTROYED |
[surface_id:2] |
0x22 |
SURFACE_FRAME |
[surface_id:2][timestamp:4][flags:1][w:2][h:2][data:N] |
0x23 |
SURFACE_TITLE |
[surface_id:2][title:N] |
0x24 |
SURFACE_RESIZED |
[surface_id:2][w:2][h:2] |
0x25 |
CLIPBOARD_CONTENT |
[mime_len:2][mime:N][data_len:4][data:M] |
0x26 |
SURFACE_LIST |
[count:2] repeated [surface_id:2][parent_id:2][w:2][h:2][title_len:2][title:N][app_id_len:2][app_id:M] |
0x27 |
SURFACE_CAPTURE |
[surface_id:2][width:4][height:4][image_data:N] — PNG or AVIF |
0x28 |
SURFACE_APP_ID |
[surface_id:2][app_id:N] |
0x29 |
SURFACE_CURSOR |
[surface_id:2][type:1] + [name_len:1][name:N] if named, nothing if hidden, [hotx:2][hoty:2][w:2][h:2][png:N] if custom; w/h and the hotspot are both logical pixels while the PNG keeps the cursor buffer's own resolution, so one scale factor places both. surface_id is the surface being hovered, not the focused one |
0x2A |
SURFACE_ENCODER |
[surface_id:2][name][0x00][codec_string] — encoder display name + WebCodecs codec string, NUL-separated |
0x2B |
FRAGMENT |
[flags:1][chunk:N] — see Fragmentation |
0x2C |
CLIPBOARD_LIST |
[count:2] repeated{ [mime_len:2][mime:N] } |
0x2D |
SURFACE_ACTIVATED |
[surface_id:2] — the Wayland client asked for its toplevel to be activated (xdg_activation_v1). Highlight the surface where it already is; do not raise it or take focus. Tokens are cheap and unacknowledged, so clients repeat this several times a second, and each would land after whatever the viewer just chose |
0x2E |
CLIPBOARD_OWNER |
[wayland:1] — 1 while a Wayland client owns the selection; 0 when empty or externally owned |
0x2F |
SURFACE_TEXT_INPUT |
[surface_id:2][flags:1][content_hint:4][content_purpose:4] — committed zwp_text_input_v3 state; flags bit 0 is enabled and bit 1 marks a fresh enable request; optional [cursor_x:2i][cursor_y:2i][cursor_w:2i][cursor_h:2i] caret tail |
0x30 |
AUDIO_FRAME |
[timestamp:4][flags:1][data:N] |
0x31 |
SURFACE_REMOTE_INPUT |
[surface_id:2][kind:1][count:1][x:2,y:2]* — where another viewer is pointing (kind 0, one point) or touching (kind 1, one per finger); count = 0 retires the marks and is what the driving viewer receives |
0x32 |
TRAY_UPDATE |
[flags:1][records:LZ4]; staged normalized tray state, see tray/notification design |
0x33 |
TRAY_MENU |
[tray_id:4][tray_revision:4][menu_revision:4][status:1][nodes:LZ4]; see tray/notification design |
0x34 |
NOTIFICATION_UPDATE |
[flags:1][records:LZ4]; staged normalized active notifications, see tray/notification design |
0x35 |
MEDIA_CONTROL |
[subtype:1][payload:N]; runtime/privacy state, lease/credit/revocation, portal prompts, and MPRIS updates/results, see media devices and portals |
0x40 |
FS_SYNCED |
[nonce:2][sync_id:2][status:1][detail_len:2][detail:N] |
0x41 |
FS_UPDATE |
[sync_id:2][update_id:4][flags:1][records:LZ4] |
0x42 |
FS_FILE |
[nonce:2][status:1][data:LZ4] |
0x43 |
FS_CLOSED |
[sync_id:2][reason:1] |
0x44 |
FS_DONE |
[nonce:2][status:1][hash:16][mtime_ns:8] — one per FS_WRITE/FS_OP (design/fs-write.md) |
0x45 |
FS_SEARCH |
[nonce:2][status:1][count:2] repeated{ [path_len:2][path:N] } (design/fs-search.md) |
0x46 |
FS_INDEX |
[nonce:2][status:1][flags:1][count:4][paths:LZ4] (design/fs-search.md) |
0x48 |
FS_READ |
[nonce:2][status:1][count:2][records:LZ4] — a status and content per requested path (design/fs-read.md) |
0x49 |
FS_UPLOAD_BEGIN |
[nonce:2][status:1][upload_id:2][hash:16][mtime_ns:8] — upload_id meaningful only on OK; hash is the current on-disk hash on CONFLICT |
0x4A |
FS_UPLOAD_CHUNK |
[upload_id:2][status:1][received:8] — per-chunk ack/progress; received is the resume point on OFFSET_MISMATCH |
0x4B |
FS_UPLOAD_FINISH |
[nonce:2][status:1][hash:16][mtime_ns:8] — the FS_DONE payload on success (zeroes otherwise) |
0x50 |
TERM_JOURNAL |
[nonce:2][pty_id:2][status:1][oldest_index:8][next_index:8][count:2][records…] — answer to C2S_TERM_JOURNAL (design/term-journal.md) |
0x51 |
TERM_OUTPUT |
[nonce:2][pty_id:2][status:1][flags:1][start_seq:8][start_col:2][next_seq:8][next_col:2][text_len:4][text:N] — answers TERM_OUTPUT and TERM_SINCE; next_* is the cursor to resume from (design/term-journal.md) |
0x52 |
TERM_COMMAND |
[nonce:2][pty_id:2][status:1][record] — answer to C2S_TERM_JOURNAL_WAIT; on timeout the record is still flagged RUNNING (design/term-journal.md) |
0xD0 |
EVENTS |
[version:1][operation:1][body:N] — correlated config/dump/start results or stream record/stop/gap (design/events.md) |
Notes:
S2C_HELLO is the first message sent on every new connection. version is the server's protocol version. boot_generation is an opaque little-endian identifier generated once per server process; clients can compare it across reconnects to detect a server restart. server_version is the server's release string (its crate version, e.g. 0.40.1) — informational only: feature negotiation always goes through the feature bits, never a version comparison. Both trailing fields were appended without a protocol bump, so legacy servers omit them and clients must treat a short HELLO as valid. features is a 4-byte bitmask:
| Bit | Name | Meaning |
|---|---|---|
| 0 | CREATE_NONCE |
Server supports CREATE2 / CREATED_N with nonce correlation |
| 1 | RESTART |
Server supports C2S_RESTART to respawn exited PTYs |
| 2 | RESIZE_BATCH |
Server accepts batched resize entries in a single C2S_RESIZE |
| 3 | COPY_RANGE |
Server supports range-based text copy |
| 4 | COMPOSITOR |
Server supports headless Wayland compositor |
| 5 | AUDIO |
Server supports audio forwarding (PipeWire capture + Opus) |
| 6 | FS |
Server supports the FS_* filesystem sync family |
| 7 | GIT |
Server supports the GIT_* git introspection family |
| 8 | LSP |
Server supports the LSP_* language intelligence family |
| 9 | KV |
Server supports the KV_* key-value family |
| 10 | NET |
Server supports the NET_* network-relay family |
| 11 | EXTENSION |
Proposed: extension lifecycle, events, and commands |
| 12 | CHANNEL |
Proposed: server supports bidirectional named channels |
| 13 | PROCESS |
Server supports native non-PTY child processes |
| 14 | CREATE_STATUS |
CREATE2(WANT_STATUS) receives an explicit failure |
| 15 | KILL_MODE |
KILL/CLOSE reach the process group; KILL takes flags |
| 16 | PTY_DEADLINE |
C2S_DEADLINE, CREATE2(HAS_DEADLINE), and EXITED.reason |
| 17 | SCROLL_BY |
Scrollback holds still: S2C_SCROLL_OFFSET and C2S_SCROLL_BY |
| 18 | SURFACE_TOUCH |
Server accepts direct contacts and exposes wl_seat.touch |
| 19 | SURFACE_TEXT_INPUT |
Server forwards committed zwp_text_input_v3 state |
| 20 | CLIENT_CONTROL |
Enumerate connections and kick another client with a reason |
| 21 | DESKTOP |
Compositor tray/notification state bridge and core API are live |
| 22 | DESKTOP_MEDIA |
Viewer media, portals, and MPRIS control family is understood |
| 23 | PROCESS_SESSION_ENV |
PROCESS_SPAWN accepts the SESSION_ENV flag (bit 2) |
| 24 | ENV |
Server answers ENV_GET with its own environment |
| 25 | APP_SOCKET |
C2S_APP_SOCKET mints per-application Wayland sockets |
| 26 | CHANNEL_WATCH |
CHANNEL_WATCH follows which channel names have a listener |
| 27 | CLIENT_ORIGIN |
The client catalog can say which connections are extensions |
| 28 | TERM_JOURNAL |
Per-command journal and sequence-addressed output |
| 29 | CREATE_EXEC |
CREATE2(HAS_ARGV) and CREATE2(HAS_ENV); Unix hosts only |
| 30 | CREATE_NO_SUBSCRIBE |
CREATE2(NO_SUBSCRIBE) skips creator frame subscription |
| 31 | EVENTS |
Server supports the blit.events.v1 binary event journal |
Bit 26 is advertised with bit 12 and never alone; it is separate because a
WATCH an older server does not know is dropped by the channel family's
unknown-kind skip rule, which a client cannot tell from a name nobody serves.
Bits 11 and 12 remain proposed for the extension and channel families under review in #167 and #173. Bit 13 is advertised when native process execution is enabled. Bits 14 and 20 are always advertised.
Bits 11 through 13 are independently omitted when BLIT_EXT=0,
BLIT_CHANNEL=0, or BLIT_PROCESS=0; disabled-family requests are refused as
specified in design/extensions.md
and design/processes.md.
Bit 28 is omitted when BLIT_TERM_JOURNAL=0; nonce-bearing requests are
refused with PERMISSION as specified in
design/term-journal.md.
Bit 14 is not extension-specific and is not controlled by those gates. It is
advertised only after the server implements the negotiated creation outcome
below; the implementation plan updates both shipped clients before enabling it.
New request/reply families should use this registry for a one-byte status
unless their wire definition explicitly declares a message-local table.
Existing message-local tables such as FS_SYNCED and NET_OPENED are
grandfathered and do not share all of these numeric meanings.
| Value | Name | Meaning |
|---|---|---|
| 0 | OK |
Request completed successfully |
| 1 | UNKNOWN_ID |
Requested identifier or handle is absent or already closed |
| 2 | NOT_FOUND |
Path, object, symbol, or backend does not exist |
| 3 | WRONG_TYPE |
Existing object cannot satisfy this operation |
| 4 | PERMISSION |
Operation is disabled or denied |
| 5 | TOO_LARGE |
Input or result exceeds a size ceiling |
| 6 | BUDGET |
A resource budget is exhausted without pagination/truncation |
| 7 | INVALID |
Request encoding, flags, or field combination is invalid |
| 8 | CANCELLED |
Operation ended through its cancellation mechanism |
| 9 | OTHER |
Unclassified backend failure; detail should diagnose it |
| 10 | WARMING |
LSP backend is not ready; retry later |
| 11 | CONFLICT |
A revision, lock, or compare-and-swap precondition failed |
| 12 | NO_MERGE_BASE |
Valid Git histories have no common ancestor |
Values 0–127 are centrally allocated common statuses; 13–127 are currently
reserved. New family-local allocations use 128–255 and must be defined by the
packet which carries them. Existing message-local tables retain their shipped
values. Consumers render unknown values distinctly from OTHER.
S2C_LIST entry layout: [pty_id:2][tag_len:2][tag:N][cmd_len:2][cmd:M] per
PTY. The trailing command field is a backward-compatible extension; old
entries without it parse as an empty command.
When HELLO advertises CREATE_STATUS, shipped clients set
CREATE2.WANT_STATUS. Once that request's nonce and feature byte are
decodable, it receives exactly one outcome: CREATED_N on success or
CREATE_FAILED on refusal. CREATE_FAILED.status uses the common registry and
detail is diagnostic UTF-8, truncated to 1 KiB on a character boundary. In
particular, a projected LIST overflow and PTY-ID or configured PTY-cap
exhaustion return BUDGET, an unrepresentable tag or command returns
TOO_LARGE, malformed fields return INVALID, and spawn failure returns
OTHER. A tag or command that cannot round-trip S2C_LIST's u16 length
prefixes is refused rather than silently truncated into a corrupt catalog
frame.
Both halves are enforced at creation, the only point where either can change: a
terminal's tag and command are fixed once it exists, so the catalog's
encoded size only ever grows by an entry that a create put there. A create whose
own entry is representable but which would push the complete S2C_LIST past the
ceiling is refused with BUDGET. The ceiling is 64 MiB, the same
MAX_DECOMPRESSED every reassembling client already enforces — not the 16 MiB
frame size, which fragmentation makes irrelevant to a logical message (see
"Fragmentation"). The bound is what a client will accept, not what fits in one
frame. Creation is also the only place a refusal can be delivered, so the older
create opcodes, which have no failure reply, refuse to the server log instead of
sending anything.
The projection is computed from the live catalog on each create rather than carried as a running total, so it cannot drift from what the encoder emits; a count that disagreed with the bytes would be the corrupt frame the check exists to prevent. Connecting preflights the same number before building the initial burst: an over-cap catalog aborts the bootstrap with a server diagnostic, since sending one would make the client drop the connection with nothing logged at either end.
The PTY cap is --max-ptys / BLIT_MAX_PTYS, unlimited by default. It counts
live terminals only — a client that runs a hundred short
commands is not holding a hundred terminals, and counting the exited ones
would refuse it work with nothing actually running.
Exited terminals are bounded separately. Their output stays readable after the
command ends, and nothing but an explicit CLOSE used to remove one, so the
server keeps at most BLIT_MAX_EXITED of them (default 1024) and evicts the
oldest first. Eviction takes the same path a CLOSE would and broadcasts the
same CLOSED, so no client change is needed to follow it. BLIT_EXITED_LINGER
adds a time bound in seconds; it is off by default, because how long a result
stays interesting is not something the server can know.
A terminal has no deadline unless a client arms one — detaching and coming
back is the point of a multiplexer, so sessions do not expire on their own.
C2S_DEADLINE and CREATE2(HAS_DEADLINE) opt in, and the server enforces it
whether or not anyone is still connected, which is the difference from every
client-side timeout. ms counts from when the server receives the message, so
re-sending refreshes: repeat it on an interval and it becomes a dead-man
switch, killing the terminal roughly one period after the orchestrator stops
checking in. ms = 0 clears the deadline and stands down an in-flight stop.
On expiry the server sends SIGTERM to the process group, waits 5 s, then sends
SIGKILL, and the resulting EXITED carries reason = 1 (DEADLINE). The
reason byte exists because a deadline kill is otherwise indistinguishable
from a user's kill -9: 0 normal, 1 deadline, 2 lease, 3 gc,
4 unit-stop. Only 0 and 1 are sent today — 2 and 4 are reserved for
design/units.md, and 3 is unused because retention
eviction only ever touches a terminal that has already sent its EXITED, and
signals itself with CLOSED. The byte is
appended and length-gated, like the trailing fields on HELLO, so a 7-byte
EXITED from an older server reads as NORMAL.
KILL and CLOSE signal the child's process group on Unix and terminate its
job object on Windows. KILL's trailing flags byte is optional and armed by
a message length of 8; bit 0 (LEADER_ONLY) restores the older behaviour of
signalling the session leader alone, which is what a caller emulating a
keystroke wants. A 7-byte KILL gets the group, so a client needs no change
to stop leaking a killed shell's children. Group delivery reaches the leader's
own group and, through TIOCGPGRP, the terminal's foreground group; a
backgrounded job sits in neither and survives. Containing that needs a cgroup,
not a signal. On Windows the job carries
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, so dropping the handle takes any
survivor with it; if the job cannot be created the PTY still runs and
degrades to a leader-only kill.
CLOSE's hangup escalates the same way an expiry does: SIGHUP to the group,
wait 5 s, then SIGKILL to the group, so a child that ignores SIGHUP — or whose
descendants inherited that disposition — does not outlive its terminal. The
escalation is invisible on the wire. CLOSED still arrives immediately and
still means the slot is gone: the terminal leaves the catalog when the hangup
goes out, and the rest is carried by the pid alone rather than by a retained
"closing" entry, which would count against the PTY cap and never reach
retention. A client learns nothing about, and waits for nothing in, the grace.
Reaping beats escalating, not the other way round: once a child has been waited
its pid may name an unrelated process group, so a hangup the child answered
promptly cancels the pending SIGKILL instead of firing it late.
This is opt-in rather than a reinterpretation of CREATED_N; a legacy client
cannot mistake an error for PTY zero. CREATE, CREATE_AT, CREATE_N, and
CREATE2 without negotiated WANT_STATUS retain their existing success-only
contract: the server refuses an inadmissible mutation without sending
CREATED or CREATED_N. A client must not set WANT_STATUS unless the server
advertised bit 14. A server must not send CREATE_FAILED for a request which
did not set it.
S2C_EXITED exit status: WEXITSTATUS for normal exits (0, 1, …); negative signal number for signal deaths (-9 = SIGKILL); i32::MIN when status is unknown.
C2S_SCROLL (0x02) names a position as offset lines above the live bottom, per client — one terminal can have several viewers reading different parts of it. That makes the offset a moving target: every line the app pushes off the top of the viewport slides the text under anyone reading the scrollback. A shell is quiet enough for it to go unnoticed; an agent streaming output is not.
The server holds those viewers still. Each tick it asks the driver how many lines actually scrolled (including once the scrollback is full and its depth stops growing, where the number is no longer inferable from the frames), grows every non-zero offset by that much, clamps it to the deepest offset that still has content, and reports the result as S2C_SCROLL_OFFSET (0x11). Sent only to a client that is scrolled back, only when its offset moved.
A client adopts the value: its own copy of the offset feeds the scrollbar, its selection anchors, and the next scroll request it sends, all of which have to keep naming the same rows the frames do. Because the frame that accompanies the re-anchor deepens the scrollback by the same number of lines, the position on screen does not move — that is the point. A frame is sent for a change in scrollback depth alone, so a client whose content is being held still still learns how deep the history under it now goes.
That leaves the client's own requests. An absolute offset only means what the user intended for as long as the bottom it counts from stays put, and the whole reason this section exists is that under a chatty app it does not: the request is computed from a view that is one round trip old, and lands short by however many lines scrolled while it was in flight. C2S_SCROLL_BY (0x1E, gated on FEATURE_SCROLL_BY) states the motion instead of the destination — the server applies it to whatever offset it currently holds, clamps, and answers with S2C_SCROLL_OFFSET. Every incremental gesture uses it: a wheel notch, a page key, a selection drag running off the edge. Absolute C2S_SCROLL stays right for the requests that really are absolute — home, end, dragging the scrollbar, and returning to the live tail — and remains the fallback against a server that does not advertise the bit.
Two complementary paths report a PTY's working directory:
- Push (
S2C_TERM_CWD_EVENT,0x0F): the server scans PTY output for OSC 7 (ESC ] 7 ; file://<host><path> (BEL|ST)), which shell integration emits at every prompt. A report is accepted only when<host>names the server machine (empty,localhost, or the server's hostname, case-insensitively — a remote-ssh shell reports the remote host, and its path is not a local path), the percent-decoded path is absolute valid UTF-8 without NUL, and it is at mostTERM_CWD_MAX(4096) bytes. Accepted reports overwrite the per-PTY stored value (last write wins); the event is broadcast to every connected client — the same fan-out asTITLEandUSED_ROWS— and only when the stored value changes, so per-prompt re-reports of an unchanged directory produce no traffic.cwdis the remainder of the message, with no length prefix. - Shell-side setup for the emitting sequence lives in shell-integration.md (fish emits OSC 7 natively; zsh/bash need a hook).
- Poll (
C2S_TERM_CWD0x1C→S2C_TERM_CWD0x0E): request/reply correlated by nonce. The reply prefers the PTY's stored OSC 7 value — it is fresher (the interactive shell's prompt-time cwd, not whichever pid the kernel happens to track) and costs no syscall. When the shell has never reported (no OSC 7 integration), the server falls back to asking the kernel about the PTY child (/proc/<pid>/cwdon Linux,proc_pidinfoon macOS). The poll therefore remains the fallback for shells without OSC 7; clients with OSC 7-integrated shells see pushes arrive ahead of any poll.
Clients that predate TERM_CWD_EVENT are unaffected: consistent with the version-stability rule above (new message types are added under new opcodes), both reference clients drop unrecognized S2C opcodes — js/core's BlitConnection.handleMessage dispatch falls through to a no-op default, and the CLI's message matches end in a catch-all _ => {}.
A shell that emits OSC 133 (or OSC 633) brackets each command with markers. The server turns those into a bounded ring of records per PTY and addresses the output as a range of sequences — absolute grid-line indices that survive scrollback eviction. Feature bit 28. Full contract in design/term-journal.md; the emitting sequences live in shell-integration.md.
- List (
C2S_TERM_JOURNAL0x50→S2C_TERM_JOURNAL0x50): records fromfrom_index, or the lastlimitwhenJOURNAL_TAILis set.oldest_indexis the lowest index still retained;next_indexis what the next command will take. - One command (
C2S_TERM_OUTPUT0x51→S2C_TERM_OUTPUT0x51): the output region ofindex, capped bymax_bytes.index = u64::MAXis the newest record. - Since a cursor (
C2S_TERM_SINCE0x52→S2C_TERM_OUTPUT0x51): everything appended after(from_seq, from_col).SINCE_PROBEreports the current cursor and returns no text.next_seq/next_colis what to send back next time.OUTPUT_TRUNCATEDmeansmax_bytescut the reply short;OUTPUT_EVICTEDmeans the start had already scrolled out;OUTPUT_ALT_SCREENmeans the PTY is on the alternate screen, where sequences do not advance. - Wait (
C2S_TERM_JOURNAL_WAIT0x53→S2C_TERM_COMMAND0x52): block untilindexfinishes.index = u64::MAXwaits on whatever is running, or on the next command to start if the shell is at a prompt. On timeout the record comes back still flaggedRUNNING.
A terminal whose shell emits no markers keeps an empty journal. Older clients that do not negotiate bit 28 never see these opcodes.
Record on the wire: [index:8][flags:1][exit_code:4][start_seq:8][end_seq:8][started_ms:8][ended_ms:8][cmd_len:2][command:N].
An opcode which multiplexes a one-byte inner kind also needs a family-defined skip rule. The proposed extension-command and native-channel families specify that clients ignore unknown S2C kinds, servers ignore unknown C2S kinds without changing handle state, and any new request kind which requires a reply is separately feature-negotiated; see design/extensions.md.
S2C_SURFACE_FRAME flags byte: bit 0 is the keyframe flag; bits 1–2 encode the codec — H.264 (0), AV1 (1), PNG (2). Bit 3 means a [timestamp_sub_us:2] field appears between the base header and encoded data. The base timestamp is a wrapping monotonic millisecond counter captured at compositor-commit time (not wire-send time); timestamp_sub_us is its 0–999 µs fractional part. The server only sends the extended layout when C2S_CLIENT_FEATURES.client_features bit 0 is set. Bits 4–7 remain reserved.
Each (client, surface) pair runs at most one server-side encoder, sized from that client's view size — or from its scaled subscribe, which overrides it. Multiple mounts on the same client share one subscription, and the size sent on the wire is derived across them: any mount wanting the surface unscaled wins outright, otherwise the largest requested size does. (Shrinking a stream further is cheap; the reverse is lossy.) S2C_SURFACE_FRAME is broadcast to every subscribed client.
S2C_AUDIO_FRAME carries Opus-encoded audio from the compositor's mixed output. timestamp is a sample offset in 48 kHz ticks. flags bits 1-2 encode the codec (0 = Opus). Audio is per-compositor (one mixed stream from all apps), not per-surface. Only sent when the AUDIO feature bit is set in S2C_HELLO.
C2S_AUDIO_SUBSCRIBE carries a bitrate_kbps field (little-endian u16): the desired Opus bitrate in kbps, e.g. 64 for 64 kbps. 0 means server default. Clients may re-send AUDIO_SUBSCRIBE to adjust bitrate without unsubscribing first. When multiple clients are subscribed, the server uses the highest requested bitrate.
data in C2S_CLIPBOARD_SET is opaque bytes and mime says what they are —
image/png for a pasted screenshot as readily as text/plain;charset=utf-8
for text. The compositor stores the pair as the external selection and
advertises exactly that type on the wl_data_offer it hands to Wayland
clients, so what an app can paste is what the browser actually had.
Text alone picks up the conventional aliases: a selection whose type begins
text/plain is additionally offered as text/plain,
text/plain;charset=utf-8 and UTF8_STRING, and answers a receive for any
of them. No other type aliases, in either direction — an app that asks an
image selection for text/plain gets an empty pipe, because bytes delivered
under a type they are not are indistinguishable, to the client, from bytes
that are.
One CLIPBOARD_SET replaces the whole selection; there is no way to offer a
second representation of the same copy. A browser holding both a text and an
image form of one clipboard therefore has to choose, and @blit-sh/core
sends the text — the picture of a spreadsheet range is rarely what pasting it
is meant to produce. The payload is bounded by the 16 MiB frame ceiling like
any other message, and is not fragmented; the reference client refuses to send
an image above 8 MiB rather than have the frame refused, and cancels the paste
outright instead of letting the keystroke land on a selection it did not
update.
A selection owned by a Wayland client does not take that browser round trip.
The compositor advertises the owner's MIME list to every wl_data_device and
splices each accepted receive fd directly back to its wl_data_source.
This is load-bearing for non-text selections: copying image/png in one
streamed app and pasting it in another works without the browser ever reading
or rewriting the image. Ownership is exclusive in either direction; a
CLIPBOARD_SET cancels the prior Wayland owner, and a client selection clears
the stored external one. Each offer pins the owner or bytes it advertised, so
a late receive on an old offer cannot read a replacement selection.
S2C_CLIPBOARD_OWNER makes that authority explicit to every web client and
is replayed before READY on connect. While it is 1, Ctrl/Cmd+V cancels the
browser's native paste and forwards the shortcut without a CLIPBOARD_SET,
preserving the app owner's full MIME set for the direct splice. The web client
marks that status unknown only when the window/tab loses authority or a real
DOM copy/cut occurs; its next paste then reads the browser clipboard, sends
CLIPBOARD_SET, and becomes the external owner. Merely moving focus between
streamed surfaces does not invalidate the Wayland owner.
Pasting that selection into a browser-rendered terminal uses its text
representation directly. The web client keeps the unsolicited
CLIPBOARD_CONTENT emitted with a Wayland text selection in memory; a client
that connected after the copy, or missed the eager content, obtains the MIME
list with CLIPBOARD_LIST and reads the preferred plain-text type with
CLIPBOARD_GET. This path does not depend on permission for a background
navigator.clipboard.writeText.
Middle-click paste reads PRIMARY, which has two possible owners.
A Wayland client owns it by setting a zwp_primary_selection_source_v1: the
compositor offers it to every bound device and splices a receive straight
through to the owner, never buffering the bytes or seeing them. Selecting
text in one app and middle-clicking in another works with the browser out of
the picture entirely.
The browser owns it with C2S_PRIMARY_SET (0x33), same framing as
CLIPBOARD_SET. The web platform exposes no PRIMARY to read on demand, so
the bytes arrive up front and the compositor serves them itself. The
reference client sends them on the middle press rather than on every
selection change — the way the clipboard is pushed on paste rather than on
copy — because owning PRIMARY continuously would permanently displace
whichever Wayland client the user last selected text in. A middle click with
nothing selected in the page therefore still pastes that client's selection.
Ownership is exclusive: whichever side claims PRIMARY displaces the other,
and a displaced Wayland owner is told with cancelled so it stops answering
receive from its own buffer. blit clipboard set --primary claims it from
the CLI. Pasting from the browser with Ctrl+V remains the clipboard's job.
Dragging an OS file from the user's desktop into a streamed app is a real
wl_data_device drag session, driven by the compositor with no client
wl_data_source behind it (wl_data_device.enter's source is allow-null
for exactly this case). The browser tracks HTML5 drag events over the
surface element and reports them with the SURFACE_DRAG_* family; the
server keeps one session per connection and the compositor enters, motions
and drops on the target surface like any other drag. surface_id, x and
y are encoded exactly as in C2S_SURFACE_POINTER — LE u16s in the
composited frame's physical pixel space — and go through the same
logical-coordinate conversion and hit-test as pointer motion.
SURFACE_DRAG_ENTER (0x35) starts the session and carries the MIME types
the browser can offer; the list reaches the app unchanged on the
compositor-owned wl_data_offer. For a file drag it may also append an item
plan: one MIME per file-kind DataTransferItem, in item order. The reference
browser sends a plan only when every item exposes a MIME with a useful
extension; WebKit items that are typeless during hover omit it rather than
committing the eventual file to .bin. iPad screenshots are the deliberate
exception: WebKit exposes only the Files marker until DROP, so the client
sends a one-file PNG plan. That makes the final 0.png URI available during
hover, giving Chromium time to deliver a file-shaped dragenter to the remote
page before release. Once DROP exposes the representation, iPad HEIC/HEIF is
decoded and re-encoded as PNG in the browser to match that plan. Chromium-backed
destinations that do not claim those Apple formats can otherwise navigate to
the staged URI instead of accepting the image drop. If another representation
materializes, or conversion is unavailable, the client sends a replacement
ENTER with the truthful type and name rather than losing the file. The trailer
is optional and append-only, so an ENTER without it is byte-identical to the
original format.
The server derives a staging name for every planned item
(0.png, 1.jpg, .webp/.gif, HEIF-family formats, TIFF, or BMP; unknown
types use .bin), creates the empty files, and can therefore answer
receive("text/uri-list") during
hover. Chromium fetches that URI list at Wayland enter before delivering
the page's dragenter; answering it immediately lets the remote app show
its drop UI before release.
A second ENTER retargets the session: the old surface gets leave, the new
one gets enter. SURFACE_DRAG_MOTION (0x36) and SURFACE_DRAG_LEAVE
(0x37) forward as motion and leave. SURFACE_DRAG_CANCEL (0x39) aborts
— Escape, or the drag leaving the browser window. DROP, CANCEL and
connection close all end the session.
The actual bytes arrive only through the staging upload or inline in
SURFACE_DRAG_DROP (0x38), whose payload is a list of items
[mime_len:2][mime][name_len:2][name][data_len:4][data]. The distinction
that matters is name:
- An item with a non-empty
nameis a file, and its bytes are not in the message:data_lenis 0 andnameis a path relative to the connection's drag staging dir naming a file the client already uploaded through the fs family. With an ENTER plan this must be the exact derived name already present in the hover URI list; both item count and names are checked at DROP. A client without the optional plan may use another safe relative name (the original implementation used0-shot.png). The staging dir is opened with an ordinaryFS_SYNCcarrying theFS_SYNC_STAGINGflag (bit 9): thepathfield is ignored (send it empty), the server resolves the sync root to the per-connection staging dir — creating it on first use — and the file bytes ride the chunkedFS_UPLOADpath with its pacing and cancel like any other upload, so a large drop never queues one giant frame ahead of interactive input.FS_SYNC_STAGINGcombined withFS_SYNC_FROM_PTYis invalid and gets the usual invalid-flag refusal. The dir is removed on connection close, never onFS_STOP: the staged URIs must outlive the drop. Anamethat escapes the staging root (../x, absolute) or names no uploaded file abandons the drop — the session ends with no offer. The server offerstext/uri-list: RFC 2483file://URIs, percent-encoded (spaces and non-ASCII bytes become%XX), one per staged item, CRLF-terminated. When the drop is a single named item its own mime is offered too, served from the staged file's bytes — an app that pastes content rather than opening files still gets it. - A name-less item is dragged content (text, HTML) and is offered directly under its own mime with its inline bytes, never touching the staging dir. Being small by nature, it is also the only part of a drop the 16 MiB frame cap still bounds.
The app reads the data the usual Wayland way. A planned text/uri-list
receive can complete between enter and drop; every other early receive
is parked until DROP supplies its payload. Receives after DROP are served
from that payload by MIME (an unoffered MIME gets an empty pipe). The
compositor sends drop followed by the terminal leave, clearing the
destination's drag UI while the offer remains valid for post-drop reads and
finish. With no source there is no
dnd_drop_performed/dnd_finished — nobody to notify. Client-initiated
drags (a Wayland app starting one via start_drag) take the complementary
path: while the physical button is held, browser mouse events are hit-tested
across mounted surface canvases and drive the compositor's implicit drag grab.
Crossing surfaces sends leave then a fresh offer/enter; receive is fd-
spliced to the source, a valid release sends drop/dnd_drop_performed, and
target finish sends dnd_finished. Source and target action masks are negotiated;
each fresh target offer receives the source mask before enter, and no
selected action is announced until that target replies with set_actions.
This ordering matters to Chromium/Electron targets: they do not start their
pre-drop MIME fetches from an incomplete v3 offer.
The compositor also advertises xdg_toplevel_drag_manager_v1. Chromium uses
that global to start a tab drag before the pointer leaves its source window,
which lets the same cross-surface grab move tabs between browser panes. An
attached toplevel remains a workspace-managed pane rather than following a
compositor-global cursor position, and is excluded from its own drop targets.
An empty mask intersection is announced as action(NONE) without leaving the
surface, because the target may renegotiate on a later motion. Release becomes
a drop only with both a non-NONE action and an accepted MIME; otherwise the
target is left and the source is cancelled. The target's accept(mime) is
relayed as wl_data_source.target(mime) so the
source commits and serves the representation the destination selected;
the source mask declared before start_drag is retained on the data source,
as required by the Wayland request order. Releasing over no surface cancels
the source.
S2C_SURFACE_REMOTE_INPUT mirrors what one viewer is doing to a surface onto the
others watching it, so a shared session shows where the other person is. The
compositor has one seat, so at most one viewer drives it at a time.
kind = 0 carries a single pointer position, drawn with that surface's current
cursor artwork (see Cursors). kind = 1 carries the live touchscreen
contacts, one point per finger on the glass — the whole set every time, not just
the contacts that changed, so a viewer draws what is actually down and a viewer
that subscribed mid-gesture is not left guessing. count = 0 retires the marks:
that is what the driving viewer itself receives, since its own cursor and its own
fingers are already on its screen, and what everyone receives when input ends.
Coordinates are the same composited-frame pixel space as C2S_SURFACE_POINTER.
All these fields are unsigned, so a sender clamps into the surface rather than
letting a position in the letterbox margin wrap to ~65535.
Pointer marks come from C2S_SURFACE_POINTER and from C2S_SURFACE_DRAG_MOTION
— a browser fires no mouse events while a drag is in flight, so without the
latter the marks would sit frozen for the whole drag. Touch marks come from
C2S_SURFACE_TOUCH, so they appear only for a viewer in direct-touch mode. In
pointer compatibility mode a touchscreen is already emulating a pointer, and it
is that pointer which gets mirrored.
Marks are retired when their owner moves to another surface, sends
C2S_SURFACE_POINTER with type = 3 (the pointer left the drawn area), ends a
drag with SURFACE_DRAG_LEAVE or SURFACE_DRAG_CANCEL (again, no mouse event
will arrive to do it), lifts its last contact, cancels or disables touch,
unsubscribes from the surface, disconnects, or the surface is destroyed.
SURFACE_DRAG_DROP is not in that list: it lands inside the surface at a known
position, and ordinary mouse events resume after it.
Only transitions go on the wire: an unchanged mark set, and a count = 0 repeat
to a client that is already the owner, are both suppressed. Every one of these
messages counts against the same outbox frame budget that gates surface video and
paced terminal output, and the browser sends pointer and touch motion
unthrottled.
The button byte in C2S_SURFACE_POINTER is DOM MouseEvent.button
numbering — 0 left, 1 middle, 2 right, 3 back, 4 forward — and the server
translates to evdev. Back and forward become BTN_SIDE and BTN_EXTRA, the
codes a physical mouse's thumb buttons actually emit and the ones toolkits
bind to history navigation; BTN_BACK and BTN_FORWARD exist but are
vestigial and largely unhandled. Unknown button numbers fall back to
BTN_LEFT.
C2S_SURFACE_POINTER_AXIS2 (0x32) carries everything wl_pointer needs to describe a scroll, because the pieces are not interchangeable:
The message's surface_id is its dispatch target, not merely a scale hint. If
the shared Wayland seat has since entered another toplevel, the compositor
re-hit-tests the last pointer position recorded for the named surface before
delivering the axis frame. An unknown, unmapped, or pointerless target drops
the scroll instead of falling through to whichever window held focus.
dx/dy— smooth distance ×100, positive = right/down, in the composited frame's pixel space. The server converts to surface-logical pixels using the same ratio it applies toSURFACE_POINTER, so a wheel and a drag move content by equal amounts on a scaled surface. Sending both axes in one message keeps a diagonal gesture in a singlewl_pointer.frame.v120_x/v120_y— discrete travel in 120ths of a detent (axis_value120's convention: 120 = one notch). Zero for devices without detents. Clients bound belowwl_pointerv8 get the equivalentaxis_discrete; sub-detent travel reaches them as smooth motion only.flagsbits 0–1 — the device source, matchingwl_pointer.axis_source(0 wheel, 1 finger, 2 continuous, 3 wheel tilt). Bit 2 marks the source as known; when clear noaxis_sourceis emitted, which is what the legacy0x22opcode does.flagsbit 3 — stop, sent with zero deltas, becomingwl_pointer.axis_stop. Only a finger-sourced sequence sends one: the protocol leaves awheelsequence unterminated and tells clients not to rely on a stop for it.
The source matters more than it looks. axis_source's zero value is wheel, so omitting the event does not read as "unknown" to a toolkit — it reads as a notched wheel, and the spec invites clients to treat those as "discrete steps of a number of lines". A trackpad's smooth pixel stream then gets multiplied by a lines-per-click factor. On macOS, where the OS has already applied its own acceleration curve and appended a momentum tail before the browser ever sees the event, that second multiply is what made remote scrolling feel violent and non-linear. Labelling the stream finger is what stops the client adding kinetics of its own.
The browser sends the stop after an idle gap deliberately longer than the window a toolkit will regress a fling velocity from — Chromium's is kFlingStartTimeoutMs, 200ms — so that macOS's momentum tail does not get a second one grafted onto it, and so that pausing mid-gesture with fingers still down does not fling at the speed you were going before you stopped. Touch drags do want kinetic scrolling and are unaffected: they end their sequence on touchend instead of waiting out the timer.
wl_pointer.axis carries the distance in surface-local pixels, as the protocol specifies and as Mutter emits — with one exception. Chromium reads that value as detents, dividing by a hardcoded kAxisValueScale = 10 and multiplying by kWheelDelta = 120 for every source including finger, then handing the result to Blink as precise pixels; a pixel-valued axis scrolls a Chromium or Electron window exactly twelve times too far. The compositor recognises those clients by the runtime payload beside their executable and gives them the same distance in detent units — ten per detent, the convention Weston established and Mutter still emits for wheels. GTK, which reads the value as GDK_SCROLL_UNIT_SURFACE pixels, and winit, which hands it to Alacritty as a PixelDelta, keep pixels. v120_* is never rescaled: its unit is unambiguous and every toolkit agrees on it.
S2C_SURFACE_TEXT_INPUT forwards state committed through
zwp_text_input_v3.commit. enabled tells the viewer whether the focused
surface currently accepts text input. requested is set only for a new
enable commit, so a browser may open its virtual keyboard without reopening
one the user already dismissed when cursor metadata changes or a viewer
reconnects. content_hint and content_purpose retain their Wayland enum
values and let the viewer choose an appropriate HTML input mode. Opening a
platform virtual keyboard remains best-effort because browsers may require a
recent user activation.
The Blit UI honors fresh requests by default; its device-local Media →
On-screen keyboard preference can opt out without changing the forwarded
Wayland state or the manual keyboard control.
The optional caret tail carries zwp_text_input_v3.set_cursor_rectangle —
where the app draws the text under edit — mapped from surface-local logical
coordinates into composited surface pixels, the same space SURFACE_POINTER
positions use. It is signed (xdggeometry can put the caret left of or above
the composited origin) and absent until the app names a rectangle. A viewer
paints its own pixels, so the host IME would otherwise anchor its candidate
window to whatever hidden element is capturing composition; parking that
element over this rectangle is what makes the popup open at the app's caret.
The compositor sends a message per _changed rectangle only: apps re-send the
same one on every keystroke.
C2S_SURFACE_TOUCH (0x3A) is opt-in through an ENABLE control message and
requires feature bit 18. Phases are 0 down, 1 up, 2 motion, 3 cancel, 4 enable,
and 5 disable. Enable, disable, and cancel carry zero contacts. Contact
coordinates are signed, multiplied by 100, and use the composited-frame pixel
space; the compositor applies the same frame-to-logical transform as pointer
input. One contact-bearing transport message becomes one wl_touch.frame,
preserving browser TouchEvent atomicity; cancel is terminal and needs no
frame.
Wayland contact ids are compositor-local slots and are reused after up; they
are not the browser's Touch.identifier. Keeping the slot set bounded also
matters to Chromium: an ever-increasing id works for dragging but stops
producing touchscreen flings once it reaches 32.
time_ms is the browser's own TouchEvent.timeStamp. C2S_SURFACE_POINTER and
C2S_SURFACE_POINTER_AXIS2 carry one for the same reason, and it is not
decoration: clients may derive velocity by differentiating position against
wl_touch.time, wl_pointer.motion or wl_pointer.axis — a fling, a stroke
width, a swipe — so the spacing between events has to be the browser's. Over a
network, arrival times would substitute jitter for real cadence.
C2S_SURFACE_INPUT carries one too, so every path that has a browser event uses
it. 0 means "unknown" — the legacy axis opcode, an IME commit's synthesised
keys, the chord and modifier keys built around a real keypress, disconnect
cleanup — and takes the compositor's own clock without disturbing the anchor,
since those interleave with real gestures and restarting the pacing around them
would be worse than not having their own time.
Direct touch also preserves that cadence in wall-clock delivery. Chromium's
Wayland backend currently discards the protocol's millisecond value and stamps
each wl_touch event when it is received. If a coalesced iPad burst is merely
given correct wl_touch.time values but drained in one pass, Chromium still sees
zero-time motion and suppresses the fling. The compositor therefore schedules
the frames at the browser's inter-event deltas through a small per-sequence
jitter buffer, never earlier than their arrival; steps over 100 ms start
immediately rather than turning a hold or clock jump into input latency. If
render or encode work misses several deadlines, the compositor rebases the
undispatched tail instead of catching every overdue frame up in one flush, so
each frame still reaches Chromium separately. The buffer keeps at most eight
pending motion frames and sheds its oldest motion history when playout would
trail arrival by more than 80 ms. Any contact update absent from the following
frame is merged forward, so current positions are never lost. The retained
motion tail is resampled from the last played contact positions to the newest
source positions; this avoids a discontinuity after sustained compaction that
Chromium treats as scroll motion but excludes from fling velocity. At least two
motion frames survive for velocity. Cancel remains urgent and drops that owner's
undispatched tail.
The client's epoch is its own, so only the deltas are used: the compositor anchors to its own clock and adds the browser's deltas on top, keeping these timestamps in one millisecond domain across the seat. It re-anchors after an idle gap, since nothing needs continuity across a pause and a stale anchor would accumulate the drift between the two clocks. The result is monotonic, because clients may assert on time going backwards — but deliberately not clamped to the current instant: a batch generated before it arrived legitimately spreads across the moment it is drained, and clamping each event to "now" would flatten the very spacing this exists to preserve. A client whose clock runs fast re-anchors once its timestamps get more than a second ahead.
Direct touch keeps that anchor per live browser owner rather than borrowing the seat's pointer/key anchor. Multiple viewers can share a dev session, and every page has an unrelated DOM timestamp epoch; using one viewer's pointer clock for another viewer's touch sequence makes every touch timestamp look invalid and collapses a queued iPad motion burst back onto compositor drain time.
Down binds a contact to the surface hit at that point. Later motion and up stay
on that surface, matching Wayland's implicit grab even when the contact crosses
a popup or subsurface boundary. The down serial also authorizes
wl_data_device.start_drag, so a touch-started drag follows that contact until
up. Starting that drag takes the seat over, so the compositor emits
wl_touch.cancel at start_drag: the client is told to forget the whole
sequence and receives no further wl_touch event for any of its contacts —
including the one still driving the drag, which now speaks through
wl_data_device. That is the only consistent option, because cancel has no
per-contact form, so a contact the drag swallows cannot be retired on its own;
withholding its up instead would leave the client holding it pressed forever.
New downs during the drag are ignored.
A contact's target unmapping, the viewer disconnecting, or direct mode being disabled cancels the sequence the same way, and also tells the server, which releases the ownership below rather than waiting for that browser's fingers to lift.
Only one connection owns a live direct-touch sequence. Another viewer's down is ignored until the owner raises its last contact or cancels; this prevents independent browsers from splicing contacts into one Wayland seat. The seat's touch capability is advertised while at least one connection has direct mode enabled. Pointer gestures remain the default, and touch input falls back to that mapping when the feature bit is absent. Trackpad wheel events and pen pointer events are unaffected.
Contacts are mirrored to the surface's other viewers as
S2C_SURFACE_REMOTE_INPUT with kind = 1, the same way a pointer position is;
see Shared input marks.
On connect, the server immediately sends:
S2C_HELLO (protocol version + feature bits + boot generation + server release)
S2C_LIST (all existing PTYs)
S2C_TITLE (one per PTY, if title is set)
S2C_EXITED (one per exited-but-retained PTY)
S2C_READY (end of initial burst)
After S2C_READY, the client can start sending commands. A successful create
subscribes its creator by default; CREATE2(NO_SUBSCRIBE) opts out. Otherwise,
S2C_UPDATE frames are not sent until the client subscribes to a PTY with
C2S_SUBSCRIBE. Each C2S_SUBSCRIBE, including one repeated for an already
subscribed PTY, starts a fresh diff stream: the next update is a full-state
keyframe. Clients use that repeat to recover after discarding or failing to
apply a delta.
S2C_EXITED is an ingestion barrier: the server applies every PTY byte drained
for that process generation to its terminal model before sending the exit. It
does not imply that a paced S2C_UPDATE reached each subscriber. An immediate
C2S_COPY_RANGE does observe the final server-side terminal model.
S2C_UPDATE payload (after opcode and pty_id) is LZ4-compressed (lz4_flex::compress_prepend_size). Decompressed:
Header (12 bytes):
[rows:2][cols:2][cursor_row:2][cursor_col:2][mode:2][title_field:2]
title_field packs flags in the upper 4 bits and title UTF-8 length in bits 0–11:
| Bit | Flag |
|---|---|
| 15 | TITLE_PRESENT |
| 14 | OPS_PRESENT |
| 13 | STRINGS_PRESENT |
| 12 | LINE_FLAGS_PRESENT |
| 0–11 | Title UTF-8 length |
Cell operations follow the header when OPS_PRESENT:
OP_COPY_RECT (0x01)— copy a rectangle of cells from another position. Encodes scrolling without retransmitting unchanged content.OP_FILL_RECT (0x02)— fill a rectangle with a single cell value. Efficient for clears and blank regions.OP_PATCH_CELLS (0x03)— bitmask-indexed individual cell updates, column-major interleaved. Only changed cells are transmitted.
Cell format — each cell is exactly 12 bytes:
Byte 0 (flags0): fg_type[2] | bg_type[2] | bold | dim | italic | underline
Byte 1 (flags1): inverse | wide | wide_continuation | content_len[3] | link
Bytes 2–4: fg color (r, g, b) or palette index
Bytes 5–7: bg color (r, g, b) or palette index
Bytes 8–11: UTF-8 content (up to 4 bytes)
Color type encoding: 0 = default terminal color, 1 = indexed (256-color palette), 2 = RGB true color.
When content_len == 7, the cell's text exceeds 4 bytes. Bytes 8–11 hold an FNV-1a hash used for diff comparison; the actual UTF-8 string is transmitted in the STRINGS_PRESENT section, keyed by cell index.
link (bit 6) marks a cell covered by an OSC 8 hyperlink. The target lives in the hyperlink section below; the bit exists so the renderer can style a link without a side-table lookup, and so a cell gaining or losing a link is visible to the byte-wise cell diff.
Hyperlink section — trailing, after the scrollback count:
[u16 uri_count] 0xFFFF = unchanged, section ends
uri_count × [u16 link_id][u16 uri_len][uri utf8]
[u16 run_count]
run_count × [u32 start_cell][u16 run_len][u16 link_id]
Like the scrollback count it follows, this section is a backward-compatible extension: a client that predates it stops reading after the scrollback count, and its absence reads as "no hyperlinks" on a new client talking to an old server. No capability negotiation is involved.
link_id is frame-local and 0 means "no link", so 0xFFFF is free to serve as the unchanged sentinel — which is what an idle frame costs: two bytes. When the state does change the table is sent in full rather than diffed, because OP_COPY_RECT / OP_FILL_RECT relocate cells and replaying those transforms against a parallel id array is a correctness trap for a section that is nearly always empty. Keyframes always send the table explicitly rather than claiming "unchanged".
URIs are deduplicated by target, capped at 4096 bytes, and dropped rather than truncated when longer — a truncated URI is a different URI. The cell→id map is run-length encoded because a hyperlink always spans contiguous cells.
The server relays targets verbatim and applies no scheme filtering: OSC 8 deliberately decouples a link's text from its target, and only the client is positioned to show the user that discrepancy. @blit-sh/core's assessUrl() classifies every target as allow / confirm / deny before it can be opened — rejecting script-executing schemes and any URI containing invisible or text-reordering codepoints, and escaping every target for display so a preview cannot misrepresent itself.
Mode bits (16-bit field in frame header):
- Bits 0–8: cursor style, app cursor keys (
DECCKM), app keypad, alternate screen, mouse mode (X10/VT200/button-event/any-event), mouse encoding (UTF-8/SGR/pixel) - Bit 9: PTY echo flag (
tcgetattr ECHO) - Bit 10: PTY canonical mode (
tcgetattr ICANON)
Mode bits are tracked by ModeTracker in blit-alacritty, which intercepts CSI/DCS sequences from raw PTY output.
S2C_FRAGMENT (0x2B) splits any bulk server message into chunks so small
frames such as audio need not sit behind a multi-megabyte write:
[0x2B][flags:1][chunk:N]
Flag bit 0 (FRAGMENT_FLAG_LAST) marks the final chunk. Chunks carry the
original message's bytes verbatim; its opcode arrives in the first chunk. The
receiver concatenates chunks into one logical message and dispatches it
normally. Fragments of different messages do not interleave, and the protocol
permits only S2C_AUDIO_FRAME between fragments.
Chunk size is transport policy and receivers must not depend on one. Splitting happens wherever the sender can see the link it is writing to:
Both hops split, each measuring its own writes: payloads over 128 KiB on sight, dropping to 4 KiB chunks once writes are seen to block and recovering when they stop.
- The server writes to a unix socket, which looks free while the gateway is keeping up. It is not: the gateway reads one frame at a time into a one-deep queue, so a browser that cannot keep up stops the gateway reading, the socket buffer fills, and a large write here blocks for as long as the link needs.
- The gateway holds the socket to the browser, so its writes measure the latency the listener actually hears.
Splitting at only one of the two leaves audio behind a blocking write at the other.
A sender may re-split a fragment it received. Doing so peels the fragment
header rather than nesting: FRAGMENT_FLAG_LAST is carried onto the final
piece only, so the receiver sees one flat sequence either way. Logical messages may
exceed the 16 MiB frame limit. What they may not exceed is MAX_DECOMPRESSED
(64 MiB): a receiver aborts a reassembly that grows past it, so that is the real
ceiling on a logical message, and the one S2C_LIST is bounded against.
The extension RFC tightens fragmentation as follows. These rules are not yet enforced by every shipped Rust and TypeScript client; implementing them in both reference clients and the shared writer is a prerequisite to advertising the proposed feature bits 11 and 12, and belongs to phase 2 of design/extensions.md:
- flag bits 1 through 7 are zero and every chunk is non-empty; a reserved flag or empty chunk aborts the connection;
- each fragment remains an ordinary frame, so
chunkis at most 16 MiB minus the two-byte fragment opcode and flags; - while reassembly is pending, any non-fragment frame other than
S2C_AUDIO_FRAMEaborts the connection without dispatching that frame; - the maximum reconstructed logical message is 64 MiB and one message uses at most 16,384 fragments.
The updated sender must not emit a larger logical message or more fragments.
The updated receiver must check cumulative length and count before extending
its buffer, abort an over-bound sequence without dispatching it, and release
pending storage on every connection exit. The proposed logical-message ceiling
is numerically the same as MAX_DECOMPRESSED below, but bounds fragment
reassembly rather than the allocation declared inside an LZ4 payload.
Fields documented as :LZ4 are lz4_flex::compress_prepend_size (a 4-byte
LE uncompressed size, then the LZ4 block). Receivers MUST check the
declared size against MAX_DECOMPRESSED (64 MiB) before allocating, so a
hostile or corrupt length cannot force a giant allocation. The constant is
protocol-wide — exported as MAX_DECOMPRESSED from blit-remote and
@blit-sh/core (the fs family's FS_MAX_DECOMPRESSED is the same value) —
and every family bounds its responses well under it, so a well-behaved
peer never trips the guard.
The FS_* family (feature bit 6) mirrors a server-side directory tree into
clients as ordered state diffs: a client FS_SYNCs a path, receives a staged
snapshot followed by live updates (RESET/SYNC flags delimit staged
series), applies LZ4-compressed UPSERT/DELETE/MOVE records to a map,
and acknowledges cumulatively via FS_ACK (byte-window pacing,
BLIT_FS_WINDOW). FS_FETCH pulls one file's full content on demand;
FS_WRITE/FS_OP write back to disk — content upserts under
compare-and-swap on the synced content hash, plus
mkdir/remove/rename/symlink/hardlink —
each answered by one FS_DONE
(design/fs-write.md). The write side shares the
family's feature bit; BLIT_FS_WRITE=0 makes a deployment read-only
(writes answer PERMISSION).
Files too large for one frame upload in chunks
(FS_UPLOAD_BEGIN/CHUNK/FINISH/CANCEL): BEGIN names the sync,
root-relative path (same %-encoding and traversal validation as
FS_WRITE), mode, total plaintext size, and base — the same CAS
precondition as FS_WRITE (NO_CAS overwrites unconditionally, base 0
is create-exclusive, anything else must equal the current content hash).
BEGIN flags alias the FS_WRITE flag bits exactly: 0x01 NO_CAS,
0x02 MKPARENTS, 0x04 DURABLE, 0x08 FOLLOW_SYMLINK; anything else
answers INVALID. The precondition is two-phase: BEGIN evaluates it
fail-fast, before any bytes flow (CONFLICT carries the current on-disk
hash, as FS_DONE does), and FINISH re-verifies it under the target's
write lock immediately before the rename, so a file changed mid-upload
fails landing with CONFLICT and the now-current hash rather than being
clobbered. The server answers BEGIN with a per-connection upload_id.
Chunks append strictly in order — each offset must equal the bytes
accepted so far — and each is acked with the cumulative received count,
which on OFFSET_MISMATCH is the resume point. The engine stages the
bytes in a temp sibling of the target (never mirrored; .blit-tmp-*
names are excluded from sync) and FINISH verifies received == size
(else SIZE_MISMATCH), fsyncs when DURABLE, and atomically renames
over the target, creating parents under MKPARENTS; the success reply
carries the FS_DONE hash/mtime payload. FINISH terminates the upload
whatever the outcome; CANCEL, sync stop, and connection close all drop
the state and remove the temp file. Limits: BLIT_FS_UPLOAD_MAX
(1 GiB default, TOO_LARGE past it) and BLIT_FS_UPLOAD_INFLIGHT
(4 concurrent uploads per connection, BUDGET past it). Upload statuses
extend the common registry with family-local values (128–255):
128 OFFSET_MISMATCH, 129 SIZE_MISMATCH, 130 UNKNOWN_UPLOAD.
Wire details, record layouts, and semantics:
design/fs-watch.md; server engine:
crates/fssync; codecs and the FsMirror reference reducer:
crates/remote/src/fs.rs (Rust) and js/core/src/fs.ts (TypeScript,
surfaced as syncFs on BlitConnection/BlitWorkspace).
The GIT_* family (feature bit 7) opens repositories by path, pushes
mutable state (HEAD, refs, in-progress operation, status) as
whole-snapshot GIT_STATE messages, and pulls immutable content
(commits, trees, blobs, diffs, patches) by content address through
nonce request/response pairs. Wire details:
design/git.md; server engine: crates/git; codecs and
the GitStateMirror reference reducer: crates/remote/src/git.rs and
js/core/src/git.ts (surfaced as openRepo on
BlitConnection/BlitWorkspace). Bounded responses carry a CURSOR
record naming where they stopped, so every enumeration is resumable;
discovery, blame, reflog and fetch occupy a second opcode block at
0xB1 through 0xB4 (GIT_BASE begins that block at 0xB0).
The LSP_* family (feature bit 8) terminates LSP at the server: warm
language-server backends are daemon-owned and shared, backend
phase/capabilities are pushed as LSP_STATE snapshots, diagnostics as
per-file replacement sets (LSP_DIAG, FULL replay on subscribe), and
definition/references/hover/symbols/rename-as-data are pulled through
the single LSP_QUERY opcode. Positions are 0-based lines with UTF-8
byte columns; the server transcodes. Wire details:
design/lsp.md; server engine: crates/lsp; codecs and
the LspStateMirror/LspDiagMirror reference reducers:
crates/remote/src/lsp.rs and js/core/src/lsp.ts (surfaced as
openLsp on BlitConnection).
The /mux WebSocket endpoint carries traffic for all gateway destinations over a single connection. This replaces the legacy model where the browser opened one WebSocket per remote (/d/<name>).
Same as the per-destination handler: the browser sends the passphrase as a text frame. The server responds with "mux" (not "ok") to confirm multiplexed mode. After auth, all subsequent frames are binary.
Every binary frame is prefixed with a 2-byte LE channel ID:
[channel_id:2 LE][payload:N] channel_id < 0xFFFF → data
[0xFFFF][control_opcode:1][...] channel_id = 0xFFFF → control
Data frames carry raw blit protocol messages (starting with the usual 1-byte opcode). The gateway strips the channel prefix before forwarding to the upstream blit server and prepends it to responses.
| Direction | Opcode | Name | Layout |
|---|---|---|---|
| C → S | 0x01 |
OPEN |
[channel_id:2][name_len:2][name:N] |
| C → S | 0x02 |
CLOSE |
[channel_id:2] |
| S → C | 0x81 |
OPENED |
[channel_id:2] |
| S → C | 0x82 |
CLOSED |
[channel_id:2] |
| S → C | 0x83 |
ERROR |
[channel_id:2][msg_len:2][msg:N] |
The client assigns channel IDs (starting from 0). OPEN maps a channel ID to a named destination; the server connects upstream and responds with OPENED or ERROR. CLOSE tears down a channel. The server also sends CLOSED when the upstream connection drops.
Browser Gateway blit server
| | |
|-- WS /mux ------------------->| |
| (text: passphrase) | |
|<-- text: "mux" ---------------| |
| | |
| [0xFFFF][OPEN][0][local] | |
| ---------------------------> |-- Unix socket ----------->|
|<-- [0xFFFF][OPENED][0] | |
| | |
| [0xFFFF][OPEN][1][rabbit] | |
| ---------------------------> |-- SSH streamlocal ------->|
|<-- [0xFFFF][OPENED][1] | |
| | |
| [0][C2S_INPUT ...] |-- write_frame(payload) -->|
|<-- [0][S2C_UPDATE ...] |<-- read_frame ------------|
|<-- [1][S2C_HELLO ...] |<-- read_frame ------------|
The legacy /d/<name> endpoint remains available for backward compatibility and embedding scenarios.
The gateway and proxy pass C2S_ACK through to the server unchanged. Each ACK retires the oldest in-flight frame and updates the server's RTT estimate. The server uses this to:
- Compute per-client bandwidth-delay product.
- Pace frame sends to match the client's actual render rate.
- Avoid pipelining more frames than the link can absorb.
See docs/server.md § Per-client frame pacing for details.