a73x

src/engine/protocol.zig

Ref:   Size: 149.0 KiB   History

//! Wire protocol: length-prefixed frames over a byte stream, plus the apply
//! semantics for deltas. Frame = 1 byte MsgType, u32 LE payload length, payload.
//! Hand-rolled deliberately — payloads are row-keyed blobs and fixed-width
//! little-endian integers, which readInt/writeInt cover without a dependency.
const std = @import("std");

pub const MsgType = enum(u8) {
    // client -> daemon
    attach = 0x01, // payload: u16 LE cols, u16 LE rows, u64 LE have_seq, u64 LE have_epoch ++ optional session-name tail (empty = default session)
    input = 0x02, // payload: raw bytes for the PTY
    resize = 0x03, // payload: u16 LE cols, u16 LE rows
    detach = 0x04, // payload: empty
    fetch_scrollback = 0x05, // payload: u32 LE start screen-row, u16 LE row count
    stats_req = 0x06, // payload: empty
    stop_req = 0x07, // payload: empty; daemon shuts down as if signalled
    endpoint_req = 0x08, // payload: empty; asks for the QUIC port, binding a listener lazily if needed
    await_req = 0x09, // payload: u64 LE since_seq, u32 LE settle_ms, u32 LE timeout_ms; either duration 0 = that mechanism off ++ optional session-name tail (empty = default session)
    status_req = 0x0a, // payload: empty, or a session-name tail (empty = default session)
    selection_req = 0x0b, // payload: SelectionReq (see encodeSelectionReq)
    sessions_req = 0x0c, // payload: empty; asks which sessions the daemon is hosting
    agent_offer = 0x0d, // payload: empty; re-sent after EVERY attach when -A (a redial re-attaches)
    agent_data = 0x0e, // payload: u32 LE channel id ++ up to agent_data_max opaque agent bytes; BOTH directions
    agent_close = 0x0f, // payload: u32 LE channel id; BOTH directions
    upgrade_req = 0x10, // payload: u8 flags (bit0 allow_same_version) ++ version bytes ++ NUL ++ absolute path bytes
    end_req = 0x11, // payload: u8 flags (bit0 force) ++ optional session-name tail (empty = default session)
    create_req = 0x12, // payload: u16 LE cols, u16 LE rows ++ explicit session name; create only, never attach
    forward_hello = 0x20, // payload: u16 LE protocol version; promotes this connection to the forwarding role
    forward_open = 0x21, // payload: u32 LE client channel id, u16 LE loopback destination port
    forward_data = 0x22, // payload: u32 LE channel id ++ bounded opaque TCP bytes; BOTH directions
    forward_credit = 0x23, // payload: u32 LE channel id, u32 LE additional receive credit; BOTH directions
    forward_half_close = 0x24, // payload: u32 LE channel id; BOTH directions
    forward_reset = 0x25, // payload: u32 LE channel id; BOTH directions
    debug_dump = 0x7f, // payload: 1 byte: 0 = plain, 1 = vt ++ optional session-name tail (empty = default session)
    // daemon -> client
    // The three replay frames were renumbered in 2026-09 when their payloads
    // stopped being VT and became cells. The old numbers 0x81 snapshot,
    // 0x85 scrollback_chunk and 0x87 delta are retired and never reused, so a
    // binary from either side of the change drops the other's frames as
    // unknown instead of parsing cells as VT or the reverse.
    snapshot = 0x95, // payload: SnapshotPrefix ++ u16 LE cursor_x ++ u16 LE cursor_y ++ rows x CellRow
    exit_status = 0x82, // payload: 1 byte exit code
    // Retired when attach became a JOIN rather than a takeover; no daemon
    // sends it. The clients still handle it (`wallview`, `interact`)
    // because a new client may attach to an old daemon that does.
    taken_over = 0x84, // payload: empty; a newer client attached, you're out
    scrollback_chunk = 0x97, // payload: u32 LE start, u16 LE count ++ count x CellRow
    stats_reply = 0x86, // payload: human-readable stats text
    delta = 0x96, // payload: DeltaHeader ++ row_count x (u16 LE row ++ u32 LE len ++ CellRow)
    pty_mode = 0x88, // payload: 1 byte flags: bit0 icanon, bit1 echo
    endpoint_reply = 0x89, // payload: u16 LE port; 0 = no listener could be produced (reason in daemon log)
    cmd_state = 0x8a, // payload: CmdState (see encodeCmdState); pushed on marks-regime transitions
    await_reply = 0x8b, // payload: CmdState ++ 1 byte AwaitReason
    status_reply = 0x8c, // payload: StatusReply (see encodeStatusReply)
    term_modes = 0x8d, // payload: u32 LE bitset; bit0 bracketed paste, then the mouse bits, alt_screen and cursor_keys (see TermModes)
    term_title = 0x8e, // payload: UTF-8 title bytes, never empty (see sampleTermTitle), never longer than term_title_max
    term_event = 0x8f, // payload: 1 byte kind ++ kind-specific bytes (see TermEvent)
    selection_reply = 0x90, // payload: SelectionReply (see encodeSelectionReply)
    sessions_reply = 0x91, // payload: the live session names, '\n'-separated, in slot order; empty payload = no sessions. A name can hold no whitespace (validSessionName), so the separator needs no escaping and no codec — the same reason stats_reply is plain text. Readers walk it with sessionsIter, which is where the trust policy lives.
    agent_open = 0x92, // payload: u32 LE channel id; daemon allocates ids, only the daemon opens
    upgrade_reply = 0x93, // payload: u8 status (0 accepted, 1 refused) ++ reason text
    end_reply = 0x94, // payload: u8 status (0 accepted, 1 refused) ++ u8 others ++ reason text
    create_reply = 0x98, // payload: CreateStatus byte ++ bounded reason text
    forward_ready = 0xa0, // payload: u16 LE protocol version
    forward_open_result = 0xa1, // payload: u32 LE channel id, u8 status (0 open, 1 refused)
    dump_reply = 0xff, // payload: requested dump bytes
    _,
};

pub const max_payload = 16 * 1024 * 1024;

/// The widest row a client will decode; a wider claim is a bad payload, not an allocation.
pub const max_cols: u16 = 4096;

/// One type byte + u32 LE payload length.
pub const frame_header_len = 5;

/// The one place the header layout is spelled. Every writer — blocking,
/// bounded, queued, and the hub's re-frame onto a WebSocket — builds its
/// header here, so the layout cannot drift between one path and another;
/// `delimitFrame` and `readFrame` are the decode side of these same bytes.
/// Returns by value because a 5-byte array is cheaper to copy than to
/// borrow, and the caller wants it beside a payload slice anyway.
pub fn encodeHeader(t: MsgType, len: usize) [frame_header_len]u8 {
    var hdr: [frame_header_len]u8 = undefined;
    hdr[0] = @intFromEnum(t);
    std.mem.writeInt(u32, hdr[1..5], @intCast(len), .little);
    return hdr;
}

pub const Frame = struct {
    type: MsgType,
    payload: []u8,

    pub fn deinit(self: Frame, alloc: std.mem.Allocator) void {
        alloc.free(self.payload);
    }
};

/// One frame's boundaries inside a buffer somebody else filled. `payload`
/// BORROWS from that buffer and is valid only until it is written to or
/// shifted; a `Frame` owns its copy.
pub const Delimited = struct {
    type: MsgType,
    payload: []const u8,
    /// Header plus payload — what the caller must drop off the front of
    /// its buffer before looking for the next frame.
    consumed: usize,
};

/// Delimit the frame at the front of `buf`, without copying. Null is a partial
/// tail — the ordinary state of a byte stream, never an error.
/// `error.FrameTooLarge` means reading on would size an allocation from a
/// number the peer chose.
pub fn delimitFrame(buf: []const u8) !?Delimited {
    if (buf.len < frame_header_len) return null;
    const len = std.mem.readInt(u32, buf[1..5], .little);
    if (len > max_payload) return error.FrameTooLarge;
    if (buf.len < frame_header_len + len) return null;
    return .{
        .type = @enumFromInt(buf[0]),
        .payload = buf[frame_header_len..][0..len],
        .consumed = frame_header_len + len,
    };
}

/// The frame at the front of `buf`, or null while only part of one is
/// here — the ordinary state of a byte stream, never an error. An error
/// is a length no frame can carry, and every caller answers it the same
/// way: it has a connection, and drops it.
pub fn takeFrame(alloc: std.mem.Allocator, buf: *std.ArrayList(u8)) !?Frame {
    const d = (try delimitFrame(buf.items)) orelse return null;
    // Copied out and `buf` shifted BEFORE the caller dispatches, because
    // a handler can reallocate `buf` under a slice into it; the payload
    // is then the caller's frame to free.
    const payload = try alloc.alloc(u8, d.payload.len);
    @memcpy(payload, d.payload);
    buf.replaceRangeAssumeCapacity(0, d.consumed, &.{});
    return .{ .type = d.type, .payload = payload };
}

pub fn writeFrame(fd: std.posix.fd_t, t: MsgType, payload: []const u8) !void {
    const hdr = encodeHeader(t, payload.len);
    try writeAllFd(fd, &hdr);
    try writeAllFd(fd, payload);
}

/// How long one reply may spend waiting on a peer that has stopped reading.
/// The same figure the daemon already gives a stalled client's drain, and
/// for the same reason: past it the peer is gone, not slow.
pub const reply_budget_ms: i32 = 250;

/// `writeFrame` for a NONBLOCKING fd: it waits out short writes until
/// `budget_ms` is spent, so a peer that stops reading cannot stop the
/// caller, while a merely slow one still gets every byte. WouldBlock
/// leaves a TRUNCATED frame — hence every caller drops the connection.
pub fn writeFrameBounded(fd: std.posix.fd_t, t: MsgType, payload: []const u8, budget_ms: i32) !void {
    const hdr = encodeHeader(t, payload.len);
    var left = budget_ms;
    try writeAllFdBounded(fd, &hdr, &left);
    try writeAllFdBounded(fd, payload, &left);
}

/// The write half of `writeFrameBounded`; `left` is the shared remainder so
/// header and payload cannot each be given the whole budget.
fn writeAllFdBounded(fd: std.posix.fd_t, data: []const u8, left: *i32) !void {
    var idx: usize = 0;
    while (idx < data.len) {
        idx += std.posix.write(fd, data[idx..]) catch |e| switch (e) {
            error.WouldBlock => 0,
            else => return e,
        };
        if (idx == data.len) return;
        if (left.* <= 0) return error.WouldBlock;
        var pfd = [_]std.posix.pollfd{.{ .fd = fd, .events = std.posix.POLL.OUT, .revents = 0 }};
        const t0 = std.time.milliTimestamp();
        const ready = std.posix.poll(&pfd, left.*) catch return error.WouldBlock;
        const spent: i32 = @intCast(@min(@as(i64, left.*), std.time.milliTimestamp() - t0));
        left.* -= spent;
        if (ready == 0) return error.WouldBlock;
    }
}

/// The queued counterpart of `writeFrame`, for a peer too slow to block on.
pub fn appendFrame(
    list: *std.ArrayList(u8),
    alloc: std.mem.Allocator,
    t: MsgType,
    payload: []const u8,
) !void {
    const hdr = encodeHeader(t, payload.len);
    try list.appendSlice(alloc, &hdr);
    try list.appendSlice(alloc, payload);
}

/// Blocking read of one frame. Returns null on clean EOF at a frame
/// boundary; errors on EOF mid-frame.
pub fn readFrame(alloc: std.mem.Allocator, fd: std.posix.fd_t) !?Frame {
    var hdr: [5]u8 = undefined;
    const first = try std.posix.read(fd, hdr[0..1]);
    if (first == 0) return null;
    try readExact(fd, hdr[1..5]);
    const len = std.mem.readInt(u32, hdr[1..5], .little);
    if (len > max_payload) return error.FrameTooLarge;
    const payload = try alloc.alloc(u8, len);
    errdefer alloc.free(payload);
    try readExact(fd, payload);
    return .{ .type = @enumFromInt(hdr[0]), .payload = payload };
}

pub fn writeAllFd(fd: std.posix.fd_t, data: []const u8) !void {
    var idx: usize = 0;
    while (idx < data.len) idx += try std.posix.write(fd, data[idx..]);
}

fn readExact(fd: std.posix.fd_t, buf: []u8) !void {
    var idx: usize = 0;
    while (idx < buf.len) {
        const n = try std.posix.read(fd, buf[idx..]);
        if (n == 0) return error.UnexpectedEof;
        idx += n;
    }
}

/// Encode a cols/rows pair (attach and resize payloads).
pub fn encodeSize(cols: u16, rows: u16) [4]u8 {
    var buf: [4]u8 = undefined;
    std.mem.writeInt(u16, buf[0..2], cols, .little);
    std.mem.writeInt(u16, buf[2..4], rows, .little);
    return buf;
}

pub const Size = struct { cols: u16, rows: u16 };

pub fn decodeSize(payload: []const u8) !Size {
    if (payload.len != 4) return error.BadPayload;
    return .{
        .cols = std.mem.readInt(u16, payload[0..2], .little),
        .rows = std.mem.readInt(u16, payload[2..4], .little),
    };
}

/// The smallest grid a session may live at, as a WIRE contract: the daemon
/// refuses an attach or resize below it, and the client's stripe cut reads the
/// same constant so a too-thin stripe is refused at the wall rather than frozen.
pub const min_session_cols = 2;
pub const min_session_rows = 2;

pub const ScrollbackReq = struct { start: u32, count: u16 };

pub fn encodeScrollbackReq(start: u32, count: u16) [6]u8 {
    var buf: [6]u8 = undefined;
    std.mem.writeInt(u32, buf[0..4], start, .little);
    std.mem.writeInt(u16, buf[4..6], count, .little);
    return buf;
}

pub fn decodeScrollbackReq(payload: []const u8) !ScrollbackReq {
    if (payload.len != 6) return error.BadPayload;
    return .{
        .start = std.mem.readInt(u32, payload[0..4], .little),
        .count = std.mem.readInt(u16, payload[4..6], .little),
    };
}

pub fn encodeEndpointReply(port: u16) [2]u8 {
    var buf: [2]u8 = undefined;
    std.mem.writeInt(u16, &buf, port, .little);
    return buf;
}

/// 0 is a legal port value here — "no listener" is a reading, not a decode
/// failure. Length is the only thing this can refuse without guessing.
pub fn decodeEndpointReply(payload: []const u8) !u16 {
    if (payload.len != 2) return error.BadPayload;
    return std.mem.readInt(u16, payload[0..2], .little);
}

/// Agent channel frames. The daemon is a blind pump: nothing here describes
/// the agent protocol, only the channel id prefix. agent_data_max caps a
/// single frame so a future bulk channel (port forwarding) cannot
/// head-of-line-block a delta.
pub const agent_id_len = 4;
pub const agent_data_max = 4096;
/// How many agent connections one daemon carries at once. Daemon-wide, and a
/// burst allowance rather than a population: ssh opens a channel per
/// authentication attempt and closes it moments later. Spelled here because
/// BOTH ends size a table from it — bigger has slots nothing can fill, smaller
/// refuses channels the daemon believes it opened.
pub const agent_chans_max = 8;
/// The one env name both ends agree on, named here for the same reason as
/// sock_env and session_env: three spellings is three chances to rename one
/// alone.
pub const agent_sock_env = "SSH_AUTH_SOCK";

/// The first forwarding wire contract. A versioned role handshake keeps an
/// old daemon's unknown-frame behaviour from looking like an accepted tunnel.
pub const forward_version: u16 = 1;
pub const forward_id_len = 4;
pub const forward_data_max: usize = 16 * 1024;
pub const forward_initial_credit: u32 = 64 * 1024;
pub const forward_channels_max: usize = 32;
pub const forward_hello_len = 2;
pub const forward_open_len = 6;
pub const forward_credit_len = 8;
pub const forward_open_result_len = 5;

pub const ForwardOpen = struct { id: u32, port: u16 };
pub const ForwardCredit = struct { id: u32, amount: u32 };
pub const ForwardOpenResult = struct { id: u32, ok: bool };

pub fn encodeForwardHello() [forward_hello_len]u8 {
    var out: [forward_hello_len]u8 = undefined;
    std.mem.writeInt(u16, &out, forward_version, .little);
    return out;
}

pub fn decodeForwardHello(payload: []const u8) !u16 {
    if (payload.len != forward_hello_len) return error.BadPayload;
    return std.mem.readInt(u16, payload[0..2], .little);
}

pub fn encodeForwardOpen(open: ForwardOpen) [forward_open_len]u8 {
    var out: [forward_open_len]u8 = undefined;
    std.mem.writeInt(u32, out[0..4], open.id, .little);
    std.mem.writeInt(u16, out[4..6], open.port, .little);
    return out;
}

pub fn decodeForwardOpen(payload: []const u8) !ForwardOpen {
    if (payload.len != forward_open_len) return error.BadPayload;
    const out: ForwardOpen = .{
        .id = std.mem.readInt(u32, payload[0..4], .little),
        .port = std.mem.readInt(u16, payload[4..6], .little),
    };
    if (out.id == 0 or out.port == 0) return error.BadPayload;
    return out;
}

pub fn encodeForwardId(id: u32) [forward_id_len]u8 {
    var out: [forward_id_len]u8 = undefined;
    std.mem.writeInt(u32, &out, id, .little);
    return out;
}

pub fn decodeForwardId(payload: []const u8) !u32 {
    if (payload.len < forward_id_len) return error.BadPayload;
    const id = std.mem.readInt(u32, payload[0..4], .little);
    if (id == 0) return error.BadPayload;
    return id;
}

pub fn forwardDataOversize(payload: []const u8) bool {
    return payload.len > forward_id_len + forward_data_max;
}

pub fn encodeForwardCredit(credit: ForwardCredit) [forward_credit_len]u8 {
    var out: [forward_credit_len]u8 = undefined;
    std.mem.writeInt(u32, out[0..4], credit.id, .little);
    std.mem.writeInt(u32, out[4..8], credit.amount, .little);
    return out;
}

pub fn decodeForwardCredit(payload: []const u8) !ForwardCredit {
    if (payload.len != forward_credit_len) return error.BadPayload;
    const out: ForwardCredit = .{
        .id = std.mem.readInt(u32, payload[0..4], .little),
        .amount = std.mem.readInt(u32, payload[4..8], .little),
    };
    if (out.id == 0 or out.amount == 0) return error.BadPayload;
    return out;
}

pub fn encodeForwardOpenResult(result: ForwardOpenResult) [forward_open_result_len]u8 {
    var out: [forward_open_result_len]u8 = undefined;
    std.mem.writeInt(u32, out[0..4], result.id, .little);
    out[4] = @intFromBool(!result.ok);
    return out;
}

pub fn decodeForwardOpenResult(payload: []const u8) !ForwardOpenResult {
    if (payload.len != forward_open_result_len or payload[4] > 1) return error.BadPayload;
    const id = std.mem.readInt(u32, payload[0..4], .little);
    if (id == 0) return error.BadPayload;
    return .{ .id = id, .ok = payload[4] == 0 };
}

test "forwarding codecs round-trip their bounded wire values" {
    try std.testing.expectEqual(forward_version, try decodeForwardHello(&encodeForwardHello()));
    const open: ForwardOpen = .{ .id = 0x12345678, .port = 65535 };
    try std.testing.expectEqual(open, try decodeForwardOpen(&encodeForwardOpen(open)));
    try std.testing.expectEqual(open.id, try decodeForwardId(&encodeForwardId(open.id)));
    const credit: ForwardCredit = .{ .id = open.id, .amount = forward_initial_credit };
    try std.testing.expectEqual(credit, try decodeForwardCredit(&encodeForwardCredit(credit)));
    for ([_]bool{ false, true }) |ok| {
        const result: ForwardOpenResult = .{ .id = open.id, .ok = ok };
        try std.testing.expectEqual(result, try decodeForwardOpenResult(&encodeForwardOpenResult(result)));
    }
    try std.testing.expect(!forwardDataOversize(&([_]u8{0} ** (forward_id_len + forward_data_max))));
    try std.testing.expect(forwardDataOversize(&([_]u8{0} ** (forward_id_len + forward_data_max + 1))));
}

test "forwarding codecs reject malformed identifiers ports credit and status" {
    try std.testing.expectError(error.BadPayload, decodeForwardHello(&.{1}));
    try std.testing.expectError(error.BadPayload, decodeForwardOpen(&.{ 0, 0, 0, 0, 80, 0 }));
    try std.testing.expectError(error.BadPayload, decodeForwardOpen(&.{ 1, 0, 0, 0, 0, 0 }));
    try std.testing.expectError(error.BadPayload, decodeForwardId(&.{ 0, 0, 0, 0 }));
    try std.testing.expectError(error.BadPayload, decodeForwardId(&.{ 1, 0, 0 }));
    try std.testing.expectError(error.BadPayload, decodeForwardCredit(&.{ 1, 0, 0, 0, 0, 0, 0, 0 }));
    try std.testing.expectError(error.BadPayload, decodeForwardOpenResult(&.{ 1, 0, 0, 0, 2 }));
}

pub fn encodeAgentId(id: u32) [agent_id_len]u8 {
    var buf: [agent_id_len]u8 = undefined;
    std.mem.writeInt(u32, &buf, id, .little);
    return buf;
}

/// agent_data carries opaque bytes after the id, so a payload longer than
/// the id is the ordinary case and only a short one is a broken frame.
pub fn decodeAgentId(payload: []const u8) !u32 {
    if (payload.len < agent_id_len) return error.BadPayload;
    return std.mem.readInt(u32, payload[0..agent_id_len], .little);
}

/// The receive side of `agent_data_max`. A cap only the sender honours is
/// an assumption about the peer.
pub fn agentDataOversize(payload: []const u8) bool {
    return payload.len > agent_id_len + agent_data_max;
}

// `upgrade_req`: the client asks the daemon to exec a new binary. The path
// is absolute because the daemon must never resolve a relative path against
// ITS cwd — the requester's intent and the daemon's cwd are two things.
pub const UpgradeReq = struct {
    allow_same_version: bool,
    version: []const u8,
    path: []const u8,
};

pub fn encodeUpgradeReq(buf: []u8, req: UpgradeReq) ![]const u8 {
    if (req.version.len == 0) return error.BadPayload;
    if (req.path.len == 0 or req.path[0] != '/') return error.BadPayload;
    const total = 1 + req.version.len + 1 + req.path.len;
    if (buf.len < total) return error.NoSpaceLeft;
    buf[0] = @intFromBool(req.allow_same_version);
    @memcpy(buf[1..][0..req.version.len], req.version);
    buf[1 + req.version.len] = 0; // NUL separator
    @memcpy(buf[2 + req.version.len ..][0..req.path.len], req.path);
    return buf[0..total];
}

pub fn parseUpgradeReq(payload: []const u8) error{BadPayload}!UpgradeReq {
    if (payload.len < 3) return error.BadPayload; // flag + at least 1 ver + NUL + at least 1 path
    const flags = payload[0];
    // Find the NUL that separates version from path.
    const nul_idx = std.mem.indexOfScalar(u8, payload[1..], 0) orelse
        return error.BadPayload;
    const version = payload[1 .. 1 + nul_idx];
    if (version.len == 0) return error.BadPayload;
    const path = payload[2 + nul_idx ..];
    if (path.len == 0 or path[0] != '/') return error.BadPayload;
    return .{
        .allow_same_version = (flags & 1) != 0,
        .version = version,
        .path = path,
    };
}

// `upgrade_reply`: the daemon's yes or no, and — when it is a no — the words
// for it. The reason is the daemon's own prose because it is the side that
// knows which check failed; the client quotes it rather than paraphrasing.
pub const UpgradeReply = struct {
    ok: bool,
    /// Empty when the daemon accepted, or when it refused without saying why.
    reason: []const u8,
};

/// The daemon's reply buffer. A reason longer than this is truncated rather
/// than refused: the bytes are prose for a person reading a terminal, and a
/// truncated explanation beats a refusal that never reaches them.
pub const upgrade_reply_max_len = 256;

pub fn encodeUpgradeReply(buf: []u8, reply: UpgradeReply) []const u8 {
    buf[0] = if (reply.ok) 0 else 1;
    const n = @min(reply.reason.len, buf.len - 1);
    @memcpy(buf[1..][0..n], reply.reason[0..n]);
    return buf[0 .. 1 + n];
}

/// Null is a payload with no status byte at all — nothing this side can call
/// an answer, which the caller reports as "no reply" rather than as a refusal
/// it could quote. A daemon that predates this verb sends no frame at all and
/// reaches the same conclusion by silence.
pub fn parseUpgradeReply(payload: []const u8) ?UpgradeReply {
    if (payload.len == 0) return null;
    return .{ .ok = payload[0] == 0, .reason = payload[1..] };
}

/// Screen-space rows; the grid's owner normalizes.
pub const SelectionPoint = struct {
    row: u32,
    col: u16,
};

/// One selection contract for direct extraction and per-client tracking.
/// Start guards the coordinate source; copy and clear name an owned gesture.
pub const SelectionReq = struct {
    action: enum(u8) { extract, start, copy, clear } = .extract,
    id: u32,
    gesture: u32 = 0,
    epoch: u64 = 0,
    source: u64 = 0,
    anchor: SelectionPoint,
    active: SelectionPoint,
};
pub const selection_text_max: usize = 1024 * 1024;
pub const selection_req_len: usize = 37;
pub const selection_reply_prefix_len: usize = 41;

fn writeSelectionPoint(out: *[6]u8, point: SelectionPoint) void {
    std.mem.writeInt(u32, out[0..4], point.row, .little);
    std.mem.writeInt(u16, out[4..6], point.col, .little);
}
fn readSelectionPoint(bytes: *const [6]u8) SelectionPoint {
    return .{ .row = std.mem.readInt(u32, bytes[0..4], .little), .col = std.mem.readInt(u16, bytes[4..6], .little) };
}
pub fn encodeSelectionReq(req: SelectionReq) [selection_req_len]u8 {
    var out: [selection_req_len]u8 = undefined;
    std.mem.writeInt(u32, out[0..4], req.id, .little);
    writeSelectionPoint(out[4..10], req.anchor);
    writeSelectionPoint(out[10..16], req.active);
    out[16] = @intFromEnum(req.action);
    std.mem.writeInt(u32, out[17..21], req.gesture, .little);
    std.mem.writeInt(u64, out[21..29], req.epoch, .little);
    std.mem.writeInt(u64, out[29..37], req.source, .little);
    return out;
}
pub fn decodeSelectionReq(bytes: []const u8) !SelectionReq {
    if (bytes.len != selection_req_len) return error.BadPayload;
    const req: SelectionReq = .{
        .id = std.mem.readInt(u32, bytes[0..4], .little),
        .anchor = readSelectionPoint(bytes[4..10]),
        .active = readSelectionPoint(bytes[10..16]),
        .action = try enumFromByte(@FieldType(SelectionReq, "action"), bytes[16]),
        .gesture = std.mem.readInt(u32, bytes[17..21], .little),
        .epoch = std.mem.readInt(u64, bytes[21..29], .little),
        .source = std.mem.readInt(u64, bytes[29..37], .little),
    };
    const valid = switch (req.action) {
        .extract => req.id != 0 and req.gesture == 0,
        .start, .copy => req.id != 0 and req.gesture != 0,
        .clear => req.id == 0 and req.gesture != 0,
    };
    if (!valid) return error.BadPayload;
    return req;
}
pub const SelectionStatus = enum(u8) { ok = 0, invalid = 1, too_large = 2, unavailable = 3 };
pub const SelectionReply = struct {
    id: u32 = 0, // zero is a position update, never a clipboard write
    gesture: u32 = 0,
    seq: u64 = 0,
    source: u64 = 0,
    history_rows: u32,
    status: SelectionStatus = .unavailable,
    anchor: SelectionPoint = .{ .row = 0, .col = 0 },
    active: SelectionPoint = .{ .row = 0, .col = 0 },
    /// Borrows the frame payload. Only successful copy/extract replies carry text.
    text: []const u8 = &.{},
};
fn checkSelectionText(reply: SelectionReply) !void {
    if (reply.status != .ok or reply.id == 0) {
        if (reply.text.len != 0) return error.BadPayload;
    } else if (reply.text.len > selection_text_max or !std.unicode.utf8ValidateSlice(reply.text)) return error.BadPayload;
}
/// Validate before appending so malformed replies leave the caller's buffer intact.
pub fn encodeSelectionReply(out: *std.ArrayList(u8), alloc: std.mem.Allocator, reply: SelectionReply) !void {
    try checkSelectionText(reply);
    var prefix: [selection_reply_prefix_len]u8 = undefined;
    std.mem.writeInt(u32, prefix[0..4], reply.id, .little);
    prefix[4] = @intFromEnum(reply.status);
    std.mem.writeInt(u32, prefix[5..9], reply.history_rows, .little);
    std.mem.writeInt(u32, prefix[9..13], reply.gesture, .little);
    std.mem.writeInt(u64, prefix[13..21], reply.seq, .little);
    std.mem.writeInt(u64, prefix[21..29], reply.source, .little);
    writeSelectionPoint(prefix[29..35], reply.anchor);
    writeSelectionPoint(prefix[35..41], reply.active);
    try out.appendSlice(alloc, &prefix);
    try out.appendSlice(alloc, reply.text);
}
pub fn decodeSelectionReply(bytes: []const u8) !SelectionReply {
    if (bytes.len < selection_reply_prefix_len) return error.BadPayload;
    const reply: SelectionReply = .{
        .id = std.mem.readInt(u32, bytes[0..4], .little),
        .status = try enumFromByte(SelectionStatus, bytes[4]),
        .history_rows = std.mem.readInt(u32, bytes[5..9], .little),
        .gesture = std.mem.readInt(u32, bytes[9..13], .little),
        .seq = std.mem.readInt(u64, bytes[13..21], .little),
        .source = std.mem.readInt(u64, bytes[21..29], .little),
        .anchor = readSelectionPoint(bytes[29..35]),
        .active = readSelectionPoint(bytes[35..41]),
        .text = bytes[selection_reply_prefix_len..],
    };
    try checkSelectionText(reply);
    return reply;
}

/// Where the command-boundary signal came from, weakest-last. `marks` is the
/// only mechanism that can carry an exit code; consumers must check it
/// before trusting one.
pub const Mechanism = enum(u8) { marks = 0, pgid = 1, settle = 2 };

pub const CmdPhase = enum(u8) { at_prompt = 0, running = 1, returned = 2 };

/// One snapshot of the session's command state machine. Rows are absolute
/// screen-space rows (0 = oldest retained history row) — meaningless while
/// the alt screen is active, and shifted once the scrollback ring prunes,
/// so spans should be fetched promptly.
pub const CmdState = struct {
    phase: CmdPhase,
    mechanism: Mechanism,
    exit_code: ?u8,
    start_row: u32,
    end_row: u32,
    /// TWO different numbers travel in this field, depending on the frame:
    ///
    ///   * In `status_reply` and the marks stream's `cmd_state` pushes, the
    ///     RETURN WATERMARK — the seq stamped when a command last returned.
    ///     This is the series `AwaitReq.since_seq` is compared against.
    ///   * In an `await_reply` resolved by pgid, settle or timeout, the delta
    ///     tracker's CURRENT seq — a grid-content ordering.
    ///
    /// So an agent must take its next `since_seq` from a status or marks reply,
    /// never a fallback one: feeding a tracker seq back compares two series.
    ///
    /// The doubling has one more consequence. The watermark IS a tracker seq,
    /// and the tracker only advances when the grid changes — so a command whose
    /// lifecycle leaves the grid byte-identical returns at the seq the previous
    /// return claimed, and the strictly-greater test falls through to settle.
    /// A command-boundary ordering riding a grid-content counter; only a seq of
    /// its own would separate them. Changing it changes the wire.
    seq: u64,
};

pub const cmd_state_len = 20;

pub fn encodeCmdState(s: CmdState) [cmd_state_len]u8 {
    var buf: [cmd_state_len]u8 = undefined;
    buf[0] = @intFromEnum(s.phase);
    buf[1] = @intFromEnum(s.mechanism);
    buf[2] = @intFromBool(s.exit_code != null);
    buf[3] = s.exit_code orelse 0;
    std.mem.writeInt(u32, buf[4..8], s.start_row, .little);
    std.mem.writeInt(u32, buf[8..12], s.end_row, .little);
    std.mem.writeInt(u64, buf[12..20], s.seq, .little);
    return buf;
}

fn enumFromByte(comptime E: type, b: u8) !E {
    return std.meta.intToEnum(E, b) catch error.BadPayload;
}

pub fn decodeCmdState(payload: []const u8) !CmdState {
    if (payload.len != cmd_state_len) return error.BadPayload;
    return .{
        .phase = try enumFromByte(CmdPhase, payload[0]),
        .mechanism = try enumFromByte(Mechanism, payload[1]),
        // byte 2 is a presence flag, not a bool-strict 0/1 check: any
        // nonzero value reads as "present" deliberately, so a peer that
        // ever widens what it writes there doesn't silently lose the code.
        .exit_code = if (payload[2] != 0) payload[3] else null,
        .start_row = std.mem.readInt(u32, payload[4..8], .little),
        .end_row = std.mem.readInt(u32, payload[8..12], .little),
        .seq = std.mem.readInt(u64, payload[12..20], .little),
    };
}

/// A request to be told when the session next returns to rest. `since_seq` is
/// what the caller already knows: only a strictly newer return may answer,
/// which makes re-issuing after a dropped connection safe. Both durations treat
/// 0 as OFF and never as "immediately" — a caller that wants to poll asks for a
/// small timeout, never a zero one.
pub const AwaitReq = struct {
    since_seq: u64,
    settle_ms: u32,
    timeout_ms: u32,
    /// Borrowed from the payload; valid only while the frame lives. Empty means
    /// the default session. Defaulted, unlike `AttachReq.name`, so the fixed-part
    /// literals already in the tree keep building — `decodeAttach` is the only
    /// place that constructs an `AttachReq`, so there is no literal there.
    name: []const u8 = "",
};

pub const await_req_len = 16;

/// Writes only the fixed 16 bytes: the payload an empty name means on the
/// wire.
pub fn encodeAwaitReq(r: AwaitReq) [await_req_len]u8 {
    std.debug.assert(r.name.len == 0);
    return awaitReqFixed(r);
}

/// The fixed 16 bytes alone, with no opinion about `r.name`. Both encoders
/// go through it: the named one writes the tail from its OWN parameter, so
/// delegating to `encodeAwaitReq` would have meant tripping that
/// function's assert on any caller that also set the field.
fn awaitReqFixed(r: AwaitReq) [await_req_len]u8 {
    var buf: [await_req_len]u8 = undefined;
    std.mem.writeInt(u64, buf[0..8], r.since_seq, .little);
    std.mem.writeInt(u32, buf[8..12], r.settle_ms, .little);
    std.mem.writeInt(u32, buf[12..16], r.timeout_ms, .little);
    return buf;
}

pub fn decodeAwaitReq(payload: []const u8) !AwaitReq {
    if (payload.len < await_req_len or payload.len > await_req_max_len) return error.BadPayload;
    return .{
        .since_seq = std.mem.readInt(u64, payload[0..8], .little),
        .settle_ms = std.mem.readInt(u32, payload[8..12], .little),
        .timeout_ms = std.mem.readInt(u32, payload[12..16], .little),
        .name = payload[await_req_len..],
    };
}

pub const AwaitReason = enum(u8) { returned = 0, settled = 1, timeout = 2 };

pub const await_reply_len = cmd_state_len + 1;

pub fn encodeAwaitReply(s: CmdState, reason: AwaitReason) [await_reply_len]u8 {
    var buf: [await_reply_len]u8 = undefined;
    buf[0..cmd_state_len].* = encodeCmdState(s);
    buf[cmd_state_len] = @intFromEnum(reason);
    return buf;
}

pub const AwaitReply = struct { state: CmdState, reason: AwaitReason };

pub fn decodeAwaitReply(payload: []const u8) !AwaitReply {
    if (payload.len != await_reply_len) return error.BadPayload;
    return .{
        .state = try decodeCmdState(payload[0..cmd_state_len]),
        .reason = try enumFromByte(AwaitReason, payload[cmd_state_len]),
    };
}

/// One structured snapshot for `mux a status`: what a driving agent needs
/// before deciding how to interact.
pub const StatusReply = struct {
    cols: u16,
    rows: u16,
    cursor_x: u16,
    cursor_y: u16,
    history_rows: u32,
    alt_screen: bool,
    mode: PtyModeFlags,
    cmd: CmdState,
};

pub const status_reply_len = 14 + cmd_state_len;

pub fn encodeStatusReply(s: StatusReply) [status_reply_len]u8 {
    var buf: [status_reply_len]u8 = undefined;
    std.mem.writeInt(u16, buf[0..2], s.cols, .little);
    std.mem.writeInt(u16, buf[2..4], s.rows, .little);
    std.mem.writeInt(u16, buf[4..6], s.cursor_x, .little);
    std.mem.writeInt(u16, buf[6..8], s.cursor_y, .little);
    std.mem.writeInt(u32, buf[8..12], s.history_rows, .little);
    buf[12] = @intFromBool(s.alt_screen);
    buf[13] = @bitCast(s.mode);
    buf[14..][0..cmd_state_len].* = encodeCmdState(s.cmd);
    return buf;
}

pub fn decodeStatusReply(payload: []const u8) !StatusReply {
    if (payload.len != status_reply_len) return error.BadPayload;
    return .{
        .cols = std.mem.readInt(u16, payload[0..2], .little),
        .rows = std.mem.readInt(u16, payload[2..4], .little),
        .cursor_x = std.mem.readInt(u16, payload[4..6], .little),
        .cursor_y = std.mem.readInt(u16, payload[6..8], .little),
        .history_rows = std.mem.readInt(u32, payload[8..12], .little),
        .alt_screen = payload[12] != 0,
        .mode = try decodePtyMode(payload[13..14]),
        .cmd = try decodeCmdState(payload[14..][0..cmd_state_len]),
    };
}

/// Who is going to echo a keystroke, and therefore whether a client may
/// echo it early.
pub const PtyModeFlags = packed struct(u8) {
    icanon: bool,
    echo: bool,
    // Reserved, and unlike `TermModes` NOT maskable: a client that meets a
    // bit it does not understand must treat the whole byte as unpredictable,
    // because these eight bits are one prediction verdict.
    _pad: u6 = 0,
};

pub const pty_mode_len = 1;

pub fn encodePtyMode(flags: PtyModeFlags) [pty_mode_len]u8 {
    return .{@bitCast(flags)};
}

pub fn decodePtyMode(payload: []const u8) !PtyModeFlags {
    if (payload.len != pty_mode_len) return error.BadPayload;
    return @bitCast(payload[0]);
}

/// Terminal modes the SESSION has set that the host terminal must be told
/// about, because the client paints a grid and no mode survives a repaint.
/// Sampled state, not events.
///
/// The reserved bits are for focus reporting and cursor shape, so adding them
/// needs no new frame type and no version check. They go out zero and decode
/// does not mask them, so a client echoing modes back cannot downgrade a newer
/// daemon's bits. u32 rather than the dozen needed: a wire field cannot be
/// narrowed later, and this frame is rare enough that four bytes is free.
pub const TermModes = packed struct(u32) {
    bracketed_paste: bool,
    // One bit per mouse DEC mode the session set, in `mouse_modes` order — that
    // table turns them back into DECSET numbers. Carried individually rather
    // than collapsed, because the client must ask its terminal for the SAME
    // modes: a report in a format the application did not ask for is garbage.
    mouse_x10: bool = false,
    mouse_normal: bool = false,
    mouse_button: bool = false,
    mouse_any: bool = false,
    mouse_utf8: bool = false,
    mouse_sgr: bool = false,
    mouse_urxvt: bool = false,
    mouse_sgr_pixels: bool = false,
    // The alternate screen (DEC 1049) and DECCKM (DEC 1). The client's
    // wheel rule needs both — the alternate screen decides whether a notch
    // becomes arrow keys, DECCKM decides which arrows — and it needs them
    // without a VT parser of its own, which is why they are sampled here
    // rather than read off a replica engine.
    alt_screen: bool = false,
    cursor_keys: bool = false,
    _pad: u21 = 0,

    /// Whether the wheel belongs to the app rather than to scrollback — a
    /// format mode spells events, it does not ask for them.
    pub fn appMouse(self: TermModes) bool {
        return self.mouse_x10 or self.mouse_normal or self.mouse_button or self.mouse_any;
    }
};

/// The DEC private mode number behind each mouse bit. One table so the bit
/// order and the escape the client writes cannot drift apart.
pub const mouse_modes = [_]struct { field: []const u8, dec: u16 }{
    .{ .field = "mouse_x10", .dec = 9 },
    .{ .field = "mouse_normal", .dec = 1000 },
    .{ .field = "mouse_button", .dec = 1002 },
    .{ .field = "mouse_any", .dec = 1003 },
    .{ .field = "mouse_utf8", .dec = 1005 },
    .{ .field = "mouse_sgr", .dec = 1006 },
    .{ .field = "mouse_urxvt", .dec = 1015 },
    .{ .field = "mouse_sgr_pixels", .dec = 1016 },
};

pub const term_modes_len = 4;

pub fn encodeTermModes(m: TermModes) [term_modes_len]u8 {
    var buf: [term_modes_len]u8 = undefined;
    std.mem.writeInt(u32, &buf, @as(u32, @bitCast(m)), .little);
    return buf;
}

pub fn decodeTermModes(payload: []const u8) !TermModes {
    if (payload.len != term_modes_len) return error.BadPayload;
    return @bitCast(std.mem.readInt(u32, payload[0..term_modes_len], .little));
}

/// A side channel the daemon's engine consumed and the client must replay
/// onto the host terminal. Unlike the sampled state in `term_modes` and
/// `term_title`, these happen once and leave nothing to read, so they are
/// queued rather than polled.
pub const TermEvent = union(Kind) {
    clipboard: Clipboard,
    bell: void,

    pub const Kind = enum(u8) { clipboard = 0, bell = 1 };

    /// `base64` BORROWS from the frame payload, UNVALIDATED.
    pub const Clipboard = struct {
        target: u8,
        base64: []const u8,
    };
};

/// The largest OSC 52 payload the wire will carry, in base64 bytes (~48 KiB of
/// text). Here because the wire module owns the shape: the daemon caps on the
/// way in and the client re-validates on the way out.
/// `Engine.Options.clipboard_max` spells it a second time and must equal it —
/// `engine` cannot import this module, so a test in `server.zig` holds them
/// together. Change this number and change that default with it.
pub const clipboard_base64_max: usize = 64 * 1024;

/// The longest `term_title` payload either end will send or act on. A title is
/// a window decoration: anything longer is a bug, or an attempt to push bytes
/// down a channel nobody inspects. 1024 is ghostty's own `max_title_len`, so a
/// title read off the engine cannot exceed it — but both ends check anyway,
/// since the peer need not be this version and that truncation is not a contract.
pub const term_title_max: usize = 1024;

/// Appends rather than returning a fixed buffer: `clipboard_base64_max` is
/// too much stack to reserve per call.
pub fn encodeClipboardEvent(
    out: *std.ArrayList(u8),
    alloc: std.mem.Allocator,
    target: u8,
    base64: []const u8,
) !void {
    try out.append(alloc, @intFromEnum(TermEvent.Kind.clipboard));
    try out.append(alloc, target);
    try out.appendSlice(alloc, base64);
}

/// Appends like `encodeClipboardEvent` despite the one-byte payload: the
/// server drain funnels both kinds into one `ArrayList`.
pub fn encodeBellEvent(out: *std.ArrayList(u8), alloc: std.mem.Allocator) !void {
    try out.append(alloc, @intFromEnum(TermEvent.Kind.bell));
}

pub fn decodeTermEvent(payload: []const u8) !TermEvent {
    if (payload.len < 1) return error.BadPayload;
    // Dispatch on the checked enum, not the raw byte: `enumFromByte` refuses
    // an unmapped value the same as before, but a `Kind` added later without
    // a matching arm below now fails to COMPILE instead of being silently
    // refused forever at runtime.
    return switch (try enumFromByte(TermEvent.Kind, payload[0])) {
        .clipboard => blk: {
            if (payload.len < 2) return error.BadPayload;
            break :blk .{ .clipboard = .{
                .target = payload[1],
                .base64 = payload[2..],
            } };
        },
        // Fixed-size like every other kind in this file: trailing bytes are
        // refused, not silently dropped (decodePtyMode, decodeCmdState, ...).
        .bell => if (payload.len == 1) .bell else error.BadPayload,
    };
}

/// What a (re)attaching client already holds. `have_seq` counts within ONE
/// daemon instance's stream, and `have_epoch` names that instance — a
/// mismatch (or 0, "I hold nothing") forces a full snapshot.
pub const AttachReq = struct {
    cols: u16,
    rows: u16,
    have_seq: u64,
    have_epoch: u64,
    /// Borrowed from the payload; valid only while the frame lives. Empty
    /// means the default session — see the note beside `session_name_max`.
    name: []const u8,
};

pub const attach_len = 20;

/// Everything past a session-scoped verb's fixed bytes is the session
/// name; empty names the default session. Older clients send exactly the
/// fixed part, which is why the tail is a tail and not a versioned field.
/// One pattern, four verbs: attach, debug_dump, status_req, await_req.
pub const session_name_max = 32;
/// The daemon's session cap, spelled here because `protocol` imports no
/// daemon and cannot see `Server.max_sessions`. Not a second owner: server.zig asserts
/// the two are equal at comptime, so a daemon that raised its cap without
/// this line does not build.
pub const sessions_max = 32;
/// Room for every name a daemon can be hosting.
pub const sessions_text_max = sessions_max * (session_name_max + 1);
pub const attach_max_len = attach_len + session_name_max;
pub const await_req_max_len = await_req_len + session_name_max;
/// debug_dump's fixed part is the single vt-mode byte.
pub const debug_dump_len = 1;
pub const debug_dump_max_len = debug_dump_len + session_name_max;
pub const default_session = "0";

/// An empty tail is the default session's spelling: a fact about the WIRE,
/// not a per-call-site convention.
pub fn resolveName(wire_name: []const u8) []const u8 {
    return if (wire_name.len == 0) default_session else wire_name;
}

/// resolveName's inverse: default session rides the wire as the empty tail.
pub fn wireName(name: []const u8) []const u8 {
    return if (std.mem.eql(u8, name, default_session)) "" else name;
}

/// What the daemon plants in every session shell it spawns: the socket path it
/// bound and the session's RESOLVED name. Not wire bytes, but a contract between
/// three modules that cannot import one another, and this is the lowest module
/// all three already import. One spelling, because nothing would catch a rename
/// on one side — the shell only ever sees what the planter wrote.
pub const sock_env = "MUX_SOCK";
pub const session_env = "MUX_SESSION";

/// A name a user may spell: printable ASCII, no space; '#' separates a
/// label's host from its session, '/' is reserved. Empty is valid ON THE
/// WIRE (it means default) but not as a user-supplied name.
pub fn validSessionName(name: []const u8) bool {
    if (name.len == 0 or name.len > session_name_max) return false;
    for (name) |c| {
        if (c < '!' or c > '~' or c == '#' or c == '/') return false;
    }
    return true;
}

/// Walk a `sessions_reply` payload: yields each line that is a valid session
/// name, skipping empties and anything `validSessionName` refuses — the reply
/// crossed a trust boundary and one "name" in it can be anything the daemon
/// put there, up to the whole frame cap. The payload is plain '\n'-separated
/// text with no codec (see `MsgType.sessions_reply`), so this iterator IS the
/// reader's half of that contract, and every reader shares it: the wall's
/// diff, the picker's count, the hub's tile diff, `mux hosts`, and the free-name
/// search all agree on which lines are names.
///
/// What the filter prevents, concretely: `encodeAttachNamed`, `encodeEndReq`
/// and `encodeDebugDumpNamed` each memcpy a name into a `session_name_max`
/// tail behind an assert — an assert states a bug in the code that built the
/// name, it does not filter a peer's input. Names taken from here are safe to
/// carry to those encoders; lines this iterator skipped never were.
pub fn sessionsIter(payload: []const u8) SessionsIter {
    return .{ .rest = payload };
}

pub const SessionsIter = struct {
    rest: []const u8,

    pub fn next(self: *SessionsIter) ?[]const u8 {
        while (self.rest.len != 0) {
            const line = if (std.mem.indexOfScalar(u8, self.rest, '\n')) |nl| blk: {
                const l = self.rest[0..nl];
                self.rest = self.rest[nl + 1 ..];
                break :blk l;
            } else blk: {
                const l = self.rest;
                self.rest = self.rest[self.rest.len..];
                break :blk l;
            };
            if (validSessionName(line)) return line;
        }
        return null;
    }
};

/// The daemon's word about ITSELF, riding the same `sessions_reply`: one
/// trailing `# mux <version>[ stale]` line. Both '#' and the space are bytes
/// `validSessionName` refuses, so every names reader — all of them walk
/// `sessionsIter` — skips this line without knowing it exists. That is the
/// whole compatibility story: an old client ignores it by construction, and
/// an old daemon never sends it, which a new client reads as "unknown" and
/// paints as nothing.
pub const sessions_meta_prefix = "# mux ";
/// `stale` is the daemon reporting that the installed binary on its OWN box
/// was replaced under it (`server_os.selfImageStale`): the one drift a
/// version string cannot show, because two builds of one dev version spell
/// the same version.
pub const sessions_meta_stale_word = " stale";
pub const sessions_meta_version_max = 32;
/// Worst-case growth of a names payload that gains the meta line: the
/// joining '\n' plus the line itself. Reply buffers add this to
/// `sessions_text_max`.
pub const sessions_meta_max =
    1 + sessions_meta_prefix.len + sessions_meta_version_max + sessions_meta_stale_word.len;
/// One `# holds NAME N` line per session, appended by a daemon that can
/// count: how many clients hold that session. The count is EVERY holder,
/// the asker included, so it is a number to READ and never a verdict: the
/// picker's session row shows it as the total, and whether an `end_req`
/// goes through is judged by the daemon against the OTHERS — the holders
/// that are not the connection asking. A client that decided from this
/// number would refuse to end a session only it is in. Spelled
/// as a `#` line so `sessionsIter`, which yields only valid session names,
/// skips it on a client that predates it — exactly as it skips the meta
/// line — and a daemon that predates it sends none, which
/// `parseSessionsHolds` reads as unknown.
pub const sessions_holds_prefix = "# holds ";
/// `# holds NAME N\n` at its widest: N is a u8, so at most three digits.
pub const sessions_holds_line_max = sessions_holds_prefix.len + session_name_max + 1 + 3 + 1;
/// What a `sessions_reply` READER must have room for: every name, every
/// holds line, and the meta line. `listSessions` refuses an overlong
/// payload as a transport error, so a receiver sized to `sessions_text_max`
/// alone would read a full daemon that states its version as an
/// unreachable box.
pub const sessions_reply_max = sessions_text_max + sessions_max * sessions_holds_line_max + sessions_meta_max;

/// Append one holds line to the payload already in `buf[0..len]` and return
/// the new length. Same join rule as `appendSessionsMeta` — a separator only
/// when there is something to separate from — and the same tolerance for a
/// buffer that cannot take the line: the payload is returned unchanged
/// rather than truncated, so a reader sees the count as unknown instead of
/// reading half a line. `sessions_reply_max` is sized so a daemon never
/// reaches that branch.
pub fn appendSessionsHolds(buf: []u8, len: usize, name: []const u8, holds: u8) usize {
    var w = len;
    if (w != 0) {
        if (w >= buf.len) return len;
        buf[w] = '\n';
        w += 1;
    }
    const line = std.fmt.bufPrint(buf[w..], "{s}{s} {d}", .{ sessions_holds_prefix, name, holds }) catch return len;
    return w + line.len;
}

/// The reader's half, across the same trust boundary as `sessionsIter`: the
/// payload is a peer's bytes, so anything that is not this exact shape reads
/// as absent. Null is "this daemon did not say", never zero — an old daemon
/// sends no holds line at all, and a caller that painted null as 0 would
/// claim nobody holds a session it cannot count.
pub fn parseSessionsHolds(payload: []const u8, name: []const u8) ?u8 {
    var lines = std.mem.splitScalar(u8, payload, '\n');
    while (lines.next()) |line| {
        if (!std.mem.startsWith(u8, line, sessions_holds_prefix)) continue;
        const rest = line[sessions_holds_prefix.len..];
        const sp = std.mem.lastIndexOfScalar(u8, rest, ' ') orelse continue;
        if (!std.mem.eql(u8, rest[0..sp], name)) continue;
        return std.fmt.parseInt(u8, rest[sp + 1 ..], 10) catch continue;
    }
    return null;
}

pub const SessionsMeta = struct { version: []const u8, stale: bool };

/// Append the meta line to the names already in `buf[0..names_len]` and
/// return the new payload length. The join rule lives HERE with the parser:
/// an empty table is only the line, never a leading separator, so "empty
/// payload = no sessions" stays true in spirit — zero names iterate out
/// either way. A version longer than `sessions_meta_version_max` is
/// truncated; the parser caps at the same bound, so the two ends agree.
pub fn appendSessionsMeta(buf: []u8, names_len: usize, version: []const u8, stale: bool) usize {
    const ver = version[0..@min(version.len, sessions_meta_version_max)];
    var w = names_len;
    if (names_len != 0) {
        buf[w] = '\n';
        w += 1;
    }
    @memcpy(buf[w..][0..sessions_meta_prefix.len], sessions_meta_prefix);
    w += sessions_meta_prefix.len;
    @memcpy(buf[w..][0..ver.len], ver);
    w += ver.len;
    if (stale) {
        @memcpy(buf[w..][0..sessions_meta_stale_word.len], sessions_meta_stale_word);
        w += sessions_meta_stale_word.len;
    }
    return w;
}

/// The reader's half, across the same trust boundary as `sessionsIter`: the
/// payload is a peer's bytes, so a meta line is accepted only whole — the
/// prefix, a version of 1..=`sessions_meta_version_max` bytes every one of
/// which `validSessionName` would accept in a name, and either nothing or
/// exactly the stale word after it. Anything else is absent, not an error:
/// a wall paints nothing for a daemon it cannot read.
pub fn parseSessionsMeta(payload: []const u8) ?SessionsMeta {
    var lines = std.mem.splitScalar(u8, payload, '\n');
    while (lines.next()) |line| {
        if (!std.mem.startsWith(u8, line, sessions_meta_prefix)) continue;
        var rest = line[sessions_meta_prefix.len..];
        var stale = false;
        if (std.mem.endsWith(u8, rest, sessions_meta_stale_word)) {
            stale = true;
            rest = rest[0 .. rest.len - sessions_meta_stale_word.len];
        }
        // `validSessionName` IS the version check: same byte set, and
        // `sessions_meta_version_max` equals `session_name_max`, asserted
        // below so a drift in either cap shows up as a build break.
        comptime std.debug.assert(sessions_meta_version_max == session_name_max);
        if (!validSessionName(rest)) continue;
        return .{ .version = rest, .stale = stale };
    }
    return null;
}

/// Whether a `sessions_reply` payload names this session. Never `indexOf`:
/// `w` is in `work` and neither is the other.
pub fn sessionsHas(payload: []const u8, name: []const u8) bool {
    var it = sessionsIter(payload);
    while (it.next()) |n| if (std.mem.eql(u8, n, name)) return true;
    return false;
}

/// A `--session` field of this type is refused at the parse, so no caller
/// carries an unspellable name as far as the wire.
pub const SessionName = struct {
    name: []const u8,

    pub fn parseCLI(s: []const u8) error{Invalid}!SessionName {
        if (!validSessionName(s)) return error.Invalid;
        return .{ .name = s };
    }
};

pub fn encodeAttach(cols: u16, rows: u16, have_seq: u64, have_epoch: u64) [attach_len]u8 {
    var buf: [attach_len]u8 = undefined;
    std.mem.writeInt(u16, buf[0..2], cols, .little);
    std.mem.writeInt(u16, buf[2..4], rows, .little);
    std.mem.writeInt(u64, buf[4..12], have_seq, .little);
    std.mem.writeInt(u64, buf[12..20], have_epoch, .little);
    return buf;
}

pub fn encodeAttachNamed(
    buf: *[attach_max_len]u8,
    cols: u16,
    rows: u16,
    have_seq: u64,
    have_epoch: u64,
    name: []const u8,
) []const u8 {
    std.debug.assert(name.len <= session_name_max);
    @memcpy(buf[0..attach_len], &encodeAttach(cols, rows, have_seq, have_epoch));
    @memcpy(buf[attach_len..][0..name.len], name);
    return buf[0 .. attach_len + name.len];
}

/// `debug_dump`'s payload: the vt-mode byte, then the session-name tail.
pub fn encodeDebugDumpNamed(
    buf: *[debug_dump_max_len]u8,
    vt: bool,
    name: []const u8,
) []const u8 {
    std.debug.assert(name.len <= session_name_max);
    buf[0] = if (vt) 1 else 0;
    @memcpy(buf[1..][0..name.len], name);
    return buf[0 .. 1 + name.len];
}

/// Explicit creation is separate from legacy attach-or-create. An unknown
/// request on an older daemon has no side effect; callers must never fall
/// back to attaching with a size after a timeout.
pub const create_req_len = 4;
pub const create_req_max_len = create_req_len + session_name_max;
pub const create_reply_max_len = 129;
pub const CreateStatus = enum(u8) { created = 0, exists = 1, refused = 2 };
pub const CreateReq = struct { cols: u16, rows: u16, name: []const u8 };
pub const CreateReply = struct { status: CreateStatus, reason: []const u8 };

pub fn encodeCreateReq(buf: *[create_req_max_len]u8, cols: u16, rows: u16, name: []const u8) []const u8 {
    std.debug.assert(validSessionName(name));
    std.mem.writeInt(u16, buf[0..2], cols, .little);
    std.mem.writeInt(u16, buf[2..4], rows, .little);
    @memcpy(buf[4..][0..name.len], name);
    return buf[0 .. 4 + name.len];
}

pub fn parseCreateReq(payload: []const u8) error{BadCreate}!CreateReq {
    if (payload.len <= create_req_len or payload.len > create_req_max_len) return error.BadCreate;
    const name = payload[4..];
    if (!validSessionName(name)) return error.BadCreate;
    return .{ .cols = std.mem.readInt(u16, payload[0..2], .little), .rows = std.mem.readInt(u16, payload[2..4], .little), .name = name };
}

pub fn encodeCreateReply(buf: *[create_reply_max_len]u8, status: CreateStatus, reason: []const u8) []const u8 {
    std.debug.assert(reason.len < create_reply_max_len);
    buf[0] = @intFromEnum(status);
    @memcpy(buf[1..][0..reason.len], reason);
    return buf[0 .. 1 + reason.len];
}

pub fn parseCreateReply(payload: []const u8) error{BadCreate}!CreateReply {
    if (payload.len == 0 or payload.len > create_reply_max_len) return error.BadCreate;
    const status = std.enums.fromInt(CreateStatus, payload[0]) orelse return error.BadCreate;
    for (payload[1..]) |byte| if (byte < 0x20 or byte > 0x7e) return error.BadCreate;
    return .{ .status = status, .reason = payload[1..] };
}

test "explicit creation rejects malformed names and reply status without aliasing default" {
    var req: [create_req_max_len]u8 = undefined;
    const parsed = try parseCreateReq(encodeCreateReq(&req, 80, 24, "work"));
    try std.testing.expectEqualStrings("work", parsed.name);
    try std.testing.expectEqual(@as(u16, 80), parsed.cols);
    try std.testing.expectEqual(@as(u16, 24), parsed.rows);
    try std.testing.expectEqualStrings("0", (try parseCreateReq(encodeCreateReq(&req, 80, 24, "0"))).name);
    try std.testing.expectError(error.BadCreate, parseCreateReq(&.{ 80, 0, 24, 0 }));
    try std.testing.expectError(error.BadCreate, parseCreateReq(&.{ 80, 0, 24, 0, '#' }));
    try std.testing.expectError(error.BadCreate, parseCreateReq(&.{ 80, 0, 24 }));
    var reply: [create_reply_max_len]u8 = undefined;
    const answer = try parseCreateReply(encodeCreateReply(&reply, .exists, "name in use"));
    try std.testing.expectEqual(CreateStatus.exists, answer.status);
    try std.testing.expectEqualStrings("name in use", answer.reason);
    try std.testing.expectError(error.BadCreate, parseCreateReply(&.{}));
    try std.testing.expectError(error.BadCreate, parseCreateReply(&.{3}));
    try std.testing.expectError(error.BadCreate, parseCreateReply(&.{ 2, 0x1b }));
}

pub const end_req_len = 1;
pub const end_req_max_len = end_req_len + session_name_max;

pub fn encodeEndReq(buf: *[end_req_max_len]u8, force: bool, name: []const u8) []const u8 {
    std.debug.assert(name.len <= session_name_max);
    buf[0] = if (force) 1 else 0;
    @memcpy(buf[end_req_len..][0..name.len], name);
    return buf[0 .. end_req_len + name.len];
}

pub const EndReply = struct { accepted: bool, others: u8, reason: []const u8 };
pub const end_reply_min_len = 2;

/// Every reason an `end_req` is refused with. Here rather than at the two
/// call sites in the daemon, so `end_reply_max_len` is derived from the
/// words instead of being a bound somebody keeps in step with them.
pub const end_reason = struct {
    pub const bad_frame = "bad frame";
    pub const no_session = "no such session";
    pub const others_attached = "others attached";
    /// An acceptance carries no reason.
    pub const accepted = "";
};

pub const end_reply_max_len = end_reply_min_len + blk: {
    var n: usize = 0;
    for (@typeInfo(end_reason).@"struct".decls) |d| n = @max(n, @field(end_reason, d.name).len);
    break :blk n;
};

/// Sized, like `encodeEndReq`: the reason is memcpy'd with no bound of its
/// own, so an under-sized buffer here is a stack smash in the daemon. Both
/// call sites already declare exactly this array.
pub fn encodeEndReply(buf: *[end_reply_max_len]u8, accepted: bool, others: u8, reason: []const u8) []const u8 {
    buf[0] = if (accepted) 0 else 1;
    buf[1] = others;
    @memcpy(buf[end_reply_min_len..][0..reason.len], reason);
    return buf[0 .. end_reply_min_len + reason.len];
}

pub fn parseEndReply(payload: []const u8) ?EndReply {
    if (payload.len < end_reply_min_len) return null;
    return .{ .accepted = payload[0] == 0, .others = payload[1], .reason = payload[end_reply_min_len..] };
}

pub fn encodeAwaitReqNamed(
    buf: *[await_req_max_len]u8,
    r: AwaitReq,
    name: []const u8,
) []const u8 {
    std.debug.assert(name.len <= session_name_max);
    @memcpy(buf[0..await_req_len], &awaitReqFixed(r));
    @memcpy(buf[await_req_len..][0..name.len], name);
    return buf[0 .. await_req_len + name.len];
}

pub fn decodeAttach(payload: []const u8) !AttachReq {
    if (payload.len < attach_len or payload.len > attach_max_len) return error.BadPayload;
    return .{
        .cols = std.mem.readInt(u16, payload[0..2], .little),
        .rows = std.mem.readInt(u16, payload[2..4], .little),
        .have_seq = std.mem.readInt(u64, payload[4..12], .little),
        .have_epoch = std.mem.readInt(u64, payload[12..20], .little),
        .name = payload[attach_len..],
    };
}

/// The grid size is here because the replica follows the grid, not its own
/// tty.
pub const SnapshotPrefix = struct {
    seq: u64,
    history_rows: u32,
    cols: u16,
    rows: u16,
    epoch: u64,
};

pub const snapshot_prefix_len = 24;

pub fn writeSnapshotPrefix(buf: *[snapshot_prefix_len]u8, p: SnapshotPrefix) void {
    std.mem.writeInt(u64, buf[0..8], p.seq, .little);
    std.mem.writeInt(u32, buf[8..12], p.history_rows, .little);
    std.mem.writeInt(u16, buf[12..14], p.cols, .little);
    std.mem.writeInt(u16, buf[14..16], p.rows, .little);
    std.mem.writeInt(u64, buf[16..24], p.epoch, .little);
}

pub fn readSnapshotPrefix(payload: []const u8) !SnapshotPrefix {
    if (payload.len < snapshot_prefix_len) return error.BadPayload;
    return .{
        .seq = std.mem.readInt(u64, payload[0..8], .little),
        .history_rows = std.mem.readInt(u32, payload[8..12], .little),
        .cols = std.mem.readInt(u16, payload[12..14], .little),
        .rows = std.mem.readInt(u16, payload[14..16], .little),
        .epoch = std.mem.readInt(u64, payload[16..24], .little),
    };
}

/// The cursor rides in the snapshot BODY rather than in the prefix so that
/// the prefix keeps its golden layout and every reader of it is unchanged.
pub const snapshot_cursor_len = 4;

pub fn writeSnapshotCursor(buf: *[snapshot_cursor_len]u8, x: u16, y: u16) void {
    std.mem.writeInt(u16, buf[0..2], x, .little);
    std.mem.writeInt(u16, buf[2..4], y, .little);
}

pub const SnapshotCursor = struct { x: u16, y: u16 };

pub fn readSnapshotCursor(payload: []const u8) !SnapshotCursor {
    if (payload.len < snapshot_prefix_len + snapshot_cursor_len) return error.BadPayload;
    const b = payload[snapshot_prefix_len..][0..snapshot_cursor_len];
    return .{
        .x = std.mem.readInt(u16, b[0..2], .little),
        .y = std.mem.readInt(u16, b[2..4], .little),
    };
}

/// No epoch: a delta only arrives on the connection its snapshot opened.
pub const DeltaHeader = struct {
    seq: u64,
    history_rows: u32,
    cursor_x: u16,
    cursor_y: u16,
    row_count: u16,
};

pub const delta_header_len = 18;

pub fn appendDeltaHeader(
    list: *std.ArrayList(u8),
    alloc: std.mem.Allocator,
    hdr: DeltaHeader,
) !void {
    var buf: [delta_header_len]u8 = undefined;
    std.mem.writeInt(u64, buf[0..8], hdr.seq, .little);
    std.mem.writeInt(u32, buf[8..12], hdr.history_rows, .little);
    std.mem.writeInt(u16, buf[12..14], hdr.cursor_x, .little);
    std.mem.writeInt(u16, buf[14..16], hdr.cursor_y, .little);
    std.mem.writeInt(u16, buf[16..18], hdr.row_count, .little);
    try list.appendSlice(alloc, &buf);
}

pub fn readDeltaHeader(payload: []const u8) !DeltaHeader {
    if (payload.len < delta_header_len) return error.BadPayload;
    return .{
        .seq = std.mem.readInt(u64, payload[0..8], .little),
        .history_rows = std.mem.readInt(u32, payload[8..12], .little),
        .cursor_x = std.mem.readInt(u16, payload[12..14], .little),
        .cursor_y = std.mem.readInt(u16, payload[14..16], .little),
        .row_count = std.mem.readInt(u16, payload[16..18], .little),
    };
}

/// Per-row prefix: u16 LE row index ++ u32 LE content length.
pub const delta_row_header_len = 6;

pub fn appendDeltaRow(
    list: *std.ArrayList(u8),
    alloc: std.mem.Allocator,
    row_index: u16,
    bytes: []const u8,
) !void {
    var buf: [delta_row_header_len]u8 = undefined;
    std.mem.writeInt(u16, buf[0..2], row_index, .little);
    std.mem.writeInt(u32, buf[2..6], @intCast(bytes.len), .little);
    try list.appendSlice(alloc, &buf);
    try list.appendSlice(alloc, bytes);
}

pub const DeltaRow = struct { row: u16, bytes: []const u8 };

pub const DeltaRowIterator = struct {
    rest: []const u8,

    pub fn next(self: *DeltaRowIterator) !?DeltaRow {
        if (self.rest.len == 0) return null;
        if (self.rest.len < delta_row_header_len) return error.BadPayload;
        const row = std.mem.readInt(u16, self.rest[0..2], .little);
        const len: usize = std.mem.readInt(u32, self.rest[2..6], .little);
        const end = delta_row_header_len + len;
        if (self.rest.len < end) return error.BadPayload;
        const bytes = self.rest[delta_row_header_len..end];
        self.rest = self.rest[end..];
        return .{ .row = row, .bytes = bytes };
    }
};

/// A payload too short to even hold a header iterates empty; callers that
/// care about the difference check it via readDeltaHeader.
pub fn deltaRowIterator(payload: []const u8) DeltaRowIterator {
    return .{ .rest = payload[@min(payload.len, delta_header_len)..] };
}

// ---------------------------------------------------------------------------
// CellRow: one grid row as runs of styled cells. The client copies these into
// a grid and never parses VT; the daemon's ghostty is the only parser left.
// A row ends at the last cell that is not a default-style blank, and the
// reader's caller fills the rest of the width with blanks.

pub const Wide = enum(u2) { narrow = 0, wide = 1, spacer_tail = 2, spacer_head = 3 };

pub const color_none: u32 = 0;

pub fn colorPalette(index: u8) u32 {
    return (1 << 24) | @as(u32, index);
}

pub fn colorRgb(r: u8, g: u8, b: u8) u32 {
    return (2 << 24) | (@as(u32, r) << 16) | (@as(u32, g) << 8) | @as(u32, b);
}

pub const CellStyle = struct {
    fg: u32 = color_none,
    bg: u32 = color_none,
    ul: u32 = color_none,
    /// ghostty Style.Flags bit order: bold 0, italic 1, faint 2, blink 3,
    /// inverse 4, invisible 5, strikethrough 6, overline 7, underline 8-10.
    flags: u16 = 0,

    pub fn eql(a: CellStyle, b: CellStyle) bool {
        return a.fg == b.fg and a.bg == b.bg and a.ul == b.ul and a.flags == b.flags;
    }

    pub fn isDefault(self: CellStyle) bool {
        return self.eql(.{});
    }
};

/// A run header is `u16 count` ++ `u8 mask` ++ the fields the mask names, in
/// bit order. The mask says what CHANGED since the previous run of the same
/// row, the way SGR is a delta against the previous style: a run that only
/// turns on bold spends three bytes on style, not twelve. Every row starts
/// again from the default style, so a row stays self-contained and no reader
/// needs the row before it.
pub const mask_flags: u8 = 1 << 0;
pub const mask_fg: u8 = 1 << 1;
pub const mask_bg: u8 = 1 << 2;
pub const mask_ul: u8 = 1 << 3;
/// Every cell in the run is one byte 0x20..0x7E, narrow, and carries no head
/// byte. Dense text is what a wire pays for most.
pub const mask_ascii: u8 = 1 << 7;
/// Unassigned. A writer that sets one is speaking a format this reader does
/// not know, so a set bit is refused rather than ignored.
const mask_reserved: u8 = 0b0111_0000;
/// Bit 15 of the style flags. ghostty's `Style.Flags` reaches bit 10 and pads
/// the rest, and the wire writes the flags as a whole u16 — so nothing may
/// set it, and `CellRowWriter.cell` asserts as much.
pub const flags_reserved: u16 = 1 << 15;

/// `count` and `mask`; the style fields after them are what the mask names.
pub const run_header_min_len = 3;
pub const cell_row_prefix_len = 2;
pub const cell_text_max = 63;

pub const DecodedCell = struct { style: CellStyle, wide: Wide, text: []const u8 };

/// The one printable-ascii predicate, shared by the writer's run decision and
/// the reader's refusal so the two cannot drift apart.
pub fn asciiByte(b: u8) bool {
    return b >= 0x20 and b <= 0x7E;
}

fn asciiCell(wide: Wide, text: []const u8) bool {
    return wide == .narrow and text.len == 1 and asciiByte(text[0]);
}

/// A visible replacement for a cell cluster containing terminal controls.
/// Ghostty can retain DEL and C0 codepoints after binary output. They are
/// cell contents, never instructions for the client's terminal: replacing
/// the whole cluster preserves its cell position without executing bytes.
/// The reader applies this too, so a client can attach to an older daemon
/// whose writer emitted those controls unchanged.
fn safeCellText(text: []const u8) []const u8 {
    for (text) |b| if (b < 0x20 or b == 0x7F) return "\xef\xbf\xbd";
    return text;
}

pub const CellRowWriter = struct {
    /// One held-back cell. The text is an offset into the writer's own `text`
    /// buffer, never the caller's slice: an encoder renders every cell into
    /// one reused buffer, and a run outlives the call that added its cells.
    const RunCell = struct { wide: Wide, text_off: u32, text_len: u8 };

    list: *std.ArrayList(u8),
    alloc: std.mem.Allocator,
    prefix_at: usize,
    ncells: u16 = 0,
    /// The open run's cells, held back until the run closes so the ascii
    /// decision is made over the whole run.
    run: std.ArrayListUnmanaged(RunCell) = .empty,
    /// The open run's cell texts, back to back.
    text: std.ArrayListUnmanaged(u8) = .empty,
    run_style: CellStyle = .{},
    /// The style the last run WRITTEN for this row carried, which the next
    /// run's mask is a delta against. A row opens at the default style.
    prev_style: CellStyle = .{},

    pub fn begin(list: *std.ArrayList(u8), alloc: std.mem.Allocator) !CellRowWriter {
        const at = list.items.len;
        try list.appendSlice(alloc, &[_]u8{ 0, 0 });
        return .{ .list = list, .alloc = alloc, .prefix_at = at };
    }

    pub fn cell(self: *CellRowWriter, style: CellStyle, wide: Wide, raw_text: []const u8) !void {
        std.debug.assert(raw_text.len <= cell_text_max);
        const text = safeCellText(raw_text);
        std.debug.assert(style.flags & flags_reserved == 0);
        if (self.run.items.len > 0 and !style.eql(self.run_style)) try self.flush();
        if (self.run.items.len == 0) self.run_style = style;
        const off: u32 = @intCast(self.text.items.len);
        try self.text.appendSlice(self.alloc, text);
        try self.run.append(self.alloc, .{ .wide = wide, .text_off = off, .text_len = @intCast(text.len) });
        self.ncells += 1;
    }

    fn cellText(self: *const CellRowWriter, c: RunCell) []const u8 {
        return self.text.items[c.text_off..][0..c.text_len];
    }

    /// A colour on the wire is its tag byte and only the bytes that tag
    /// needs: nothing for none, an index for a palette entry, three for RGB.
    /// The packed u32 the rest of mux carries has the tag in its top byte
    /// already, which is what makes the shift below the whole conversion.
    fn writeColor(self: *CellRowWriter, col: u32) !void {
        const code: u8 = @intCast(col >> 24);
        try self.list.append(self.alloc, code);
        switch (code) {
            0 => {},
            1 => try self.list.append(self.alloc, @truncate(col)),
            2 => try self.list.appendSlice(self.alloc, &[_]u8{
                @truncate(col >> 16),
                @truncate(col >> 8),
                @truncate(col),
            }),
            // colorPalette and colorRgb mint every colour mux holds, and
            // color_none is zero: there is no fourth tag to write.
            else => unreachable,
        }
    }

    fn flush(self: *CellRowWriter) !void {
        const cells = self.run.items;
        if (cells.len == 0) return;
        var ascii = true;
        for (cells) |c| {
            if (!asciiCell(c.wide, self.cellText(c))) {
                ascii = false;
                break;
            }
        }
        const s = self.run_style;
        const prev = self.prev_style;
        var mask: u8 = 0;
        if (s.flags != prev.flags) mask |= mask_flags;
        if (s.fg != prev.fg) mask |= mask_fg;
        if (s.bg != prev.bg) mask |= mask_bg;
        if (s.ul != prev.ul) mask |= mask_ul;
        if (ascii) mask |= mask_ascii;

        var hdr: [run_header_min_len]u8 = undefined;
        std.mem.writeInt(u16, hdr[0..2], @intCast(cells.len), .little);
        hdr[2] = mask;
        try self.list.appendSlice(self.alloc, &hdr);
        if (mask & mask_flags != 0) {
            var fb: [2]u8 = undefined;
            std.mem.writeInt(u16, &fb, s.flags, .little);
            try self.list.appendSlice(self.alloc, &fb);
        }
        if (mask & mask_fg != 0) try self.writeColor(s.fg);
        if (mask & mask_bg != 0) try self.writeColor(s.bg);
        if (mask & mask_ul != 0) try self.writeColor(s.ul);
        self.prev_style = s;

        for (cells) |c| {
            const text = self.cellText(c);
            if (ascii) {
                try self.list.append(self.alloc, text[0]);
            } else {
                const head: u8 = (@as(u8, @intFromEnum(c.wide)) << 6) | c.text_len;
                try self.list.append(self.alloc, head);
                try self.list.appendSlice(self.alloc, text);
            }
        }
        self.run.clearRetainingCapacity();
        self.text.clearRetainingCapacity();
    }

    /// Frees the writer's held-back run. Safe after `finish`, and safe twice,
    /// so a caller that may fail mid-row can `errdefer w.deinit()`.
    pub fn deinit(self: *CellRowWriter) void {
        self.run.deinit(self.alloc);
        self.run = .empty;
        self.text.deinit(self.alloc);
        self.text = .empty;
    }

    /// Closes the open run and stamps ncells. The writer is spent after this.
    pub fn finish(self: *CellRowWriter) void {
        self.flush() catch |e| switch (e) {
            error.OutOfMemory => @panic("CellRowWriter.finish: out of memory"),
        };
        std.mem.writeInt(u16, self.list.items[self.prefix_at..][0..2], self.ncells, .little);
        self.deinit();
    }
};

pub const CellRowReader = struct {
    rest: []const u8,
    ncells: u16,
    read: u16 = 0,
    run_left: u16 = 0,
    /// The style in force, carried across runs of this row: a header names
    /// only the fields that changed, so the rest stand.
    cur: CellStyle = .{},
    run_ascii: bool = false,

    pub fn init(bytes: []const u8) !CellRowReader {
        if (bytes.len < cell_row_prefix_len) return error.BadPayload;
        return .{
            .rest = bytes[cell_row_prefix_len..],
            .ncells = std.mem.readInt(u16, bytes[0..2], .little),
        };
    }

    /// The inverse of `CellRowWriter.writeColor`: a tag byte and only the
    /// bytes that tag needs. An unknown tag is refused, not skipped — its
    /// length is exactly what this reader would not know.
    fn readColor(self: *CellRowReader) !u32 {
        if (self.rest.len < 1) return error.BadPayload;
        const code = self.rest[0];
        self.rest = self.rest[1..];
        switch (code) {
            0 => return color_none,
            1 => {
                if (self.rest.len < 1) return error.BadPayload;
                const idx = self.rest[0];
                self.rest = self.rest[1..];
                return colorPalette(idx);
            },
            2 => {
                if (self.rest.len < 3) return error.BadPayload;
                const col = colorRgb(self.rest[0], self.rest[1], self.rest[2]);
                self.rest = self.rest[3..];
                return col;
            },
            else => return error.BadPayload,
        }
    }

    pub fn next(self: *CellRowReader) !?DecodedCell {
        if (self.read == self.ncells) return null;
        if (self.run_left == 0) {
            if (self.rest.len < run_header_min_len) return error.BadPayload;
            const count = std.mem.readInt(u16, self.rest[0..2], .little);
            const mask = self.rest[2];
            // A run may not claim cells the row does not have.
            if (count == 0 or count > self.ncells - self.read) return error.BadPayload;
            if (mask & mask_reserved != 0) return error.BadPayload;
            self.rest = self.rest[run_header_min_len..];
            if (mask & mask_flags != 0) {
                if (self.rest.len < 2) return error.BadPayload;
                self.cur.flags = std.mem.readInt(u16, self.rest[0..2], .little);
                self.rest = self.rest[2..];
            }
            if (mask & mask_fg != 0) self.cur.fg = try self.readColor();
            if (mask & mask_bg != 0) self.cur.bg = try self.readColor();
            if (mask & mask_ul != 0) self.cur.ul = try self.readColor();
            self.run_left = count;
            self.run_ascii = mask & mask_ascii != 0;
        }
        self.run_left -= 1;
        self.read += 1;
        if (self.run_ascii) {
            if (self.rest.len < 1) return error.BadPayload;
            // The same predicate the writer applies before it opens an ascii
            // run: a byte outside it in an ascii run is not a cell any encoder
            // of ours produced.
            if (!asciiByte(self.rest[0])) return error.BadPayload;
            const text = self.rest[0..1];
            self.rest = self.rest[1..];
            return .{ .style = self.cur, .wide = .narrow, .text = text };
        }
        if (self.rest.len < 1) return error.BadPayload;
        const head = self.rest[0];
        const len: usize = head & 0x3f;
        if (self.rest.len < 1 + len) return error.BadPayload;
        const text = safeCellText(self.rest[1 .. 1 + len]);
        // Consume the encoded length, not the replacement's UTF-8 length:
        // following cells and rows still begin at their original offsets.
        self.rest = self.rest[1 + len ..];
        return .{ .style = self.cur, .wide = @enumFromInt(head >> 6), .text = text };
    }

    /// The bytes after this row — the next row of a dense run, or nothing.
    pub fn remaining(self: *const CellRowReader) []const u8 {
        return self.rest;
    }
};

test "the frame header layout is these bytes, and the decoders read them back" {
    const alloc = std.testing.allocator;
    const golden = [_]u8{ 0x02, 3, 0, 0, 0, 'a', 'b', 'c' };

    // Every writer builds its header in `encodeHeader`, so they cannot
    // disagree with each other. What is still worth pinning is the LAYOUT:
    // the golden bytes are the wire contract, and a peer running an older
    // build is decoding them with its own copy of this rule. So the encode
    // side is checked against literal bytes, and the decode side is checked
    // against the same literal bytes rather than against a round trip — a
    // round trip through readFrame passes even if encode and decode drift
    // together, and says nothing about what actually goes on the wire.
    var list: std.ArrayList(u8) = .empty;
    defer list.deinit(alloc);
    try appendFrame(&list, alloc, .input, "abc");
    try std.testing.expectEqualSlices(u8, &golden, list.items);

    const d = (try delimitFrame(&golden)).?;
    try std.testing.expectEqual(MsgType.input, d.type);
    try std.testing.expectEqualSlices(u8, "abc", d.payload);
    try std.testing.expectEqual(golden.len, d.consumed);

    const p = try std.posix.pipe();
    defer std.posix.close(p[0]);
    try writeFrame(p[1], .input, "abc");
    std.posix.close(p[1]);
    var sent: [golden.len]u8 = undefined;
    try readExact(p[0], &sent);
    try std.testing.expectEqualSlices(u8, &golden, &sent);
    try std.testing.expectEqualSlices(u8, list.items, &sent);

    // The comparisons above read a fixed count, so a writeFrame that emitted
    // a ninth byte would still pass them. The write end is already closed,
    // so anything left unread surfaces here as a successful read instead.
    var extra: [1]u8 = undefined;
    try std.testing.expectError(error.UnexpectedEof, readExact(p[0], &extra));
}

test "appendFrame concatenates frames the way a queue would" {
    const alloc = std.testing.allocator;
    var list: std.ArrayList(u8) = .empty;
    defer list.deinit(alloc);
    try appendFrame(&list, alloc, .detach, "");
    try appendFrame(&list, alloc, .exit_status, &.{7});
    try std.testing.expectEqualSlices(u8, &[_]u8{
        0x04, 0, 0, 0, 0, // detach, empty payload
        0x82, 1, 0, 0, 0, 7, // exit_status, one byte
    }, list.items);

    // ...and the concatenation is what readFrame walks back off the wire.
    const p = try std.posix.pipe();
    defer std.posix.close(p[0]);
    try writeAllFd(p[1], list.items);
    std.posix.close(p[1]);
    const f1 = (try readFrame(alloc, p[0])).?;
    defer f1.deinit(alloc);
    try std.testing.expectEqual(MsgType.detach, f1.type);
    const f2 = (try readFrame(alloc, p[0])).?;
    defer f2.deinit(alloc);
    try std.testing.expectEqual(MsgType.exit_status, f2.type);
    try std.testing.expectEqualSlices(u8, &.{7}, f2.payload);
}

test "takeFrame: a partial tail is not a frame and not an error" {
    const alloc = std.testing.allocator;
    var buf: std.ArrayList(u8) = .empty;
    defer buf.deinit(alloc);

    // Nothing, and less than a header: the two shapes a datagram that
    // carried the start of a frame leaves behind.
    try std.testing.expect(try takeFrame(alloc, &buf) == null);
    try buf.appendSlice(alloc, &[_]u8{ 0x0a, 1, 0 });
    try std.testing.expect(try takeFrame(alloc, &buf) == null);

    // A whole header whose payload is still in flight. This is the case a
    // blocking read would have sat on: the length is known, the bytes are
    // not here, and the answer is to wait rather than to read.
    buf.clearRetainingCapacity();
    const partial = [_]u8{ @intFromEnum(MsgType.input), 4, 0, 0, 0, 'a', 'b' };
    try buf.appendSlice(alloc, &partial);
    try std.testing.expect(try takeFrame(alloc, &buf) == null);
    // A null answer consumed nothing: the partial tail is still there,
    // byte for byte, for the bytes that complete it.
    try std.testing.expectEqualSlices(u8, &partial, buf.items);

    // The same bytes, completed.
    try buf.appendSlice(alloc, "cd");
    const got = (try takeFrame(alloc, &buf)).?;
    defer got.deinit(alloc);
    try std.testing.expectEqual(MsgType.input, got.type);
    try std.testing.expectEqualStrings("abcd", got.payload);
    try std.testing.expectEqual(@as(usize, 0), buf.items.len);
}

test "takeFrame: two frames in one buffer, walked one call at a time" {
    const alloc = std.testing.allocator;
    // What a single datagram routinely carries: the push we skip and the
    // reply we asked for. A walk that stopped after one would leave the
    // answer sitting in the buffer while the deadline ran out.
    var buf: std.ArrayList(u8) = .empty;
    defer buf.deinit(alloc);
    try appendFrame(&buf, alloc, .pty_mode, &[_]u8{0});
    try appendFrame(&buf, alloc, .status_reply, "xy");

    const first = (try takeFrame(alloc, &buf)).?;
    defer first.deinit(alloc);
    try std.testing.expectEqual(MsgType.pty_mode, first.type);

    const second = (try takeFrame(alloc, &buf)).?;
    defer second.deinit(alloc);
    try std.testing.expectEqual(MsgType.status_reply, second.type);
    try std.testing.expectEqualStrings("xy", second.payload);
    try std.testing.expectEqual(@as(usize, 0), buf.items.len);

    // An empty payload is a frame like any other — `status_req` and
    // `detach` are nothing else — and must not read as "nothing yet".
    var empty: std.ArrayList(u8) = .empty;
    defer empty.deinit(alloc);
    try appendFrame(&empty, alloc, .detach, "");
    const none = (try takeFrame(alloc, &empty)).?;
    defer none.deinit(alloc);
    try std.testing.expectEqual(MsgType.detach, none.type);
    try std.testing.expectEqual(@as(usize, 0), empty.items.len);
}

test "takeFrame: a length no frame can carry is refused, not allocated" {
    const alloc = std.testing.allocator;
    // The peer chose this number. Reading on would mean allocating against
    // it; every caller answers the refusal by dropping the connection.
    var buf: std.ArrayList(u8) = .empty;
    defer buf.deinit(alloc);
    try buf.append(alloc, @intFromEnum(MsgType.input));
    var len: [4]u8 = undefined;
    std.mem.writeInt(u32, &len, max_payload + 1, .little);
    try buf.appendSlice(alloc, &len);
    try std.testing.expectError(error.FrameTooLarge, takeFrame(alloc, &buf));
}

test "scrollback request encode/decode round trip" {
    const req = try decodeScrollbackReq(&encodeScrollbackReq(70000, 24));
    try std.testing.expectEqual(@as(u32, 70000), req.start);
    try std.testing.expectEqual(@as(u16, 24), req.count);
}

test "frame round trip over a pipe" {
    const alloc = std.testing.allocator;
    const p = try std.posix.pipe();
    const fds = [2]std.posix.fd_t{ p[1], p[0] }; // write end, read end
    defer std.posix.close(fds[1]);

    try writeFrame(fds[0], .input, "keystrokes");
    try writeFrame(fds[0], .attach, &encodeSize(120, 40));
    std.posix.close(fds[0]);

    const f1 = (try readFrame(alloc, fds[1])).?;
    defer f1.deinit(alloc);
    try std.testing.expectEqual(MsgType.input, f1.type);
    try std.testing.expectEqualStrings("keystrokes", f1.payload);

    const f2 = (try readFrame(alloc, fds[1])).?;
    defer f2.deinit(alloc);
    try std.testing.expectEqual(MsgType.attach, f2.type);
    const sz = try decodeSize(f2.payload);
    try std.testing.expectEqual(@as(u16, 120), sz.cols);
    try std.testing.expectEqual(@as(u16, 40), sz.rows);

    try std.testing.expectEqual(@as(?Frame, null), try readFrame(alloc, fds[1]));
}

test "size encode/decode round trip" {
    const sz = try decodeSize(&encodeSize(213, 58));
    try std.testing.expectEqual(@as(u16, 213), sz.cols);
    try std.testing.expectEqual(@as(u16, 58), sz.rows);
}

test "attach v3 encode/decode round trip" {
    const a = try decodeAttach(&encodeAttach(120, 40, 987654321, 0xA1B2C3D4E5F60718));
    try std.testing.expectEqual(@as(u16, 120), a.cols);
    try std.testing.expectEqual(@as(u16, 40), a.rows);
    try std.testing.expectEqual(@as(u64, 987654321), a.have_seq);
    try std.testing.expectEqual(@as(u64, 0xA1B2C3D4E5F60718), a.have_epoch);
}

test "attach: a bare 20-byte payload decodes with an empty name (old-client wire compat)" {
    const req = try decodeAttach(&encodeAttach(120, 40, 7, 9));
    try std.testing.expectEqual(@as(u16, 120), req.cols);
    try std.testing.expectEqual(@as(u64, 7), req.have_seq);
    try std.testing.expectEqualStrings("", req.name);
}

test "attach: the name tail rides behind the fixed 20 bytes and round-trips" {
    var buf: [attach_max_len]u8 = undefined;
    const wire = encodeAttachNamed(&buf, 80, 24, 3, 5, "wall-b");
    try std.testing.expectEqual(@as(usize, attach_len + 6), wire.len);
    const req = try decodeAttach(wire);
    try std.testing.expectEqual(@as(u16, 80), req.cols);
    try std.testing.expectEqualStrings("wall-b", req.name);
    // An empty name encodes to exactly an old client's bytes — this IS the
    // cross-version story, so it is pinned, not assumed.
    const bare = encodeAttachNamed(&buf, 80, 24, 3, 5, "");
    try std.testing.expectEqualSlices(u8, &encodeAttach(80, 24, 3, 5), bare);
}

test "attach: shorter than the fixed part, or a name past the cap, is BadPayload" {
    try std.testing.expectError(error.BadPayload, decodeAttach(&[_]u8{0} ** 19));
    const long = [_]u8{0} ** (attach_len + session_name_max + 1);
    try std.testing.expectError(error.BadPayload, decodeAttach(&long));
    // Exactly at the cap is still good — the boundary is ">", not ">=".
    const at_cap = [_]u8{0} ** attach_max_len;
    const req = try decodeAttach(&at_cap);
    try std.testing.expectEqual(@as(usize, session_name_max), req.name.len);
}

test "await_req: the name tail rides behind the fixed 16 bytes; bare stays bare" {
    var buf: [await_req_max_len]u8 = undefined;
    const wire = encodeAwaitReqNamed(&buf, .{ .since_seq = 77, .settle_ms = 500, .timeout_ms = 30_000 }, "b");
    const req = try decodeAwaitReq(wire);
    try std.testing.expectEqual(@as(u64, 77), req.since_seq);
    try std.testing.expectEqualStrings("b", req.name);
    const bare = encodeAwaitReqNamed(&buf, .{ .since_seq = 77, .settle_ms = 500, .timeout_ms = 30_000 }, "");
    try std.testing.expectEqualSlices(u8, &encodeAwaitReq(.{ .since_seq = 77, .settle_ms = 500, .timeout_ms = 30_000 }), bare);
    // Same off-by-one boundary as attach: exactly at the cap succeeds,
    // one byte over is BadPayload — await_req had no such pin before.
    const at_cap = [_]u8{0} ** await_req_max_len;
    const at_cap_req = try decodeAwaitReq(&at_cap);
    try std.testing.expectEqual(@as(usize, session_name_max), at_cap_req.name.len);
    const over_cap = [_]u8{0} ** (await_req_max_len + 1);
    try std.testing.expectError(error.BadPayload, decodeAwaitReq(&over_cap));
}

test "session names: 1..32 printable bytes, no space, no '#', no '/'" {
    try std.testing.expect(validSessionName("0"));
    try std.testing.expect(validSessionName("wall-b"));
    try std.testing.expect(validSessionName("a" ** session_name_max));
    try std.testing.expect(!validSessionName(""));
    try std.testing.expect(!validSessionName("a" ** (session_name_max + 1)));
    try std.testing.expect(!validSessionName("has space"));
    try std.testing.expect(!validSessionName("has#hash"));
    try std.testing.expect(!validSessionName("has/slash"));
    try std.testing.expect(!validSessionName("ctrl\x01"));
}

test "sessionsIter: one walk, one trust policy, over a hostile payload" {
    const collect = struct {
        fn f(payload: []const u8, out: *[8][]const u8) usize {
            var n: usize = 0;
            var it = sessionsIter(payload);
            while (it.next()) |name| : (n += 1) out[n] = name;
            return n;
        }
    }.f;
    var got: [8][]const u8 = undefined;

    // A normal payload walks exactly as a plain '\n' split would: this is the
    // pin that converting the readers changed nothing for a real daemon.
    try std.testing.expectEqual(@as(usize, 3), collect("0\nwork\ndev", &got));
    try std.testing.expectEqualStrings("0", got[0]);
    try std.testing.expectEqualStrings("work", got[1]);
    try std.testing.expectEqualStrings("dev", got[2]);
    // A trailing newline is a separator, not a fourth empty session.
    try std.testing.expectEqual(@as(usize, 3), collect("0\nwork\ndev\n", &got));
    try std.testing.expectEqual(@as(usize, 0), collect("", &got));
    try std.testing.expectEqual(@as(usize, 0), collect("\n\n\n", &got));

    // The trust boundary. A `sessions_reply` is bounded only in TOTAL, so a
    // hostile or buggy daemon can put a 1056-byte "name", a name with a
    // space, or a control byte on one line. Each is skipped and the names
    // around it still arrive: a bad line costs its own row, never the list.
    const long = "x" ** 1056;
    try std.testing.expectEqual(@as(usize, 2), collect("0\n" ++ long ++ "\nwork", &got));
    try std.testing.expectEqualStrings("0", got[0]);
    try std.testing.expectEqualStrings("work", got[1]);
    try std.testing.expectEqual(@as(usize, 2), collect("0\nhas space\nwork", &got));
    try std.testing.expectEqualStrings("work", got[1]);
    try std.testing.expectEqual(@as(usize, 1), collect("bad\x01name\n0", &got));
    try std.testing.expectEqualStrings("0", got[0]);

    // Every name it yields fits the tail `encodeAttachNamed` asserts on, so a
    // caller may hand one straight to the encoder.
    var it = sessionsIter("0\n" ++ long ++ "\nwork\n");
    while (it.next()) |name| try std.testing.expect(name.len <= session_name_max);
}

test "sessionsHas: a membership test that a hostile line cannot answer" {
    try std.testing.expect(sessionsHas("0\nwork", "work"));
    try std.testing.expect(sessionsHas("0\nwork\n", "0"));
    // Never `indexOf`: `w` is in `work` and neither is the other.
    try std.testing.expect(!sessionsHas("0\nwork", "w"));
    try std.testing.expect(!sessionsHas("0\nwork", "workshop"));
    // A line the iterator refuses is not a session, so nothing matches it —
    // a daemon cannot keep a tile alive by naming it with an unspellable line.
    try std.testing.expect(!sessionsHas("has space\n0", "has space"));
    try std.testing.expect(!sessionsHas("", ""));
}

test "delta build/iterate round trip" {
    const alloc = std.testing.allocator;
    var payload: std.ArrayList(u8) = .empty;
    defer payload.deinit(alloc);

    try appendDeltaHeader(&payload, alloc, .{
        .seq = 42,
        .history_rows = 7,
        .cursor_x = 3,
        .cursor_y = 5,
        .row_count = 2,
    });
    try appendDeltaRow(&payload, alloc, 5, "\x1b[0mhello");
    try appendDeltaRow(&payload, alloc, 23, "\x1b[0mworld");

    const hdr = try readDeltaHeader(payload.items);
    try std.testing.expectEqual(@as(u64, 42), hdr.seq);
    try std.testing.expectEqual(@as(u32, 7), hdr.history_rows);
    try std.testing.expectEqual(@as(u16, 2), hdr.row_count);

    var it = deltaRowIterator(payload.items);
    const r1 = (try it.next()).?;
    try std.testing.expectEqual(@as(u16, 5), r1.row);
    try std.testing.expectEqualStrings("\x1b[0mhello", r1.bytes);
    const r2 = (try it.next()).?;
    try std.testing.expectEqual(@as(u16, 23), r2.row);
    try std.testing.expectEqualStrings("\x1b[0mworld", r2.bytes);
    try std.testing.expectEqual(@as(?DeltaRow, null), try it.next());
}

test "delta row with a bogus length is rejected, not trusted" {
    const alloc = std.testing.allocator;
    var payload: std.ArrayList(u8) = .empty;
    defer payload.deinit(alloc);
    try appendDeltaHeader(&payload, alloc, .{
        .seq = 1,
        .history_rows = 0,
        .cursor_x = 0,
        .cursor_y = 0,
        .row_count = 1,
    });
    // A row header claiming u32-max bytes of content that isn't there.
    var row_hdr: [6]u8 = undefined;
    std.mem.writeInt(u16, row_hdr[0..2], 3, .little);
    std.mem.writeInt(u32, row_hdr[2..6], 0xFFFF_FFFF, .little);
    try payload.appendSlice(alloc, &row_hdr);

    var it = deltaRowIterator(payload.items);
    try std.testing.expectError(error.BadPayload, it.next());
}

test "delta payload too short for a header iterates empty" {
    var it = deltaRowIterator("short");
    try std.testing.expectEqual(@as(?DeltaRow, null), try it.next());
}

test "encodeAttach golden bytes" {
    try std.testing.expectEqualSlices(u8, &[_]u8{
        0x78, 0x00, // cols 120
        0x28, 0x00, // rows 40
        0xB1, 0x68, 0xDE, 0x3A, 0x00, 0x00, 0x00, 0x00, // have_seq 987654321
        0x18, 0x07, 0xF6, 0xE5, 0xD4, 0xC3, 0xB2, 0xA1, // have_epoch 0xA1B2C3D4E5F60718
    }, &encodeAttach(120, 40, 987654321, 0xA1B2C3D4E5F60718));
}

test "delta header golden bytes" {
    const alloc = std.testing.allocator;
    var payload: std.ArrayList(u8) = .empty;
    defer payload.deinit(alloc);
    try appendDeltaHeader(&payload, alloc, .{
        .seq = 258,
        .history_rows = 7,
        .cursor_x = 3,
        .cursor_y = 5,
        .row_count = 2,
    });
    try std.testing.expectEqualSlices(u8, &[_]u8{
        0x02, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // seq 258
        0x07, 0x00, 0x00, 0x00, // history_rows 7
        0x03, 0x00, // cursor_x 3
        0x05, 0x00, // cursor_y 5
        0x02, 0x00, // row_count 2
    }, payload.items);
}

test "decodeAttach rejects a wrong-length payload" {
    try std.testing.expectError(error.BadPayload, decodeAttach(&encodeSize(80, 24)));
    try std.testing.expectError(error.BadPayload, decodeAttach(&[_]u8{0} ** 19));
    // A v2 attach (12 bytes, no epoch) carries no epoch to check, so it is
    // rejected outright rather than read as epoch 0 — an old client must
    // fail loudly, not be handed a session it cannot reason about.
    try std.testing.expectError(error.BadPayload, decodeAttach(&[_]u8{0} ** 12));
    // 21 bytes is no longer a bad length on its own: with named sessions,
    // one byte beyond the fixed part is a one-byte session name, not
    // garbage. The
    // upper bound (name past session_name_max) is covered separately.
}

test "readDeltaHeader rejects a payload one byte short" {
    try std.testing.expectError(error.BadPayload, readDeltaHeader(&[_]u8{0} ** 17));
}

test "snapshot prefix round trip and golden bytes" {
    const p = SnapshotPrefix{
        .seq = 258,
        .history_rows = 7,
        .cols = 120,
        .rows = 40,
        .epoch = 0xDEADBEEFCAFEF00D,
    };
    var buf: [snapshot_prefix_len]u8 = undefined;
    writeSnapshotPrefix(&buf, p);
    try std.testing.expectEqualSlices(u8, &[_]u8{
        0x02, 0x01, 0, 0, 0, 0, 0, 0, // seq u64 LE
        0x07, 0, 0, 0, // history_rows u32 LE
        0x78, 0, // cols u16 LE
        0x28, 0, // rows u16 LE
        0x0D, 0xF0, 0xFE, 0xCA, 0xEF, 0xBE, 0xAD, 0xDE, // epoch u64 LE
    }, &buf);
    const q = try readSnapshotPrefix(&buf);
    try std.testing.expectEqual(p, q);
}

test "snapshot cursor golden bytes, read back from behind the prefix" {
    // The cursor sits immediately after the prefix, so a reader offsets by
    // both lengths. Pinned as literal bytes rather than as a round trip: a
    // peer on the other side of an upgrade decodes them with its own copy of
    // this rule, and encode and decode drifting together would pass a round
    // trip and paint the cursor in the wrong place.
    var payload: [snapshot_prefix_len + snapshot_cursor_len]u8 = undefined;
    writeSnapshotPrefix(payload[0..snapshot_prefix_len], .{
        .seq = 1,
        .history_rows = 0,
        .cols = 80,
        .rows = 24,
        .epoch = 1,
    });
    writeSnapshotCursor(payload[snapshot_prefix_len..][0..snapshot_cursor_len], 0x0102, 0x0304);
    try std.testing.expectEqualSlices(u8, &.{ 0x02, 0x01, 0x04, 0x03 }, payload[snapshot_prefix_len..]);
    const cur = try readSnapshotCursor(&payload);
    try std.testing.expectEqual(@as(u16, 0x0102), cur.x);
    try std.testing.expectEqual(@as(u16, 0x0304), cur.y);
    // A payload that holds the prefix but not the cursor is refused: it
    // would otherwise read four bytes of the first row as a position.
    try std.testing.expectError(
        error.BadPayload,
        readSnapshotCursor(payload[0 .. snapshot_prefix_len + 3]),
    );
}

test "snapshot prefix rejects short payloads" {
    try std.testing.expectError(error.BadPayload, readSnapshotPrefix(&[_]u8{0} ** 15));
    // A v1 prefix (16 bytes, no epoch) is short now: honouring it would mean
    // inventing an epoch, and an invented epoch is exactly the thing the
    // field exists to prevent.
    try std.testing.expectError(error.BadPayload, readSnapshotPrefix(&[_]u8{0} ** 23));
}

test "pty_mode frame round-trips and matches golden bytes" {
    const alloc = std.testing.allocator;
    var buf: std.ArrayList(u8) = .empty;
    defer buf.deinit(alloc);
    try appendFrame(&buf, alloc, .pty_mode, &encodePtyMode(.{ .icanon = true, .echo = true }));
    // 0x88 type, 4-byte LE len=1, flags byte: bit0 icanon, bit1 echo.
    try std.testing.expectEqualSlices(u8, &.{ 0x88, 0x01, 0x00, 0x00, 0x00, 0b11 }, buf.items);

    const flags = try decodePtyMode(buf.items[5..]);
    try std.testing.expect(flags.icanon);
    try std.testing.expect(flags.echo);
}

test "pty mode flags survive every combination, and each bit keeps its place" {
    // Pinned individually: the two bits mean opposite things to a predictor
    // (canonical-with-echo is where prediction is free, raw is where it must
    // be earned), so a silent swap would be the worst kind of wrong.
    try std.testing.expectEqualSlices(u8, &.{0b01}, &encodePtyMode(.{ .icanon = true, .echo = false }));
    try std.testing.expectEqualSlices(u8, &.{0b10}, &encodePtyMode(.{ .icanon = false, .echo = true }));

    for ([_]PtyModeFlags{
        .{ .icanon = false, .echo = false },
        .{ .icanon = true, .echo = false },
        .{ .icanon = false, .echo = true },
        .{ .icanon = true, .echo = true },
    }) |f| {
        const bytes = encodePtyMode(f);
        // Reserved bits go out zero, so a later flag can be added without a
        // receiver having to guess whether it was ever garbage.
        try std.testing.expectEqual(@as(u8, 0), bytes[0] & 0b1111_1100);
        const back = try decodePtyMode(&bytes);
        try std.testing.expectEqual(f.icanon, back.icanon);
        try std.testing.expectEqual(f.echo, back.echo);
    }
}

test "decodePtyMode rejects a payload that is not exactly one byte" {
    try std.testing.expectError(error.BadPayload, decodePtyMode(&.{}));
    try std.testing.expectError(error.BadPayload, decodePtyMode(&.{ 0b11, 0 }));
}

test "decodeEndpointReply rejects a payload that is not exactly two bytes" {
    try std.testing.expectError(error.BadPayload, decodeEndpointReply(&.{}));
    try std.testing.expectError(error.BadPayload, decodeEndpointReply(&.{0xCA}));
    try std.testing.expectError(error.BadPayload, decodeEndpointReply(&.{ 0xCA, 0xA8, 0 }));
    // 0 is a value, not a refusal: "no listener" gets decoded, then judged.
    try std.testing.expectEqual(@as(u16, 0), try decodeEndpointReply(&encodeEndpointReply(0)));
}

test "truncated row header is rejected" {
    const alloc = std.testing.allocator;
    var payload: std.ArrayList(u8) = .empty;
    defer payload.deinit(alloc);
    try appendDeltaHeader(&payload, alloc, .{
        .seq = 1,
        .history_rows = 0,
        .cursor_x = 0,
        .cursor_y = 0,
        .row_count = 1,
    });
    try payload.appendSlice(alloc, &[_]u8{ 0xAA, 0xBB, 0xCC }); // half a row header

    var it = deltaRowIterator(payload.items);
    try std.testing.expectError(error.BadPayload, it.next());
}

test "stop_req round-trips through writeFrame/readFrame" {
    const alloc = std.testing.allocator;
    const p = try std.posix.pipe();
    defer std.posix.close(p[0]);
    defer std.posix.close(p[1]);
    try writeFrame(p[1], .stop_req, "");
    const f = (try readFrame(alloc, p[0])).?;
    defer f.deinit(alloc);
    try std.testing.expectEqual(MsgType.stop_req, f.type);
    try std.testing.expectEqual(@as(usize, 0), f.payload.len);
}

test "endpoint_req/endpoint_reply round-trip through writeFrame/readFrame" {
    const alloc = std.testing.allocator;
    const p = try std.posix.pipe();
    defer std.posix.close(p[0]);
    defer std.posix.close(p[1]);

    try writeFrame(p[1], .endpoint_req, "");
    try writeFrame(p[1], .endpoint_reply, &encodeEndpointReply(43210));

    const req = (try readFrame(alloc, p[0])).?;
    defer req.deinit(alloc);
    try std.testing.expectEqual(MsgType.endpoint_req, req.type);
    try std.testing.expectEqual(@as(usize, 0), req.payload.len);

    const rep = (try readFrame(alloc, p[0])).?;
    defer rep.deinit(alloc);
    try std.testing.expectEqual(MsgType.endpoint_reply, rep.type);
    try std.testing.expectEqual(@as(u16, 43210), try decodeEndpointReply(rep.payload));
}

test "sessions_req/sessions_reply round-trip through writeFrame/readFrame" {
    const alloc = std.testing.allocator;
    const p = try std.posix.pipe();
    defer std.posix.close(p[0]);
    defer std.posix.close(p[1]);

    try writeFrame(p[1], .sessions_req, "");
    try writeFrame(p[1], .sessions_reply, "0\nwork\n2");

    const req = (try readFrame(alloc, p[0])).?;
    defer req.deinit(alloc);
    try std.testing.expectEqual(MsgType.sessions_req, req.type);
    try std.testing.expectEqual(@as(usize, 0), req.payload.len);

    const rep = (try readFrame(alloc, p[0])).?;
    defer rep.deinit(alloc);
    try std.testing.expectEqual(MsgType.sessions_reply, rep.type);
    // The separator survives because no name can contain it: every field
    // this splits into is a name validSessionName would accept.
    var names = std.mem.splitScalar(u8, rep.payload, '\n');
    while (names.next()) |n| try std.testing.expect(validSessionName(n));
    try std.testing.expectEqualStrings("0\nwork\n2", rep.payload);
}

test "sessions meta: the daemon's line rides behind the names and parses back" {
    var buf: [sessions_text_max + sessions_meta_max]u8 = undefined;
    const names = "0\nwork";
    @memcpy(buf[0..names.len], names);
    const full = buf[0..appendSessionsMeta(&buf, names.len, "0.0.1-17", false)];

    const meta = parseSessionsMeta(full) orelse return error.MetaAbsent;
    try std.testing.expectEqualStrings("0.0.1-17", meta.version);
    try std.testing.expect(!meta.stale);

    // The compatibility contract itself: a names reader sees exactly the
    // names, in order, and nothing of the line. Every reader in the tree
    // walks this iterator, so this one walk is every reader's test.
    var it = sessionsIter(full);
    try std.testing.expectEqualStrings("0", it.next() orelse return error.NameLost);
    try std.testing.expectEqualStrings("work", it.next() orelse return error.NameLost);
    try std.testing.expect(it.next() == null);
    try std.testing.expect(!sessionsHas(full, "mux"));
}

test "sessions meta: stale survives, and an empty table is only the line" {
    var buf: [sessions_meta_max]u8 = undefined;
    const full = buf[0..appendSessionsMeta(&buf, 0, "0.0.1-17", true)];

    const meta = parseSessionsMeta(full) orelse return error.MetaAbsent;
    try std.testing.expect(meta.stale);
    try std.testing.expectEqualStrings("0.0.1-17", meta.version);
    // A daemon hosting nothing still answers "no sessions" to a names
    // reader: the payload is non-empty now, so emptiness of NAMES must come
    // from the iterator, never the byte count.
    var it = sessionsIter(full);
    try std.testing.expect(it.next() == null);
    // No leading separator on an empty table.
    try std.testing.expect(full[0] == '#');
}

test "sessions meta: junk is absent, never a version" {
    // No line at all, and lines broken every way a peer can break one:
    // empty version, a trailer that is not the stale word, bytes no name
    // may hold, and a version past the cap.
    const absent = [_][]const u8{
        "0\nwork",
        "",
        "# mux ",
        "# mux 0.0.1-17 soon",
        "# mux 0.0.1-17 stale extra",
        "# mux bad version",
        "# mux \x01evil",
        "# mux " ++ "x" ** 33,
        "#mux 0.0.1-17",
    };
    for (absent) |payload|
        try std.testing.expect(parseSessionsMeta(payload) == null);
    // A version at the cap, mid-payload, still parses: the line need not be
    // last for the reader, only for the writer.
    const capped = "a\n# mux " ++ "x" ** 32 ++ " stale" ++ "\nb";
    const meta = parseSessionsMeta(capped) orelse return error.MetaAbsent;
    try std.testing.expectEqualStrings("x" ** 32, meta.version);
    try std.testing.expect(meta.stale);
}

test "sessions holds: a holds line rides beside the names, old readers skip it, and a name reads its own count" {
    var buf: [sessions_reply_max]u8 = undefined;
    @memcpy(buf[0..4], "0\nwk");
    var len: usize = 4;
    len = appendSessionsHolds(&buf, len, "0", 1);
    len = appendSessionsHolds(&buf, len, "wk", 0);
    const payload = buf[0..len];
    try std.testing.expectEqualStrings("0\nwk\n# holds 0 1\n# holds wk 0", payload);

    // The iterator every old client walks yields the names and nothing else.
    var it = sessionsIter(payload);
    try std.testing.expectEqualStrings("0", it.next().?);
    try std.testing.expectEqualStrings("wk", it.next().?);
    try std.testing.expect(it.next() == null);

    try std.testing.expectEqual(@as(?u8, 1), parseSessionsHolds(payload, "0"));
    try std.testing.expectEqual(@as(?u8, 0), parseSessionsHolds(payload, "wk"));
    // A name the daemon did not count, and an old daemon's payload with no
    // holds lines at all, both read as unknown rather than zero.
    try std.testing.expect(parseSessionsHolds(payload, "w") == null);
    try std.testing.expect(parseSessionsHolds("0\nwk", "0") == null);
    // The meta line and the holds lines coexist in either order.
    const with_meta = appendSessionsMeta(&buf, len, "0.0.1-18", false);
    try std.testing.expectEqual(@as(?u8, 1), parseSessionsHolds(buf[0..with_meta], "0"));
    try std.testing.expectEqualStrings("0.0.1-18", parseSessionsMeta(buf[0..with_meta]).?.version);
}

test "sessions holds: sessions_reply_max holds every session's name, holds line and the meta line" {
    // The daemon writes names, then one holds line per session, then meta,
    // into ONE buffer of this size; the bound must cover the worst case.
    const worst_line = sessions_holds_prefix.len + session_name_max + 1 + 3;
    try std.testing.expect(sessions_reply_max >= sessions_text_max + sessions_max * (worst_line + 1) + sessions_meta_max);
}

test "wireName: the default session is spelled as the empty tail" {
    try std.testing.expectEqualStrings("", wireName("0"));
    try std.testing.expectEqualStrings("", wireName(resolveName("")));
    try std.testing.expectEqualStrings("side", wireName("side"));
}

test "sessions message values are pinned" {
    try std.testing.expectEqual(@as(u8, 0x0c), @intFromEnum(MsgType.sessions_req));
    try std.testing.expectEqual(@as(u8, 0x91), @intFromEnum(MsgType.sessions_reply));
}

test "end message values are pinned" {
    try std.testing.expectEqual(@as(u8, 0x11), @intFromEnum(MsgType.end_req));
    try std.testing.expectEqual(@as(u8, 0x94), @intFromEnum(MsgType.end_reply));
}

test "end_req/end_reply codecs round-trip, and a short reply is refused" {
    var rq: [end_req_max_len]u8 = undefined;
    const req = encodeEndReq(&rq, true, "work");
    try std.testing.expectEqual(@as(u8, 1), req[0]);
    try std.testing.expectEqualStrings("work", req[end_req_len..]);
    const bare = encodeEndReq(&rq, false, "");
    try std.testing.expectEqual(@as(usize, end_req_len), bare.len);

    var rp: [end_reply_max_len]u8 = undefined;
    const refused = encodeEndReply(&rp, false, 2, "others attached");
    const parsed = parseEndReply(refused) orelse return error.ReplyDidNotParse;
    try std.testing.expect(!parsed.accepted);
    try std.testing.expectEqual(@as(u8, 2), parsed.others);
    try std.testing.expectEqualStrings("others attached", parsed.reason);
    const ok = parseEndReply(encodeEndReply(&rp, true, 0, "")) orelse return error.ReplyDidNotParse;
    try std.testing.expect(ok.accepted);
    try std.testing.expect(parseEndReply(&.{0}) == null);
}

test "end_reply_max_len holds every reason the daemon has, encoded into a buffer of exactly that size" {
    // The daemon's two reply buffers are this long. Encoding writes without
    // a bound of its own, so a reason that outgrew the buffer would be a
    // stack smash in the daemon and not a refusal anywhere.
    inline for (@typeInfo(end_reason).@"struct".decls) |d| {
        const reason = @field(end_reason, d.name);
        var buf: [end_reply_max_len]u8 = undefined;
        const wire = encodeEndReply(&buf, false, 1, reason);
        const parsed = parseEndReply(wire) orelse return error.ReplyDidNotParse;
        try std.testing.expectEqualStrings(reason, parsed.reason);
    }
}

test "endpoint frames match golden bytes" {
    const alloc = std.testing.allocator;
    var buf: std.ArrayList(u8) = .empty;
    defer buf.deinit(alloc);
    try appendFrame(&buf, alloc, .endpoint_req, "");
    try appendFrame(&buf, alloc, .endpoint_reply, &encodeEndpointReply(43210));
    // 0x08 type + empty payload; 0x89 type, LE len=2, port 43210 LE.
    try std.testing.expectEqualSlices(u8, &.{
        0x08, 0x00, 0x00, 0x00, 0x00,
        0x89, 0x02, 0x00, 0x00, 0x00,
        0xCA, 0xA8,
    }, buf.items);
}

test "cmd_state encode/decode round trip and golden bytes" {
    const s = CmdState{
        .phase = .returned,
        .mechanism = .marks,
        .exit_code = 1,
        .start_row = 80,
        .end_row = 92,
        .seq = 258,
    };
    const buf = encodeCmdState(s);
    try std.testing.expectEqualSlices(u8, &[_]u8{
        2, // phase returned
        0, // mechanism marks
        1, // has_exit
        1, // exit_code
        0x50, 0, 0, 0, // start_row 80
        0x5C, 0, 0, 0, // end_row 92
        0x02, 0x01, 0, 0, 0, 0, 0, 0, // seq 258
    }, &buf);
    const back = try decodeCmdState(&buf);
    try std.testing.expectEqual(s, back);
}

test "cmd_state with no exit code round-trips null, not zero" {
    const s = CmdState{ .phase = .running, .mechanism = .pgid, .exit_code = null, .start_row = 5, .end_row = 0, .seq = 9 };
    const buf = encodeCmdState(s);
    // No exit code means byte 3 is unused, but it still goes out as a clean
    // zero rather than whatever the encoder happened to have lying around.
    try std.testing.expectEqualSlices(u8, &.{ 0, 0 }, buf[2..4]);
    const back = try decodeCmdState(&buf);
    try std.testing.expectEqual(@as(?u8, null), back.exit_code);
}

test "cmd_state rejects wrong length and unknown enum bytes" {
    try std.testing.expectError(error.BadPayload, decodeCmdState(&[_]u8{0} ** (cmd_state_len - 1)));
    try std.testing.expectError(error.BadPayload, decodeCmdState(&[_]u8{0} ** (cmd_state_len + 1)));
    var bad = encodeCmdState(.{ .phase = .at_prompt, .mechanism = .settle, .exit_code = null, .start_row = 0, .end_row = 0, .seq = 0 });
    bad[0] = 9; // phase out of range
    try std.testing.expectError(error.BadPayload, decodeCmdState(&bad));
    bad[0] = 0;
    bad[1] = 9; // mechanism out of range
    try std.testing.expectError(error.BadPayload, decodeCmdState(&bad));
}

test "await_req encode/decode round trip" {
    const r = try decodeAwaitReq(&encodeAwaitReq(.{ .since_seq = 77, .settle_ms = 500, .timeout_ms = 30_000 }));
    try std.testing.expectEqual(@as(u64, 77), r.since_seq);
    try std.testing.expectEqual(@as(u32, 500), r.settle_ms);
    try std.testing.expectEqual(@as(u32, 30_000), r.timeout_ms);
    try std.testing.expectError(error.BadPayload, decodeAwaitReq(&[_]u8{0} ** 15));
}

test "encodeAwaitReq golden bytes" {
    try std.testing.expectEqualSlices(u8, &[_]u8{
        0x02, 0x01, 0, 0, 0, 0, 0, 0, // since_seq 258
        0xF4, 0x01, 0, 0, // settle_ms 500
        0x30, 0x75, 0, 0, // timeout_ms 30000
    }, &encodeAwaitReq(.{ .since_seq = 258, .settle_ms = 500, .timeout_ms = 30_000 }));
}

test "await_reply is a CmdState plus a reason byte" {
    const s = CmdState{ .phase = .returned, .mechanism = .settle, .exit_code = null, .start_row = 0, .end_row = 3, .seq = 4 };
    const buf = encodeAwaitReply(s, .settled);
    try std.testing.expectEqual(@as(usize, await_reply_len), buf.len);
    const back = try decodeAwaitReply(&buf);
    try std.testing.expectEqual(AwaitReason.settled, back.reason);
    try std.testing.expectEqual(s, back.state);
    try std.testing.expectError(error.BadPayload, decodeAwaitReply(&[_]u8{0} ** (await_reply_len - 1)));
    try std.testing.expectError(error.BadPayload, decodeAwaitReply(&[_]u8{0} ** (await_reply_len + 1)));
    var bad = buf;
    bad[cmd_state_len] = 9;
    try std.testing.expectError(error.BadPayload, decodeAwaitReply(&bad));
}

test "status_reply encode/decode round trip" {
    const s = StatusReply{
        .cols = 120,
        .rows = 40,
        .cursor_x = 3,
        .cursor_y = 5,
        .history_rows = 77,
        .alt_screen = true,
        .mode = .{ .icanon = true, .echo = true },
        .cmd = .{ .phase = .at_prompt, .mechanism = .marks, .exit_code = 0, .start_row = 1, .end_row = 2, .seq = 6 },
    };
    const back = try decodeStatusReply(&encodeStatusReply(s));
    try std.testing.expectEqual(s, back);
    try std.testing.expectError(error.BadPayload, decodeStatusReply(&[_]u8{0} ** (status_reply_len - 1)));
    try std.testing.expectError(error.BadPayload, decodeStatusReply(&[_]u8{0} ** (status_reply_len + 1)));
}

test "encodeStatusReply pins the 14-byte prefix layout" {
    const s = StatusReply{
        .cols = 120,
        .rows = 40,
        .cursor_x = 3,
        .cursor_y = 5,
        .history_rows = 77,
        .alt_screen = true,
        .mode = .{ .icanon = true, .echo = true },
        .cmd = .{ .phase = .at_prompt, .mechanism = .marks, .exit_code = 0, .start_row = 1, .end_row = 2, .seq = 6 },
    };
    const buf = encodeStatusReply(s);
    try std.testing.expectEqual(@as(usize, status_reply_len), buf.len);
    // The trailing CmdState is already golden-tested via encodeCmdState;
    // pinning the 14-byte prefix plus total length is enough here.
    try std.testing.expectEqualSlices(u8, &[_]u8{
        0x78, 0x00, // cols 120
        0x28, 0x00, // rows 40
        0x03, 0x00, // cursor_x 3
        0x05, 0x00, // cursor_y 5
        0x4D, 0x00, 0x00, 0x00, // history_rows 77
        1, // alt_screen
        0b11, // mode: icanon + echo
    }, buf[0..14]);
}

test "term_event: clipboard round-trips and matches golden bytes" {
    const alloc = std.testing.allocator;
    var buf: std.ArrayList(u8) = .empty;
    defer buf.deinit(alloc);

    var payload: std.ArrayList(u8) = .empty;
    defer payload.deinit(alloc);
    try encodeClipboardEvent(&payload, alloc, 'c', "aGk=");
    try appendFrame(&buf, alloc, .term_event, payload.items);

    // 0x8f type, LE len=6, kind byte 0 (clipboard), target 'c', then base64.
    try std.testing.expectEqualSlices(
        u8,
        &.{ 0x8f, 0x06, 0x00, 0x00, 0x00, 0x00, 'c', 'a', 'G', 'k', '=' },
        buf.items,
    );

    const ev = try decodeTermEvent(buf.items[5..]);
    try std.testing.expectEqual(TermEvent.Kind.clipboard, @as(TermEvent.Kind, ev));
    try std.testing.expectEqual(@as(u8, 'c'), ev.clipboard.target);
    try std.testing.expectEqualStrings("aGk=", ev.clipboard.base64);
}

test "term_event: bell is a kind byte and nothing else" {
    const alloc = std.testing.allocator;
    var payload: std.ArrayList(u8) = .empty;
    defer payload.deinit(alloc);
    try encodeBellEvent(&payload, alloc);
    try std.testing.expectEqualSlices(u8, &.{0x01}, payload.items);

    const ev = try decodeTermEvent(payload.items);
    try std.testing.expectEqual(TermEvent.Kind.bell, @as(TermEvent.Kind, ev));
}

test "term_event: a payload that is truncated, overlong or unknown is refused, never guessed" {
    // Empty: no kind byte at all.
    try std.testing.expectError(error.BadPayload, decodeTermEvent(&[_]u8{}));
    // Clipboard kind with no target byte.
    try std.testing.expectError(error.BadPayload, decodeTermEvent(&[_]u8{0x00}));
    // A kind this version does not know. Refused rather than defaulted:
    // guessing a kind means acting on a payload we cannot parse.
    try std.testing.expectError(error.BadPayload, decodeTermEvent(&[_]u8{0x7e}));
    // Bell is fixed-size: trailing bytes are refused, not dropped, matching
    // every other fixed-size decoder in this file (decodePtyMode and kin).
    try std.testing.expectError(error.BadPayload, decodeTermEvent(&[_]u8{ 0x01, 0xAA, 0xBB }));
}

test "term_modes round-trips and matches golden bytes" {
    const alloc = std.testing.allocator;
    var buf: std.ArrayList(u8) = .empty;
    defer buf.deinit(alloc);
    try appendFrame(&buf, alloc, .term_modes, &encodeTermModes(.{ .bracketed_paste = true }));
    // 0x8d type, LE len=4, then the LE bitset with bit0 set.
    try std.testing.expectEqualSlices(
        u8,
        &.{ 0x8d, 0x04, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00 },
        buf.items,
    );

    const m = try decodeTermModes(buf.items[5..]);
    try std.testing.expect(m.bracketed_paste);
    try std.testing.expect(!(try decodeTermModes(&[_]u8{ 0x00, 0x00, 0x00, 0x00 })).bracketed_paste);
}

test "term_modes: the mouse bits are one per DEC mode, and only tracking is ownership" {
    // Bit positions are wire, so they are pinned as bytes rather than as
    // field reads: 1006 (SGR reports) is bit 6, i.e. 0x40.
    try std.testing.expectEqualSlices(
        u8,
        &.{ 0x40, 0x00, 0x00, 0x00 },
        &encodeTermModes(.{ .bracketed_paste = false, .mouse_sgr = true }),
    );
    const sgr_only = try decodeTermModes(&[_]u8{ 0x40, 0x00, 0x00, 0x00 });
    try std.testing.expect(sgr_only.mouse_sgr);
    // A format with no tracking mode is an application that named a
    // spelling and asked for nothing to spell: the wheel is still the
    // client's. This is the bit the client's hijack decision hangs on.
    try std.testing.expect(!sgr_only.appMouse());

    // 1000 is bit 2 (0x04), 1002 bit 3 (0x08): vim's `mouse=a` set.
    const vim = try decodeTermModes(&[_]u8{ 0x4c, 0x00, 0x00, 0x00 });
    try std.testing.expect(vim.mouse_normal and vim.mouse_button and vim.mouse_sgr);
    try std.testing.expect(vim.appMouse());
    try std.testing.expect(!vim.bracketed_paste);

    // Every table entry names a field that exists, and the order of the
    // table is the order of the bits — the client writes DECSET from one
    // and reads the other.
    inline for (mouse_modes, 0..) |m, i| {
        const one: u32 = @as(u32, 1) << @intCast(i + 1);
        const decoded: TermModes = @bitCast(one);
        try std.testing.expect(@field(decoded, m.field));
    }
}

test "term_modes: alt_screen and cursor_keys round-trip and are off by default" {
    // The client's wheel rule used to ask its replica engine whether the
    // alternate screen was up and whether DECCKM was set. Carrying both as
    // sampled bits is what lets a client decide without an engine.
    const on = encodeTermModes(.{ .bracketed_paste = false, .alt_screen = true, .cursor_keys = true });
    const back = try decodeTermModes(&on);
    try std.testing.expect(back.alt_screen);
    try std.testing.expect(back.cursor_keys);
    try std.testing.expect(!back.bracketed_paste);
    const none = try decodeTermModes(&encodeTermModes(.{ .bracketed_paste = false }));
    try std.testing.expect(!none.alt_screen and !none.cursor_keys);
}

test "term_modes: reserved bits go out zero and come back ignored" {
    // Reserved bits are what let focus reporting and cursor shape land
    // later without a new frame type or a version check, so both halves
    // are pinned: we never SET one, and we never choke on one a future
    // daemon set.
    try std.testing.expectEqualSlices(
        u8,
        &.{ 0x00, 0x00, 0x00, 0x00 },
        &encodeTermModes(.{ .bracketed_paste = false }),
    );
    const m = try decodeTermModes(&[_]u8{ 0x01, 0x00, 0x00, 0xF0 });
    try std.testing.expect(m.bracketed_paste);

    // A value we CONSTRUCT has them zero; one that came off the wire keeps
    // what the peer sent, so a client that ever echoes modes back cannot
    // silently downgrade a newer daemon's.
    try std.testing.expectEqualSlices(u8, &.{ 0x01, 0x00, 0x00, 0xF0 }, &encodeTermModes(m));
}

test "term_title round-trips and matches golden bytes" {
    const alloc = std.testing.allocator;
    var buf: std.ArrayList(u8) = .empty;
    defer buf.deinit(alloc);
    try appendFrame(&buf, alloc, .term_title, "vim");
    // 0x8e type, LE len=3, then the title's bytes verbatim: the payload has
    // no structure of its own to encode or decode.
    try std.testing.expectEqualSlices(
        u8,
        &.{ 0x8e, 0x03, 0x00, 0x00, 0x00, 'v', 'i', 'm' },
        buf.items,
    );
    try std.testing.expectEqualStrings("vim", buf.items[5..]);
}

test "term_modes: a payload not exactly four bytes is refused" {
    try std.testing.expectError(error.BadPayload, decodeTermModes(&.{}));
    try std.testing.expectError(error.BadPayload, decodeTermModes(&[_]u8{ 0x01, 0x00 }));
    try std.testing.expectError(error.BadPayload, decodeTermModes(&[_]u8{ 0x01, 0x00, 0x00, 0x00, 0x00 }));
}

test "selection request has a fixed little-endian wire layout" {
    const req = SelectionReq{
        .id = 0x01020304,
        .anchor = .{ .row = 0x11121314, .col = 0x2122 },
        .active = .{ .row = 0x31323334, .col = 0x4142 },
    };
    const bytes = encodeSelectionReq(req);
    try std.testing.expectEqualSlices(u8, &.{
        0x04, 0x03, 0x02, 0x01,
        0x14, 0x13, 0x12, 0x11,
        0x22, 0x21, 0x34, 0x33,
        0x32, 0x31, 0x42, 0x41,
    }, bytes[0..16]);
    try std.testing.expectEqualSlices(u8, &(@as([21]u8, @splat(0))), bytes[16..]);
    try std.testing.expectEqualDeep(req, try decodeSelectionReq(&bytes));
    try std.testing.expectError(error.BadPayload, decodeSelectionReq(bytes[0..15]));
    var long: [selection_req_len + 1]u8 = .{0} ** (selection_req_len + 1);
    @memcpy(long[0..selection_req_len], &bytes);
    try std.testing.expectError(error.BadPayload, decodeSelectionReq(&long));
}

test "selection reply validates status and text shape" {
    const alloc = std.testing.allocator;
    var payload: std.ArrayList(u8) = .empty;
    defer payload.deinit(alloc);

    try encodeSelectionReply(&payload, alloc, .{ .id = 7, .status = .ok, .history_rows = 0x11223344, .text = "hello" });
    try std.testing.expectEqualSlices(
        u8,
        &.{ 7, 0, 0, 0, 0, 0x44, 0x33, 0x22, 0x11 },
        payload.items[0..9],
    );
    const ok = try decodeSelectionReply(payload.items);
    try std.testing.expectEqual(@as(u32, 7), ok.id);
    try std.testing.expectEqual(SelectionStatus.ok, ok.status);
    try std.testing.expectEqual(@as(u32, 0x11223344), ok.history_rows);
    try std.testing.expectEqualStrings("hello", ok.text);

    payload.clearRetainingCapacity();
    try encodeSelectionReply(&payload, alloc, .{ .id = 9, .status = .invalid, .history_rows = 5, .text = "" });
    try std.testing.expectEqualSlices(u8, &.{ 9, 0, 0, 0, 1, 5, 0, 0, 0 }, payload.items[0..9]);
    const invalid = try decodeSelectionReply(payload.items);
    try std.testing.expectEqual(@as(u32, 9), invalid.id);
    try std.testing.expectEqual(SelectionStatus.invalid, invalid.status);
    try std.testing.expectEqual(@as(u32, 5), invalid.history_rows);
    try std.testing.expectEqual(@as(usize, 0), invalid.text.len);

    payload.items[4] = 0xff;
    try std.testing.expectError(error.BadPayload, decodeSelectionReply(payload.items));
    payload.items[4] = @intFromEnum(SelectionStatus.invalid);
    try payload.append(alloc, 'x');
    try std.testing.expectError(error.BadPayload, decodeSelectionReply(payload.items));
    payload.items[4] = @intFromEnum(SelectionStatus.ok);
    payload.items[selection_reply_prefix_len] = 0xff;
    try std.testing.expectError(error.BadPayload, decodeSelectionReply(payload.items));

    try std.testing.expectError(error.BadPayload, decodeSelectionReply(&.{ 1, 0, 0, 0, 0xff, 0, 0, 0, 0 }));
    try std.testing.expectError(error.BadPayload, decodeSelectionReply(&.{ 1, 0, 0, 0, 1, 0, 0, 0, 0, 'x' }));
    try std.testing.expectError(error.BadPayload, decodeSelectionReply(&.{ 1, 0, 0, 0, 0, 0, 0, 0, 0, 0xff }));

    // The watermark is part of the prefix, so a reply carrying only the
    // pre-watermark five bytes is short, not a legacy reply to interpret.
    const prefix: [selection_reply_prefix_len]u8 = @splat(0);
    for (0..selection_reply_prefix_len) |len| {
        try std.testing.expectError(error.BadPayload, decodeSelectionReply(prefix[0..len]));
    }
}

test "selection tracking has one request and reply codec with guarded action shapes" {
    var req: SelectionReq = .{ .action = .start, .id = 7, .gesture = 9, .epoch = 11, .source = 13, .anchor = .{ .row = 70000, .col = 3 }, .active = .{ .row = 70001, .col = 4 } };
    const bytes = encodeSelectionReq(req);
    try std.testing.expectEqualDeep(req, try decodeSelectionReq(&bytes));
    try std.testing.expectEqualSlices(u8, &.{ 1, 9, 0, 0, 0, 11, 0, 0, 0, 0, 0, 0, 0, 13, 0, 0, 0, 0, 0, 0, 0 }, bytes[16..]);
    req.id = 0;
    try std.testing.expectError(error.BadPayload, decodeSelectionReq(&encodeSelectionReq(req)));
    req.action = .clear;
    try std.testing.expectEqualDeep(req, try decodeSelectionReq(&encodeSelectionReq(req)));
    req.gesture = 0;
    try std.testing.expectError(error.BadPayload, decodeSelectionReq(&encodeSelectionReq(req)));
    var bad = bytes;
    bad[16] = 0xff;
    try std.testing.expectError(error.BadPayload, decodeSelectionReq(&bad));

    const reply: SelectionReply = .{ .gesture = 9, .seq = 15, .source = 13, .history_rows = 69999, .status = .ok, .anchor = req.anchor, .active = req.active };
    var out: std.ArrayList(u8) = .empty;
    defer out.deinit(std.testing.allocator);
    try encodeSelectionReply(&out, std.testing.allocator, reply);
    try std.testing.expectEqualDeep(reply, try decodeSelectionReply(out.items));
    try out.append(std.testing.allocator, 'x');
    try std.testing.expectError(error.BadPayload, decodeSelectionReply(out.items));
}

test "selection status discriminants retain exact scalar prefixes" {
    const alloc = std.testing.allocator;
    const cases = [_]struct { status: SelectionStatus, wire: u8 }{
        .{ .status = .ok, .wire = 0 },
        .{ .status = .invalid, .wire = 1 },
        .{ .status = .too_large, .wire = 2 },
        .{ .status = .unavailable, .wire = 3 },
    };
    for (cases) |case| {
        var payload: std.ArrayList(u8) = .empty;
        defer payload.deinit(alloc);
        try encodeSelectionReply(&payload, alloc, .{ .id = 0x01020304, .status = case.status, .history_rows = 0x0a0b0c0d, .text = "" });
        try std.testing.expectEqualSlices(
            u8,
            &.{ 0x04, 0x03, 0x02, 0x01, case.wire, 0x0d, 0x0c, 0x0b, 0x0a },
            payload.items[0..9],
        );
        const reply = try decodeSelectionReply(payload.items);
        try std.testing.expectEqual(@as(u32, 0x01020304), reply.id);
        try std.testing.expectEqual(case.status, reply.status);
        try std.testing.expectEqual(@as(u32, 0x0a0b0c0d), reply.history_rows);
        try std.testing.expectEqual(@as(usize, 0), reply.text.len);
    }
}

test "selection text accepts the exact cap and rejects one byte more" {
    const alloc = std.testing.allocator;
    const at_cap = try alloc.alloc(u8, selection_text_max);
    defer alloc.free(at_cap);
    @memset(at_cap, 'x');

    var payload: std.ArrayList(u8) = .empty;
    defer payload.deinit(alloc);
    try encodeSelectionReply(&payload, alloc, .{ .id = 1, .status = .ok, .history_rows = 0, .text = at_cap });
    try std.testing.expectEqual(selection_reply_prefix_len + selection_text_max, payload.items.len);
    const decoded = try decodeSelectionReply(payload.items);
    try std.testing.expectEqual(selection_text_max, decoded.text.len);

    const over_cap = try alloc.alloc(u8, selection_text_max + 1);
    defer alloc.free(over_cap);
    @memset(over_cap, 'x');
    try std.testing.expectError(error.BadPayload, encodeSelectionReply(&payload, alloc, .{ .id = 1, .status = .ok, .history_rows = 0, .text = over_cap }));

    var oversized_payload = try alloc.alloc(u8, selection_reply_prefix_len + selection_text_max + 1);
    defer alloc.free(oversized_payload);
    std.mem.writeInt(u32, oversized_payload[0..4], 1, .little);
    oversized_payload[4] = @intFromEnum(SelectionStatus.ok);
    std.mem.writeInt(u32, oversized_payload[5..9], 0, .little);
    @memset(oversized_payload[selection_reply_prefix_len..], 'x');
    try std.testing.expectError(error.BadPayload, decodeSelectionReply(oversized_payload));
}

test "selection reply validation errors do not modify a reused output buffer" {
    const alloc = std.testing.allocator;
    var payload: std.ArrayList(u8) = .empty;
    defer payload.deinit(alloc);
    try payload.appendSlice(alloc, "sentinel");

    try std.testing.expectError(error.BadPayload, encodeSelectionReply(&payload, alloc, .{ .id = 1, .status = .invalid, .history_rows = 0, .text = "x" }));
    try std.testing.expectEqualStrings("sentinel", payload.items);
    try std.testing.expectError(error.BadPayload, encodeSelectionReply(&payload, alloc, .{ .id = 1, .status = .ok, .history_rows = 0, .text = &.{0xff} }));
    try std.testing.expectEqualStrings("sentinel", payload.items);

    const over_cap = try alloc.alloc(u8, selection_text_max + 1);
    defer alloc.free(over_cap);
    @memset(over_cap, 'x');
    try std.testing.expectError(error.BadPayload, encodeSelectionReply(&payload, alloc, .{ .id = 1, .status = .ok, .history_rows = 0, .text = over_cap }));
    try std.testing.expectEqualStrings("sentinel", payload.items);
}

test "decoded selection text borrows the payload" {
    var payload = [_]u8{ 7, 0, 0, 0, 0, 3, 0, 0, 0 } ++ [_]u8{0} ** 32 ++ [_]u8{ 'o', 'n', 'e' };
    const reply = try decodeSelectionReply(&payload);
    try std.testing.expectEqualStrings("one", reply.text);
    payload[selection_reply_prefix_len] = 'O';
    try std.testing.expectEqualStrings("One", reply.text);
}

test "selection message values and fixed lengths are pinned" {
    try std.testing.expectEqual(@as(u8, 0x0b), @intFromEnum(MsgType.selection_req));
    try std.testing.expectEqual(@as(u8, 0x90), @intFromEnum(MsgType.selection_reply));
    try std.testing.expectEqual(@as(usize, 37), selection_req_len);
    try std.testing.expectEqual(@as(usize, 41), selection_reply_prefix_len);
    try std.testing.expectEqual(@as(usize, 1024 * 1024), selection_text_max);
}

test "agent message values are pinned" {
    // Wire compatibility: these bytes are forever. 0x83 stays a hole — a
    // retired type's byte is never reused; data/close sit in the low range
    // despite flowing both ways — direction is which end reads, not the high bit.
    try std.testing.expectEqual(@as(u8, 0x0d), @intFromEnum(MsgType.agent_offer));
    try std.testing.expectEqual(@as(u8, 0x0e), @intFromEnum(MsgType.agent_data));
    try std.testing.expectEqual(@as(u8, 0x0f), @intFromEnum(MsgType.agent_close));
    try std.testing.expectEqual(@as(u8, 0x92), @intFromEnum(MsgType.agent_open));
}

test "agent_open/agent_data round-trip through writeFrame/readFrame" {
    const alloc = std.testing.allocator;
    const p = try std.posix.pipe();
    defer std.posix.close(p[0]);
    defer std.posix.close(p[1]);

    try writeFrame(p[1], .agent_open, &encodeAgentId(7));
    var data: [agent_id_len + 3]u8 = undefined;
    @memcpy(data[0..agent_id_len], &encodeAgentId(7));
    @memcpy(data[agent_id_len..], "abc");
    try writeFrame(p[1], .agent_data, &data);

    const open = (try readFrame(alloc, p[0])).?;
    defer open.deinit(alloc);
    try std.testing.expectEqual(MsgType.agent_open, open.type);
    try std.testing.expectEqual(@as(u32, 7), try decodeAgentId(open.payload));

    const d = (try readFrame(alloc, p[0])).?;
    defer d.deinit(alloc);
    try std.testing.expectEqual(@as(u32, 7), try decodeAgentId(d.payload));
    try std.testing.expectEqualSlices(u8, "abc", d.payload[agent_id_len..]);
}

test "decodeAgentId refuses a short payload" {
    try std.testing.expectError(error.BadPayload, decodeAgentId("abc"));
}

test "encodeUpgradeReq/parseUpgradeReq round-trip preserves flag, version, path" {
    var buf: [256]u8 = undefined;
    const encoded = try encodeUpgradeReq(&buf, .{
        .allow_same_version = true,
        .version = "0.0.1-14",
        .path = "/home/user/bin/mux",
    });
    const decoded = try parseUpgradeReq(encoded);
    try std.testing.expect(decoded.allow_same_version);
    try std.testing.expectEqualStrings("0.0.1-14", decoded.version);
    try std.testing.expectEqualStrings("/home/user/bin/mux", decoded.path);
}

test "parseUpgradeReq: a payload with no NUL is BadPayload" {
    // flag + version bytes, but no NUL terminator before the path
    try std.testing.expectError(error.BadPayload, parseUpgradeReq(&.{ 0, '0', '.', '0', '.', '1' }));
}

test "parseUpgradeReq: a relative path is BadPayload" {
    // Manually crafted: flag ++ version ++ NUL ++ relative-path
    const bad = &[_]u8{ 0, '0', '.', '0', '.', '1', 0, 'r', 'e', 'l', '/', 'm' };
    try std.testing.expectError(error.BadPayload, parseUpgradeReq(bad));
}

test "encodeUpgradeReply/parseUpgradeReply: accepted, refused with words, and no status byte" {
    var buf: [upgrade_reply_max_len]u8 = undefined;

    const ok = parseUpgradeReply(encodeUpgradeReply(&buf, .{ .ok = true, .reason = "" })) orelse
        return error.ReplyDidNotParse;
    try std.testing.expect(ok.ok);
    try std.testing.expectEqualStrings("", ok.reason);

    const wire = encodeUpgradeReply(&buf, .{ .ok = false, .reason = "version: output mismatch" });
    try std.testing.expectEqual(@as(u8, 1), wire[0]);
    const no = parseUpgradeReply(wire) orelse return error.ReplyDidNotParse;
    try std.testing.expect(!no.ok);
    // The client matches this string to add its rename hint, so the reason
    // must survive the round trip byte for byte.
    try std.testing.expectEqualStrings("version: output mismatch", no.reason);

    // An empty payload is not an accepted upgrade: without a status byte
    // there is no answer to read, and reading one as "accepted" would tell a
    // user their daemon exec'd when nothing did.
    try std.testing.expect(parseUpgradeReply("") == null);

    // A reason past the buffer is cut, never refused and never overrun.
    var small: [8]u8 = undefined;
    const cut = parseUpgradeReply(encodeUpgradeReply(&small, .{ .ok = false, .reason = "0123456789" })) orelse
        return error.ReplyDidNotParse;
    try std.testing.expectEqualStrings("0123456", cut.reason);
}

test "parseUpgradeReq: an empty version is BadPayload" {
    // Manually crafted: flag ++ NUL ++ path (version is zero-length)
    const bad = &[_]u8{ 0, 0, '/', 'a' };
    try std.testing.expectError(error.BadPayload, parseUpgradeReq(bad));
}

// Forces semantic analysis of every pub decl under `zig build test`, so an
// unreferenced decl must at least compile (the silent-module-loss hazard,
// decisions.md). Pub decls only: std.meta.declarations sees nothing private.
test {
    std.testing.refAllDeclsRecursive(@This());
}

test "agentDataOversize draws the line at one full frame" {
    // The exact boundary, because both ends size a buffer from it: a frame
    // carrying `agent_data_max` bytes is the largest either shipped sender
    // produces and must pass, one byte more is a peer that does not follow
    // the rule.
    var full: [agent_id_len + agent_data_max]u8 = undefined;
    try std.testing.expect(!agentDataOversize(&full));
    var over: [agent_id_len + agent_data_max + 1]u8 = undefined;
    try std.testing.expect(agentDataOversize(&over));
    try std.testing.expect(!agentDataOversize(full[0..agent_id_len]));
}

/// std.posix has no socketpair on the pinned 0.15.2, and these two tests
/// need a socket whose peer they can refuse to read.
fn testSocketPair() ![2]std.posix.fd_t {
    var fds: [2]std.posix.fd_t = undefined;
    const rc = std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &fds);
    if (rc != 0) return error.SocketPairFailed;
    return fds;
}

test "writeFrameBounded: a peer that stopped reading costs the budget, not the caller's life" {
    // The daemon answers observers from its single loop. An unbounded write
    // to a socket whose peer never reads stops every session on the box, so
    // the write spends a budget and then says so.
    const fds = try testSocketPair();
    defer std.posix.close(fds[0]);
    defer std.posix.close(fds[1]);
    // Nonblocking, as `acceptConn` makes an observer's fd: without it the
    // kernel blocks inside write(2) and no budget can be observed at all.
    const fl = try std.posix.fcntl(fds[0], std.posix.F.GETFL, 0);
    _ = try std.posix.fcntl(fds[0], std.posix.F.SETFL, fl | @as(u32, 1 << @bitOffsetOf(std.posix.O, "NONBLOCK")));
    // Far more than any socket buffer, and nothing ever reads fds[1].
    const big = try std.testing.allocator.alloc(u8, 8 * 1024 * 1024);
    defer std.testing.allocator.free(big);
    @memset(big, 'x');
    const t0 = std.time.milliTimestamp();
    try std.testing.expectError(error.WouldBlock, writeFrameBounded(fds[0], .dump_reply, big, 50));
    const spent = std.time.milliTimestamp() - t0;
    try std.testing.expect(spent < 2000);
}

test "writeFrameBounded: a peer that reads gets every byte, short writes and all" {
    const pair = try testSocketPair();
    defer std.posix.close(pair[0]);
    defer std.posix.close(pair[1]);
    const fl = try std.posix.fcntl(pair[0], std.posix.F.GETFL, 0);
    _ = try std.posix.fcntl(pair[0], std.posix.F.SETFL, fl | @as(u32, 1 << @bitOffsetOf(std.posix.O, "NONBLOCK")));
    const payload = "0\nx\ny";
    try writeFrameBounded(pair[0], .sessions_reply, payload, 250);
    const got = (try readFrame(std.testing.allocator, pair[1])).?;
    defer got.deinit(std.testing.allocator);
    try std.testing.expectEqual(MsgType.sessions_reply, got.type);
    try std.testing.expectEqualStrings(payload, got.payload);
}

test "cellrow: two default ascii cells are one ascii run" {
    const alloc = std.testing.allocator;
    var list: std.ArrayList(u8) = .empty;
    defer list.deinit(alloc);
    var w = try CellRowWriter.begin(&list, alloc);
    try w.cell(.{}, .narrow, "a");
    try w.cell(.{}, .narrow, "b");
    w.finish();
    // ncells=2; run: count=2, mask=ascii and nothing else, since the row
    // opens at the default style and this run is it; then "ab".
    try std.testing.expectEqualSlices(u8, &[_]u8{
        2, 0, // ncells
        2,   0,   0x80, // run header
        'a', 'b',
    }, list.items);
}

test "cellrow: an ascii run carrying a control byte is refused" {
    // The wire is hand-buildable by whatever is on the far side, so the run
    // header can claim ascii over a byte the writer would never have put
    // there. The reader applies the writer's own predicate rather than
    // trusting the flag.
    const alloc = std.testing.allocator;
    var list: std.ArrayList(u8) = .empty;
    defer list.deinit(alloc);
    try list.appendSlice(alloc, &[_]u8{ 1, 0, 1, 0, 0x80, 0x1b });
    var r = try CellRowReader.init(list.items);
    try std.testing.expectError(error.BadPayload, r.next());
}

test "cellrow: head-form controls become visible replacements without shifting cells or rows" {
    const alloc = std.testing.allocator;
    var list: std.ArrayList(u8) = .empty;
    defer list.deinit(alloc);
    const unsafe = "a\x1b[31mb";
    // Two styled cells, first wide with an embedded ESC. This is an old
    // daemon's wire: the current writer would normalize it before sending.
    try list.appendSlice(alloc, &.{ 2, 0, 2, 0, mask_flags, 1, 0, (1 << 6) | unsafe.len });
    try list.appendSlice(alloc, unsafe);
    try list.appendSlice(alloc, &.{ 1, 'z', 1, 0, 1, 0, mask_ascii, 'Y' });
    var r = try CellRowReader.init(list.items);
    const replaced = (try r.next()).?;
    try std.testing.expectEqualStrings("\xef\xbf\xbd", replaced.text);
    try std.testing.expectEqual(Wide.wide, replaced.wide);
    try std.testing.expectEqual(@as(u16, 1), replaced.style.flags);
    try std.testing.expectEqualStrings("z", (try r.next()).?.text);
    try std.testing.expectEqual(@as(?DecodedCell, null), try r.next());
    var next = try CellRowReader.init(r.remaining());
    try std.testing.expectEqualStrings("Y", (try next.next()).?.text);
    try std.testing.expectEqual(@as(?DecodedCell, null), try next.next());
    try std.testing.expectEqual(@as(usize, 0), next.remaining().len);
}

test "cellrow: writer and old-wire reader replace every C0 and DEL control" {
    const alloc = std.testing.allocator;
    for (0..33) |i| {
        const control: u8 = if (i == 32) 0x7f else @intCast(i);
        var list: std.ArrayList(u8) = .empty;
        defer list.deinit(alloc);
        var w = try CellRowWriter.begin(&list, alloc);
        try w.cell(.{}, .narrow, &.{control});
        w.finish();
        try std.testing.expectEqualSlices(u8, &.{ 1, 0, 1, 0, 0, 3, 0xef, 0xbf, 0xbd }, list.items);
        var old = try CellRowReader.init(&.{ 1, 0, 1, 0, 0, 1, control });
        try std.testing.expectEqualStrings("\xef\xbf\xbd", (try old.next()).?.text);
        try std.testing.expectEqual(@as(?DecodedCell, null), try old.next());
        try std.testing.expectEqual(@as(usize, 0), old.remaining().len);
    }
}

test "cellrow: an OSC 52 spelled across cells never decodes into a row" {
    // The trust boundary this check exists for. The client has no VT parser:
    // paint.rowToVtFrom writes a cell's text to the user's real terminal
    // verbatim, so a daemon that spread an escape sequence one byte per cell
    // would be typing on that user's screen — here, a clipboard write. The
    // row must be refused, not decoded.
    const alloc = std.testing.allocator;
    var list: std.ArrayList(u8) = .empty;
    defer list.deinit(alloc);
    const osc = "\x1b]52;c;bXV4\x07";
    try list.appendSlice(alloc, &[_]u8{ @intCast(osc.len), 0 });
    try list.appendSlice(alloc, &[_]u8{ @intCast(osc.len), 0, 0x80 });
    try list.appendSlice(alloc, osc);

    var r = try CellRowReader.init(list.items);
    var decoded: usize = 0;
    const refused = while (decoded <= osc.len) {
        const cell_or_end = r.next() catch break true;
        if (cell_or_end == null) break false;
        decoded += 1;
    } else false;
    if (!refused) {
        std.debug.print(
            "an OSC 52 clipboard write spelled across {d} cells decoded as a row; " ++
                "the client would write those bytes to the user's terminal\n",
            .{decoded},
        );
        return error.EscapeSequenceDecoded;
    }
}

test "cellrow: the writer owns each cell's text, so a reused caller buffer is safe" {
    const alloc = std.testing.allocator;
    var list: std.ArrayList(u8) = .empty;
    defer list.deinit(alloc);
    // An encoder renders each cell into one reused stack buffer; the run holds
    // its cells until the style changes, so the writer must copy the bytes
    // rather than remember the caller's slice — a borrowed slice would emit
    // the last cell's glyph for every cell of the run.
    var buf: [4]u8 = undefined;
    var w = try CellRowWriter.begin(&list, alloc);
    buf[0] = 'a';
    try w.cell(.{}, .narrow, buf[0..1]);
    buf[0] = 'b';
    try w.cell(.{}, .narrow, buf[0..1]);
    w.finish();
    var r = try CellRowReader.init(list.items);
    try std.testing.expectEqualStrings("a", (try r.next()).?.text);
    try std.testing.expectEqualStrings("b", (try r.next()).?.text);
}

test "cellrow: a wide glyph and its spacer, a grapheme, and a styled run round-trip" {
    const alloc = std.testing.allocator;
    var list: std.ArrayList(u8) = .empty;
    defer list.deinit(alloc);
    const red: CellStyle = .{ .fg = colorPalette(1), .flags = 1 }; // bold red
    var w = try CellRowWriter.begin(&list, alloc);
    try w.cell(.{}, .wide, "漢");
    try w.cell(.{}, .spacer_tail, "");
    try w.cell(.{}, .narrow, "e\u{301}");
    try w.cell(red, .narrow, "x");
    try w.cell(red, .narrow, "");
    w.finish();

    var r = try CellRowReader.init(list.items);
    try std.testing.expectEqual(@as(u16, 5), r.ncells);
    const c0 = (try r.next()).?;
    try std.testing.expectEqual(Wide.wide, c0.wide);
    try std.testing.expectEqualStrings("漢", c0.text);
    const c1 = (try r.next()).?;
    try std.testing.expectEqual(Wide.spacer_tail, c1.wide);
    try std.testing.expectEqualStrings("", c1.text);
    const c2 = (try r.next()).?;
    try std.testing.expectEqualStrings("e\u{301}", c2.text);
    const c3 = (try r.next()).?;
    try std.testing.expect(c3.style.eql(red));
    try std.testing.expectEqualStrings("x", c3.text);
    const c4 = (try r.next()).?;
    try std.testing.expect(c4.style.eql(red));
    try std.testing.expectEqualStrings("", c4.text);
    try std.testing.expectEqual(@as(?DecodedCell, null), try r.next());
    try std.testing.expectEqual(@as(usize, 0), r.remaining().len);
}

test "cellrow: an ascii run is only taken when every cell qualifies" {
    const alloc = std.testing.allocator;
    var list: std.ArrayList(u8) = .empty;
    defer list.deinit(alloc);
    var w = try CellRowWriter.begin(&list, alloc);
    try w.cell(.{}, .narrow, "a");
    try w.cell(.{}, .narrow, "é"); // 2 bytes: breaks the ascii form for the whole run
    w.finish();
    // ncells=2; run: count=2, mask=0 (default style, not ascii); cells:
    // head 1 'a', head 2 0xC3 0xA9
    try std.testing.expectEqualSlices(u8, &[_]u8{
        2, 0, // ncells
        2,    0,    0, // run header
        1,    'a',  2,
        0xC3, 0xA9,
    }, list.items);
}

test "cellrow: a run header carries only what changed since the run before it" {
    const alloc = std.testing.allocator;
    var list: std.ArrayList(u8) = .empty;
    defer list.deinit(alloc);
    const red: CellStyle = .{ .fg = colorPalette(1) };
    const bold_red: CellStyle = .{ .fg = colorPalette(1), .flags = 1 };
    const on_rgb: CellStyle = .{ .fg = colorPalette(1), .flags = 1, .bg = colorRgb(1, 2, 3) };
    var w = try CellRowWriter.begin(&list, alloc);
    try w.cell(.{}, .narrow, "a");
    try w.cell(.{}, .narrow, "b");
    try w.cell(red, .narrow, "c");
    try w.cell(bold_red, .narrow, "d");
    try w.cell(on_rgb, .narrow, "e");
    w.finish();
    // The third run turns bold on and keeps the red: its header names the
    // flags and NOT the foreground, which is the whole point of the mask.
    // The fourth adds a background and repeats neither of them.
    try std.testing.expectEqualSlices(u8, &[_]u8{
        5, 0, // ncells
        2,   0,   0x80, // run 1: default style, ascii
        'a', 'b',
        1, 0, 0x82, // run 2: ascii, fg changed
        0x01, 0x01, // fg: palette, index 1
        'c',
        1, 0, 0x81, // run 3: ascii, flags changed — fg is unsaid
        0x01, 0x00, // flags: bold
        'd',
        1, 0, 0x84, // run 4: ascii, bg changed
        0x02, 0x01, 0x02, 0x03, // bg: rgb 1,2,3
        'e',
    }, list.items);

    var r = try CellRowReader.init(list.items);
    _ = try r.next();
    _ = try r.next();
    try std.testing.expect((try r.next()).?.style.eql(red));
    // The reader carried the red across the header that never repeated it.
    try std.testing.expect((try r.next()).?.style.eql(bold_red));
    try std.testing.expect((try r.next()).?.style.eql(on_rgb));
}

test "cellrow: the reader leaves the bytes after the row alone" {
    const alloc = std.testing.allocator;
    var list: std.ArrayList(u8) = .empty;
    defer list.deinit(alloc);
    var w = try CellRowWriter.begin(&list, alloc);
    try w.cell(.{}, .narrow, "a");
    w.finish();
    try list.appendSlice(alloc, "tail");
    var r = try CellRowReader.init(list.items);
    _ = try r.next();
    try std.testing.expectEqual(@as(?DecodedCell, null), try r.next());
    try std.testing.expectEqualStrings("tail", r.remaining());
}

test "cellrow: malformed rows are BadPayload, never a read past the end" {
    // A run that claims more cells than the row's ncells: refused at the
    // run header, before a cell is read.
    var over = try CellRowReader.init(&[_]u8{ 1, 0, 2, 0, 0x80, 'a', 'b' });
    try std.testing.expectError(error.BadPayload, over.next());
    // A cell whose text_len runs past the payload.
    var short_text = try CellRowReader.init(&[_]u8{ 1, 0, 1, 0, 0x00, 5, 'a' });
    try std.testing.expectError(error.BadPayload, short_text.next());
    // A row shorter than its own prefix.
    try std.testing.expectError(error.BadPayload, CellRowReader.init(&[_]u8{1}));
    // A row that ends mid-run header.
    var mid_header = try CellRowReader.init(&[_]u8{ 1, 0, 1, 0 });
    try std.testing.expectError(error.BadPayload, mid_header.next());
    // A mask bit this format has not assigned: the fields it would name are
    // of a length this reader cannot know, so it stops rather than guesses.
    var reserved = try CellRowReader.init(&[_]u8{ 1, 0, 1, 0, 0x10, 'a' });
    try std.testing.expectError(error.BadPayload, reserved.next());
    // A colour tag past RGB, for the same reason.
    var bad_color = try CellRowReader.init(&[_]u8{ 1, 0, 1, 0, 0x02, 3, 0, 0, 0, 'a' });
    try std.testing.expectError(error.BadPayload, bad_color.next());
    // A header whose mask names a colour the payload does not carry.
    var truncated_color = try CellRowReader.init(&[_]u8{ 1, 0, 1, 0, 0x02, 1 });
    try std.testing.expectError(error.BadPayload, truncated_color.next());
}