a73x

docs/superpowers/plans/2026-09-02-wall-is-the-layout.md

Ref:   Size: 83.5 KiB   History

# The Wall Is the Layout — 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:** Tiles come from the layout file and from nothing else; the once-a-second poll grades panes and never adds one; sessions join a wall through the picker; `x` removes a pane without ending its session.

**Architecture:** The layout sidecar (`$XDG_STATE_HOME/mux/layout`, leaves spelled `HOST#SESSION`) becomes authored intent, saved on every change and loaded strictly. `wall_host.planHostDiff` loses its `births` output; `wall_layout.seedLayout` loses its healing against live lists; the picker gains a session level backed by a side-connection `end_req`; the hub reads and writes the same file. Each task leaves `make check` green and the e2e groups it touches green.

**Tech Stack:** Zig 0.15.2 (vendored at `deps/zig/zig`), POSIX shell e2e under `test/`, ptyclient fixture for real-terminal legs.

**Spec:** `docs/superpowers/specs/2026-09-02-wall-is-the-layout-design.md`

## Global Constraints

- Build only with `deps/zig/zig` (`make build test e2e check`); the system zig does not build this tree.
- `make check` before every commit; it runs fmt, unit tests, shell syntax, and the comment-reference gate (`zig build check`). A cited symbol in a comment must resolve.
- A unit test must never write to fd 1: it wedges `zig build test` silently. Diagnostics go to `std.debug.print`.
- Every e2e leg runs under an isolated `XDG_STATE_HOME` and `XDG_RUNTIME_DIR` (e2e_lib does this); every hand rig exports both.
- Commit subjects are `type: what changed`, type in `feat fix refactor test docs build chore`, no scope.
- Fixtures are plural by default: two daemons, three sessions each, unless the leg is about N=1.
- `test/e2e.sh` pins the scenario count (`OK_COUNT`) and the convergence count; every added or removed `ok "..."` line moves the first pin, and `E2E_ONLY=<group>` runs never check it.
- Comment rule: say *why*, plainly. No project-history codenames.
- The wire stays backward compatible: an old client against a new daemon and a new client against an old daemon must both keep working for everything that worked before.

---

## File map

| File | Responsibility after this plan |
|---|---|
| `src/engine/protocol.zig` | `# holds NAME N` lines in `sessions_reply`: `appendSessionsHolds`, `parseSessionsHolds`, the grown `sessions_reply_max` |
| `src/server/server.zig` | the `.sessions_req` arm appends one holds line per session |
| `src/client/client.zig` | `endSession`: `end_req` over a side connection, mirroring `birthSession` |
| `src/client/layout.zig` | unchanged parser and `Tree`; used by both the wall and the hub |
| `src/tui/wall_layout.zig` | `seedLayout` takes every leaf verbatim, refuses a bad file with its line; `persist` is the one save path, gated on `Shared.layout_path` |
| `src/tui/wall_host.zig` | `planHostDiff` grades (bind, gone, vanish) and births nothing; `applyHostList` places no tiles |
| `src/tui/wallview.zig` | `Shared.layout_path`; `removePane`; the keyboard loop persists after every change; `keeps_wall` gone |
| `src/tui/interact.zig` | the picker filter has two levels and the new actions |
| `src/tui/wall_picker.zig` | session rows, `pickAdd`, `pickEnd` |
| `src/client/webhub.zig` | `Hub.init` takes the layout's leaves; `applyList` grades; `spawn` writes the layout |
| `src/cli/webhub_main.zig` | reads the layout file and hands its leaves to the hub |
| `test/e2e_09_hosts.sh`, `test/e2e_12_panes.sh`, `test/e2e_13_birth.sh`, `test/e2e_06_web.sh`, `test/e2e_07_wallcli.sh` | rewritten legs; `test/e2e.sh` pin |
| `README.md`, `CLAUDE.md`, `docs/decisions.md` | the new model stated |

---

### Task 1: `# holds NAME N` lines in `sessions_reply`

**Files:**
- Modify: `src/engine/protocol.zig` (beside `appendSessionsMeta` / `parseSessionsMeta`)
- Modify: `src/server/server.zig` (the `.sessions_req` arm of the observer frame switch, the one that calls `self.sessions.text`)
- Test: `src/engine/protocol.zig` (inline tests), `src/server/server_test_session.zig`

**Interfaces:**
- Produces: `proto.sessions_holds_prefix: []const u8 = "# holds "`, `proto.appendSessionsHolds(buf: []u8, len: usize, name: []const u8, holds: u8) usize`, `proto.parseSessionsHolds(payload: []const u8, name: []const u8) ?u8`, and a larger `proto.sessions_reply_max`.
- Consumed by: Task 6 (the picker's session rows).

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

Append to `src/engine/protocol.zig`, next to the `parseSessionsMeta` tests:

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

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

Run: `deps/zig/zig build test 2>&1 | grep -a "error:" | head -5`
Expected: compile errors naming `appendSessionsHolds`, `parseSessionsHolds`, `sessions_holds_prefix`.

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

In `src/engine/protocol.zig`, replace the `sessions_reply_max` line and add after `sessions_meta_max`:

```zig
/// One `# holds NAME N` line per session, appended by a daemon that can
/// count: how many clients hold that session. The picker's session list
/// shows it, and the end key's first press is judged against it. 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;
pub const sessions_reply_max = sessions_text_max + sessions_max * sessions_holds_line_max + sessions_meta_max;

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;
}

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

`sessions_max` is the existing constant `sessions_text_max` is derived from; keep the names as they are in the file.

- [ ] **Step 4: Run the protocol tests**

Run: `deps/zig/zig build test 2>&1 | grep -a "sessions holds\|error:" | head -5`
Expected: no errors; the two tests are not named on failure lines.

- [ ] **Step 5: Write the failing daemon test**

Append to `src/server/server_test_session.zig` (use the file's existing `TestDaemon`/harness import — read its first test to copy the setup shape; the assertion below is the whole point):

```zig
test "sessions_req: a daemon that states its version also states how many clients hold each session" {
    // Two sessions, one of them held by a client: the holds lines say 1
    // and 0, and an old client's iterator still yields exactly the names.
    var d = try h.TestDaemon.start(std.testing.allocator, .{ .version = "0.0.1-test" });
    defer d.stop();
    try d.createSession("wk");
    var held = try d.attachClient("0");
    defer held.close();

    var out: [proto.sessions_reply_max]u8 = undefined;
    const reply = try d.ask(.sessions_req, "", .sessions_reply, &out);
    try std.testing.expectEqual(@as(?u8, 1), proto.parseSessionsHolds(reply, "0"));
    try std.testing.expectEqual(@as(?u8, 0), proto.parseSessionsHolds(reply, "wk"));
    var names = proto.sessionsIter(reply);
    try std.testing.expectEqualStrings("0", names.next().?);
    try std.testing.expectEqualStrings("wk", names.next().?);
    try std.testing.expect(names.next() == null);
    try std.testing.expectEqualStrings("0.0.1-test", proto.parseSessionsMeta(reply).?.version);
}
```

If the harness spells `start`, `createSession`, `attachClient`, or `ask` differently, use the harness's own names (grep `pub fn` in `src/server/server_test_harness.zig`); do not add a second harness.

- [ ] **Step 6: Run it to see it fail**

Run: `deps/zig/zig build test 2>&1 | grep -a "states how many\|error:" | head -5`
Expected: FAIL on the first `expectEqual` (`parseSessionsHolds` returns null: the daemon sends no holds lines yet).

- [ ] **Step 7: Append the holds lines in the daemon**

In `src/server/server.zig`, the `.sessions_req` arm currently reads:

```zig
var buf: [proto.sessions_reply_max]u8 = undefined;
const names = self.sessions.text(buf[0..proto.sessions_text_max]);
const len = if (self.version.len != 0)
    proto.appendSessionsMeta(&buf, names.len, self.version, selfImageStale())
else
    names.len;
self.replyTo(p, .sessions_reply, buf[0..len]);
```

Replace with:

```zig
var buf: [proto.sessions_reply_max]u8 = undefined;
const names = self.sessions.text(buf[0..proto.sessions_text_max]);
// Holds lines and the meta line ride the same gate: a daemon with no
// version to state appends nothing, so a bare fixture's payload stays
// byte-identical to the old wire and the exact-equality test keeps
// pinning it.
var len = names.len;
if (self.version.len != 0) {
    for (self.sessions.table, 0..) |slot, si| {
        const s = slot orelse continue;
        const holds: u8 = @intCast(@min(self.clientsInSession(si), std.math.maxInt(u8)));
        len = proto.appendSessionsHolds(&buf, len, s.name(), holds);
    }
    len = proto.appendSessionsMeta(&buf, len, self.version, selfImageStale());
}
self.replyTo(p, .sessions_reply, buf[0..len]);
```

`clientsInSession` exists (`fn clientsInSession(self: *const Server, si: usize) usize`). `appendSessionsMeta` takes `names_len` as the current length; passing `len` is the same contract.

- [ ] **Step 8: Run the whole gate**

Run: `make check 2>&1 | tail -3; echo rc=$?`
Expected: `rc=0`. If the sibling exact-equality test on a version-less daemon fails, the gate above is wrong; both appends must sit under `self.version.len != 0`.

- [ ] **Step 9: Commit**

```bash
git add src/engine/protocol.zig src/server/server.zig src/server/server_test_session.zig
git commit -m "feat: sessions_reply says how many clients hold each session"
```

---

### Task 2: The layout loads verbatim and refuses a bad file with its line

**Files:**
- Modify: `src/tui/wall_layout.zig` (`seedSidecar`, `seedLayout`, `seedAttempt`, `SeedPlan`)
- Modify: `src/tui/wallview.zig` (the `seed_plan` block in `run`; the `dropped` notice)
- Test: `src/tui/wall_test_layout.zig`

**Interfaces:**
- Consumes: `layout.parse(alloc, bytes) ?ParsedLayout` (spellings + tree + focus), `Host.spec.spelling`, `wv.max_tiles`.
- Produces: `wall_layout.seedLayout(alloc, table, shared, bytes, entry_spelling) SeedResult`, where

```zig
pub const SeedResult = union(enum) {
    plan: SeedPlan,
    /// The file is not a wall: the first offending line, borrowed from
    /// `bytes`, for the caller to print. The wall then starts as if the
    /// file were missing.
    refused: []const u8,
    /// No file, or a file with no leaves this wall can seat at this size.
    none,
};
```

`SeedPlan` keeps its fields (`panes: []?SeedPane`, `focus: ?usize`, `dropped: usize`); `dropped` now counts only leaves that name the session this `mux` runs inside (the self-loop refusal) and leaves cut to fit the terminal.

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

Append to `src/tui/wall_test_layout.zig` (it already imports `wall_layout`, `wv`, `Shared`, `Host` and the harness `fixture`; reuse `fixture.testHost` for hosts):

```zig
test "seedLayout: every leaf of a good file is a pane, in the file's tree, and nothing else is consulted" {
    var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = true };
    defer shared.tree.deinit();
    var table = [_]Host{
        fixture.testHost(&shared, "--sock /a", "/a"),
        fixture.testHost(&shared, "box", "/b"),
    };
    // No poll answer on either host: the file alone decides.
    const file =
        \\mux-layout 1
        \\beside 0
        \\ leaf 1 --sock /a#0
        \\ leaf 1 box#work
        \\ leaf 1 --sock /a#2
        \\focus 1
        \\
    ;
    var res = wall_layout.seedLayout(std.testing.allocator, &table, &shared, file, null);
    defer if (res == .plan) res.plan.deinit(std.testing.allocator);
    try std.testing.expect(res == .plan);
    try std.testing.expectEqual(@as(usize, 3), res.plan.panes.len);
    try std.testing.expectEqualStrings("0", res.plan.panes[0].?.session);
    try std.testing.expectEqual(@as(usize, 0), res.plan.panes[0].?.host);
    try std.testing.expectEqualStrings("work", res.plan.panes[1].?.session);
    try std.testing.expectEqual(@as(usize, 1), res.plan.panes[1].?.host);
    try std.testing.expectEqual(@as(?usize, 1), res.plan.focus);
    try std.testing.expectEqual(@as(usize, 0), res.plan.dropped);
    try std.testing.expectEqual(@as(usize, 3), shared.tree.count());
}

test "seedLayout: a leaf whose host is not in the hosts file refuses the whole file and names the line" {
    var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = true };
    defer shared.tree.deinit();
    var table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")};
    const file =
        \\mux-layout 1
        \\beside 0
        \\ leaf 1 --sock /a#0
        \\ leaf 1 nowhere#0
        \\
    ;
    const res = wall_layout.seedLayout(std.testing.allocator, &table, &shared, file, null);
    try std.testing.expect(res == .refused);
    try std.testing.expectEqualStrings("nowhere#0", res.refused);
    // Nothing was seated: the caller starts as if the file were missing.
    try std.testing.expect(shared.tree.root == null);
}

test "seedLayout: a leaf with no session, a bad name, or a repeat refuses; garbage refuses with its first line" {
    var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = true };
    defer shared.tree.deinit();
    var table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")};
    const no_session = "mux-layout 1\nleaf 0 --sock /a\n";
    const bad_name = "mux-layout 1\nleaf 0 --sock /a#no space\n";
    const repeat = "mux-layout 1\nbeside 0\n leaf 1 --sock /a#0\n leaf 1 --sock /a#0\n";
    const garbage = "not a layout\n";
    for ([_][]const u8{ no_session, bad_name, repeat, garbage }) |file| {
        const res = wall_layout.seedLayout(std.testing.allocator, &table, &shared, file, null);
        try std.testing.expect(res == .refused);
        try std.testing.expect(res.refused.len > 0);
    }
    try std.testing.expectEqualStrings("--sock /a#0", wall_layout.seedLayout(std.testing.allocator, &table, &shared, repeat, null).refused);
    try std.testing.expectEqualStrings("not a layout", wall_layout.seedLayout(std.testing.allocator, &table, &shared, garbage, null).refused);
}

test "seedLayout: the entry spelling takes leaf 0 when the file has it, and is inserted beside the focus when it does not" {
    var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = true };
    defer shared.tree.deinit();
    var table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")};
    const file = "mux-layout 1\nbeside 0\n leaf 1 --sock /a#0\n leaf 1 --sock /a#1\nfocus 1\n";
    var has = wall_layout.seedLayout(std.testing.allocator, &table, &shared, file, "--sock /a#1");
    defer has.plan.deinit(std.testing.allocator);
    try std.testing.expect(has == .plan);
    // The entry is pane 0 by contract; the other leaf follows.
    try std.testing.expect(has.plan.panes[0] == null); // the entry tile is the caller's
    try std.testing.expectEqualStrings("0", has.plan.panes[1].?.session);
    try std.testing.expectEqual(@as(usize, 2), shared.tree.count());

    shared.tree.deinit();
    shared.tree = layout.Tree.init(std.testing.allocator);
    var not = wall_layout.seedLayout(std.testing.allocator, &table, &shared, file, "--sock /a#9");
    defer not.plan.deinit(std.testing.allocator);
    try std.testing.expect(not == .plan);
    try std.testing.expectEqual(@as(usize, 3), shared.tree.count());
}
```

`layout` here is `@import("client").layout`, already imported by the test file's siblings; add the import if the file lacks it.

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

Run: `deps/zig/zig build test 2>&1 | grep -a "error:" | head -5`
Expected: compile errors on `SeedResult`/`.refused`.

- [ ] **Step 3: Rewrite `seedLayout` and `seedAttempt`**

Replace `seedLayout` in `src/tui/wall_layout.zig` with:

```zig
pub const SeedResult = union(enum) {
    plan: SeedPlan,
    refused: []const u8,
    none,
};

/// The layout is authored: every leaf is a pane, in the saved tree. The
/// only things that keep a leaf off the wall are the session this `mux`
/// runs inside (a wall may not attach to itself) and a terminal too small
/// for the whole tree, which trims from the end. Anything else wrong with
/// the file — a host the hosts file does not list, a leaf with no session
/// or a bad name, a repeated leaf, more leaves than the wall seats, or
/// text that is not a layout — refuses the FILE, with the first bad line,
/// because silently seating part of a wall is how a user loses one.
pub fn seedLayout(
    alloc: std.mem.Allocator,
    table: []const Host,
    shared: *Shared,
    bytes: []const u8,
    entry_spelling: ?[]const u8,
) SeedResult {
    var probe = layout.parse(alloc, bytes) orelse return .{ .refused = firstLine(bytes) };
    defer probe.deinit(alloc);
    var keeps = std.ArrayListUnmanaged(SeedKeep){};
    defer keeps.deinit(alloc);
    var entry_at: ?usize = null;
    var dropped: usize = 0;
    for (probe.spellings.items, 0..) |sp, i| {
        if (entry_spelling) |es| {
            if (entry_at == null and std.mem.eql(u8, sp, es)) {
                entry_at = i;
                continue;
            }
        }
        const cut = std.mem.lastIndexOfScalar(u8, sp, '#') orelse return .{ .refused = sp };
        const sess = sp[cut + 1 ..];
        if (!proto.validSessionName(sess)) return .{ .refused = sp };
        const hi = for (table, 0..) |*h, j| {
            if (std.mem.eql(u8, h.spec.spelling, sp[0..cut])) break j;
        } else return .{ .refused = sp };
        if (table[hi].self_name) |self| {
            if (std.mem.eql(u8, self, sess)) {
                dropped += 1;
                continue;
            }
        }
        for (keeps.items) |k| {
            if (std.mem.eql(u8, probe.spellings.items[k.saved], sp)) return .{ .refused = sp };
        }
        keeps.append(alloc, .{ .saved = i, .host = hi }) catch return .none;
    }
    const base: usize = if (entry_spelling != null) 1 else 0;
    if (base + keeps.items.len > wv.max_tiles) return .{ .refused = probe.spellings.items[keeps.items[wv.max_tiles - base].saved] };
    if (keeps.items.len == 0 and entry_at == null) return .none;
    var n = keeps.items.len;
    while (true) : (n -= 1) {
        if (seedAttempt(alloc, shared, bytes, entry_at, keeps.items[0..n], base)) |plan| {
            var out = plan;
            out.dropped = dropped + (keeps.items.len - n);
            return .{ .plan = out };
        }
        if (n == 0) return .none;
    }
}

fn firstLine(bytes: []const u8) []const u8 {
    const nl = std.mem.indexOfScalar(u8, bytes, '\n') orelse bytes.len;
    return bytes[0..nl];
}
```

`seedAttempt` is unchanged except that it is now reached only with leaves that passed every check; leave its body as is. Note `probe` is now deferred rather than deinit'd by hand at each exit: remove the hand `probe.deinit(alloc)` calls that the old body had.

`seedSidecar` changes its return to match and prints the refusal:

```zig
pub fn seedSidecar(alloc: std.mem.Allocator, table: []const Host, shared: *Shared, entry_spelling: ?[]const u8) ?SeedPlan {
    if (!shared.is_tty) return null;
    const path = hosts.layoutPath(alloc) catch return null;
    defer alloc.free(path);
    const bytes = loadLayout(alloc, path) orelse return null;
    defer alloc.free(bytes);
    return switch (seedLayout(alloc, table, shared, bytes, entry_spelling)) {
        .plan => |p| p,
        .refused => |line| blk: {
            // Said once, on stderr, before the alternate screen: the wall
            // then starts as if the file were missing, and the line is the
            // thing to fix or delete.
            std.debug.print("mux: layout ignored ({s}): {s}\n", .{ path, line });
            break :blk null;
        },
        .none => null,
    };
}
```

- [ ] **Step 4: Run the tests**

Run: `deps/zig/zig build test 2>&1 | grep -a "seedLayout\|error:" | head -8`
Expected: the four new tests pass; the OLD `seedLayout` tests in `wall_test_layout.zig` that assert healing against live lists (leaves dropped because a poll did not list them) now fail.

- [ ] **Step 5: Retire the healing tests**

In `src/tui/wall_test_layout.zig`, delete every test whose name says a leaf is dropped, healed, or kept according to a host's LIST (grep `seedLayout` in the file; each such test feeds `fixture.setList` before seeding). Keep tests about the tree, `doResize`, `relayout`, and the entry insertion. For each deleted test, check the new tests above cover the file-shape it exercised (self-loop drop stays: keep the test that pins `self_name` dropping a leaf, updating its expectation to `.plan` with `dropped == 1`).

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

Run: `make check 2>&1 | tail -3; echo rc=$?`
Expected: `rc=0`.

- [ ] **Step 7: Commit**

```bash
git add src/tui/wall_layout.zig src/tui/wall_test_layout.zig src/tui/wallview.zig
git commit -m "feat: the layout file seats every leaf it names, and a bad file is refused with its line"
```

---

### Task 3: The poll grades panes and births nothing

**Files:**
- Modify: `src/tui/wall_host.zig` (`planHostDiff`, `applyHostList`, the `BirthNames` type, the `//!` header)
- Modify: `src/tui/wall_test_host.zig`

**Interfaces:**
- Produces: `wall_host.planHostDiff(tiles, present, live, host, list, self_name, binds, vanish, gones) void` — the `births` parameter is gone. `BirthNames` is deleted.
- Consumed by: `applyHostList` only.

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

Append to `src/tui/wall_test_host.zig`:

```zig
test "planHostDiff: a session the daemon has and the wall does not is nobody's business: no birth, no tile" {
    var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
    var tiles = fixture.diffFixture(&shared);
    var present = [_]bool{ true, true, true };
    var binds = TileIdxs{};
    var vanish = TileIdxs{};
    var gones = TileIdxs{};
    // Host 0 answers with its two panes' sessions and three the wall never
    // asked for. Twice, so the grace has been spent and a vanish would show.
    wall_host.planHostDiff(&tiles, &present, 3, 0, "a\nb\nx\ny\nz\n", null, &binds, &vanish, &gones);
    wall_host.planHostDiff(&tiles, &present, 3, 0, "a\nb\nx\ny\nz\n", null, &binds, &vanish, &gones);
    try std.testing.expectEqual(@as(usize, 0), vanish.len);
    try std.testing.expectEqual(@as(usize, 0), gones.len);
    try std.testing.expectEqual(@as(usize, 3), wv.presentCount(&present));
}
```

`fixture.diffFixture` seats tiles `a` and `b` on host 0 and `a` on host 1 (read its body in `wall_test_harness.zig` to confirm the names; adjust the list above to the fixture's real names).

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

Run: `deps/zig/zig build test 2>&1 | grep -a "error:" | head -3`
Expected: compile error — `planHostDiff` still takes ten arguments.

- [ ] **Step 3: Drop births from the planner and the applier**

In `src/tui/wall_host.zig`:

1. Delete the `BirthNames` type (the `Fixed(...)` instantiation for names) if nothing else uses it (grep).
2. `planHostDiff`: remove the `births: *BirthNames` parameter and, in the first loop, the `if (!found) births.append(name);` line. The first loop's only remaining job is `binds`: a pending pane whose session the list names.
3. `applyHostList`: delete the `var births = BirthNames{};`, the `while (placed < births.len)` loop, and the `unplaced` notice block. Keep binds, gones, vanish, drift, and `dressSilent`.
4. Rewrite the `//!` header's last sentence: "A host contributes nothing but the GRADE of the panes the layout already gave it — `applyHostList` binds a pending pane whose session the list names, marks `gone` one it does not, and vanishes a live pane whose shell has ended. The layout is the only source of tiles."

- [ ] **Step 4: Update the existing planner tests**

In `src/tui/wall_test_host.zig`, every `planHostDiff` call loses its `&births` argument. The test "names the daemon has and the wall does not are births…" becomes an assertion that nothing was born (delete its `births` expectations; keep the vanish half). Any test named for births of unlisted names is replaced by the Step 1 test.

- [ ] **Step 5: Run the gate**

Run: `make check 2>&1 | tail -3; echo rc=$?`
Expected: `rc=0`.

- [ ] **Step 6: Run the two wall groups**

Run: `E2E_ONLY=09_hosts make e2e 2>&1 | grep -a "FAIL\|e2e OK (" | head -3`
Expected: FAIL at "every live session of every listed daemon is a tile…". That leg pins the old model and is rewritten in Task 9; note the failure and continue. Do NOT edit the leg here.

- [ ] **Step 7: Commit**

```bash
git add src/tui/wall_host.zig src/tui/wall_test_host.zig
git commit -m "feat: the poll grades panes and adds none; the layout is the only source of tiles"
```

---

### Task 4: One save path, and every change goes through it

**Files:**
- Modify: `src/tui/wallview.zig` (`Shared` gains `layout_path`; `run` sets it; the keyboard loop calls `persist` after births, splits, resizes, vanishes, detach)
- Modify: `src/tui/wall_layout.zig` (`saveSidecar` becomes `persist`, gated on `layout_path`)
- Modify: `src/tui/wall_picker.zig` (`pickBirth`, `pickForget` persist)
- Test: `src/tui/wall_test_layout.zig`

**Interfaces:**
- Produces: `Shared.layout_path: ?[]const u8 = null`; `wall_layout.persist(w: Wall) void` (replaces `saveSidecar`; same body, gated on `w.shared.layout_path`).
- Consumed by: Tasks 5, 6, 8.

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

Append to `src/tui/wall_test_layout.zig`:

```zig
test "persist: a birth and a vanish each write the layout, and no layout_path writes nothing" {
    const testtmp = @import("testtmp");
    var tmp = try testtmp.TmpDir.make();
    defer tmp.cleanup();
    var path_buf: [64]u8 = undefined;
    const path = try std.fmt.bufPrint(&path_buf, "{s}/layout", .{tmp.path()});

    var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = false };
    defer shared.tree.deinit();
    var tiles: [wv.max_tiles]Tile = undefined;
    var present = [_]bool{false} ** wv.max_tiles;
    var live: usize = 0;
    var hosts_table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")};
    const w = fixture.wallOf(std.testing.allocator, &tiles, &present, &live, &shared, &hosts_table);

    // Not yet a wall that persists: nothing is written.
    wall_layout.persist(w);
    try std.testing.expectError(error.FileNotFound, std.fs.cwd().access(path, .{}));

    shared.layout_path = path;
    const at = wv.birthTile(w, .{
        .r = .{ .target = hosts_table[0].spec.target, .label = "", .session = "0" },
        .from = 0,
        .place = .beside_focus,
        .creates = false,
        .born_from = null,
        .host = 0,
        .borrowed = true,
    }).?;
    wall_layout.persist(w);
    const first = try std.fs.cwd().readFileAlloc(std.testing.allocator, path, 4096);
    defer std.testing.allocator.free(first);
    try std.testing.expect(std.mem.indexOf(u8, first, "leaf 0 --sock /a#0") != null);

    wv.vanishTile(w.liveTiles(), w.livePresent(), &shared, at, null);
    wall_layout.persist(w);
    const second = try std.fs.cwd().readFileAlloc(std.testing.allocator, path, 4096);
    defer std.testing.allocator.free(second);
    try std.testing.expect(std.mem.indexOf(u8, second, "#0") == null);
    fixture.endPumps(&tiles);
}
```

`birthTile` is `pub`; `Birth` is a private struct literal, which is fine for an anonymous literal. If `birthTile` refuses because `tiles` is uninitialized memory, seat the tile through `fixture.claimBench` the way `wall_test_wall.zig` does, then call `persist`.

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

Run: `deps/zig/zig build test 2>&1 | grep -a "error:" | head -3`
Expected: `no member named 'layout_path'`, `persist` undefined.

- [ ] **Step 3: Implement**

In `src/tui/wallview.zig`, in `Shared` beside `is_tty`:

```zig
/// Where this wall is written, or null for a wall that persists nothing
/// (a piped `mux`, a test). Set once by `run` on a terminal. The ONE
/// gate every save reads, so a test points it at a file and proves what
/// an operation wrote rather than that a save was called.
layout_path: ?[]const u8 = null,
```

In `run`, right after `is_tty` is known and before the host table is built:

```zig
if (is_tty) shared.layout_path = hosts.layoutPath(alloc) catch null;
defer if (shared.layout_path) |p| alloc.free(p);
```

In `src/tui/wall_layout.zig`, rename `saveSidecar` to `persist` and make its body:

```zig
/// The one save path. Every change to the pane set or the tree comes
/// through here — a birth, a removal, a split, a resize, a detach — so two
/// terminals on one device see each other's adds on their next start, and
/// a wall that crashes loses nothing it committed.
pub fn persist(w: Wall) void {
    const path = w.shared.layout_path orelse return;
    saveLayoutTo(w.alloc, path, w.liveTiles(), w.livePresent(), w.shared);
}
```

Also drop the `if (!shared.is_tty) return null;` line from `seedSidecar` in favour of `const path = shared.layout_path orelse return null;` so load and save agree on one gate.

Call sites of `persist` in `src/tui/wallview.zig`'s keyboard loop (find each by its action tag):
- after a successful `birthTile` in the `.new_session, .split_right, .split_below` arm;
- in the `.detach` arm, where `saveSidecar` was;
- after `doResize` returns true in the `.resize` arm;
- after every `vanishTile` that follows an `endAction` `.vanish` (the pump-ended path) and after `closePicker` when `birth_at` is non-null;
- after the entry tile is seated at startup (the `addFirst(0)` and the seed plan's insertion), once, before the keys loop.

In `src/tui/wall_picker.zig`: `pickBirth` calls `wall_layout.persist(w)` after `spawnPump`; `pickForget` calls it after its vanish loop.

- [ ] **Step 4: Run the gate**

Run: `make check 2>&1 | tail -3; echo rc=$?`
Expected: `rc=0`. The comment gate needs `saveSidecar` gone from every comment; grep and reword to `persist`.

- [ ] **Step 5: Commit**

```bash
git add src/tui/wallview.zig src/tui/wall_layout.zig src/tui/wall_picker.zig src/tui/wall_test_layout.zig
git commit -m "feat: every change to the wall writes the layout through one save path"
```

---

### Task 5: `x` removes the pane; the session lives on

**Files:**
- Modify: `src/tui/wallview.zig` (`removePane`; the `.end_session` arm)
- Test: `src/tui/wall_test_wall.zig`

**Interfaces:**
- Produces: `wv.removePane(w: Wall, z: usize) void`.
- `endKey`, `intentForEnd`, `onEndReply`, `EndKey` stay for Task 6 (the picker's end path reuses `end_arm_ms`).

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

Append to `src/tui/wall_test_wall.zig`, modelled on the file's existing `claimBench`/`endBench` tests:

```zig
test "removePane: the pane leaves the wall, its pump is told to detach, and nothing is asked to end" {
    var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = false };
    defer shared.tree.deinit();
    var tiles = [_]Tile{ fixture.claimBench(&shared, 0), fixture.claimBench(&shared, 1) };
    var present = [_]bool{ true, true };
    try shared.tree.addFirst(0);
    try shared.tree.splitRight(0, 1);
    const w = fixture.wallAll(std.testing.allocator, &tiles, &present, &shared);
    shared.sel = 1;

    wv.removePane(w, 1);
    try std.testing.expect(!present[1]);
    try std.testing.expect(present[0]);
    try std.testing.expect(tiles[1].detach_req.load(.acquire));
    // The daemon was NOT asked to end anything: the ask mailbox is empty.
    try std.testing.expectEqual(@as(u8, 0), tiles[1].ask.load(.acquire));
    try std.testing.expectEqual(@as(usize, 0), shared.sel);
    var buf: [128]u8 = undefined;
    try std.testing.expectEqualStrings("[pane removed - the session is still on its daemon]", wv.takeNotice(&shared, &buf));
    fixture.endPumps(&tiles);
}
```

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

Run: `deps/zig/zig build test 2>&1 | grep -a "error:" | head -3`
Expected: `removePane` undefined.

- [ ] **Step 3: Implement**

In `src/tui/wallview.zig`, below `vanishTile`:

```zig
/// `Ctrl-\ x`: this pane leaves THIS wall. The session is the daemon's and
/// keeps running for whoever else holds it; ending one is the picker's
/// job, beside the count of who else is there. The pump is told to say
/// goodbye (`detach_req`) so the daemon frees the slot now rather than at
/// a timeout, then the tile is vanished and the layout written without it.
pub fn removePane(w: Wall, z: usize) void {
    if (z >= w.live.* or !w.present[z]) return;
    const t = &w.tiles[z];
    t.detach_req.store(true, .release);
    ring(t);
    vanishTile(w.liveTiles(), w.livePresent(), w.shared, z, null);
    setNotice(w.shared, "[pane removed - the session is still on its daemon]");
    wall_layout.relayout(w, w.shared.sel);
    wall_layout.persist(w);
}
```

Read `vanishTile` and the pump's `detach_req` handling (`wall_pump.zig`, the block that writes `.detach` and sets `detach_ack`) to confirm a pump parked in `dial` also exits on `removed`; the `.drop` arm of the old handler already relied on that for never-up tiles.

Replace the `.end_session` arm of the keyboard loop with:

```zig
.end_session => if (z < w.live.* and present[z]) {
    removePane(w, z);
    if (!shared.is_tty and presentCount(present[0..live]) == 0) {
        exit_code = 0;
        exit_msg = "mux: aborted before attaching";
        break :keys;
    }
},
```

Keep the `endKey`/`intentForEnd`/`onEndReply` functions and `end_arm_ms`; Task 6 moves their caller.

- [ ] **Step 4: Run the gate**

Run: `make check 2>&1 | tail -3; echo rc=$?`
Expected: `rc=0`. Tests in `wall_test_wall.zig` that drove `endKey` through the keyboard path may now be unreachable; keep the pure `endKey` tests (they still pin the two-step timing Task 6 reuses).

- [ ] **Step 5: Commit**

```bash
git add src/tui/wallview.zig src/tui/wall_test_wall.zig
git commit -m "feat: x takes the pane off this wall and ends nothing"
```

---

### Task 6: The picker's session level: add, birth, end

**Files:**
- Modify: `src/tui/interact.zig` (`PrefixFilter`: `pick_level`, actions `pick_enter`, `pick_back`, `pick_end`)
- Modify: `src/client/client.zig` (`endSession`)
- Modify: `src/tui/wall_picker.zig` (`sessionRows`, `pickAdd`, `pickEnd`, `paintPicker` at the session level)
- Modify: `src/tui/wallview.zig` (the picker arm of the keyboard loop: `picker_row`, level state)
- Test: `src/tui/interact.zig` (inline), `src/tui/wall_test_picker.zig`, `src/client/client.zig` (inline, against a real daemon via the existing `TestDaemon` shape used by `birthSession`'s test)

**Interfaces:**
- `interact.PrefixFilter` gains `pick_level: enum { hosts, sessions } = .hosts`. Actions added: `pick_enter` (Enter at either level), `pick_back` (Esc at the session level), `pick_end` (`x` at the session level). `pick_forget` is `x` at the host level only. `pick_birth` is `c` at either level. The filter sets `picking = false` on `pick_enter` at the session level, on `pick_birth`, on `pick_forget`, and on close; it stays open on `pick_enter` at the host level, on `pick_back`, and on `pick_end`.
- `client.endSession(alloc: std.mem.Allocator, target: Target, name: []const u8, force: bool) !EndOutcome` with

```zig
pub const EndOutcome = struct {
    accepted: bool,
    others: u8,
    reason_buf: [proto.end_reply_max_len]u8 = undefined,
    reason_len: usize = 0,
    pub fn reason(self: *const EndOutcome) []const u8 {
        return self.reason_buf[0..self.reason_len];
    }
};
```

- `wall_picker.sessionRows(body: *PickerBody, h: *Host, w: Wall, host: usize, sel_row: usize, cols: u16) void`
- `wall_picker.pickAdd(w: Wall, host: usize, row: usize) ?usize` — the tile index added or zoomed to; null with a notice.
- `wall_picker.pickEnd(w: Wall, host: usize, row: usize, now: i64) void` — sends `end_req`, arms the 3 s force window per (host, name) in `PickerEnd` state on `Shared`, sets the notice.

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

Append to `src/tui/interact.zig`'s tests, beside the existing `pick_open` tests (copy their `PrefixFilter{}` construction and the byte sequence they use to open the picker — `Ctrl-\ s`):

```zig
test "picker levels: Enter on a host opens its sessions, Esc backs out a level, Enter on a session closes with pick_enter" {
    var f = PrefixFilter{};
    var open = [_]u8{ detach_key, 's' };
    try std.testing.expectEqual(PrefixFilter.Action.pick_open, f.feed(&open).action);
    try std.testing.expectEqual(PrefixFilter.PickLevel.hosts, f.pick_level);

    var enter = [_]u8{'\r'};
    try std.testing.expectEqual(PrefixFilter.Action.pick_enter, f.feed(&enter).action);
    try std.testing.expect(f.picking);
    try std.testing.expectEqual(PrefixFilter.PickLevel.sessions, f.pick_level);

    var esc = [_]u8{0x1b};
    try std.testing.expectEqual(PrefixFilter.Action.pick_back, f.feed(&esc).action);
    try std.testing.expect(f.picking);
    try std.testing.expectEqual(PrefixFilter.PickLevel.hosts, f.pick_level);

    _ = f.feed(&enter);
    try std.testing.expectEqual(PrefixFilter.Action.pick_enter, f.feed(&enter).action);
    try std.testing.expect(!f.picking);
    try std.testing.expectEqual(PrefixFilter.PickLevel.hosts, f.pick_level);
}

test "picker levels: x forgets at the host level and ends at the session level; c births at either; every byte stays the popup's" {
    var f = PrefixFilter{};
    var open = [_]u8{ detach_key, 's' };
    _ = f.feed(&open);
    var x = [_]u8{'x'};
    try std.testing.expectEqual(PrefixFilter.Action.pick_forget, f.feed(&x).action);
    try std.testing.expect(!f.picking);

    _ = f.feed(&open);
    var enter = [_]u8{'\r'};
    _ = f.feed(&enter);
    const ended = f.feed(&x);
    try std.testing.expectEqual(PrefixFilter.Action.pick_end, ended.action);
    try std.testing.expectEqual(@as(usize, 0), ended.forward.len);
    try std.testing.expect(f.picking); // the popup stays up to show the count or the end

    var c = [_]u8{'c'};
    try std.testing.expectEqual(PrefixFilter.Action.pick_birth, f.feed(&c).action);
    try std.testing.expect(!f.picking);
    try std.testing.expectEqual(PrefixFilter.PickLevel.hosts, f.pick_level);
}
```

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

Run: `deps/zig/zig build test 2>&1 | grep -a "error:" | head -3`
Expected: `pick_level`, `PickLevel`, `pick_enter` undefined.

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

In `src/tui/interact.zig`, `PrefixFilter`:

```zig
pub const PickLevel = enum { hosts, sessions };
/// Which list the popup shows. The filter owns it because Enter and Esc
/// mean different things at each level, and a key must resolve to ONE
/// action without the wall's help.
pick_level: PickLevel = .hosts,
```

Add to `Action`: `pick_enter`, `pick_back`, `pick_end`.

In the `if (self.picking)` switch:

```zig
0x1b => {
    const tail = buf[i + 1 ..];
    if (arrowMove(tail)) |d|
        return .{ .forward = buf[0..kept], .action = .{ .pick_move = d } };
    if (tail.len > 0 and (tail[0] == '[' or tail[0] == 'O'))
        return .{ .forward = buf[0..kept], .action = .none };
    if (self.pick_level == .sessions) {
        self.pick_level = .hosts;
        return .{ .forward = buf[0..kept], .action = .pick_back };
    }
    self.picking = false;
    return .{ .forward = buf[0..kept], .action = .pick_close };
},
'\r', '\n' => {
    if (self.pick_level == .hosts) {
        self.pick_level = .sessions;
        return .{ .forward = buf[0..kept], .action = .pick_enter };
    }
    self.picking = false;
    self.pick_level = .hosts;
    return .{ .forward = buf[0..kept], .action = .pick_enter };
},
'c' => {
    self.picking = false;
    self.pick_level = .hosts;
    return .{ .forward = buf[0..kept], .action = .pick_birth };
},
'x' => {
    if (self.pick_level == .sessions)
        return .{ .forward = buf[0..kept], .action = .pick_end };
    self.picking = false;
    return .{ .forward = buf[0..kept], .action = .pick_forget };
},
's', 0x03 => {
    self.picking = false;
    self.pick_level = .hosts;
    return .{ .forward = buf[0..kept], .action = .pick_close };
},
```

The old `'\r', '\n', 'c' => pick_birth` arm is replaced by the two arms above. Add `.pick_enter, .pick_back, .pick_end` to `wall_picker.isPickAction`.

- [ ] **Step 4: Run the filter tests**

Run: `deps/zig/zig build test 2>&1 | grep -a "picker levels\|error:" | head -5`
Expected: pass. Existing tests that expected Enter to be `pick_birth` now see `pick_enter`; update those expectations (Enter births nothing any more; `c` does).

- [ ] **Step 5: Write the failing `endSession` test**

In `src/client/client.zig`, beside `birthSession`'s test (grep `birthSession` in the file's tests for the daemon-fixture shape; it starts a real daemon on a `TmpDir` socket):

```zig
test "endSession: a held session refuses with the count, force ends it, and an unknown name is refused with the daemon's reason" {
    // (fixture: a real daemon on a tmp socket, one session "0" with one
    // client attached — same setup as birthSession's test above)
    var first = try endSession(std.testing.allocator, .{ .sock = sock_path }, "0", false);
    try std.testing.expect(!first.accepted);
    try std.testing.expectEqual(@as(u8, 1), first.others);
    var forced = try endSession(std.testing.allocator, .{ .sock = sock_path }, "0", true);
    try std.testing.expect(forced.accepted);
    var missing = try endSession(std.testing.allocator, .{ .sock = sock_path }, "nope", false);
    try std.testing.expect(!missing.accepted);
    try std.testing.expectEqualStrings(proto.end_reason.no_session, missing.reason());
}
```

- [ ] **Step 6: Implement `endSession`**

In `src/client/client.zig`, after `birthSession`:

```zig
pub const EndOutcome = struct {
    accepted: bool,
    others: u8,
    reason_buf: [proto.end_reply_max_len]u8 = undefined,
    reason_len: usize = 0,
    pub fn reason(self: *const EndOutcome) []const u8 {
        return self.reason_buf[0..self.reason_len];
    }
};

/// `end_req` on a side connection of its own: the picker ends a session
/// that may have no pane on this wall, so there is no pump to ask
/// through. The daemon owns the two-step; this only carries `force`.
pub fn endSession(alloc: std.mem.Allocator, target: Target, name: []const u8, force: bool) !EndOutcome {
    var tr = try Transport.open(alloc, target, null, -1, null);
    defer tr.close();
    var buf: [proto.end_req_max_len]u8 = undefined;
    const req = proto.encodeEndReq(&buf, force, proto.wireName(name));
    const deadline = std.time.milliTimestamp() + birth_budget_ms;
    const f = roundTrip(&tr, alloc, .end_req, req, &.{ .end_reply, .exit_status }, deadline) catch |e| return switch (e) {
        error.Timeout => error.Timeout,
        else => error.Transport,
    };
    defer f.deinit(alloc);
    if (f.type != .end_reply) return error.Refused;
    const r = try proto.decodeEndReply(f.payload);
    var out: EndOutcome = .{ .accepted = r.accepted, .others = r.others };
    const n = @min(r.reason.len, out.reason_buf.len);
    @memcpy(out.reason_buf[0..n], r.reason[0..n]);
    out.reason_len = n;
    return out;
}
```

`proto.decodeEndReply` exists beside `encodeEndReply` (grep to confirm the name; if it is `parseEndReply`, use that).

- [ ] **Step 7: Run the client test**

Run: `deps/zig/zig build test 2>&1 | grep -a "endSession\|error:" | head -5`
Expected: pass.

- [ ] **Step 8: Write the failing picker tests**

Append to `src/tui/wall_test_picker.zig` (it has `fixture.testHost`, `fixture.setList`, and `pickerFrame` for painted rows):

```zig
test "sessionRows: a host's sessions, marked when already on this wall, with the holder count when the daemon says" {
    var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
    defer shared.tree.deinit();
    var hosts_table = [_]Host{fixture.testHost(&shared, "box", "/b")};
    fixture.setList(&hosts_table[0], "0\nwork\n# holds 0 2\n# holds work 0\n# mux 0.0.1-18");
    var tiles = [_]Tile{fixture.claimBench(&shared, 0)};
    tiles[0].host = 0;
    tiles[0].r.session = "work";
    var present = [_]bool{true};
    const w = fixture.wallAll(std.testing.allocator, &tiles, &present, &shared);
    _ = w.hosts; // wallAll has no hosts; the rows take the table directly

    var body = wall_picker.PickerBody{};
    wall_picker.sessionRows(&body, &hosts_table[0], fixture.wallOf(std.testing.allocator, &tiles, &present, w.live, &shared, &hosts_table), 0, 1, 80);
    try std.testing.expectEqual(@as(usize, 2), body.n);
    try std.testing.expect(std.mem.indexOf(u8, body.row(0), "0") != null);
    try std.testing.expect(std.mem.indexOf(u8, body.row(0), "2 clients") != null);
    try std.testing.expect(std.mem.indexOf(u8, body.row(1), "work") != null);
    try std.testing.expect(std.mem.indexOf(u8, body.row(1), "on this wall") != null);
    fixture.endPumps(&tiles);
}

test "sessionRows: an old daemon's list shows no count rather than zero" {
    var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
    defer shared.tree.deinit();
    var hosts_table = [_]Host{fixture.testHost(&shared, "box", "/b")};
    fixture.setList(&hosts_table[0], "0\n");
    var tiles: [1]Tile = undefined;
    var present = [_]bool{false};
    var live: usize = 0;
    var body = wall_picker.PickerBody{};
    wall_picker.sessionRows(&body, &hosts_table[0], fixture.wallOf(std.testing.allocator, &tiles, &present, &live, &shared, &hosts_table), 0, 0, 80);
    try std.testing.expectEqual(@as(usize, 1), body.n);
    try std.testing.expect(std.mem.indexOf(u8, body.row(0), "client") == null);
}

test "pickAdd: a listed session becomes a pane once; a second add zooms to it" {
    var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = false };
    defer shared.tree.deinit();
    var hosts_table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")};
    fixture.setList(&hosts_table[0], "0\nwork\n");
    var tiles: [wv.max_tiles]Tile = undefined;
    var present = [_]bool{false} ** wv.max_tiles;
    var live: usize = 0;
    const w = fixture.wallOf(std.testing.allocator, &tiles, &present, &live, &shared, &hosts_table);

    const first = wall_picker.pickAdd(w, 0, 1).?;
    try std.testing.expectEqualStrings("work", tiles[first].r.session);
    try std.testing.expect(!tiles[first].creates);
    try std.testing.expectEqual(@as(usize, 1), wv.presentCount(w.livePresent()));
    const again = wall_picker.pickAdd(w, 0, 1).?;
    try std.testing.expectEqual(first, again);
    try std.testing.expectEqual(@as(usize, 1), wv.presentCount(w.livePresent()));
    try std.testing.expectEqual(first, shared.sel);
    fixture.endPumps(&tiles);
}
```

- [ ] **Step 9: Implement the picker's session level**

In `src/tui/wall_picker.zig`:

```zig
/// The second level: one row per session the host's last answer named.
/// "on this wall" when the layout already has it, and the holder count
/// when the daemon is new enough to say (`proto.parseSessionsHolds`); an
/// old daemon's rows carry no count rather than a zero that would read as
/// "safe to end".
pub fn sessionRows(body: *PickerBody, h: *Host, w: Wall, host: usize, sel_row: usize, cols: u16) void {
    body.n = 0;
    var list_buf: [proto.sessions_reply_max]u8 = undefined;
    const list = h.poll.snapshot(&list_buf);
    var it = proto.sessionsIter(list);
    var row: usize = 0;
    while (it.next()) |name| : (row += 1) {
        if (body.n >= wv.max_tiles) break;
        var state_buf: [48]u8 = undefined;
        var state: []const u8 = "";
        const on_wall = paneOf(w, host, name) != null;
        if (proto.parseSessionsHolds(list, name)) |n| {
            state = std.fmt.bufPrint(&state_buf, "{s}{d} client{s}", .{
                if (on_wall) "on this wall, " else "",
                n,
                if (n == 1) "" else "s",
            }) catch "";
        } else if (on_wall) state = "on this wall";
        body.host[body.n] = host;
        body.lens[body.n] = pickerRow(&body.text[body.n], body.n + 1, false, name, state, row == sel_row, cols).len;
        body.n += 1;
    }
}

fn paneOf(w: Wall, host: usize, name: []const u8) ?usize {
    for (w.liveTiles(), w.livePresent(), 0..) |*t, p, i| {
        if (p and wall_host.ownedBy(t, host) and std.mem.eql(u8, proto.resolveName(t.r.session), name)) return i;
    }
    return null;
}

fn sessionAt(h: *Host, row: usize, out: *[proto.session_name_max]u8) ?[]const u8 {
    var list_buf: [proto.sessions_reply_max]u8 = undefined;
    const list = h.poll.snapshot(&list_buf);
    var it = proto.sessionsIter(list);
    var i: usize = 0;
    while (it.next()) |name| : (i += 1) {
        if (i == row) {
            @memcpy(out[0..name.len], name);
            return out[0..name.len];
        }
    }
    return null;
}

/// Enter on a session row: a pane for it, joined (never created), zoomed
/// to. A session already on the wall is only zoomed to. The layout is
/// written, because this is one of the three places a pane comes from.
pub fn pickAdd(w: Wall, host: usize, row: usize) ?usize {
    if (host >= w.hosts.len) return null;
    const h = &w.hosts[host];
    var name_buf: [proto.session_name_max]u8 = undefined;
    const name = sessionAt(h, row, &name_buf) orelse {
        wv.setNotice(w.shared, "[no session on that row]");
        return null;
    };
    if (paneOf(w, host, name)) |at| {
        wv.setFocus(w.liveTiles(), w.shared, at);
        return at;
    }
    var target = h.spec.target;
    if (target == .hand) target.hand.asked = true;
    const anchor = wall_layout.anchorTile(w.livePresent(), w.shared.sel);
    const has_anchor = wv.presentCount(w.livePresent()) > 0;
    const at = wv.birthTile(w, .{
        .r = .{ .target = target, .label = "", .session = name, .agent = false },
        .from = anchor,
        .place = .beside_focus,
        .creates = false,
        .born_from = if (has_anchor) anchor else null,
        .host = host,
        .borrowed = true,
    }) orelse {
        wv.setNotice(w.shared, "[no room on the wall for another pane]");
        return null;
    };
    wv.spawnPump(&w.tiles[at]);
    wv.setFocus(w.liveTiles(), w.shared, at);
    wall_layout.persist(w);
    return at;
}

/// `x` on a session row: the daemon's two-step, from a side connection.
/// The first press on a session others hold is refused with the count and
/// arms 3 s; a second press inside that window forces. Armed per host and
/// name on `Shared`, so a press on a different row is a first press.
pub fn pickEnd(w: Wall, host: usize, row: usize, now: i64) void {
    if (host >= w.hosts.len) return;
    const h = &w.hosts[host];
    var name_buf: [proto.session_name_max]u8 = undefined;
    const name = sessionAt(h, row, &name_buf) orelse return;
    const armed = w.shared.pick_end.armedFor(host, name, now);
    const out = client.endSession(w.alloc, h.spec.target, name, armed) catch |e| {
        var buf: [96]u8 = undefined;
        wv.setNotice(w.shared, std.fmt.bufPrint(&buf, "[could not ask {s} to end {s}: {s}]", .{ h.spec.spelling, name, @errorName(e) }) catch "[could not ask the daemon]");
        return;
    };
    var buf: [128]u8 = undefined;
    if (out.accepted) {
        w.shared.pick_end.clear();
        wv.setNotice(w.shared, std.fmt.bufPrint(&buf, "[ending {s} on {s}]", .{ name, h.spec.spelling }) catch "[ending the session]");
    } else {
        w.shared.pick_end.arm(host, name, now + wv.end_arm_ms);
        wv.setNotice(w.shared, std.fmt.bufPrint(&buf, "[{s}: {d} other client{s} attached - x again within 3s to end anyway]", .{
            name, out.others, if (out.others == 1) "" else "s",
        }) catch "[others attached - x again to end anyway]");
    }
    h.poll.poke.store(true, .release);
}
```

Add to `Shared` in `wallview.zig`:

```zig
/// The picker's end two-step, per host and name: a second `x` on the SAME
/// row inside the window forces, any other row is a first press.
pick_end: PickEnd = .{},

pub const PickEnd = struct {
    host: usize = 0,
    name: client.SessionName = .{},
    until: i64 = 0,
    pub fn armedFor(self: *const PickEnd, host: usize, name: []const u8, now: i64) bool {
        return now < self.until and self.host == host and std.mem.eql(u8, self.name.slice(), name);
    }
    pub fn arm(self: *PickEnd, host: usize, name: []const u8, until: i64) void {
        self.host = host;
        self.name = client.SessionName.of(name);
        self.until = until;
    }
    pub fn clear(self: *PickEnd) void {
        self.until = 0;
    }
};
```

`end_arm_ms` must be `pub` in `wallview.zig`. `client.SessionName.of` exists (the hub uses it).

`paintPicker` gains the level: when `prefix.pick_level == .sessions`, the body comes from `sessionRows` for `host_table[picker_sel]` and the title line names the host; otherwise as today. `pickerStep`/`pickerAt` at the session level step over `body.n` rows rather than hosts; add `picker_row: usize` beside `picker_sel` in the keyboard loop and clamp it to the row count on each repaint.

In `wallview.zig`'s picker arm of the keyboard loop:

```zig
.pick_enter => if (input.prefix.pick_level == .sessions) {
    // Enter at the host level just opened the list; nothing to do but paint.
    picker_row = 0;
} else {
    // Enter at the session level closed the popup with a choice.
    birth_at = wall_picker.pickAdd(w, picker_sel, picker_row);
},
.pick_back => picker_row = 0,
.pick_end => wall_picker.pickEnd(w, picker_sel, picker_row, std.time.milliTimestamp()),
.pick_move => |d| if (input.prefix.pick_level == .sessions) {
    picker_row = wall_picker.rowStep(picker_row, d, wall_picker.sessionCount(&w.hosts[picker_sel]));
} else picker_sel = wall_picker.pickerStep(w.hosts, picker_sel, d),
.pick_select => |row| if (input.prefix.pick_level == .sessions) {
    picker_row = @min(row - 1, wall_picker.sessionCount(&w.hosts[picker_sel]) -| 1);
} else if (wall_picker.pickerAt(w.hosts, row - 1)) |hi| picker_sel = hi,
```

with in `wall_picker.zig`:

```zig
pub fn sessionCount(h: *Host) usize {
    var list_buf: [proto.sessions_reply_max]u8 = undefined;
    var it = proto.sessionsIter(h.poll.snapshot(&list_buf));
    var n: usize = 0;
    while (it.next()) |_| n += 1;
    return n;
}

pub fn rowStep(row: usize, d: i8, n: usize) usize {
    if (n == 0) return 0;
    if (d < 0) return if (row == 0) n - 1 else row - 1;
    return if (row + 1 >= n) 0 else row + 1;
}
```

Note the order problem: the filter flips `pick_level` BEFORE the wall sees the action, so on `.pick_enter` the wall reads the NEW level: `.sessions` means the list just opened, `.hosts` means a session was chosen. The two arms above are written for that.

- [ ] **Step 10: Run the gate**

Run: `make check 2>&1 | tail -3; echo rc=$?`
Expected: `rc=0`.

- [ ] **Step 11: Commit**

```bash
git add src/tui/interact.zig src/client/client.zig src/tui/wall_picker.zig src/tui/wallview.zig src/tui/wall_test_picker.zig
git commit -m "feat: the picker lists a host's sessions; Enter adds one, c births, x ends with the daemon's two-step"
```

---

### Task 7: The hub serves and writes the layout

**Files:**
- Modify: `src/client/webhub.zig` (`Hub.init`, `applyList`, `spawn`, `json`)
- Modify: `src/cli/webhub_main.zig` (reads the layout, hands leaves to `Hub.init`)
- Test: `src/client/webhub.zig` (inline tests)

**Interfaces:**
- `webhub.Leaf = struct { host: usize, session: []const u8 }`
- `Hub.init(alloc, specs: []const client.HostSpec, leaves: []const Leaf) !Hub` — one tile per leaf, ids in leaf order.
- `Hub.applyList(host_idx, list, reachable)` grades: a tile whose session the list lacks gets `state = .gone` after the one-list grace; one the list names again leaves `gone`; nothing is born, nothing vanishes.
- `Hub.spawn(id) !client.SessionName` births as today AND appends the new leaf to the layout file beside the tile `id` names, then adds the tile.
- `webhub.readLeaves(alloc, path, specs) ![]Leaf` — parse the layout with `layout.parse`, map each spelling `HOST#SESSION` to a spec index; a leaf naming an unlisted host or a bad spelling refuses the file (`error.BadLayout`) and `mux web` prints the line and serves an empty wall.
- `webhub.appendLeaf(alloc, path, specs, beside: Leaf, new: Leaf) !void` — read-modify-write: `layout.parse`, find the leaf id of `beside`, `Tree.insert`, `serialize` with the spellings, `hosts.saveBytes`. A missing file becomes a one-leaf tree.

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

Replace the test "hub: a listed name births a tile once; ids are birth order and never reused" in `src/client/webhub.zig` with:

```zig
test "hub: tiles are the layout's leaves in order; a list names born elsewhere add nothing; a missing session reads gone and comes back" {
    const alloc = std.testing.allocator;
    const specs = [_]client.HostSpec{
        .{ .spelling = "--sock /tmp/a", .target = .{ .sock = "/tmp/a" }, .poll_target = .{ .sock = "/tmp/a" } },
        .{ .spelling = "box", .target = .{ .sock = "/tmp/b" }, .poll_target = .{ .sock = "/tmp/b" } },
    };
    const leaves = [_]Leaf{ .{ .host = 1, .session = "0" }, .{ .host = 0, .session = "0" }, .{ .host = 0, .session = "b" } };
    var hub = try Hub.init(alloc, &specs, &leaves);
    defer hub.deinit();
    try std.testing.expectEqual(@as(usize, 3), hub.tiles.items.len);
    try std.testing.expectEqual(@as(u32, 0), hub.tiles.items[0].id);
    try std.testing.expectEqualStrings("box", specs[hub.tiles.items[0].host].spelling);

    hub.applyList(0, "0\nb\nstranger\n", true);
    try std.testing.expectEqual(@as(usize, 3), hub.tiles.items.len);

    hub.applyList(0, "0\n", true);
    hub.applyList(0, "0\n", true);
    try std.testing.expectEqual(@as(usize, 3), hub.tiles.items.len);
    try std.testing.expectEqual(TileState.gone, hub.tiles.items[2].state);
    hub.applyList(0, "0\nb\n", true);
    try std.testing.expect(hub.tiles.items[2].state != .gone);

    const json = try hub.json(alloc);
    defer alloc.free(json);
    try std.testing.expectEqualStrings(
        \\[{"id":0,"label":"box","session":"0","state":"connecting"},{"id":1,"label":"--sock /tmp/a","session":"0","state":"connecting"},{"id":2,"label":"--sock /tmp/a","session":"b","state":"connecting"}]
    , json);
}

test "readLeaves and appendLeaf: the layout round-trips through the hub, and a bad file is refused with its line" {
    const alloc = std.testing.allocator;
    const testtmp = @import("testtmp");
    var tmp = try testtmp.TmpDir.make();
    defer tmp.cleanup();
    var pb: [64]u8 = undefined;
    const path = try std.fmt.bufPrint(&pb, "{s}/layout", .{tmp.path()});
    const specs = [_]client.HostSpec{
        .{ .spelling = "--sock /tmp/a", .target = .{ .sock = "/tmp/a" }, .poll_target = .{ .sock = "/tmp/a" } },
    };
    // No file yet: the first append makes a one-leaf tree.
    try appendLeaf(alloc, path, &specs, null, .{ .host = 0, .session = "0" });
    try appendLeaf(alloc, path, &specs, .{ .host = 0, .session = "0" }, .{ .host = 0, .session = "b" });
    const leaves = try readLeaves(alloc, path, &specs);
    defer alloc.free(leaves);
    try std.testing.expectEqual(@as(usize, 2), leaves.len);
    try std.testing.expectEqualStrings("0", leaves[0].session);
    try std.testing.expectEqualStrings("b", leaves[1].session);

    try std.fs.cwd().writeFile(.{ .sub_path = path, .data = "mux-layout 1\nleaf 0 nowhere#0\n" });
    try std.testing.expectError(error.BadLayout, readLeaves(alloc, path, &specs));
}
```

If `TileState` has no `gone` variant, add one (the browser's `controlMessage` table gains a `gone` string: "session ended on its daemon").

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

Run: `deps/zig/zig build test 2>&1 | grep -a "error:" | head -3`
Expected: `Leaf`, `readLeaves`, `appendLeaf` undefined.

- [ ] **Step 3: Implement**

In `src/client/webhub.zig`:

```zig
pub const Leaf = struct { host: usize, session: []const u8 };

/// The layout's leaves in tree order, each mapped to a spec index. The
/// same strictness as the terminal wall: a leaf the hosts file cannot
/// place, or one with no session, refuses the FILE, because a hub that
/// silently served part of a wall would be a wall the user cannot see is
/// short.
pub fn readLeaves(alloc: std.mem.Allocator, path: []const u8, specs: []const client.HostSpec) ![]Leaf {
    const bytes = std.fs.cwd().readFileAlloc(alloc, path, 1024 * 1024) catch |e| switch (e) {
        error.FileNotFound => return alloc.alloc(Leaf, 0),
        else => return e,
    };
    defer alloc.free(bytes);
    var parsed = layout.parse(alloc, bytes) orelse return error.BadLayout;
    defer parsed.deinit(alloc);
    var out = std.ArrayListUnmanaged(Leaf){};
    errdefer {
        for (out.items) |l| alloc.free(l.session);
        out.deinit(alloc);
    }
    for (parsed.spellings.items) |sp| {
        const cut = std.mem.lastIndexOfScalar(u8, sp, '#') orelse return error.BadLayout;
        const sess = sp[cut + 1 ..];
        if (!proto.validSessionName(sess)) return error.BadLayout;
        const hi = for (specs, 0..) |s, i| {
            if (std.mem.eql(u8, s.spelling, sp[0..cut])) break i;
        } else return error.BadLayout;
        try out.append(alloc, .{ .host = hi, .session = try alloc.dupe(u8, sess) });
    }
    return out.toOwnedSlice(alloc);
}

/// Read-modify-write over the atomic rename `hosts.saveBytes` does. Two
/// writers in the same instant lose one update; the hosts file accepts
/// the same, and the terminal wall re-reads on its next start.
pub fn appendLeaf(alloc: std.mem.Allocator, path: []const u8, specs: []const client.HostSpec, beside: ?Leaf, new: Leaf) !void {
    var parsed: layout.ParsedLayout = blk: {
        const bytes = std.fs.cwd().readFileAlloc(alloc, path, 1024 * 1024) catch |e| switch (e) {
            error.FileNotFound => break :blk .{ .tree = layout.Tree.init(alloc) },
            else => return e,
        };
        defer alloc.free(bytes);
        break :blk layout.parse(alloc, bytes) orelse return error.BadLayout;
    };
    defer parsed.deinit(alloc);
    const new_sp = try std.fmt.allocPrint(alloc, "{s}#{s}", .{ specs[new.host].spelling, new.session });
    errdefer alloc.free(new_sp);
    const new_id: u8 = @intCast(parsed.spellings.items.len);
    if (new_id >= layout.max_leaves) return error.WallFull;
    var anchor: ?u8 = null;
    if (beside) |b| {
        for (parsed.spellings.items, 0..) |sp, i| {
            const cut = std.mem.lastIndexOfScalar(u8, sp, '#') orelse continue;
            if (std.mem.eql(u8, sp[0..cut], specs[b.host].spelling) and std.mem.eql(u8, sp[cut + 1 ..], b.session)) {
                anchor = @intCast(i);
                break;
            }
        }
    }
    if (parsed.tree.root == null) try parsed.tree.addFirst(new_id) else try parsed.tree.insert(anchor orelse 0, new_id);
    try parsed.spellings.append(alloc, new_sp);
    var buf = std.ArrayListUnmanaged(u8){};
    defer buf.deinit(alloc);
    try parsed.tree.serialize(parsed.spellings.items, parsed.focus, buf.writer(alloc));
    try hosts.saveBytes(path, buf.items);
}
```

`layout.max_leaves` may be spelled differently (the wall uses `wv.max_tiles`); use the constant `Tree` itself bounds ids by (grep `u8` ids in `layout.zig`; if there is none, use `wv.max_tiles`'s value, 32, stated once in `layout.zig` as `pub const max_leaves`). `ParsedLayout.focus` and `.spellings` are the fields the wall's seed reads; `webhub` imports `layout` as `client.layout` and `hosts` as `client.hosts` (check the file's existing imports).

`Hub.init(alloc, specs, leaves)`: after the host setup, for each leaf call the existing `self.birth(leaf.host, leaf.session)` (which dupes the name and assigns the next id) — ids are leaf order, matching the wall's tree order.

`applyList`: delete the births loop; in the walk, replace `self.vanish(i)` with `t.state = .gone` (keep the one-list grace), and when `sessionsHas` is true and `t.state == .gone`, set `t.state = .connecting` so the tile's pump redials. Read `pumpTile` to confirm a hub pump parked on a refused attach redials when the state is flipped; if it needs a doorbell, use the mechanism the hub already has for `.reconnecting`.

`spawn(id)`: after `client.birthSession` succeeds, call `appendLeaf(self.alloc, self.layout_path, self.specs, .{ .host = hi, .session = self.tiles.items[idx].session }, .{ .host = hi, .session = name.slice() })` and then `self.birth(hi, name.slice())` under the mutex so the tile exists before the poll answers. `Hub` gains `layout_path: []const u8` and `specs: []const client.HostSpec` fields set by `init`.

`json`: add `"state":"<tag>"` per tile from `@tagName(t.state)`.

In `src/cli/webhub_main.zig`, after `hosts.load`: `const layout_path = try hosts.layoutPath(arena);` then

```zig
const leaves = webhub.readLeaves(arena, layout_path, specs) catch |e| switch (e) {
    error.BadLayout => blk: {
        std.debug.print("mux web: layout ignored ({s}): not a wall this hosts file can place\n", .{layout_path});
        break :blk &.{};
    },
    else => return e,
};
var hub = try webhub.Hub.init(arena, specs, leaves);
```

and pass `layout_path` into the hub (`hub.layout_path = layout_path;` or an `init` parameter).

- [ ] **Step 4: Run the gate**

Run: `make check 2>&1 | tail -3; echo rc=$?`
Expected: `rc=0`. The `web/mux.js` page reads `/tiles`; the extra `state` field is additive and the page ignores unknown fields (confirm by grepping `mux.js` for how it reads the array; it indexes by name).

- [ ] **Step 5: Commit**

```bash
git add src/client/webhub.zig src/cli/webhub_main.zig
git commit -m "feat: the hub serves the layout's panes and writes a birth back into it"
```

---

### Task 8: New machine, the entry pane, and the end of `keeps_wall`

**Files:**
- Modify: `src/tui/wallview.zig` (`run`'s startup; `Tile.keeps_wall`, `Birth.keeps_wall`, the `endAction` branch; `Entry`)
- Modify: `src/tui/wall_picker.zig` (`pickBirth` no longer sets `keeps_wall`; it persists)
- Modify: `src/tui/wall_test_wall.zig` (the `keeps_wall` tests)

**Interfaces:** none new. `keeps_wall` is deleted everywhere.

- [ ] **Step 1: Startup writes the wall it seated**

In `run`, after the entry tile is seated and any seed plan applied (the block ending in `shared.last_flat = init_flat;`), add:

```zig
// The wall as seated is the wall as written: a first `mux` on a machine
// leaves a one-leaf layout behind, and `mux HOST` leaves its new pane in
// the file. Before the keys loop, so a wall that dies at once still
// persisted what it showed.
wall_layout.persist(w);
```

placing it where `w` exists (the `Wall` value is built before the keys loop; put the call right after that construction).

- [ ] **Step 2: Delete `keeps_wall`**

Remove the field from `Tile` and from `Birth`, the assignment in `birthTileOrRefuse`, the `.keeps_wall = true` in `pickBirth`, and the `if (t.keeps_wall and is_tty and stdin_open)` branch in `endAction`. The branch it guarded returned `.refocus`/kept the wall for a refused picker birth; on a terminal, `endAction`'s `.exited`/`refused` paths already leave the wall standing when other panes are present, and an empty wall opens the picker. Run the `wall_test_wall.zig` tests that set `keeps_wall = true` (four sites): rewrite each to assert the SAME outcome without the flag — a refused picker birth on a terminal with `stdin_open` vanishes the tile with its message and the wall stands. If one of the four cannot pass without the flag, that is the case `keeps_wall` was really for; keep the flag and record why in its doc comment, and note it in the commit.

- [ ] **Step 3: Run the gate**

Run: `make check 2>&1 | tail -3; echo rc=$?`
Expected: `rc=0`.

- [ ] **Step 4: Hand-run the new-machine path**

```bash
export XDG_STATE_HOME=/tmp/nm-$$/s XDG_RUNTIME_DIR=/tmp/nm-$$/r XDG_CONFIG_HOME=/tmp/nm-$$/c XDG_CACHE_HOME=/tmp/nm-$$/k HOME=/tmp/nm-$$/h
mkdir -p $XDG_STATE_HOME $XDG_RUNTIME_DIR $XDG_CONFIG_HOME $XDG_CACHE_HOME $HOME
deps/zig/zig build
./zig-out/bin/ptyclient --cols 100 --rows 30 --out /tmp/nm-$$/cap -- ./zig-out/bin/mux <<'EOF'
expect \$ 10000
send \x1cd
EOF
cat $XDG_STATE_HOME/mux/hosts; cat $XDG_STATE_HOME/mux/layout
./zig-out/bin/mux d stop
```

Expected: the hosts file holds one `--sock` line; the layout holds `leaf 0 --sock <path>#0` and nothing else.

- [ ] **Step 5: Commit**

```bash
git add src/tui/wallview.zig src/tui/wall_picker.zig src/tui/wall_test_wall.zig
git commit -m "feat: a first mux writes its one-pane wall, and a refused birth needs no flag to leave the wall standing"
```

---

### Task 9: The e2e suite says the new model

**Files:**
- Modify: `test/e2e_09_hosts.sh`, `test/e2e_12_panes.sh`, `test/e2e_13_birth.sh`, `test/e2e_06_web.sh`, `test/e2e_07_wallcli.sh`, `test/e2e.sh`

Every leg below runs under e2e_lib's isolated state; a leg that needs its own layout writes `$XDG_STATE_HOME/mux/layout` for the wall it starts, using the format `mux-layout 1` / `leaf 0 HOST#SESSION` (single leaf) or `beside 0` with indented `leaf 1 ...` children. Legs use `ptyclient` for anything about what the wall SHOWS; `pipe_mux` legs cannot see tiles.

- [ ] **Step 1: Rewrite the legs that pin the old model**

By scenario name (the `ok "..."` line), each becomes:

`e2e_09_hosts.sh`
- "every live session of every listed daemon is a tile, n walks them, and a birth writes nothing down" → **"a wall shows its layout's panes and no more; a session born elsewhere never appears"**: two daemons, three sessions each (`fill_sessions`), a layout naming two panes on daemon A and one on B; a ptyclient wall; assert the three labels are on screen and the other three names are NOT (`expect` on each present, and a `settle` then `grep -c` on the capture for each absent name = 0); then `mux a` births a fourth session on A; `settle 2500`; the new name is still absent; `Ctrl-\ n` walks exactly the three.
- "x refuses while others are attached, then ends; the other client sees the exit" → moves to the picker: **"the picker's x refuses while others hold the session, ends on the second press, and the other client sees the exit"**: wall A holds session `0` as a pane; a second `pipe_mux` client holds it too; on the wall `Ctrl-\ s`, Enter on the host, `x` on row `0`: the notice names `1 other client`; `x` again within 3 s; the pipe client's capture gets `exit_status`; `mux d stats` says sessions dropped by one.
- "x on a wall ends the focused tile's session and no other, and that tile leaves the next list" → **"x on a wall removes the focused pane and ends nothing: the session keeps its other client, and the layout loses the leaf"**: two panes; a pipe client on the focused pane's session; `Ctrl-\ x`; assert the pane is gone from the screen, `mux d stats` still lists the session with `clients=1`, the pipe client is still attached (send a line, await it), and the layout file no longer names the leaf.
- "mux records the daemon on the wall, never a session, and starts a listed local one that is gone" → keep, and add: the layout after `mux --sock S` names `S#0` exactly once.
- "the first mux on a machine starts the local daemon and writes it down" → add the layout assertion: one leaf, `--sock <default>#0`.
- "a daemon that goes down keeps its panes wearing unreachable, and comes back re-creating nothing" → keep as is (the model is unchanged for unreachable).
- "the picker births on the host a digit names, forgets a host without ending it, adds one back…" → Enter is now `c` for the birth: replace `send \r` with `send c` where the leg births; add after it: Enter on the host opens the session list (`expect` the new session's row), Esc backs out.
- "an empty wall opens the picker, Esc leaves the one line, and Ctrl-\\ d leaves at once" → keep; it now also covers "a hosts file with lines and no layout".

`e2e_12_panes.sh`
- "the layout heals on live drift: …" → **"a poll changes nothing on the wall: a newcomer stays off it, a survivor keeps its pane, the ended one's pane leaves with its shell"**: same rig, but the newcomer born by `mux a` is asserted ABSENT after `settle 2500`, and the ended session's pane leaves (shell `exit`), and the layout file matches the screen.
- "a corrupted sidecar degrades silently to the default layout" → **"a corrupted layout is reported with its line and the wall starts as if it were missing"**: write garbage; run a wall with `mux --sock S` (an entry); stderr carries `mux: layout ignored (`; the wall shows the entry pane alone; after detach the layout file is the one-leaf wall.
- "a resized layout survives a detach/reattach round trip via the sidecar" → keep; the sidecar IS the wall now, nothing else changes.
- "a rebooted daemon's panes wear gone and the cut never moves; Enter revives, x dismisses" → keep; `x` on a gone pane removes it (already the behaviour).

`e2e_13_birth.sh`
- "the picker's a adds a host by spelling: its sessions become tiles…" → **"…its sessions are listed in the picker, not put on the wall; Enter on one adds it"**: after `a` adds the host, assert no tile appeared (`grep -c` of its session names on the capture = 0 after `settle`), then Enter on the host, Enter on the first row, and the pane appears.
- "a new tile takes the lowest free digit, and the daemon takes the name back" → `c` in the picker instead of Enter; otherwise unchanged.
- "a birth the daemon refuses paints [refused] and leaves the wall standing" → `c` instead of Enter.

`e2e_06_web.sh`
- "the hub's wall is the hosts file; tiles are those daemons' live sessions" → **"the hub's wall is the layout: /tiles lists its leaves in order, a session born elsewhere is not listed, and + writes the new pane into the file"**: write a layout of two leaves across two daemons; start `mux web`; `curl /tiles` equals the two leaves in tree order (parse with the leg's existing JSON grep); `mux a` births a session on daemon A; `sleep 2.5`; `/tiles` unchanged; `POST /tiles/0`; `/tiles` has three; the layout file names the new session.

`e2e_07_wallcli.sh`
- "mux --sock: every session the daemon has, and a live delta, on one terminal" → **"mux --sock: the entry pane and the layout's other panes, and a live delta, on one terminal"**: seed a layout with two leaves on the daemon (the entry `#0` and `#1`), leave a third session off it, assert the two and not the third.

- [ ] **Step 2: Add the legs the spec names that no rewrite above covers**

In `e2e_09_hosts.sh`, after the picker leg:

```sh
# ---- two devices, one daemon: each wall is its own layout ----------------
#
# The whole point of the change: a second state dir against the SAME two
# daemons starts with nothing but what it adds, and the first wall never
# learns of it. Two XDG_STATE_HOMEs stand in for two machines.
DEV2="${TMPDIR:-/tmp}/mux-e2e-dev2-$$"
defer_rm "$DEV2"
mkdir -p "$DEV2/mux"
printf -- '--sock %s\n--sock %s\n' "$SOCKA" "$SOCKB" > "$DEV2/mux/hosts"
# Device 1: a two-pane wall on A#0 and B#0, already running from the leg
# above under $HSTATE (its layout has exactly those leaves).
# Device 2: no layout. `mux` opens the picker on an empty wall; Enter on
# host B, Enter on its second row adds B#1 and nothing else.
XDG_STATE_HOME="$DEV2" XDG_RUNTIME_DIR="$HRUN" timeout 60 "$PTYCLIENT" --cols 100 --rows 30 \
    --out "$OUT.dev2.cap" --err "$OUT.dev2.cap.err" -- "$MUX" > "$OUT.dev2.pc" 2>&1 <<EOF
expect no sessions on the wall 10000
send \x1cs
expect sessions 10000
send 2
send \r
settle 400 10000
send j
send \r
expect ${SOCKB##*/}#1 15000
settle 1500 10000
send \x1cd
EOF
grep -q "leaf 0 --sock $SOCKB#1" "$DEV2/mux/layout" || {
    echo "e2e FAIL: device 2's layout is not the one pane it added:"; cat "$DEV2/mux/layout"; exit 1; }
grep -c "#0" "$OUT.dev2.cap" | grep -qx 0 || {
    echo "e2e FAIL: device 2 saw a pane it never added"; exit 1; }
# Device 1 is unchanged: its layout still has two leaves and no B#1.
[ "$(grep -c '^ leaf' "$HSTATE/mux/layout")" -eq 2 ] || {
    echo "e2e FAIL: device 1's layout changed under device 2's add:"; cat "$HSTATE/mux/layout"; exit 1; }
ok "two walls on the same daemons are two layouts; neither learns of the other's panes"
```

Adapt `$SOCKA`, `$SOCKB`, `$HSTATE`, `$HRUN` to the names the group's earlier legs use (grep the file's `start_daemon` lines); the `expect` strings must match what the picker paints (`hostState` prints `N sessions`; the empty-wall hint is `emptyWallHint`'s text — read it and pin the real words).

- [ ] **Step 3: Update the pins**

Count: the rewrites keep their `ok` lines one-for-one; Step 2 adds one. `test/e2e.sh`: `109` → `110` on both lines. The convergence count is unchanged unless a rewritten leg drops an `assert_converged`; if the full run prints a different number, read which leg changed it before touching the pin.

- [ ] **Step 4: Run each group alone, then the whole suite**

```bash
for g in 09_hosts 12_panes 13_birth 06_web 07_wallcli; do E2E_ONLY=$g make e2e 2>&1 | grep -a "FAIL\|e2e OK (" | head -3; done
make e2e 2>&1 | tail -3
```

Expected: every group `e2e OK (N scenarios in G; the pin is the whole suite's)`; the full run `e2e OK (110 scenarios, 38 convergence points)`.

- [ ] **Step 5: Mutation-check the two new oracles**

Reintroduce a birth in `applyHostList` (the smallest mutant: call `wv.birthTile` for the first unlisted name) and run `E2E_ONLY=09_hosts`; the "shows its layout's panes and no more" leg must FAIL. Restore by copying the saved original file back (never an inverse sed). Then drop the `wall_layout.persist(w)` call from `removePane` and run the same group; the "x removes the focused pane" leg must FAIL on the layout assertion. Restore, re-run green.

- [ ] **Step 6: Commit**

```bash
git add test/
git commit -m "test: the e2e suite pins the wall as the layout"
```

---

### Task 10: Docs say the new model

**Files:**
- Modify: `README.md` (the "wall of hosts" paragraphs quoted in the spec's Problem section; the `x` key; the picker keys)
- Modify: `CLAUDE.md` (replace the invariant bullets "The wall file lists DAEMONS; tiles are their live sessions", "Hosts live in the picker, not on the wall", "Ctrl-\ x ends a session; the daemon owns the two-step", and "The layout sidecar is derived convenience, not authored intent")
- Modify: `docs/decisions.md` (a dated section)

- [ ] **Step 1: README**

Replace the paragraph beginning "`mux` on a machine that has never run it records your own daemon" and the one before it (which says tiles are whatever the daemons have live) with:

> The wall is your layout. `$XDG_STATE_HOME/mux/layout` names the panes you have opened, each `HOST#SESSION`, in the tree you arranged them in; every `mux` on this machine opens exactly that. Sessions live on daemons and a daemon may have more of them than your wall shows: a session born by `mux a`, a browser, or another machine is on no wall until you add it. `Ctrl-\ s` lists your daemons, Enter on one lists its sessions with who else holds each, Enter on a session adds it as a pane, `c` starts a new one there, `x` ends one (asking first when someone else holds it), Esc backs out. On the wall, `Ctrl-\ x` takes the focused pane off this wall and ends nothing.
>
> `mux` on a machine that has never run it records your own daemon and opens one pane on its session `0` — a first run is still just a shell. `mux HOST` opens zoomed on HOST's session `0`, adding HOST to your daemons and the pane to your wall if they were not there.

Update the key table's `x` row and the picker rows to match.

- [ ] **Step 2: CLAUDE.md**

Replace the four bullets named above with:

```
- **The layout is the wall; the poll grades it and adds nothing.**
  `$XDG_STATE_HOME/mux/layout` is authored intent: a pane tree whose
  leaves are `HOST#SESSION`, `HOST` a hosts-file line verbatim. Tiles come
  from it and from three doors only — the file on start, the picker, and a
  chord split — never from a daemon's `sessions_reply`; `mux HOST`'s entry
  pane goes through the same seat-then-`persist` path. The once-a-second
  poll binds a pending pane whose session the list names, marks `gone` one
  it does not, and vanishes a live pane whose shell ended. `persist` is
  the ONE save path, gated on `Shared.layout_path`, and every change to
  the pane set or tree calls it. A file that fails `seedLayout` — a host
  the hosts file lacks, a leaf without a session, a repeat, garbage — is
  reported with its line and treated as missing; a missing file is a wall
  of one local pane (session `0`) when there is an entry, and an empty
  wall that opens the picker when there is not. The hub reads and writes
  the same file (`webhub.readLeaves`, `webhub.appendLeaf`).
- **Hosts and sessions live in the picker.** `Ctrl-\ s` is a MODE of
  `interact.PrefixFilter` with two levels (`pick_level`): hosts, then a
  host's sessions with `# holds` counts. Enter descends or adds, `c`
  births at `client.nextFreeName`, `x` forgets a host or ends a session
  (`client.endSession` on a side connection; the daemon's two-step, armed
  3 s per host and name in `Shared.pick_end`), `a` edits a spelling, Esc
  backs out. Tiles do not paint while it is open.
- **`Ctrl-\ x` removes a pane and ends nothing.** `removePane` tells the
  pump to detach, vanishes the tile, and persists. Ending is the picker's.
```

Keep the daemon-side facts from the old `x` bullet (bounded end, `end_req`/`end_reply` opcodes, `mux d upgrade` refused while ending) in the "picker" bullet or the daemon section; they did not change.

- [ ] **Step 3: decisions.md**

Append a section `## 2026-09-02 — the wall is the layout` stating: the user's report and expectation (quoted in the spec), the three approaches and why the flip won, the strictness reversal, the `# holds` line and why a `#` line, `x`'s new meaning and where ending went, what the hub does, the pin count, and anything Task 8's `keeps_wall` step learned.

- [ ] **Step 4: Gate and commit**

Run: `make check 2>&1 | tail -3; echo rc=$?` — the comment gate reads `CLAUDE.md`'s cited symbols; every backticked name above must exist.

```bash
git add README.md CLAUDE.md docs/decisions.md
git commit -m "docs: the wall is the layout"
```

---

## Delivery

After Task 10: `make ci` (detached, `setsid nohup make ci > /tmp/ci.log 2>&1 &`, then watch the log; ~50 min), then `make install`, then a hands-on demo on a real terminal with the user's own two daemons before anything is called done. The demo script is part of the deliverable: run it yourself first.