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.