a73x

docs/superpowers/plans/2026-09-04-cells-on-the-wire.md

Ref:   Size: 77.3 KiB   History

# Cells on the Wire Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** The replay frames carry the daemon's grid as cells, the client copies them into a grid of its own and paints from it, and no client binary links ghostty-vt.

**Architecture:** `protocol.zig` gains a `CellRow` codec (runs of styled, length-prefixed UTF-8 cells). `engine.zig` encodes ghostty's page cells into it on the daemon; a new `grid.zig` decodes into a flat client grid that `Replica` owns; `paint.zig` serializes grid rows to VT for the terminal, and the wasm painter reads the grid where it read ghostty's pages. `term` stops importing ghostty-vt; a new `engine` module row carries it for the daemon and the test oracles.

**Tech Stack:** Zig 0.15.2 (`deps/zig/zig`, vendored), ghostty-vt 1.3.0 (daemon side only after this), `zig build test`, `make check`, `make e2e`, `make bench`.

**Spec:** `docs/superpowers/specs/2026-09-04-cells-on-the-wire-design.md`

## Global Constraints

- Toolchain is `./deps/zig/zig`; system zig does not build this. `make check` before every commit; capture `$?` before piping (`make check; echo rc=$?`).
- There is no `-Dtest-filter` and `zig test FILE` does not link here: run the whole suite, `./deps/zig/zig build test 2>&1 | tail -30`. A `zig build test` that prints nothing for minutes is a test writing to stdout (fd 1 is the runner's protocol stream) — use `std.debug.print` (stderr) in measurement tests, never stdout.
- Commit subjects are `type: what changed`, type ∈ `feat fix refactor test docs build chore`, no parenthesised scope. Intermediate commits inside Task 5 may be `--fixup`; autosquash before delivery.
- `build.zig` rule 4: outside a `test` block, no file under `src/engine/` or `src/client/` spells `\x1b`, `termios`, `isatty`, `tcgetattr`, `tcsetattr` without a `// folder rule 4 exemption:` line. `grid.zig` and the new protocol code must carry no escape byte in production lines. Rule 7: nothing outside `src/os/` spells `std.os.linux`, `/proc`, `memfd`, `close_range`, `exit_group`, `SO_PEERCRED`, `MSG_NOSIGNAL`.
- Comments say why; every cited symbol must resolve (`zig build check` gates references).
- Any hand-run rig exports an isolated `XDG_STATE_HOME` and `XDG_RUNTIME_DIR` first.
- Never quote a speed number from the dev tree; byte counts are fine.
- Frame numbers: `snapshot = 0x95`, `delta = 0x96`, `scrollback_chunk = 0x97`; `0x81`, `0x85`, `0x87` retired and never reused.
- Colour packing in memory: `0` none, `(1 << 24) | index` palette, `(2 << 24) | r << 16 | g << 8 | b` RGB. Style flags bits 0–10 = ghostty `Style.Flags` bit order. Cell head = `wide << 6 | text_len`, `text_len ≤ 63`.
- Run header (revised 2026-09-04 after the Task 2 gate refused the 16-byte absolute form): `u16 LE count` ++ `u8 mask` ++ present fields — mask bit0 `flags` (u16 LE follows), bit1 `fg`, bit2 `bg`, bit3 `ul`, bit7 `ascii`; a present colour is `u8 code` (0 none, 1 palette + 1 index byte, 2 RGB + 3 bytes). Absent fields are UNCHANGED from the previous run of the same row; the first run of a row starts from the default style. Task 1's golden bytes were rewritten to this form in Task 2's fix round.

---

## File map

| File | Responsibility after this plan |
|---|---|
| `src/engine/protocol.zig` | Frame numbers, `CellRow` codec (`CellRowWriter`, `CellRowReader`, `CellStyle`, `Wide`, colour packing), snapshot cursor, `TermModes` bits. No engine, no ghostty. |
| `src/engine/grid.zig` (new) | `Grid`, `Row`, `Cell`; `applyRow`, `decodeRows`, `dumpPlain`, `clipCol`, `snapWide`. Platform-free, wasm-clean. |
| `src/engine/replica.zig` | `Replica` over `*Grid`. |
| `src/engine/term.zig` | Root of `term`: `protocol`, `replica`, `grid`. |
| `src/engine/engine.zig` | Root of the new `engine` module: ghostty wrapper, `encodeViewportRow`, `encodeScrollback`, `mirrorInto`. |
| `src/engine/delta.zig` | Child of `engine`: tracker hashes encoded rows; `buildSnapshot`. |
| `src/server/server.zig` | Three call sites: `buildSnapshotPayload`, `accrueSnapshotEquiv`, `onFetchScrollback`; `sampleTermModes` gains two bits. |
| `src/tui/paint.zig` | `rowToVt` serializer; renders take `*const Grid`. |
| `src/tui/interact.zig` | `Core.rep` over a `Grid`; scrollback pages are decoded rows; wheel rule reads `term_modes`. |
| `src/client/wasm_core.zig` | Grid-backed viewport and scroll view; no `Engine`. |
| `test/wsclient.zig` | Replica over a `Grid`. |
| `src/server/server_test_harness.zig`, `src/tui/wall_test_harness.zig` | Replica side is a `Grid`; screens authored through `Engine.mirrorInto`. |
| `build.zig` | `engine` row; `term` drops ghostty; wasm drops ghostty. |
| `docs/decisions.md`, `CLAUDE.md`, `README.md` | The record, the table, the wire sentence. |

---

### Task 1: The `CellRow` codec in `protocol.zig`

**Files:**
- Modify: `src/engine/protocol.zig` (append after the `DeltaRowIterator` block, before `ComposedDelta`)
- Test: `src/engine/protocol.zig` (tests at the end of the file)

**Interfaces:**
- Produces:
  - `pub const Wide = enum(u2) { narrow = 0, wide = 1, spacer_tail = 2, spacer_head = 3 }`
  - `pub const CellStyle = struct { fg: u32 = 0, bg: u32 = 0, ul: u32 = 0, flags: u16 = 0 }` with `eql` and `isDefault`
  - `pub fn colorPalette(index: u8) u32`, `pub fn colorRgb(r: u8, g: u8, b: u8) u32`, `pub const color_none: u32 = 0`
  - `pub const run_ascii: u16 = 1 << 15`, `pub const run_header_len = 16`, `pub const cell_row_prefix_len = 2`, `pub const cell_text_max = 63`
  - `pub const CellRowWriter` — `begin(list, alloc) !CellRowWriter`, `cell(style, wide, text) !void`, `finish() void`
  - `pub const DecodedCell = struct { style: CellStyle, wide: Wide, text: []const u8 }`
  - `pub const CellRowReader` — `init(bytes) !CellRowReader`, `next() !?DecodedCell`, `remaining() []const u8`, `ncells: u16`

- [ ] **Step 1: Write the failing golden tests**

Append to `src/engine/protocol.zig`:

```zig
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, flags=ascii, fg=bg=ul=0; then "ab".
    try std.testing.expectEqualSlices(u8, &[_]u8{
        2, 0, // ncells
        2, 0, 0x00, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // run header
        'a', 'b',
    }, list.items);
}

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, flags=0; cells: head 1 'a', head 2 0xC3 0xA9
    try std.testing.expectEqualSlices(u8, &[_]u8{
        2, 0,
        2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
        1, 'a', 2, 0xC3, 0xA9,
    }, list.items);
}

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, 0x00, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, '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, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 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, 0 });
    try std.testing.expectError(error.BadPayload, mid_header.next());
}
```

- [ ] **Step 2: Run the suite to see them fail**

Run: `./deps/zig/zig build test 2>&1 | tail -20`
Expected: compile errors naming `CellRowWriter`, `CellRowReader`, `CellStyle`.

- [ ] **Step 3: Implement the codec**

Insert into `src/engine/protocol.zig` after `deltaRowIterator`:

```zig
// ---------------------------------------------------------------------------
// 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(.{});
    }
};

/// Run flag bit: 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 run_ascii: u16 = 1 << 15;
pub const run_header_len = 16;
pub const cell_row_prefix_len = 2;
pub const cell_text_max = 63;

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

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

pub const CellRowWriter = struct {
    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(DecodedCell) = .empty,
    run_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, text: []const u8) !void {
        std.debug.assert(text.len <= cell_text_max);
        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;
        try self.run.append(self.alloc, .{ .style = style, .wide = wide, .text = text });
        self.ncells += 1;
    }

    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, c.text)) {
                ascii = false;
                break;
            }
        }
        var hdr: [run_header_len]u8 = undefined;
        std.mem.writeInt(u16, hdr[0..2], @intCast(cells.len), .little);
        std.mem.writeInt(u16, hdr[2..4], self.run_style.flags | (if (ascii) run_ascii else 0), .little);
        std.mem.writeInt(u32, hdr[4..8], self.run_style.fg, .little);
        std.mem.writeInt(u32, hdr[8..12], self.run_style.bg, .little);
        std.mem.writeInt(u32, hdr[12..16], self.run_style.ul, .little);
        try self.list.appendSlice(self.alloc, &hdr);
        for (cells) |c| {
            if (ascii) {
                try self.list.append(self.alloc, c.text[0]);
            } else {
                const head: u8 = (@as(u8, @intFromEnum(c.wide)) << 6) | @as(u8, @intCast(c.text.len));
                try self.list.append(self.alloc, head);
                try self.list.appendSlice(self.alloc, c.text);
            }
        }
        self.run.clearRetainingCapacity();
    }

    /// 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.run.deinit(self.alloc);
    }
};

pub const CellRowReader = struct {
    rest: []const u8,
    ncells: u16,
    read: u16 = 0,
    run_left: u16 = 0,
    run_style: 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),
        };
    }

    pub fn next(self: *CellRowReader) !?DecodedCell {
        if (self.read == self.ncells) return null;
        if (self.run_left == 0) {
            if (self.rest.len < run_header_len) return error.BadPayload;
            const count = std.mem.readInt(u16, self.rest[0..2], .little);
            const flags = std.mem.readInt(u16, self.rest[2..4], .little);
            // A run may not claim cells the row does not have.
            if (count == 0 or count > self.ncells - self.read) return error.BadPayload;
            self.run_left = count;
            self.run_ascii = flags & run_ascii != 0;
            self.run_style = .{
                .flags = flags & ~run_ascii,
                .fg = std.mem.readInt(u32, self.rest[4..8], .little),
                .bg = std.mem.readInt(u32, self.rest[8..12], .little),
                .ul = std.mem.readInt(u32, self.rest[12..16], .little),
            };
            self.rest = self.rest[run_header_len..];
        }
        self.run_left -= 1;
        self.read += 1;
        if (self.run_ascii) {
            if (self.rest.len < 1) return error.BadPayload;
            const text = self.rest[0..1];
            self.rest = self.rest[1..];
            return .{ .style = self.run_style, .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 = self.rest[1 .. 1 + len];
        self.rest = self.rest[1 + len ..];
        return .{ .style = self.run_style, .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;
    }
};
```

- [ ] **Step 4: Run the suite to see them pass**

Run: `./deps/zig/zig build test 2>&1 | tail -20`
Expected: no failures; the five `cellrow:` tests ran (add a deliberate `expect(false)` to one, see it fail, remove it — a pin that never fired is not a pin).

- [ ] **Step 5: Commit**

```bash
./deps/zig/zig fmt src/engine/protocol.zig
make check; echo rc=$?
git add src/engine/protocol.zig
git commit -m "feat: the CellRow codec, a grid row as runs of styled cells"
```

---

### Task 2: The daemon encoder and the wire-size measurement

**Files:**
- Modify: `src/engine/engine.zig` (after `dumpScrollback`, before `extractSelection`)
- Test: `src/engine/engine.zig`

**Interfaces:**
- Consumes: `proto.CellRowWriter`, `proto.CellStyle`, `proto.Wide`, `proto.colorPalette`, `proto.colorRgb` (Task 1). `engine.zig` reaches them as `@import("protocol.zig")` until Task 6 moves it to `@import("term").protocol`.
- Produces:
  - `pub fn encodeViewportRow(self: *Engine, alloc, y: u16) ![]u8`
  - `pub fn encodeScrollback(self: *Engine, alloc, start: u32, count: u16) !EncodedRows` where `pub const EncodedRows = struct { first: u32, count: u16, bytes: []u8 }` — the clamped window `dumpScrollback` computes today, rows dense
  - `fn packStyle(style: vt.Style) proto.CellStyle` (private)

- [ ] **Step 1: Write the failing round-trip tests**

Append to `src/engine/engine.zig`'s tests:

```zig
fn decodeAll(alloc: std.mem.Allocator, bytes: []const u8) ![]proto.DecodedCell {
    var out: std.ArrayList(proto.DecodedCell) = .empty;
    var r = try proto.CellRowReader.init(bytes);
    while (try r.next()) |c| try out.append(alloc, c);
    return out.toOwnedSlice(alloc);
}

test "encodeViewportRow: ascii, a wide glyph with its spacer, a grapheme, and trailing blanks dropped" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 12, .rows = 2 });
    defer e.deinit();
    e.feed("ab漢e\u{301}");
    const row = try e.encodeViewportRow(alloc, 0);
    defer alloc.free(row);
    const cells = try decodeAll(alloc, row);
    defer alloc.free(cells);
    // a b 漢 (spacer) é — five cells; the seven blanks after are not sent.
    try std.testing.expectEqual(@as(usize, 5), cells.len);
    try std.testing.expectEqualStrings("a", cells[0].text);
    try std.testing.expectEqual(proto.Wide.wide, cells[2].wide);
    try std.testing.expectEqualStrings("漢", cells[2].text);
    try std.testing.expectEqual(proto.Wide.spacer_tail, cells[3].wide);
    try std.testing.expectEqualStrings("e\u{301}", cells[4].text);
}

test "encodeViewportRow: styles pack as the wire says, and a bg-only cell is not blank" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 8, .rows = 1 });
    defer e.deinit();
    // bold red on palette-4 blue 'x', then EL with the blue background held:
    // the cells after x carry a background and no glyph.
    e.feed("\x1b[1;31;44mx\x1b[0m\x1b[44m\x1b[K");
    const row = try e.encodeViewportRow(alloc, 0);
    defer alloc.free(row);
    const cells = try decodeAll(alloc, row);
    defer alloc.free(cells);
    try std.testing.expectEqual(@as(usize, 8), cells.len);
    try std.testing.expectEqual(proto.colorPalette(1), cells[0].style.fg);
    try std.testing.expectEqual(proto.colorPalette(4), cells[0].style.bg);
    try std.testing.expectEqual(@as(u16, 1), cells[0].style.flags & 1);
    try std.testing.expectEqualStrings("", cells[7].text);
    try std.testing.expectEqual(proto.colorPalette(4), cells[7].style.bg);
}

test "encodeViewportRow: an empty row is ncells 0 and nothing else" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 8, .rows = 1 });
    defer e.deinit();
    const row = try e.encodeViewportRow(alloc, 0);
    defer alloc.free(row);
    try std.testing.expectEqualSlices(u8, &[_]u8{ 0, 0 }, row);
}

test "encodeScrollback: clamps like dumpScrollback and returns dense rows" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 8, .rows = 2, .max_scrollback = 10 });
    defer e.deinit();
    e.feed("one\r\ntwo\r\nthree\r\nfour");
    // history: one two; viewport: three four. Ask for 3 rows from 1: two three four.
    const got = try e.encodeScrollback(alloc, 1, 3);
    defer alloc.free(got.bytes);
    try std.testing.expectEqual(@as(u32, 1), got.first);
    try std.testing.expectEqual(@as(u16, 3), got.count);
    var r = try proto.CellRowReader.init(got.bytes);
    try std.testing.expectEqualStrings("t", (try r.next()).?.text);
    // Past the end clamps to what exists.
    const tail = try e.encodeScrollback(alloc, 100, 5);
    defer alloc.free(tail.bytes);
    try std.testing.expectEqual(@as(u32, 3), tail.first);
    try std.testing.expectEqual(@as(u16, 1), tail.count);
}
```

- [ ] **Step 2: Run to see them fail**

Run: `./deps/zig/zig build test 2>&1 | tail -20`
Expected: `no member named 'encodeViewportRow'`.

- [ ] **Step 3: Implement the encoder**

Insert into `Engine` after `dumpScrollback`:

```zig
    pub const EncodedRows = struct { first: u32, count: u16, bytes: []u8 };

    fn packColor(col: anytype) u32 {
        return switch (col) {
            .none => proto.color_none,
            .palette => |p| proto.colorPalette(p),
            .rgb => |c| proto.colorRgb(c.r, c.g, c.b),
        };
    }

    fn packStyle(style: vt.Style) proto.CellStyle {
        return .{
            .fg = packColor(style.fg_color),
            .bg = packColor(style.bg_color),
            .ul = packColor(style.underline_color),
            .flags = @bitCast(style.flags),
        };
    }

    /// One row of the active screen at `pt` as a CellRow. The last cell sent
    /// is the last one that is not a default blank; a bare-background cell
    /// counts as content, or a coloured EL would vanish.
    fn encodeRowAt(self: *Engine, alloc: std.mem.Allocator, pt: vt.point.Point) ![]u8 {
        const screen = self.term.screens.active;
        var list: std.ArrayList(u8) = .empty;
        errdefer list.deinit(alloc);
        var w = try proto.CellRowWriter.begin(&list, alloc);
        const pin = screen.pages.pin(pt) orelse {
            w.finish();
            return list.toOwnedSlice(alloc);
        };
        const page = &pin.node.data;
        const rac = pin.rowAndCell();
        const cells = page.getCells(rac.row);
        const cols: usize = self.term.cols;

        // Find the last cell worth sending.
        var last: usize = 0;
        var any = false;
        for (cells[0..cols], 0..) |c, x| {
            const blank = c.style_id == 0 and c.wide == .narrow and switch (c.content_tag) {
                .codepoint => c.content.codepoint == 0 or c.content.codepoint == ' ',
                .codepoint_grapheme => false,
                .bg_color_palette, .bg_color_rgb => false,
            };
            if (!blank) {
                last = x;
                any = true;
            }
        }
        if (!any) {
            w.finish();
            return list.toOwnedSlice(alloc);
        }

        var text: [proto.cell_text_max]u8 = undefined;
        for (cells[0 .. last + 1]) |*c| {
            var style: proto.CellStyle = if (c.style_id == 0) .{} else packStyle(page.styles.get(page.memory, c.style_id).*);
            var len: usize = 0;
            switch (c.content_tag) {
                .codepoint, .codepoint_grapheme => {
                    if (c.content.codepoint != 0) {
                        len += std.unicode.utf8Encode(@intCast(c.content.codepoint), text[len..]) catch 0;
                    }
                    if (c.hasGrapheme()) {
                        if (page.lookupGrapheme(c)) |cps| {
                            for (cps) |cp| {
                                var buf: [4]u8 = undefined;
                                const n = std.unicode.utf8Encode(@intCast(cp), &buf) catch continue;
                                if (len + n > proto.cell_text_max) break;
                                @memcpy(text[len .. len + n], buf[0..n]);
                                len += n;
                            }
                        }
                    }
                },
                .bg_color_palette => style.bg = proto.colorPalette(c.content.color_palette),
                .bg_color_rgb => style.bg = proto.colorRgb(c.content.color_rgb.r, c.content.color_rgb.g, c.content.color_rgb.b),
            }
            const wide: proto.Wide = switch (c.wide) {
                .narrow => .narrow,
                .wide => .wide,
                .spacer_tail => .spacer_tail,
                .spacer_head => .spacer_head,
            };
            // A space is the same blank as no text; sending it would cost a
            // byte per cell of every padded line.
            const t: []const u8 = if (len == 1 and text[0] == ' ') "" else text[0..len];
            try w.cell(style, wide, t);
        }
        w.finish();
        return list.toOwnedSlice(alloc);
    }

    pub fn encodeViewportRow(self: *Engine, alloc: std.mem.Allocator, y: u16) ![]u8 {
        std.debug.assert(y < self.term.rows);
        return self.encodeRowAt(alloc, .{ .viewport = .{ .x = 0, .y = y } });
    }

    /// Screen-space rows [start, start+count) on the active screen, clamped
    /// to what exists, dense. Row 0 is the oldest retained history row.
    pub fn encodeScrollback(self: *Engine, alloc: std.mem.Allocator, start: u32, count: u16) !EncodedRows {
        const total: u32 = self.historyRows() + self.term.rows;
        const first = @min(start, total -| 1);
        const last = @min(first + count -| 1, total -| 1);
        var list: std.ArrayList(u8) = .empty;
        errdefer list.deinit(alloc);
        var y = first;
        var n: u16 = 0;
        while (y <= last and total > 0) : (y += 1) {
            const row = try self.encodeRowAt(alloc, .{ .screen = .{ .x = 0, .y = y } });
            defer alloc.free(row);
            try list.appendSlice(alloc, row);
            n += 1;
        }
        return .{ .first = first, .count = n, .bytes = try list.toOwnedSlice(alloc) };
    }
```

If `page.getCells`, `page.styles.get(page.memory, id)`, `c.hasGrapheme()` or `page.lookupGrapheme(c)` do not resolve under ghostty 1.3.0, read the same operations in `vt.formatter` (`~/.cache/zig/p/ghostty-1.3.0-*/src/terminal/formatter.zig`, the cell loop around line 660) and spell them the way it does; the four names above are what `PageList.Cell.style()` and the formatter use.

- [ ] **Step 4: Run to see them pass**

Run: `./deps/zig/zig build test 2>&1 | tail -20`
Expected: the four `encode…` tests pass. Break one assertion on purpose, see it fail, restore.

- [ ] **Step 5: Write the measurement test**

Append:

```zig
fn vtBytes(alloc: std.mem.Allocator, e: *Engine) !usize {
    var n: usize = 0;
    var y: u16 = 0;
    while (y < e.term.rows) : (y += 1) {
        const b = try e.dumpVtRow(alloc, y);
        defer alloc.free(b);
        n += b.len;
    }
    return n;
}

fn cellBytes(alloc: std.mem.Allocator, e: *Engine) !usize {
    var n: usize = 0;
    var y: u16 = 0;
    while (y < e.term.rows) : (y += 1) {
        const b = try e.encodeViewportRow(alloc, y);
        defer alloc.free(b);
        n += b.len;
    }
    return n;
}

test "cells: wire size vs VT rows (measurement; the spec's gate reads this)" {
    const alloc = std.testing.allocator;
    const Screen = struct { name: []const u8, feed: []const u8 };
    const prose = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor in\r\n" ** 24;
    const vim = ("\x1b[33m 12 \x1b[0m\x1b[34mfn\x1b[0m main() \x1b[35m{\x1b[0m \x1b[32m// a comment that runs on\x1b[0m \x1b[31mreturn\x1b[0m 0;\r\n") ** 24;
    const htop = ("\x1b[42m 1 \x1b[0m\x1b[7m[||||||||    12.3%]\x1b[0m \x1b[36m1234\x1b[0m \x1b[33mroot\x1b[0m \x1b[1m20\x1b[0m   0 \x1b[32m 12.0\x1b[0m \x1b[31m 0.4\x1b[0m /usr/bin/thing --flag\r\n") ** 24;
    const shell = "$ ls\r\nbuild.zig  docs  src  test\r\n$ \r\n";
    const screens = [_]Screen{
        .{ .name = "prose", .feed = prose },
        .{ .name = "vim", .feed = vim },
        .{ .name = "htop", .feed = htop },
        .{ .name = "shell", .feed = shell },
    };
    for (screens) |s| {
        var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
        defer e.deinit();
        e.feed(s.feed);
        const v = try vtBytes(alloc, e);
        const c = try cellBytes(alloc, e);
        std.debug.print("\ncells-measure {s}: vt={d} cells={d} ratio={d:.2}\n", .{ s.name, v, c, @as(f64, @floatFromInt(c)) / @as(f64, @floatFromInt(v)) });
    }
}
```

- [ ] **Step 6: Run it and record the numbers**

Run: `./deps/zig/zig build test 2>&1 | grep cells-measure`
Expected: four lines. Write them into `docs/decisions.md` under a new dated heading `## 2026-09-04 — cells on the wire: the size measurement`, with the gate verdict: prose ≤ 1.5 and vim/htop ≤ 1.2 means continue; otherwise STOP after committing and report to the user — the spec's alternative (VT frames for terminal clients, cells for native/browser) is a different plan.

- [ ] **Step 7: Commit**

```bash
./deps/zig/zig fmt src/engine/engine.zig
make check; echo rc=$?
git add src/engine/engine.zig docs/decisions.md
git commit -m "feat: the daemon encodes a grid row as cells, and the size is measured"
```

---

### Task 3: `grid.zig`, the client grid, pinned against the engine

**Files:**
- Create: `src/engine/grid.zig`
- Modify: `src/engine/term.zig` (export `grid`), `src/engine/engine.zig` (`mirrorInto` + oracle tests)
- Test: `src/engine/grid.zig`, `src/engine/engine.zig`

**Interfaces:**
- Consumes: `proto.CellRowReader`, `proto.DecodedCell`, `proto.CellStyle`, `proto.Wide` (Task 1); `Engine.encodeViewportRow` (Task 2).
- Produces:
  - `pub const CursorPos = struct { x: u16, y: u16 }`
  - `pub const Cell = struct { style: proto.CellStyle = .{}, wide: proto.Wide = .narrow, text_off: u32 = 0, text_len: u8 = 0 }`
  - `pub const Row = struct { cells: []Cell, text: std.ArrayListUnmanaged(u8) = .empty; pub fn textOf(self: *const Row, c: Cell) []const u8; pub fn deinit(self: *Row, alloc) void }`
  - `pub const RowView = struct { col_off: u16, cols: u16 }` (moves here from `Engine.RowView`; same fields)
  - `pub const ColSpan = struct { from: u16, to: u16 }`
  - `pub fn clipColOf(r: *const Row, cols: u16, view: RowView) ?u16`, `pub fn snapWideOf(r: *const Row, cols: u16, from: u16, to: u16) ColSpan` — row-level, so a fetched scrollback row (no grid) gets the same rules
  - `pub const Grid = struct { alloc, cols: u16, rows: u16, cursor: CursorPos, lines: []Row; init(alloc, cols, rows) !*Grid; deinit(); resize(cols, rows) !void; clear(); applyRow(y, bytes) !void; row(y) *const Row; dumpPlain(alloc) ![]const u8; clipCol(y, view) ?u16; snapWide(y, from, to) ColSpan }` — the last two delegate to the row-level functions
  - `pub fn decodeRow(alloc, into: *Row, bytes: []const u8, cols: u16) ![]const u8` — fills `into`, returns the bytes after the row; validates in a first pass so a bad payload leaves the row untouched
  - `pub fn decodeRows(alloc, bytes, count: u16, cols: u16) ![]Row` and `pub fn freeRows(alloc, rows: []Row) void`
  - `Engine.mirrorInto(self, g: *Grid) !void` — resize, encode every viewport row, apply, set cursor

- [ ] **Step 1: Write the failing grid tests**

Create `src/engine/grid.zig` with the header and tests only:

```zig
//! The client's grid: what a replica holds instead of an emulator. Cells
//! arrive already parsed (protocol.CellRow) and are copied into rows; the
//! only rules here are the two wide-glyph rules a painter needs and the
//! plain-text dump every harness compares against the daemon's.
//!
//! Deliberately platform-free: no posix, no fds, no clocks, no escape
//! bytes — this compiles for wasm32-freestanding and sits under rule 4.
const std = @import("std");
const proto = @import("protocol.zig");

test "grid: applyRow fills the row and blanks the rest of the width" {
    const alloc = std.testing.allocator;
    const g = try Grid.init(alloc, 6, 2);
    defer g.deinit();
    var list: std.ArrayList(u8) = .empty;
    defer list.deinit(alloc);
    var w = try proto.CellRowWriter.begin(&list, alloc);
    try w.cell(.{ .fg = proto.colorPalette(2) }, .narrow, "h");
    try w.cell(.{}, .wide, "漢");
    try w.cell(.{}, .spacer_tail, "");
    w.finish();
    try g.applyRow(1, list.items);
    const r = g.row(1);
    try std.testing.expectEqualStrings("h", r.textOf(r.cells[0]));
    try std.testing.expectEqual(proto.colorPalette(2), r.cells[0].style.fg);
    try std.testing.expectEqual(proto.Wide.wide, r.cells[1].wide);
    try std.testing.expectEqual(proto.Wide.spacer_tail, r.cells[2].wide);
    try std.testing.expectEqual(@as(u8, 0), r.cells[3].text_len);
    try std.testing.expect(r.cells[5].style.isDefault());
    // Row 0 was never written and is blank.
    try std.testing.expectEqual(@as(u8, 0), g.row(0).cells[0].text_len);
}

test "grid: a row wider than the grid is BadPayload and leaves the row untouched" {
    const alloc = std.testing.allocator;
    const g = try Grid.init(alloc, 2, 1);
    defer g.deinit();
    var list: std.ArrayList(u8) = .empty;
    defer list.deinit(alloc);
    var w = try proto.CellRowWriter.begin(&list, alloc);
    try w.cell(.{}, .narrow, "a");
    try w.cell(.{}, .narrow, "b");
    try w.cell(.{}, .narrow, "c");
    w.finish();
    try std.testing.expectError(error.BadPayload, g.applyRow(0, list.items));
    try std.testing.expectEqual(@as(u8, 0), g.row(0).cells[0].text_len);
}

test "grid: dumpPlain trims trailing blanks per row and joins rows with newlines" {
    const alloc = std.testing.allocator;
    const g = try Grid.init(alloc, 6, 3);
    defer g.deinit();
    var list: std.ArrayList(u8) = .empty;
    defer list.deinit(alloc);
    var w = try proto.CellRowWriter.begin(&list, alloc);
    try w.cell(.{}, .narrow, "a");
    try w.cell(.{}, .narrow, "");
    try w.cell(.{}, .narrow, "b");
    w.finish();
    try g.applyRow(0, list.items);
    const s = try g.dumpPlain(alloc);
    defer alloc.free(s);
    try std.testing.expectEqualStrings("a b\n\n", s);
}

test "grid: clipCol steps inward off a wide glyph at the pane edge; snapWide steps outward" {
    const alloc = std.testing.allocator;
    const g = try Grid.init(alloc, 6, 1);
    defer g.deinit();
    var list: std.ArrayList(u8) = .empty;
    defer list.deinit(alloc);
    var w = try proto.CellRowWriter.begin(&list, alloc);
    try w.cell(.{}, .narrow, "a");
    try w.cell(.{}, .wide, "漢");
    try w.cell(.{}, .spacer_tail, "");
    try w.cell(.{}, .narrow, "b");
    w.finish();
    try g.applyRow(0, list.items);
    // A pane 2 wide would cut 漢 in half: the last column that fits is 0.
    try std.testing.expectEqual(@as(?u16, 0), g.clipCol(0, .{ .col_off = 0, .cols = 2 }));
    try std.testing.expectEqual(@as(?u16, 3), g.clipCol(0, .{ .col_off = 0, .cols = 6 }));
    try std.testing.expectEqual(@as(?u16, null), g.clipCol(0, .{ .col_off = 0, .cols = 0 }));
    // A drag from the spacer to the wide cell covers the whole glyph.
    const s = g.snapWide(0, 2, 1);
    try std.testing.expectEqual(@as(u16, 1), s.from);
    try std.testing.expectEqual(@as(u16, 2), s.to);
}

test "grid: decodeRows reads a dense run and refuses a short one" {
    const alloc = std.testing.allocator;
    var list: std.ArrayList(u8) = .empty;
    defer list.deinit(alloc);
    var i: usize = 0;
    while (i < 2) : (i += 1) {
        var w = try proto.CellRowWriter.begin(&list, alloc);
        try w.cell(.{}, .narrow, "x");
        w.finish();
    }
    const rows = try decodeRows(alloc, list.items, 2, 4);
    defer freeRows(alloc, rows);
    try std.testing.expectEqual(@as(usize, 2), rows.len);
    try std.testing.expectEqualStrings("x", rows[1].textOf(rows[1].cells[0]));
    try std.testing.expectError(error.BadPayload, decodeRows(alloc, list.items, 3, 4));
}
```

- [ ] **Step 2: Run to see them fail**

Add `pub const grid = @import("grid.zig");` and `_ = grid;` to `src/engine/term.zig` first, then run: `./deps/zig/zig build test 2>&1 | tail -20`
Expected: `use of undeclared identifier 'Grid'`.

- [ ] **Step 3: Implement the grid**

Insert above the tests in `src/engine/grid.zig`:

```zig
pub const CursorPos = struct { x: u16, y: u16 };

pub const Cell = struct {
    style: proto.CellStyle = .{},
    wide: proto.Wide = .narrow,
    text_off: u32 = 0,
    text_len: u8 = 0,

    pub fn isBlank(self: Cell) bool {
        return self.text_len == 0 and self.wide == .narrow and self.style.isDefault();
    }
};

pub const Row = struct {
    cells: []Cell,
    /// Every cell's text, back to back; a cell slices into it.
    text: std.ArrayListUnmanaged(u8) = .empty,

    pub fn textOf(self: *const Row, c: Cell) []const u8 {
        return self.text.items[c.text_off .. c.text_off + c.text_len];
    }

    fn blank(self: *Row) void {
        @memset(self.cells, .{});
        self.text.clearRetainingCapacity();
    }

    pub fn deinit(self: *Row, alloc: std.mem.Allocator) void {
        alloc.free(self.cells);
        self.text.deinit(alloc);
    }
};

pub const RowView = struct { col_off: u16, cols: u16 };

/// Decode one CellRow into `into` (already sized to `cols`). Two passes on
/// purpose — validate, then fill — so a bad payload leaves the row as it
/// was without a scratch copy: the caller resyncs from a snapshot, and half
/// a row would paint as a lie until then.
pub fn decodeRow(alloc: std.mem.Allocator, into: *Row, bytes: []const u8, cols: u16) ![]const u8 {
    if (cols > proto.max_cols) return error.BadPayload;
    var check = try proto.CellRowReader.init(bytes);
    if (check.ncells > cols) return error.BadPayload;
    var text_total: usize = 0;
    while (try check.next()) |c| text_total += c.text.len;
    // Every byte has been read once and is well-formed; nothing below can fail
    // except the allocation, which happens before the row is touched.
    try into.text.ensureTotalCapacity(alloc, text_total);
    into.blank();
    var r = proto.CellRowReader.init(bytes) catch unreachable;
    var x: usize = 0;
    while (r.next() catch unreachable) |c| : (x += 1) {
        into.cells[x] = .{
            .style = c.style,
            .wide = c.wide,
            .text_off = @intCast(into.text.items.len),
            .text_len = @intCast(c.text.len),
        };
        into.text.appendSliceAssumeCapacity(c.text);
    }
    return r.remaining();
}

/// A column span, inclusive at both ends.
pub const ColSpan = struct { from: u16, to: u16 };

/// The last column of `r` (a row `cols` wide) that fits in `view`, or null
/// when not one character does. Inward: half a wide glyph past a pane edge
/// is a column stolen from the neighbour.
pub fn clipColOf(r: *const Row, cols: u16, view: RowView) ?u16 {
    if (view.cols == 0 or cols == 0) return null;
    var hi: u16 = @min(view.cols - 1, cols - 1);
    if (r.cells[hi].wide == .wide) {
        if (hi == 0) return null;
        hi -= 1;
    }
    return hi;
}

/// Widen [from, to] to whole glyphs. Outward: half a character under the
/// pointer means the character is under the pointer.
pub fn snapWideOf(r: *const Row, cols: u16, from: u16, to: u16) ColSpan {
    var lo = @min(from, to);
    var hi = @max(from, to);
    if (lo > 0 and r.cells[lo].wide == .spacer_tail) lo -= 1;
    if (hi + 1 < cols and r.cells[hi].wide == .wide) hi += 1;
    return .{ .from = lo, .to = hi };
}

pub fn decodeRows(alloc: std.mem.Allocator, bytes: []const u8, count: u16, cols: u16) ![]Row {
    var rows = try alloc.alloc(Row, count);
    var made: usize = 0;
    errdefer {
        for (rows[0..made]) |*r| r.deinit(alloc);
        alloc.free(rows);
    }
    var rest = bytes;
    for (rows) |*r| {
        r.* = .{ .cells = try alloc.alloc(Cell, cols) };
        made += 1;
        @memset(r.cells, .{});
        rest = try decodeRow(alloc, r, rest, cols);
    }
    return rows;
}

pub fn freeRows(alloc: std.mem.Allocator, rows: []Row) void {
    for (rows) |*r| r.deinit(alloc);
    alloc.free(rows);
}

pub const Grid = struct {
    alloc: std.mem.Allocator,
    cols: u16,
    rows: u16,
    cursor: CursorPos = .{ .x = 0, .y = 0 },
    lines: []Row,

    pub fn init(alloc: std.mem.Allocator, cols: u16, rows: u16) !*Grid {
        const g = try alloc.create(Grid);
        errdefer alloc.destroy(g);
        g.* = .{ .alloc = alloc, .cols = cols, .rows = rows, .lines = &.{} };
        try g.allocLines(cols, rows);
        return g;
    }

    fn allocLines(self: *Grid, cols: u16, rows: u16) !void {
        const lines = try self.alloc.alloc(Row, rows);
        var made: usize = 0;
        errdefer {
            for (lines[0..made]) |*r| r.deinit(self.alloc);
            self.alloc.free(lines);
        }
        for (lines) |*r| {
            r.* = .{ .cells = try self.alloc.alloc(Cell, cols) };
            made += 1;
            @memset(r.cells, .{});
        }
        self.freeLines();
        self.lines = lines;
        self.cols = cols;
        self.rows = rows;
    }

    fn freeLines(self: *Grid) void {
        for (self.lines) |*r| r.deinit(self.alloc);
        self.alloc.free(self.lines);
        self.lines = &.{};
    }

    pub fn deinit(self: *Grid) void {
        self.freeLines();
        self.alloc.destroy(self);
    }

    /// A resize is a blank grid: the snapshot that carries it repaints every row.
    pub fn resize(self: *Grid, cols: u16, rows: u16) !void {
        try self.allocLines(cols, rows);
        self.cursor = .{ .x = 0, .y = 0 };
    }

    pub fn clear(self: *Grid) void {
        for (self.lines) |*r| r.blank();
        self.cursor = .{ .x = 0, .y = 0 };
    }

    pub fn applyRow(self: *Grid, y: u16, bytes: []const u8) !void {
        if (y >= self.rows) return error.BadPayload;
        _ = try decodeRow(self.alloc, &self.lines[y], bytes, self.cols);
    }

    pub fn row(self: *const Grid, y: u16) *const Row {
        return &self.lines[y];
    }

    /// The text Engine.dumpPlain produces for the same screen: a space per
    /// blank or spacer-less empty cell, nothing for a spacer, trailing spaces
    /// trimmed per row, rows joined by newlines. The engine.zig oracle test
    /// is the authority; this is written to match it.
    pub fn dumpPlain(self: *const Grid, alloc: std.mem.Allocator) ![]const u8 {
        var out: std.ArrayList(u8) = .empty;
        errdefer out.deinit(alloc);
        for (self.lines, 0..) |*r, y| {
            const start = out.items.len;
            for (r.cells) |c| {
                switch (c.wide) {
                    .spacer_tail, .spacer_head => continue,
                    .narrow, .wide => {},
                }
                if (c.text_len == 0) try out.append(alloc, ' ') else try out.appendSlice(alloc, r.textOf(c));
            }
            while (out.items.len > start and out.items[out.items.len - 1] == ' ') out.items.len -= 1;
            if (y + 1 < self.lines.len) try out.append(alloc, '\n');
        }
        return out.toOwnedSlice(alloc);
    }

    pub fn clipCol(self: *const Grid, y: u16, view: RowView) ?u16 {
        return clipColOf(&self.lines[y], self.cols, view);
    }

    pub fn snapWide(self: *const Grid, y: u16, from: u16, to: u16) ColSpan {
        return snapWideOf(&self.lines[y], self.cols, from, to);
    }
};
```

Add to `src/engine/protocol.zig` beside `max_payload`: `pub const max_cols: u16 = 4096;` with the comment `/// The widest row a client will decode; a wider claim is a bad payload, not an allocation.` (and `decodeRow` refuses `cols > max_cols` with `error.BadPayload` before sizing its scratch).

- [ ] **Step 4: Run to see them pass**

Run: `./deps/zig/zig build test 2>&1 | tail -20`
Expected: the five `grid:` tests pass.

- [ ] **Step 5: Write the oracle tests and `mirrorInto` in `engine.zig`**

Add to `Engine` (after `encodeScrollback`):

```zig
    /// Encode every viewport row into `g` — the test bridge between an
    /// authored screen and the grid a client would hold, and the only way a
    /// harness gets a Grid from bytes without a daemon.
    pub fn mirrorInto(self: *Engine, g: *Grid) !void {
        if (g.cols != self.term.cols or g.rows != self.term.rows)
            try g.resize(@intCast(self.term.cols), @intCast(self.term.rows));
        var y: u16 = 0;
        while (y < self.term.rows) : (y += 1) {
            const row = try self.encodeViewportRow(self.alloc, y);
            defer self.alloc.free(row);
            try g.applyRow(y, row);
        }
        const cur = self.cursorPos();
        g.cursor = .{ .x = cur.x, .y = cur.y };
    }
```

with `const Grid = @import("grid.zig").Grid;` at the top (Task 6 turns it into `@import("term").grid.Grid`). Then the oracle tests:

```zig
test "grid oracle: the grid's dumpPlain and cursor agree with the engine's for every screen shape" {
    const alloc = std.testing.allocator;
    const screens = [_][]const u8{
        "plain text\r\nsecond line",
        "w\u{6f22}\u{5b57}x e\u{301} \u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467} end",
        "\x1b[1;31mred bold\x1b[0m \x1b[44mblue bg\x1b[0m\x1b[K",
        "short\r\n\r\n\r\nafter blanks",
        "\x1b[?1049h\x1b[HTUI on alt\x1b[5;10Hcursor here",
        "line one\r\nline two\r\n" ** 30, // scrolled: history exists, viewport is the tail
        "\x1b[3;1Hcol\x1b[3;40Hfar\x1b[8;1H",
    };
    for (screens) |s| {
        var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
        defer e.deinit();
        e.feed(s);
        const g = try Grid.init(alloc, 1, 1);
        defer g.deinit();
        try e.mirrorInto(g);
        const want = try e.dumpPlain(alloc);
        defer alloc.free(want);
        const got = try g.dumpPlain(alloc);
        defer alloc.free(got);
        try std.testing.expectEqualStrings(want, got);
        try std.testing.expectEqual(e.cursorPos().x, g.cursor.x);
        try std.testing.expectEqual(e.cursorPos().y, g.cursor.y);
    }
}

test "grid oracle: clipCol and snapWide agree with the engine's on a row with wide glyphs" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 10, .rows = 1 });
    defer e.deinit();
    e.feed("a\u{6f22}b\u{5b57}");
    const g = try Grid.init(alloc, 1, 1);
    defer g.deinit();
    try e.mirrorInto(g);
    var cols: u16 = 0;
    while (cols <= 10) : (cols += 1) {
        const view = Engine.RowView{ .col_off = 0, .cols = cols };
        try std.testing.expectEqual(e.clipCol(0, view), g.clipCol(0, .{ .col_off = 0, .cols = cols }));
    }
    var from: u16 = 0;
    while (from < 6) : (from += 1) {
        var to: u16 = from;
        while (to < 6) : (to += 1) {
            const a = e.snapWide(0, from, to);
            const b = g.snapWide(0, from, to);
            try std.testing.expectEqual(a.from, b.from);
            try std.testing.expectEqual(a.to, b.to);
        }
    }
}
```

`Engine.clipCol` and `Engine.snapWide` are private today; make them `pub` for this task (they are deleted in Task 6).

- [ ] **Step 6: Run the oracle**

Run: `./deps/zig/zig build test 2>&1 | tail -30`
Expected: both oracle tests pass. If `dumpPlain` disagrees on a screen, the ENGINE is right: adjust `Grid.dumpPlain`'s trimming (the likely differences are how ghostty trims trailing blank ROWS and whether a bg-only cell prints as a space) until the test passes on every shape, and say which rule moved in the commit body.

- [ ] **Step 7: Commit**

```bash
./deps/zig/zig fmt src/engine/grid.zig src/engine/engine.zig src/engine/term.zig src/engine/protocol.zig
make check; echo rc=$?
git add src/engine/grid.zig src/engine/engine.zig src/engine/term.zig src/engine/protocol.zig
git commit -m "feat: the client grid, pinned against the engine as its oracle"
```

---

### Task 4: `term_modes` carries the alternate screen and cursor-keys bits

**Files:**
- Modify: `src/engine/protocol.zig` (`TermModes`), `src/server/server.zig` (`sampleTermModes` ~2581–2610), `src/tui/interact.zig` (~1747)
- Test: `src/engine/protocol.zig`, `src/server/server_test_session.zig`, `src/tui/interact.zig`

**Interfaces:**
- Produces: `proto.TermModes.alt_screen: bool`, `proto.TermModes.cursor_keys: bool`.

- [ ] **Step 1: Write the failing tests**

In `src/engine/protocol.zig`, next to the existing `TermModes` round-trip test:

```zig
test "term_modes: alt_screen and cursor_keys round-trip and are off by default" {
    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);
}
```

In `src/server/server_test_session.zig`, find the existing test that asserts a `term_modes` frame after `\x1b[?2004h` (grep `term_modes`), and add one of the same shape whose shell writes `\x1b[?1049h\x1b[?1h` and expects the next `term_modes` frame to decode with `alt_screen == true` and `cursor_keys == true`, then `\x1b[?1l\x1b[?1049l` and a frame with both false.

In `src/tui/interact.zig`, find the test that covers the wheel-on-alt-screen rule (grep `sendAltScroll` or `alternate scroll`); it authors the alt screen by feeding `\x1b[?1049h` into the replica. Change it to set `core.semantic.terminal_modes = .{ .bracketed_paste = false, .alt_screen = true, .cursor_keys = false }` instead, and add the inverse case (modes say primary → the wheel is a scrollback move, not arrows).

- [ ] **Step 2: Run to see them fail**

Run: `./deps/zig/zig build test 2>&1 | tail -20`
Expected: `no field named 'alt_screen'`.

- [ ] **Step 3: Implement**

`protocol.zig`: in `pub const TermModes = packed struct(u32)`, add `alt_screen: bool = false, cursor_keys: bool = false,` after the mouse fields and shrink the trailing padding by 2 bits (the struct must stay exactly 32 bits — the compiler refuses otherwise). Update the `term_modes` comment on the `MsgType` line: `bit0 bracketed paste, mouse bits, alt_screen, cursor_keys`.

`server.zig` `sampleTermModes`: where `.bracketed_paste = eng.bracketedPaste(),` is set, add `.alt_screen = eng.onAltScreen(), .cursor_keys = eng.cursorKeys(),` — both in the sampler and in the attach-time send at ~2780.

`interact.zig:1747`: replace `self.rep.eng.onAltScreen()` with `self.semantic.terminal_modes.alt_screen` and `self.rep.eng.cursorKeys()` with `self.semantic.terminal_modes.cursor_keys` (`semantic` is the `client_core` state `semanticFrame` updates; if the field is named differently in `Core`, use that name — it is the one whose `.receive` is called in `semanticFrame`).

- [ ] **Step 4: Run to see them pass**

Run: `./deps/zig/zig build test 2>&1 | tail -20`
Expected: pass. Then `make e2e 2>&1 | tail -5` — the mouse group (`E2E_ONLY=08_mouse make e2e`) exercises the wheel rule against a real `less`.

- [ ] **Step 5: Commit**

```bash
make check; echo rc=$?
git add src/engine/protocol.zig src/server/server.zig src/server/server_test_session.zig src/tui/interact.zig
git commit -m "feat: term_modes carries the alternate screen and DECCKM, so the wheel rule needs no engine"
```

---

### Task 5: The flip — the wire carries cells, the replica is a grid, every painter reads it

This is one task because the type of `Replica`'s target is one edge of the compile graph: the daemon's payloads and the replica's decoder change together or the server tests cannot round-trip, and every client painter holds the replica. Tasks 1–4 built and tested each piece; this task wires them. Commit in `--fixup` steps as each file compiles; `make check` must be green before the last commit of the task.

**Files:**
- Modify: `src/engine/protocol.zig`, `src/engine/delta.zig`, `src/engine/replica.zig`, `src/server/server.zig`, `src/server/server_test_harness.zig`, `src/server/server_test_attach.zig`, `src/server/server_test_session.zig`, `src/tui/paint.zig`, `src/tui/interact.zig`, `src/tui/wallview.zig`, `src/tui/wall_pump.zig`, `src/tui/wall_test_harness.zig`, `src/client/wasm_core.zig`, `test/wsclient.zig`
- Test: all of the above; `make e2e`

**Interfaces:**
- Consumes: everything from Tasks 1–4.
- Produces:
  - `proto.MsgType.snapshot = 0x95`, `.delta = 0x96`, `.scrollback_chunk = 0x97`
  - `proto.snapshot_cursor_len = 4`, `proto.readSnapshotCursor(payload) !grid.CursorPos`, `proto.writeSnapshotCursor(buf: *[4]u8, cur)`
  - `delta.buildSnapshot(alloc, eng: *Engine, prefix: proto.SnapshotPrefix) ![]u8`
  - `Replica { grid: *Grid, ... }` — `init(alloc, g: *Grid)`; `apply` unchanged in signature
  - `paint.rowToVtFrom(alloc, r: *const grid.Row, cols: u16, view: grid.RowView, span: ?Span) ![]u8` — the serializer, on a row and its width
  - `paint.rowToVt(alloc, g: *const Grid, y: u16, view: grid.RowView, span: ?Span) ![]u8` — `rowToVtFrom(alloc, g.row(y), g.cols, view, span)`
  - `paint.renderClipped(alloc, g: *const Grid, vp, hl, rows, owns_screen, out_fd)`, `paint.paintDeltaClipped(alloc, payload, g: *const Grid, vp, hl, out_fd)`, `paint.renderScrollback(alloc, rows: []const grid.Row, g_cols: u16, vp, owns_screen, out_fd)`

- [ ] **Step 5.1: Frame numbers and the snapshot cursor (`protocol.zig`)**

Renumber in `MsgType`:

```zig
    // Retired 2026-09-04 with the VT payloads they carried; never reused, so a
    // binary from either side of the break drops the other's frames instead
    // of painting them. 0x81 snapshot, 0x85 scrollback_chunk, 0x87 delta.
    snapshot = 0x95, // payload: SnapshotPrefix ++ u16 LE cursor_x ++ u16 LE cursor_y ++ rows × CellRow
    scrollback_chunk = 0x97, // payload: u32 LE start, u16 LE count ++ count × CellRow
    delta = 0x96, // payload: DeltaHeader ++ row_count × (u16 LE row ++ u32 LE len ++ CellRow)
```

Add after `readSnapshotPrefix`:

```zig
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) };
}
```

Delete `ComposedDelta`, `composeDelta`, and the `// folder rule 4 exemption:` line at the top of the file. Fix the golden tests that pinned the old numbers and the `composeDelta` tests (delete the latter; the reader tests in Task 1 replace them). Any test in the file whose production line now needs an escape byte must be inside a `test` block or rule 4 fails `make check`.

- [ ] **Step 5.2: The tracker and the snapshot builder (`delta.zig`)**

Replace the three `eng.dumpVtRow(alloc, @intCast(y))` calls (`rebuild`, `update`, `buildDeltaSince`) with `eng.encodeViewportRow(alloc, @intCast(y))`; the hash and `appendDeltaRow` calls are unchanged. Add:

```zig
/// The snapshot payload: prefix, cursor, every viewport row dense. The
/// cursor rides here rather than in the prefix so the prefix's golden pin
/// and its readers stay as they were.
pub fn buildSnapshot(alloc: std.mem.Allocator, eng: *Engine, prefix: proto.SnapshotPrefix) ![]u8 {
    var out: std.ArrayList(u8) = .empty;
    errdefer out.deinit(alloc);
    var pbuf: [proto.snapshot_prefix_len]u8 = undefined;
    proto.writeSnapshotPrefix(&pbuf, prefix);
    try out.appendSlice(alloc, &pbuf);
    var cbuf: [proto.snapshot_cursor_len]u8 = undefined;
    const cur = eng.cursorPos();
    proto.writeSnapshotCursor(&cbuf, cur.x, cur.y);
    try out.appendSlice(alloc, &cbuf);
    var y: u16 = 0;
    while (y < eng.term.rows) : (y += 1) {
        const row = try eng.encodeViewportRow(alloc, y);
        defer alloc.free(row);
        try out.appendSlice(alloc, row);
    }
    return out.toOwnedSlice(alloc);
}
```

Add a test: feed `"a\r\nb"` into an 4×2 engine, `buildSnapshot`, `readSnapshotPrefix` + `readSnapshotCursor` agree with the engine, and `grid.decodeRows(alloc, payload[28..], 2, 4)` yields `a` and `b`.

- [ ] **Step 5.3: The daemon's three sites (`server.zig`)**

`buildSnapshotPayload` (~2695): replace the `dumpState` + prefix assembly with `delta_mod.buildSnapshot(self.alloc, s.eng, .{ .seq = ..., .history_rows = ..., .cols = ..., .rows = ..., .epoch = ... })` using the same values it writes today.

`accrueSnapshotEquiv` (~2415): replace the `dumpState` length with `buildSnapshot`'s length (build, measure, free) — the stat means "what a full snapshot would have cost" and must measure the thing sent.

`onFetchScrollback` (~1928): replace `dumpScrollback` with `encodeScrollback`; the header writes `got.first` and `got.count`, then `got.bytes`.

`dumpState` stays for `upgrade.zig`'s manifest and `debug_dump`; do not touch them.

- [ ] **Step 5.4: The replica over a grid (`replica.zig`)**

```zig
const Grid = @import("grid.zig").Grid;
const grid_mod = @import("grid.zig");

pub const Replica = struct {
    alloc: std.mem.Allocator,
    /// Borrowed. The grid the frames are copied into.
    grid: *Grid,
    grid_size: proto.Size,
    session_epoch: u64 = 0,
    last_seq: u64 = 0,
    history_rows: u32 = 0,
    state_since_attach: bool = false,

    pub const Applied = enum { painted, resync };

    pub fn init(alloc: std.mem.Allocator, g: *Grid) Replica {
        return .{ .alloc = alloc, .grid = g, .grid_size = .{ .cols = g.cols, .rows = g.rows } };
    }

    /// `.snapshot`: a short prefix or cursor is error.BadPayload with nothing
    /// consumed; a row that will not decode is error.BadPayload too — a
    /// snapshot is the resync, so a resync cannot fix one, and the pump ends
    /// the tile with the error. `.delta`: a bad row is `.resync`.
    pub fn apply(self: *Replica, t: proto.MsgType, payload: []const u8) !Applied {
        switch (t) {
            .snapshot => {
                const prefix = try proto.readSnapshotPrefix(payload);
                const cur = try proto.readSnapshotCursor(payload);
                self.state_since_attach = true;
                self.session_epoch = prefix.epoch;
                self.last_seq = prefix.seq;
                self.history_rows = prefix.history_rows;
                if (prefix.cols != self.grid_size.cols or prefix.rows != self.grid_size.rows) {
                    try self.grid.resize(prefix.cols, prefix.rows);
                    self.grid_size = .{ .cols = prefix.cols, .rows = prefix.rows };
                }
                self.grid.clear();
                var rest = payload[proto.snapshot_prefix_len + proto.snapshot_cursor_len ..];
                var y: u16 = 0;
                while (y < prefix.rows) : (y += 1) {
                    rest = try grid_mod.decodeRow(self.alloc, &self.grid.lines[y], rest, self.grid.cols);
                }
                self.grid.cursor = .{ .x = cur.x, .y = cur.y };
                return .painted;
            },
            .delta => {
                self.state_since_attach = true;
                const hdr = proto.readDeltaHeader(payload) catch return .resync;
                var it = proto.deltaRowIterator(payload);
                var seen: usize = 0;
                while (it.next() catch return .resync) |row| {
                    self.grid.applyRow(row.row, row.bytes) catch return .resync;
                    seen += 1;
                }
                if (seen != hdr.row_count) return .resync;
                self.history_rows = hdr.history_rows;
                self.last_seq = hdr.seq;
                self.grid.cursor = .{ .x = hdr.cursor_x, .y = hdr.cursor_y };
                return .painted;
            },
            else => unreachable,
        }
    }
    // attachArgs and scrollStart unchanged.
};
```

Rewrite the file's tests to build payloads with `CellRowWriter` (the `testSnapshot` helper takes prefix + cursor + rows) and add: `"replica: a snapshot whose rows do not decode is BadPayload, not resync"` (a snapshot body of `"\x1b[1mVT"` bytes — inside the test block — errors out of `apply`), and `"replica: a delta whose row is wider than the grid is resync and the grid is untouched"`.

- [ ] **Step 5.5: The server harness and tests**

`server_test_harness.zig`: `applyFrame(alloc, replica: *Grid, frame)` builds `Replica.init(alloc, replica)`; `ReplicaWait.replica: *Grid`; `ReplicaFeed` likewise; every `Engine.init` that constructs the REPLICA side becomes `Grid.init(alloc, cols, rows)` (`const Grid = @import("term").grid.Grid;`); every `replica.dumpPlain(alloc)` keeps its name. The DAEMON-side engines stay engines. `server_test_attach.zig` and `server_test_session.zig`: the same substitution where they hold a replica; any test that fed VT into a replica to author expected state now authors through a scratch `Engine` + `mirrorInto` a `Grid` and compares `dumpPlain`.

Run: `./deps/zig/zig build test 2>&1 | tail -30` — expect the wall/client/wasm/wsclient modules to fail to compile still; the server tests must PASS here before moving on (commit `--fixup`).

- [ ] **Step 5.6: The serializer and the painters (`paint.zig`)**

Replace `dumpRow` and the three renders' engine parameters. The serializer:

```zig
/// One grid row as VT for a terminal: SGR on every style change, a wide
/// glyph once with its spacer skipped, a space for an empty cell, and a
/// stop at the last cell that is not a default blank (the caller's ECH has
/// cleared the rest). `span` (grid columns, inclusive) is painted inverted
/// and PLAIN, snapped outward to whole glyphs, closed by a full reset —
/// the three-piece shape Engine.dumpVtRowSpan had, on cells.
pub fn rowToVtFrom(alloc: std.mem.Allocator, r: *const grid.Row, cols: u16, view: grid.RowView, span: ?Span) ![]u8 {
    var out: std.ArrayList(u8) = .empty;
    errdefer out.deinit(alloc);
    try out.appendSlice(alloc, "\x1b[0m");
    const last = grid.clipColOf(r, cols, view) orelse return out.toOwnedSlice(alloc);
    const snapped: ?grid.ColSpan = if (span) |s| blk: {
        if (s.from > last) break :blk null;
        break :blk grid.snapWideOf(r, cols, s.from, @min(s.to, last));
    } else null;
    var end: u16 = last;
    // Trailing default blanks are the caller's clear, not ours.
    while (end > 0 and r.cells[end].isBlank() and (snapped == null or end > snapped.?.to)) end -= 1;
    var cur: proto.CellStyle = .{};
    var x: u16 = 0;
    var inverted = false;
    while (x <= end) : (x += 1) {
        const c = r.cells[x];
        if (c.wide == .spacer_tail or c.wide == .spacer_head) continue;
        const in_span = if (snapped) |s| x >= s.from and x <= s.to else false;
        if (in_span and !inverted) {
            try out.writer(alloc).print("\x1b[{d}G\x1b[0m\x1b[7m", .{x + 1 + view.col_off});
            inverted = true;
            cur = .{};
        } else if (!in_span and inverted) {
            try out.writer(alloc).print("\x1b[0m\x1b[{d}G", .{x + 1 + view.col_off});
            inverted = false;
            cur = .{};
        }
        if (!in_span and !c.style.eql(cur)) {
            try appendSgr(&out, alloc, c.style);
            cur = c.style;
        }
        if (c.text_len == 0) try out.append(alloc, ' ') else try out.appendSlice(alloc, r.textOf(c));
    }
    try out.appendSlice(alloc, "\x1b[0m");
    return out.toOwnedSlice(alloc);
}

pub fn rowToVt(alloc: std.mem.Allocator, g: *const Grid, y: u16, view: grid.RowView, span: ?Span) ![]u8 {
    return rowToVtFrom(alloc, g.row(y), g.cols, view, span);
}

fn appendColor(out: *std.ArrayList(u8), alloc: std.mem.Allocator, base: u8, packed_col: u32) !void {
    const w = out.writer(alloc);
    switch (packed_col >> 24) {
        0 => try w.print(";{d}", .{base + 9}), // default: 39 / 49 / 59
        1 => {
            const idx: u8 = @truncate(packed_col);
            if (base != 58 and idx < 8) {
                try w.print(";{d}", .{base + idx});
            } else if (base != 58 and idx < 16) {
                try w.print(";{d}", .{base + 60 + (idx - 8)});
            } else try w.print(";{d};5;{d}", .{ base + 8, idx });
        },
        else => try w.print(";{d};2;{d};{d};{d}", .{ base + 8, (packed_col >> 16) & 0xff, (packed_col >> 8) & 0xff, packed_col & 0xff }),
    }
}

/// A full SGR for `s` from a reset: every attribute the wire carries, so
/// a host terminal never inherits a neighbour cell's style.
fn appendSgr(out: *std.ArrayList(u8), alloc: std.mem.Allocator, s: proto.CellStyle) !void {
    const w = out.writer(alloc);
    try w.writeAll("\x1b[0");
    if (s.flags & (1 << 0) != 0) try w.writeAll(";1");
    if (s.flags & (1 << 2) != 0) try w.writeAll(";2");
    if (s.flags & (1 << 1) != 0) try w.writeAll(";3");
    switch ((s.flags >> 8) & 0x7) {
        0 => {},
        1 => try w.writeAll(";4"),
        2 => try w.writeAll(";4:2"),
        3 => try w.writeAll(";4:3"),
        4 => try w.writeAll(";4:4"),
        5 => try w.writeAll(";4:5"),
        else => try w.writeAll(";4"),
    }
    if (s.flags & (1 << 3) != 0) try w.writeAll(";5");
    if (s.flags & (1 << 4) != 0) try w.writeAll(";7");
    if (s.flags & (1 << 5) != 0) try w.writeAll(";8");
    if (s.flags & (1 << 6) != 0) try w.writeAll(";9");
    if (s.flags & (1 << 7) != 0) try w.writeAll(";53");
    if (s.fg != 0) try appendColor(out, alloc, 30, s.fg);
    if (s.bg != 0) try appendColor(out, alloc, 40, s.bg);
    if (s.ul != 0) try appendColor(out, alloc, 58, s.ul);
    try w.writeAll("m");
}
```

`renderClipped(alloc, g: *const Grid, vp, hl, rows, owns_screen, out_fd)`: `grid_rows = g.rows`; per row `rowToVt(alloc, g, y, view, spanFor(hl, y, g))` where `spanFor` is today's `hl.span` callback ask. `finishPaint` takes `g.cursor`.

`paintDeltaClipped(alloc, payload, g, vp, hl, out_fd)`: read the header, collect the row indices from `deltaRowIterator` into a stack array of `u16` (cap `proto.max_cols`... rows, use the grid's `rows`), and call the row-list path of `renderClipped` — the verbatim branch, the over-wide branch and the `ech`-plus-`row.bytes` path are gone; every row paints from the grid clipped to the pane.

`renderScrollback(alloc, rows: []const grid.Row, g_cols: u16, vp, owns_screen, out_fd)`: paints each fetched row through `rowToVtFrom(alloc, &rows[n], g_cols, view, null)` at `appendRowAt(n)`, blank rows past the chunk; the `appendClippedHistory` scratch engine (`paint.zig:243`) is deleted because clipping is now `clipColOf` on the row.

Every test in `paint.zig` that did `var replica = try Engine.init(...); replica.feed(...)` now does that AND `const g = try Grid.init(alloc, 1, 1); try replica.mirrorInto(g);` and passes `g`. Tests that read the painted bytes back through `screenAfter`/`besideScreen` keep their Engine (that is the oracle reading the paint). `paint.zig` lives under `src/tui/`, outside rule 4's folders, so its escape bytes need no exemption marker.

Pins that must FIRE against the new serializer (break each once, watch it fail, restore): the span-bounded ECH pin, the `col_off` rail pin (`besideScreen`), the selection-inverts-plain pin, the wide-glyph-at-pane-edge pin.

- [ ] **Step 5.7: The interaction loop (`interact.zig`)**

- `Core.rep: Replica` is initialised at ~1249 with `const g = try Grid.init(alloc, size.cols, size.rows); .rep = Replica.init(alloc, g)`; the deinit that freed `eng` frees `g`.
- Every `self.rep.eng.term.cols`/`.rows` → `self.rep.grid.cols`/`.rows`; `self.rep.eng.cursorPos()` → `self.rep.grid.cursor`; `dumpPlain` → `self.rep.grid.dumpPlain`.
- `replicaCellChar` and the overlay judge take `*const Grid` and call `dumpPlain` on it.
- `scrollbackPage`: `const hdr_count = readInt(u16, payload[4..6])`; `const rows = try grid.decodeRows(self.alloc, payload[6..], hdr_count, self.rep.grid.cols); defer grid.freeRows(self.alloc, rows);` then `renderScrollback(self.alloc, rows, self.rep.grid.cols, ...)`.
- `paintDeltaClipped`/`renderClipped` call sites pass `self.rep.grid`.
- Tests: every `replica.feed("...")` / `core.rep.eng.feed("...")` that authored a screen becomes `var author = try Engine.init(alloc, .{...}); defer author.deinit(); author.feed("..."); try author.mirrorInto(core.rep.grid);`. Write the helper once at the top of the test section:

```zig
fn authorScreen(alloc: std.mem.Allocator, g: *Grid, bytes: []const u8) !void {
    var e = try Engine.init(alloc, .{ .cols = g.cols, .rows = g.rows });
    defer e.deinit();
    e.feed(bytes);
    try e.mirrorInto(g);
}
```

`wallview.zig` and `wall_pump.zig`: grep `\.eng\b` and `Engine`; every read of the replica engine becomes the grid read with the same meaning. `wall_test_harness.zig:217`: if that engine PLAYS THE DAEMON (feeds bytes and builds frames), it stays an `Engine`; if it plays the replica, it becomes a `Grid`.

- [ ] **Step 5.8: The wasm core (`wasm_core.zig`)**

- `c.eng: *Engine` → `c.grid: *Grid` (`Grid.init(alloc, cols, rows)`); `c.rep = Replica.init(alloc, c.grid)`.
- `paintRow(c, g: *const Grid, y)`: for each x, `const cell = g.row(y).cells[x]`; `viewport[base] = first codepoint of g.row(y).textOf(cell)` (decode with `std.unicode.utf8Decode` on the first sequence; 0 for empty); `[base+1] = cell.style.fg`, `[base+2] = cell.style.bg` (already packed as JS expects); `[base+3] = cell.style.flags | wide<<16 | spacer<<17` with the same switch on `cell.wide`.
- `mux_scroll_feed(len)`: replace the scratch engine with `c.scroll_rows: ?[]grid.Row` = `grid.decodeRows(alloc, input_buf[0..len], rows_in_chunk, c.cols)` where `rows_in_chunk` is `len`'s row count — JS strips the 6-byte header today and passes the rows; change the JS to pass the WHOLE chunk (header included) so the count is read here: `const count = readInt(u16, input_buf[4..6])`, rows from `input_buf[6..len]`. (`web/mux.js:771`: pass `chunk` rather than `rows`.) `mux_read_scroll_viewport` paints `c.scroll_rows` through a `paintRowFrom(c, r: *const grid.Row, y)` sibling of `paintRow` (the `Grid` form calls it with `g.row(y)`), blank rows past the chunk.
- `mux_dump_plain` → `c.grid.dumpPlain`.
- Remove the `Engine` import and `.max_scrollback`.

Then `./deps/zig/zig build 2>&1 | tail -5` builds `mux_core.wasm`; run `make e2e` (the web group drives the page through `wsclient` and `verify.js`).

- [ ] **Step 5.9: The browser stand-in (`wsclient.zig`)**

`Replica.init(alloc, grid)` over a `Grid.init(alloc, 80, 24)`; `cl.rep.eng.dumpPlain` → `cl.rep.grid.dumpPlain`. Its self-test at ~749 keeps `daemon_eng` as an `Engine` (it plays the daemon) and mirrors the FIXTURE side into a `Grid`, comparing `dumpPlain`.

- [ ] **Step 5.10: Green**

Run: `make check; echo rc=$?` — expect 0. Run: `make e2e 2>&1 | tail -8; echo rc=${PIPESTATUS[0]}` — expect 0. If an e2e leg reads the grid through `mux d dump`, it still works (`dumpPlain`/`dumpVt` are daemon-side and untouched).

- [ ] **Step 5.11: Commit (autosquash the fixups)**

```bash
git add -A src test web
git commit -m "feat: the wire carries cells; the replica is a grid and every painter reads it"
# then fold the --fixup commits made along the way:
GIT_SEQUENCE_EDITOR=: git rebase -i --autosquash $(git merge-base HEAD main)
```

---

### Task 6: The module split — the client links no emulator

**Files:**
- Modify: `build.zig` (module table, ghostty edges, wasm wiring), `src/engine/term.zig`, `src/engine/engine.zig`, `src/engine/delta.zig`, `src/server/server.zig`, `src/server/server_test_harness.zig`, `src/tui/paint.zig`, `src/tui/interact.zig`, `src/tui/wall_test_harness.zig`, `test/render.zig`, `test/wsclient.zig`, `CLAUDE.md` (the layout table row)

**Interfaces:**
- Produces: module row `engine` (`src/engine/engine.zig`, child `delta.zig`), `.imports = &.{"term"}`, links ghostty-vt; `term` has no ghostty edge and `.wasm = true`; `daemon` and `render` import `engine`; `wall`, `client`, `wsclient` list `engine` in `.test_imports`.

- [ ] **Step 1: Write the failing pins**

Two shell pins in `test/bans.sh`, beside the folder-rule checks and in their style (a named check that prints `bans ok: …` or fails the script):

```sh
# The term module is the wasm root and the client's whole view of the wire:
# it spells no emulator. engine.zig is the one file allowed the dependency.
if grep -l '"ghostty-vt"' src/engine/term.zig src/engine/protocol.zig src/engine/replica.zig src/engine/grid.zig >/dev/null 2>&1; then
  fail "a term child imports ghostty-vt"
fi
if grep -q 'engine.zig' src/engine/term.zig; then
  fail "term.zig re-exports the engine"
fi
# No client row links the engine outside its tests: the production client
# parses no VT. The table lines are one per row, so a row's .imports is on
# the line that names it.
for row in client wall webhub term; do
  if grep -E "\.name = \"$row\"" build.zig | grep -q '\.imports = &\.{[^}]*"engine"'; then
    fail "module row $row imports engine"
  fi
done
echo "bans ok: the engine is the daemon's alone"
```

- [ ] **Step 2: Run to see them fail**

Run: `make check 2>&1 | tail -5` — `bans.sh` fails with `term.zig re-exports the engine`.

- [ ] **Step 3: Split**

`term.zig`: remove `pub const engine` and `pub const delta` (and their `_ =` lines); keep `protocol`, `replica`, `grid`.

`engine.zig`: `const proto = @import("term").protocol;` and `const Grid = @import("term").grid.Grid;` replace the relative imports; add `pub const delta = @import("delta.zig");` and `test { _ = delta; }` at the bottom so `engine` is a root. `delta.zig`: `const proto = @import("term").protocol;` and `const Engine = @import("engine.zig").Engine;` (a sibling in the same module is a relative import).

`build.zig` table: add `.{ .name = "engine", .path = "src/engine/engine.zig", .imports = &.{"term"} },` next to `term`; move the `ghostty_dep` `addImport("ghostty-vt", ...)` from `term_mod` to `mods[idxOf("engine")]`; `daemon` imports gain `"engine"`; `render` imports become `&.{ "term", "engine" }`; `wall`, `client` and `wsclient` gain `"engine"` in `.test_imports` (for `wsclient`, add the field). In the wasm block delete `ghostty_wasm_dep` and the `term_wasm_mod.addImport("ghostty-vt", …)` lines; `term` stays `.wasm = true`.

Every `@import("term").engine.Engine` in `server.zig`, `server_test_harness.zig`, `paint.zig` (tests), `interact.zig` (tests), `wall_test_harness.zig`, `render.zig`, `wsclient.zig` becomes `@import("engine").Engine`; `delta` references in `server.zig` become `@import("engine").delta`.

Delete from `engine.zig`: `dumpVtRow`, `dumpVtRowClipped`, `dumpVtRowSpan`, `dumpScrollback`, `RowView`, `clipCol`, `snapWide`, `viewportSpan` if nothing else uses it — `dumpVt`, `dumpVtFrom`, `dumpPlain`, `dumpState`, `extractSelection` stay. Delete the `vtBytes` half of the Task 2 measurement test (keep `cellBytes`, print cells only, and note in the test name that the VT figure was recorded in decisions.md on 2026-09-04).

`CLAUDE.md` layout table: the `src/engine/` row becomes ``term`(`term.zig`) — `protocol` `replica` `grid` · `engine`(`engine.zig`) — `delta` — the daemon's ghostty-vt; no client row imports it outside a test``.

- [ ] **Step 4: Run to see everything pass**

Run: `make check; echo rc=$?` — expect 0, and `bans.sh`'s new line prints its ok. Then `ls -la zig-out/bin/mux_core.wasm` (or wherever `make web` puts it — grep `mux_core` in the Makefile) before and after: record both sizes in the commit body.

- [ ] **Step 5: Commit**

```bash
git add build.zig src test CLAUDE.md
git commit -m "build: the engine is its own module, and no client row links ghostty-vt"
```

---

### Task 7: The gates and the record

**Files:**
- Modify: `docs/decisions.md`, `CLAUDE.md` (invariants: "One replay core" wording; the `proxy`/wire sentence), `README.md` (if it describes the wire as VT — grep `delta`), `test/xversion.sh` (a comment at the top), `RETRO.md` is untracked and not ours

- [ ] **Step 1: The delivery gate**

Run: `make ci 2>&1 | tail -12; echo rc=${PIPESTATUS[0]}` — expect 0. This is `check + e2e + agent + throughput`; every group must print its verdict line (watch for a group that never ran).

- [ ] **Step 2: The real bytes**

In an isolated rig (`export XDG_STATE_HOME=$SCRATCH/state XDG_RUNTIME_DIR=$SCRATCH/run`), run `make bench` on this branch and, from the `main` checkout at `/home/xanderle/code/rad/mux`, `make bench` there too. Both print delta bytes and the snapshot-equivalent figure from `mux d stats`. Record both pairs.

- [ ] **Step 3: The record**

`docs/decisions.md`: extend the Task 2 heading into the full entry — the three-parse finding, the verbatim-paint finding, the wire format, the renumbering rationale (blank tile, never garbage), the Task 2 synthetic ratios, the Task 7 bench pairs, the wasm size before/after, the clean-break policy and the `make xversion` note (no old side to grade until the next release; the gate re-enters when one exists).

`CLAUDE.md`: the invariant "**One replay core.** CLI, wasm, and test fixtures all go through `replica.zig`" gains "over a `grid`, never an engine — the client parses no VT". Add to the top summary: "ghostty-vt engine runs authoritatively in `mux d`; the wire carries its grid as cells and the client copies them". Re-measure the file sizes the "Reading this repo" section quotes for the files this branch changed.

`test/xversion.sh`: a comment at the top: `# 2026-09-04: the cells-on-the-wire break has no old side to grade; XVER_OLD_WORKTREE must be this branch or newer.`

- [ ] **Step 4: Commit**

```bash
make check; echo rc=$?
git add docs/decisions.md CLAUDE.md README.md test/xversion.sh
git commit -m "docs: record cells on the wire, its measurements and the clean break"
```

- [ ] **Step 5: Autosquash and hand over**

`git log --oneline main..HEAD` must read as the feature's story: codec, encoder+measurement, grid, modes, the flip, the split, the record. Then the finishing-a-development-branch skill.