a73x

49907e99

docs: plan — the wall is the layout, ten tasks

a73x   2026-09-03 05:20

Commit message
docs: plan — the wall is the layout, ten tasks

docs/superpowers/plans/2026-09-02-wall-is-the-layout.md
Old New
@@ -0,0 +1,1679 @@
1 # The Wall Is the Layout — Implementation Plan
2
3 > **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.
4
5 **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.
6
7 **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.
8
9 **Tech Stack:** Zig 0.15.2 (vendored at `deps/zig/zig`), POSIX shell e2e under `test/`, ptyclient fixture for real-terminal legs.
10
11 **Spec:** `docs/superpowers/specs/2026-09-02-wall-is-the-layout-design.md`
12
13 ## Global Constraints
14
15 - Build only with `deps/zig/zig` (`make build test e2e check`); the system zig does not build this tree.
16 - `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.
17 - A unit test must never write to fd 1: it wedges `zig build test` silently. Diagnostics go to `std.debug.print`.
18 - Every e2e leg runs under an isolated `XDG_STATE_HOME` and `XDG_RUNTIME_DIR` (e2e_lib does this); every hand rig exports both.
19 - Commit subjects are `type: what changed`, type in `feat fix refactor test docs build chore`, no scope.
20 - Fixtures are plural by default: two daemons, three sessions each, unless the leg is about N=1.
21 - `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.
22 - Comment rule: say *why*, plainly. No project-history codenames.
23 - 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.
24
25 ---
26
27 ## File map
28
29 | File | Responsibility after this plan |
30 |---|---|
31 | `src/engine/protocol.zig` | `# holds NAME N` lines in `sessions_reply`: `appendSessionsHolds`, `parseSessionsHolds`, the grown `sessions_reply_max` |
32 | `src/server/server.zig` | the `.sessions_req` arm appends one holds line per session |
33 | `src/client/client.zig` | `endSession`: `end_req` over a side connection, mirroring `birthSession` |
34 | `src/client/layout.zig` | unchanged parser and `Tree`; used by both the wall and the hub |
35 | `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` |
36 | `src/tui/wall_host.zig` | `planHostDiff` grades (bind, gone, vanish) and births nothing; `applyHostList` places no tiles |
37 | `src/tui/wallview.zig` | `Shared.layout_path`; `removePane`; the keyboard loop persists after every change; `keeps_wall` gone |
38 | `src/tui/interact.zig` | the picker filter has two levels and the new actions |
39 | `src/tui/wall_picker.zig` | session rows, `pickAdd`, `pickEnd` |
40 | `src/client/webhub.zig` | `Hub.init` takes the layout's leaves; `applyList` grades; `spawn` writes the layout |
41 | `src/cli/webhub_main.zig` | reads the layout file and hands its leaves to the hub |
42 | `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 |
43 | `README.md`, `CLAUDE.md`, `docs/decisions.md` | the new model stated |
44
45 ---
46
47 ### Task 1: `# holds NAME N` lines in `sessions_reply`
48
49 **Files:**
50 - Modify: `src/engine/protocol.zig` (beside `appendSessionsMeta` / `parseSessionsMeta`)
51 - Modify: `src/server/server.zig` (the `.sessions_req` arm of the observer frame switch, the one that calls `self.sessions.text`)
52 - Test: `src/engine/protocol.zig` (inline tests), `src/server/server_test_session.zig`
53
54 **Interfaces:**
55 - 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`.
56 - Consumed by: Task 6 (the picker's session rows).
57
58 - [ ] **Step 1: Write the failing protocol tests**
59
60 Append to `src/engine/protocol.zig`, next to the `parseSessionsMeta` tests:
61
62 ```zig
63 test "sessions holds: a holds line rides beside the names, old readers skip it, and a name reads its own count" {
64 var buf: [sessions_reply_max]u8 = undefined;
65 @memcpy(buf[0..4], "0\nwk");
66 var len: usize = 4;
67 len = appendSessionsHolds(&buf, len, "0", 1);
68 len = appendSessionsHolds(&buf, len, "wk", 0);
69 const payload = buf[0..len];
70 try std.testing.expectEqualStrings("0\nwk\n# holds 0 1\n# holds wk 0", payload);
71
72 // The iterator every old client walks yields the names and nothing else.
73 var it = sessionsIter(payload);
74 try std.testing.expectEqualStrings("0", it.next().?);
75 try std.testing.expectEqualStrings("wk", it.next().?);
76 try std.testing.expect(it.next() == null);
77
78 try std.testing.expectEqual(@as(?u8, 1), parseSessionsHolds(payload, "0"));
79 try std.testing.expectEqual(@as(?u8, 0), parseSessionsHolds(payload, "wk"));
80 // A name the daemon did not count, and an old daemon's payload with no
81 // holds lines at all, both read as unknown rather than zero.
82 try std.testing.expect(parseSessionsHolds(payload, "w") == null);
83 try std.testing.expect(parseSessionsHolds("0\nwk", "0") == null);
84 // The meta line and the holds lines coexist in either order.
85 const with_meta = appendSessionsMeta(&buf, len, "0.0.1-18", false);
86 try std.testing.expectEqual(@as(?u8, 1), parseSessionsHolds(buf[0..with_meta], "0"));
87 try std.testing.expectEqualStrings("0.0.1-18", parseSessionsMeta(buf[0..with_meta]).?.version);
88 }
89
90 test "sessions holds: sessions_reply_max holds every session's name, holds line and the meta line" {
91 // The daemon writes names, then one holds line per session, then meta,
92 // into ONE buffer of this size; the bound must cover the worst case.
93 const worst_line = sessions_holds_prefix.len + session_name_max + 1 + 3;
94 try std.testing.expect(sessions_reply_max >= sessions_text_max + sessions_max * (worst_line + 1) + sessions_meta_max);
95 }
96 ```
97
98 - [ ] **Step 2: Run the tests to see them fail**
99
100 Run: `deps/zig/zig build test 2>&1 | grep -a "error:" | head -5`
101 Expected: compile errors naming `appendSessionsHolds`, `parseSessionsHolds`, `sessions_holds_prefix`.
102
103 - [ ] **Step 3: Implement the codec**
104
105 In `src/engine/protocol.zig`, replace the `sessions_reply_max` line and add after `sessions_meta_max`:
106
107 ```zig
108 /// One `# holds NAME N` line per session, appended by a daemon that can
109 /// count: how many clients hold that session. The picker's session list
110 /// shows it, and the end key's first press is judged against it. Spelled
111 /// as a `#` line so `sessionsIter`, which yields only valid session names,
112 /// skips it on a client that predates it — exactly as it skips the meta
113 /// line — and a daemon that predates it sends none, which
114 /// `parseSessionsHolds` reads as unknown.
115 pub const sessions_holds_prefix = "# holds ";
116 /// `# holds NAME N\n` at its widest: N is a u8, so at most three digits.
117 pub const sessions_holds_line_max = sessions_holds_prefix.len + session_name_max + 1 + 3 + 1;
118 pub const sessions_reply_max = sessions_text_max + sessions_max * sessions_holds_line_max + sessions_meta_max;
119
120 pub fn appendSessionsHolds(buf: []u8, len: usize, name: []const u8, holds: u8) usize {
121 var w = len;
122 if (w != 0) {
123 if (w >= buf.len) return len;
124 buf[w] = '\n';
125 w += 1;
126 }
127 const line = std.fmt.bufPrint(buf[w..], "{s}{s} {d}", .{ sessions_holds_prefix, name, holds }) catch return len;
128 return w + line.len;
129 }
130
131 pub fn parseSessionsHolds(payload: []const u8, name: []const u8) ?u8 {
132 var lines = std.mem.splitScalar(u8, payload, '\n');
133 while (lines.next()) |line| {
134 if (!std.mem.startsWith(u8, line, sessions_holds_prefix)) continue;
135 const rest = line[sessions_holds_prefix.len..];
136 const sp = std.mem.lastIndexOfScalar(u8, rest, ' ') orelse continue;
137 if (!std.mem.eql(u8, rest[0..sp], name)) continue;
138 return std.fmt.parseInt(u8, rest[sp + 1 ..], 10) catch continue;
139 }
140 return null;
141 }
142 ```
143
144 `sessions_max` is the existing constant `sessions_text_max` is derived from; keep the names as they are in the file.
145
146 - [ ] **Step 4: Run the protocol tests**
147
148 Run: `deps/zig/zig build test 2>&1 | grep -a "sessions holds\|error:" | head -5`
149 Expected: no errors; the two tests are not named on failure lines.
150
151 - [ ] **Step 5: Write the failing daemon test**
152
153 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):
154
155 ```zig
156 test "sessions_req: a daemon that states its version also states how many clients hold each session" {
157 // Two sessions, one of them held by a client: the holds lines say 1
158 // and 0, and an old client's iterator still yields exactly the names.
159 var d = try h.TestDaemon.start(std.testing.allocator, .{ .version = "0.0.1-test" });
160 defer d.stop();
161 try d.createSession("wk");
162 var held = try d.attachClient("0");
163 defer held.close();
164
165 var out: [proto.sessions_reply_max]u8 = undefined;
166 const reply = try d.ask(.sessions_req, "", .sessions_reply, &out);
167 try std.testing.expectEqual(@as(?u8, 1), proto.parseSessionsHolds(reply, "0"));
168 try std.testing.expectEqual(@as(?u8, 0), proto.parseSessionsHolds(reply, "wk"));
169 var names = proto.sessionsIter(reply);
170 try std.testing.expectEqualStrings("0", names.next().?);
171 try std.testing.expectEqualStrings("wk", names.next().?);
172 try std.testing.expect(names.next() == null);
173 try std.testing.expectEqualStrings("0.0.1-test", proto.parseSessionsMeta(reply).?.version);
174 }
175 ```
176
177 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.
178
179 - [ ] **Step 6: Run it to see it fail**
180
181 Run: `deps/zig/zig build test 2>&1 | grep -a "states how many\|error:" | head -5`
182 Expected: FAIL on the first `expectEqual` (`parseSessionsHolds` returns null: the daemon sends no holds lines yet).
183
184 - [ ] **Step 7: Append the holds lines in the daemon**
185
186 In `src/server/server.zig`, the `.sessions_req` arm currently reads:
187
188 ```zig
189 var buf: [proto.sessions_reply_max]u8 = undefined;
190 const names = self.sessions.text(buf[0..proto.sessions_text_max]);
191 const len = if (self.version.len != 0)
192 proto.appendSessionsMeta(&buf, names.len, self.version, selfImageStale())
193 else
194 names.len;
195 self.replyTo(p, .sessions_reply, buf[0..len]);
196 ```
197
198 Replace with:
199
200 ```zig
201 var buf: [proto.sessions_reply_max]u8 = undefined;
202 const names = self.sessions.text(buf[0..proto.sessions_text_max]);
203 // Holds lines and the meta line ride the same gate: a daemon with no
204 // version to state appends nothing, so a bare fixture's payload stays
205 // byte-identical to the old wire and the exact-equality test keeps
206 // pinning it.
207 var len = names.len;
208 if (self.version.len != 0) {
209 for (self.sessions.table, 0..) |slot, si| {
210 const s = slot orelse continue;
211 const holds: u8 = @intCast(@min(self.clientsInSession(si), std.math.maxInt(u8)));
212 len = proto.appendSessionsHolds(&buf, len, s.name(), holds);
213 }
214 len = proto.appendSessionsMeta(&buf, len, self.version, selfImageStale());
215 }
216 self.replyTo(p, .sessions_reply, buf[0..len]);
217 ```
218
219 `clientsInSession` exists (`fn clientsInSession(self: *const Server, si: usize) usize`). `appendSessionsMeta` takes `names_len` as the current length; passing `len` is the same contract.
220
221 - [ ] **Step 8: Run the whole gate**
222
223 Run: `make check 2>&1 | tail -3; echo rc=$?`
224 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`.
225
226 - [ ] **Step 9: Commit**
227
228 ```bash
229 git add src/engine/protocol.zig src/server/server.zig src/server/server_test_session.zig
230 git commit -m "feat: sessions_reply says how many clients hold each session"
231 ```
232
233 ---
234
235 ### Task 2: The layout loads verbatim and refuses a bad file with its line
236
237 **Files:**
238 - Modify: `src/tui/wall_layout.zig` (`seedSidecar`, `seedLayout`, `seedAttempt`, `SeedPlan`)
239 - Modify: `src/tui/wallview.zig` (the `seed_plan` block in `run`; the `dropped` notice)
240 - Test: `src/tui/wall_test_layout.zig`
241
242 **Interfaces:**
243 - Consumes: `layout.parse(alloc, bytes) ?ParsedLayout` (spellings + tree + focus), `Host.spec.spelling`, `wv.max_tiles`.
244 - Produces: `wall_layout.seedLayout(alloc, table, shared, bytes, entry_spelling) SeedResult`, where
245
246 ```zig
247 pub const SeedResult = union(enum) {
248 plan: SeedPlan,
249 /// The file is not a wall: the first offending line, borrowed from
250 /// `bytes`, for the caller to print. The wall then starts as if the
251 /// file were missing.
252 refused: []const u8,
253 /// No file, or a file with no leaves this wall can seat at this size.
254 none,
255 };
256 ```
257
258 `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.
259
260 - [ ] **Step 1: Write the failing tests**
261
262 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):
263
264 ```zig
265 test "seedLayout: every leaf of a good file is a pane, in the file's tree, and nothing else is consulted" {
266 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = true };
267 defer shared.tree.deinit();
268 var table = [_]Host{
269 fixture.testHost(&shared, "--sock /a", "/a"),
270 fixture.testHost(&shared, "box", "/b"),
271 };
272 // No poll answer on either host: the file alone decides.
273 const file =
274 \\mux-layout 1
275 \\beside 0
276 \\ leaf 1 --sock /a#0
277 \\ leaf 1 box#work
278 \\ leaf 1 --sock /a#2
279 \\focus 1
280 \\
281 ;
282 var res = wall_layout.seedLayout(std.testing.allocator, &table, &shared, file, null);
283 defer if (res == .plan) res.plan.deinit(std.testing.allocator);
284 try std.testing.expect(res == .plan);
285 try std.testing.expectEqual(@as(usize, 3), res.plan.panes.len);
286 try std.testing.expectEqualStrings("0", res.plan.panes[0].?.session);
287 try std.testing.expectEqual(@as(usize, 0), res.plan.panes[0].?.host);
288 try std.testing.expectEqualStrings("work", res.plan.panes[1].?.session);
289 try std.testing.expectEqual(@as(usize, 1), res.plan.panes[1].?.host);
290 try std.testing.expectEqual(@as(?usize, 1), res.plan.focus);
291 try std.testing.expectEqual(@as(usize, 0), res.plan.dropped);
292 try std.testing.expectEqual(@as(usize, 3), shared.tree.count());
293 }
294
295 test "seedLayout: a leaf whose host is not in the hosts file refuses the whole file and names the line" {
296 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = true };
297 defer shared.tree.deinit();
298 var table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")};
299 const file =
300 \\mux-layout 1
301 \\beside 0
302 \\ leaf 1 --sock /a#0
303 \\ leaf 1 nowhere#0
304 \\
305 ;
306 const res = wall_layout.seedLayout(std.testing.allocator, &table, &shared, file, null);
307 try std.testing.expect(res == .refused);
308 try std.testing.expectEqualStrings("nowhere#0", res.refused);
309 // Nothing was seated: the caller starts as if the file were missing.
310 try std.testing.expect(shared.tree.root == null);
311 }
312
313 test "seedLayout: a leaf with no session, a bad name, or a repeat refuses; garbage refuses with its first line" {
314 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = true };
315 defer shared.tree.deinit();
316 var table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")};
317 const no_session = "mux-layout 1\nleaf 0 --sock /a\n";
318 const bad_name = "mux-layout 1\nleaf 0 --sock /a#no space\n";
319 const repeat = "mux-layout 1\nbeside 0\n leaf 1 --sock /a#0\n leaf 1 --sock /a#0\n";
320 const garbage = "not a layout\n";
321 for ([_][]const u8{ no_session, bad_name, repeat, garbage }) |file| {
322 const res = wall_layout.seedLayout(std.testing.allocator, &table, &shared, file, null);
323 try std.testing.expect(res == .refused);
324 try std.testing.expect(res.refused.len > 0);
325 }
326 try std.testing.expectEqualStrings("--sock /a#0", wall_layout.seedLayout(std.testing.allocator, &table, &shared, repeat, null).refused);
327 try std.testing.expectEqualStrings("not a layout", wall_layout.seedLayout(std.testing.allocator, &table, &shared, garbage, null).refused);
328 }
329
330 test "seedLayout: the entry spelling takes leaf 0 when the file has it, and is inserted beside the focus when it does not" {
331 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = true };
332 defer shared.tree.deinit();
333 var table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")};
334 const file = "mux-layout 1\nbeside 0\n leaf 1 --sock /a#0\n leaf 1 --sock /a#1\nfocus 1\n";
335 var has = wall_layout.seedLayout(std.testing.allocator, &table, &shared, file, "--sock /a#1");
336 defer has.plan.deinit(std.testing.allocator);
337 try std.testing.expect(has == .plan);
338 // The entry is pane 0 by contract; the other leaf follows.
339 try std.testing.expect(has.plan.panes[0] == null); // the entry tile is the caller's
340 try std.testing.expectEqualStrings("0", has.plan.panes[1].?.session);
341 try std.testing.expectEqual(@as(usize, 2), shared.tree.count());
342
343 shared.tree.deinit();
344 shared.tree = layout.Tree.init(std.testing.allocator);
345 var not = wall_layout.seedLayout(std.testing.allocator, &table, &shared, file, "--sock /a#9");
346 defer not.plan.deinit(std.testing.allocator);
347 try std.testing.expect(not == .plan);
348 try std.testing.expectEqual(@as(usize, 3), shared.tree.count());
349 }
350 ```
351
352 `layout` here is `@import("client").layout`, already imported by the test file's siblings; add the import if the file lacks it.
353
354 - [ ] **Step 2: Run to see them fail**
355
356 Run: `deps/zig/zig build test 2>&1 | grep -a "error:" | head -5`
357 Expected: compile errors on `SeedResult`/`.refused`.
358
359 - [ ] **Step 3: Rewrite `seedLayout` and `seedAttempt`**
360
361 Replace `seedLayout` in `src/tui/wall_layout.zig` with:
362
363 ```zig
364 pub const SeedResult = union(enum) {
365 plan: SeedPlan,
366 refused: []const u8,
367 none,
368 };
369
370 /// The layout is authored: every leaf is a pane, in the saved tree. The
371 /// only things that keep a leaf off the wall are the session this `mux`
372 /// runs inside (a wall may not attach to itself) and a terminal too small
373 /// for the whole tree, which trims from the end. Anything else wrong with
374 /// the file — a host the hosts file does not list, a leaf with no session
375 /// or a bad name, a repeated leaf, more leaves than the wall seats, or
376 /// text that is not a layout — refuses the FILE, with the first bad line,
377 /// because silently seating part of a wall is how a user loses one.
378 pub fn seedLayout(
379 alloc: std.mem.Allocator,
380 table: []const Host,
381 shared: *Shared,
382 bytes: []const u8,
383 entry_spelling: ?[]const u8,
384 ) SeedResult {
385 var probe = layout.parse(alloc, bytes) orelse return .{ .refused = firstLine(bytes) };
386 defer probe.deinit(alloc);
387 var keeps = std.ArrayListUnmanaged(SeedKeep){};
388 defer keeps.deinit(alloc);
389 var entry_at: ?usize = null;
390 var dropped: usize = 0;
391 for (probe.spellings.items, 0..) |sp, i| {
392 if (entry_spelling) |es| {
393 if (entry_at == null and std.mem.eql(u8, sp, es)) {
394 entry_at = i;
395 continue;
396 }
397 }
398 const cut = std.mem.lastIndexOfScalar(u8, sp, '#') orelse return .{ .refused = sp };
399 const sess = sp[cut + 1 ..];
400 if (!proto.validSessionName(sess)) return .{ .refused = sp };
401 const hi = for (table, 0..) |*h, j| {
402 if (std.mem.eql(u8, h.spec.spelling, sp[0..cut])) break j;
403 } else return .{ .refused = sp };
404 if (table[hi].self_name) |self| {
405 if (std.mem.eql(u8, self, sess)) {
406 dropped += 1;
407 continue;
408 }
409 }
410 for (keeps.items) |k| {
411 if (std.mem.eql(u8, probe.spellings.items[k.saved], sp)) return .{ .refused = sp };
412 }
413 keeps.append(alloc, .{ .saved = i, .host = hi }) catch return .none;
414 }
415 const base: usize = if (entry_spelling != null) 1 else 0;
416 if (base + keeps.items.len > wv.max_tiles) return .{ .refused = probe.spellings.items[keeps.items[wv.max_tiles - base].saved] };
417 if (keeps.items.len == 0 and entry_at == null) return .none;
418 var n = keeps.items.len;
419 while (true) : (n -= 1) {
420 if (seedAttempt(alloc, shared, bytes, entry_at, keeps.items[0..n], base)) |plan| {
421 var out = plan;
422 out.dropped = dropped + (keeps.items.len - n);
423 return .{ .plan = out };
424 }
425 if (n == 0) return .none;
426 }
427 }
428
429 fn firstLine(bytes: []const u8) []const u8 {
430 const nl = std.mem.indexOfScalar(u8, bytes, '\n') orelse bytes.len;
431 return bytes[0..nl];
432 }
433 ```
434
435 `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.
436
437 `seedSidecar` changes its return to match and prints the refusal:
438
439 ```zig
440 pub fn seedSidecar(alloc: std.mem.Allocator, table: []const Host, shared: *Shared, entry_spelling: ?[]const u8) ?SeedPlan {
441 if (!shared.is_tty) return null;
442 const path = hosts.layoutPath(alloc) catch return null;
443 defer alloc.free(path);
444 const bytes = loadLayout(alloc, path) orelse return null;
445 defer alloc.free(bytes);
446 return switch (seedLayout(alloc, table, shared, bytes, entry_spelling)) {
447 .plan => |p| p,
448 .refused => |line| blk: {
449 // Said once, on stderr, before the alternate screen: the wall
450 // then starts as if the file were missing, and the line is the
451 // thing to fix or delete.
452 std.debug.print("mux: layout ignored ({s}): {s}\n", .{ path, line });
453 break :blk null;
454 },
455 .none => null,
456 };
457 }
458 ```
459
460 - [ ] **Step 4: Run the tests**
461
462 Run: `deps/zig/zig build test 2>&1 | grep -a "seedLayout\|error:" | head -8`
463 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.
464
465 - [ ] **Step 5: Retire the healing tests**
466
467 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`).
468
469 - [ ] **Step 6: Run the gate**
470
471 Run: `make check 2>&1 | tail -3; echo rc=$?`
472 Expected: `rc=0`.
473
474 - [ ] **Step 7: Commit**
475
476 ```bash
477 git add src/tui/wall_layout.zig src/tui/wall_test_layout.zig src/tui/wallview.zig
478 git commit -m "feat: the layout file seats every leaf it names, and a bad file is refused with its line"
479 ```
480
481 ---
482
483 ### Task 3: The poll grades panes and births nothing
484
485 **Files:**
486 - Modify: `src/tui/wall_host.zig` (`planHostDiff`, `applyHostList`, the `BirthNames` type, the `//!` header)
487 - Modify: `src/tui/wall_test_host.zig`
488
489 **Interfaces:**
490 - Produces: `wall_host.planHostDiff(tiles, present, live, host, list, self_name, binds, vanish, gones) void` — the `births` parameter is gone. `BirthNames` is deleted.
491 - Consumed by: `applyHostList` only.
492
493 - [ ] **Step 1: Write the failing test**
494
495 Append to `src/tui/wall_test_host.zig`:
496
497 ```zig
498 test "planHostDiff: a session the daemon has and the wall does not is nobody's business: no birth, no tile" {
499 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
500 var tiles = fixture.diffFixture(&shared);
501 var present = [_]bool{ true, true, true };
502 var binds = TileIdxs{};
503 var vanish = TileIdxs{};
504 var gones = TileIdxs{};
505 // Host 0 answers with its two panes' sessions and three the wall never
506 // asked for. Twice, so the grace has been spent and a vanish would show.
507 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\nb\nx\ny\nz\n", null, &binds, &vanish, &gones);
508 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\nb\nx\ny\nz\n", null, &binds, &vanish, &gones);
509 try std.testing.expectEqual(@as(usize, 0), vanish.len);
510 try std.testing.expectEqual(@as(usize, 0), gones.len);
511 try std.testing.expectEqual(@as(usize, 3), wv.presentCount(&present));
512 }
513 ```
514
515 `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).
516
517 - [ ] **Step 2: Run to see it fail**
518
519 Run: `deps/zig/zig build test 2>&1 | grep -a "error:" | head -3`
520 Expected: compile error — `planHostDiff` still takes ten arguments.
521
522 - [ ] **Step 3: Drop births from the planner and the applier**
523
524 In `src/tui/wall_host.zig`:
525
526 1. Delete the `BirthNames` type (the `Fixed(...)` instantiation for names) if nothing else uses it (grep).
527 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.
528 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`.
529 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."
530
531 - [ ] **Step 4: Update the existing planner tests**
532
533 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.
534
535 - [ ] **Step 5: Run the gate**
536
537 Run: `make check 2>&1 | tail -3; echo rc=$?`
538 Expected: `rc=0`.
539
540 - [ ] **Step 6: Run the two wall groups**
541
542 Run: `E2E_ONLY=09_hosts make e2e 2>&1 | grep -a "FAIL\|e2e OK (" | head -3`
543 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.
544
545 - [ ] **Step 7: Commit**
546
547 ```bash
548 git add src/tui/wall_host.zig src/tui/wall_test_host.zig
549 git commit -m "feat: the poll grades panes and adds none; the layout is the only source of tiles"
550 ```
551
552 ---
553
554 ### Task 4: One save path, and every change goes through it
555
556 **Files:**
557 - Modify: `src/tui/wallview.zig` (`Shared` gains `layout_path`; `run` sets it; the keyboard loop calls `persist` after births, splits, resizes, vanishes, detach)
558 - Modify: `src/tui/wall_layout.zig` (`saveSidecar` becomes `persist`, gated on `layout_path`)
559 - Modify: `src/tui/wall_picker.zig` (`pickBirth`, `pickForget` persist)
560 - Test: `src/tui/wall_test_layout.zig`
561
562 **Interfaces:**
563 - Produces: `Shared.layout_path: ?[]const u8 = null`; `wall_layout.persist(w: Wall) void` (replaces `saveSidecar`; same body, gated on `w.shared.layout_path`).
564 - Consumed by: Tasks 5, 6, 8.
565
566 - [ ] **Step 1: Write the failing test**
567
568 Append to `src/tui/wall_test_layout.zig`:
569
570 ```zig
571 test "persist: a birth and a vanish each write the layout, and no layout_path writes nothing" {
572 const testtmp = @import("testtmp");
573 var tmp = try testtmp.TmpDir.make();
574 defer tmp.cleanup();
575 var path_buf: [64]u8 = undefined;
576 const path = try std.fmt.bufPrint(&path_buf, "{s}/layout", .{tmp.path()});
577
578 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = false };
579 defer shared.tree.deinit();
580 var tiles: [wv.max_tiles]Tile = undefined;
581 var present = [_]bool{false} ** wv.max_tiles;
582 var live: usize = 0;
583 var hosts_table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")};
584 const w = fixture.wallOf(std.testing.allocator, &tiles, &present, &live, &shared, &hosts_table);
585
586 // Not yet a wall that persists: nothing is written.
587 wall_layout.persist(w);
588 try std.testing.expectError(error.FileNotFound, std.fs.cwd().access(path, .{}));
589
590 shared.layout_path = path;
591 const at = wv.birthTile(w, .{
592 .r = .{ .target = hosts_table[0].spec.target, .label = "", .session = "0" },
593 .from = 0,
594 .place = .beside_focus,
595 .creates = false,
596 .born_from = null,
597 .host = 0,
598 .borrowed = true,
599 }).?;
600 wall_layout.persist(w);
601 const first = try std.fs.cwd().readFileAlloc(std.testing.allocator, path, 4096);
602 defer std.testing.allocator.free(first);
603 try std.testing.expect(std.mem.indexOf(u8, first, "leaf 0 --sock /a#0") != null);
604
605 wv.vanishTile(w.liveTiles(), w.livePresent(), &shared, at, null);
606 wall_layout.persist(w);
607 const second = try std.fs.cwd().readFileAlloc(std.testing.allocator, path, 4096);
608 defer std.testing.allocator.free(second);
609 try std.testing.expect(std.mem.indexOf(u8, second, "#0") == null);
610 fixture.endPumps(&tiles);
611 }
612 ```
613
614 `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`.
615
616 - [ ] **Step 2: Run to see it fail**
617
618 Run: `deps/zig/zig build test 2>&1 | grep -a "error:" | head -3`
619 Expected: `no member named 'layout_path'`, `persist` undefined.
620
621 - [ ] **Step 3: Implement**
622
623 In `src/tui/wallview.zig`, in `Shared` beside `is_tty`:
624
625 ```zig
626 /// Where this wall is written, or null for a wall that persists nothing
627 /// (a piped `mux`, a test). Set once by `run` on a terminal. The ONE
628 /// gate every save reads, so a test points it at a file and proves what
629 /// an operation wrote rather than that a save was called.
630 layout_path: ?[]const u8 = null,
631 ```
632
633 In `run`, right after `is_tty` is known and before the host table is built:
634
635 ```zig
636 if (is_tty) shared.layout_path = hosts.layoutPath(alloc) catch null;
637 defer if (shared.layout_path) |p| alloc.free(p);
638 ```
639
640 In `src/tui/wall_layout.zig`, rename `saveSidecar` to `persist` and make its body:
641
642 ```zig
643 /// The one save path. Every change to the pane set or the tree comes
644 /// through here — a birth, a removal, a split, a resize, a detach — so two
645 /// terminals on one device see each other's adds on their next start, and
646 /// a wall that crashes loses nothing it committed.
647 pub fn persist(w: Wall) void {
648 const path = w.shared.layout_path orelse return;
649 saveLayoutTo(w.alloc, path, w.liveTiles(), w.livePresent(), w.shared);
650 }
651 ```
652
653 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.
654
655 Call sites of `persist` in `src/tui/wallview.zig`'s keyboard loop (find each by its action tag):
656 - after a successful `birthTile` in the `.new_session, .split_right, .split_below` arm;
657 - in the `.detach` arm, where `saveSidecar` was;
658 - after `doResize` returns true in the `.resize` arm;
659 - after every `vanishTile` that follows an `endAction` `.vanish` (the pump-ended path) and after `closePicker` when `birth_at` is non-null;
660 - after the entry tile is seated at startup (the `addFirst(0)` and the seed plan's insertion), once, before the keys loop.
661
662 In `src/tui/wall_picker.zig`: `pickBirth` calls `wall_layout.persist(w)` after `spawnPump`; `pickForget` calls it after its vanish loop.
663
664 - [ ] **Step 4: Run the gate**
665
666 Run: `make check 2>&1 | tail -3; echo rc=$?`
667 Expected: `rc=0`. The comment gate needs `saveSidecar` gone from every comment; grep and reword to `persist`.
668
669 - [ ] **Step 5: Commit**
670
671 ```bash
672 git add src/tui/wallview.zig src/tui/wall_layout.zig src/tui/wall_picker.zig src/tui/wall_test_layout.zig
673 git commit -m "feat: every change to the wall writes the layout through one save path"
674 ```
675
676 ---
677
678 ### Task 5: `x` removes the pane; the session lives on
679
680 **Files:**
681 - Modify: `src/tui/wallview.zig` (`removePane`; the `.end_session` arm)
682 - Test: `src/tui/wall_test_wall.zig`
683
684 **Interfaces:**
685 - Produces: `wv.removePane(w: Wall, z: usize) void`.
686 - `endKey`, `intentForEnd`, `onEndReply`, `EndKey` stay for Task 6 (the picker's end path reuses `end_arm_ms`).
687
688 - [ ] **Step 1: Write the failing test**
689
690 Append to `src/tui/wall_test_wall.zig`, modelled on the file's existing `claimBench`/`endBench` tests:
691
692 ```zig
693 test "removePane: the pane leaves the wall, its pump is told to detach, and nothing is asked to end" {
694 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = false };
695 defer shared.tree.deinit();
696 var tiles = [_]Tile{ fixture.claimBench(&shared, 0), fixture.claimBench(&shared, 1) };
697 var present = [_]bool{ true, true };
698 try shared.tree.addFirst(0);
699 try shared.tree.splitRight(0, 1);
700 const w = fixture.wallAll(std.testing.allocator, &tiles, &present, &shared);
701 shared.sel = 1;
702
703 wv.removePane(w, 1);
704 try std.testing.expect(!present[1]);
705 try std.testing.expect(present[0]);
706 try std.testing.expect(tiles[1].detach_req.load(.acquire));
707 // The daemon was NOT asked to end anything: the ask mailbox is empty.
708 try std.testing.expectEqual(@as(u8, 0), tiles[1].ask.load(.acquire));
709 try std.testing.expectEqual(@as(usize, 0), shared.sel);
710 var buf: [128]u8 = undefined;
711 try std.testing.expectEqualStrings("[pane removed - the session is still on its daemon]", wv.takeNotice(&shared, &buf));
712 fixture.endPumps(&tiles);
713 }
714 ```
715
716 - [ ] **Step 2: Run to see it fail**
717
718 Run: `deps/zig/zig build test 2>&1 | grep -a "error:" | head -3`
719 Expected: `removePane` undefined.
720
721 - [ ] **Step 3: Implement**
722
723 In `src/tui/wallview.zig`, below `vanishTile`:
724
725 ```zig
726 /// `Ctrl-\ x`: this pane leaves THIS wall. The session is the daemon's and
727 /// keeps running for whoever else holds it; ending one is the picker's
728 /// job, beside the count of who else is there. The pump is told to say
729 /// goodbye (`detach_req`) so the daemon frees the slot now rather than at
730 /// a timeout, then the tile is vanished and the layout written without it.
731 pub fn removePane(w: Wall, z: usize) void {
732 if (z >= w.live.* or !w.present[z]) return;
733 const t = &w.tiles[z];
734 t.detach_req.store(true, .release);
735 ring(t);
736 vanishTile(w.liveTiles(), w.livePresent(), w.shared, z, null);
737 setNotice(w.shared, "[pane removed - the session is still on its daemon]");
738 wall_layout.relayout(w, w.shared.sel);
739 wall_layout.persist(w);
740 }
741 ```
742
743 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.
744
745 Replace the `.end_session` arm of the keyboard loop with:
746
747 ```zig
748 .end_session => if (z < w.live.* and present[z]) {
749 removePane(w, z);
750 if (!shared.is_tty and presentCount(present[0..live]) == 0) {
751 exit_code = 0;
752 exit_msg = "mux: aborted before attaching";
753 break :keys;
754 }
755 },
756 ```
757
758 Keep the `endKey`/`intentForEnd`/`onEndReply` functions and `end_arm_ms`; Task 6 moves their caller.
759
760 - [ ] **Step 4: Run the gate**
761
762 Run: `make check 2>&1 | tail -3; echo rc=$?`
763 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).
764
765 - [ ] **Step 5: Commit**
766
767 ```bash
768 git add src/tui/wallview.zig src/tui/wall_test_wall.zig
769 git commit -m "feat: x takes the pane off this wall and ends nothing"
770 ```
771
772 ---
773
774 ### Task 6: The picker's session level: add, birth, end
775
776 **Files:**
777 - Modify: `src/tui/interact.zig` (`PrefixFilter`: `pick_level`, actions `pick_enter`, `pick_back`, `pick_end`)
778 - Modify: `src/client/client.zig` (`endSession`)
779 - Modify: `src/tui/wall_picker.zig` (`sessionRows`, `pickAdd`, `pickEnd`, `paintPicker` at the session level)
780 - Modify: `src/tui/wallview.zig` (the picker arm of the keyboard loop: `picker_row`, level state)
781 - 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)
782
783 **Interfaces:**
784 - `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`.
785 - `client.endSession(alloc: std.mem.Allocator, target: Target, name: []const u8, force: bool) !EndOutcome` with
786
787 ```zig
788 pub const EndOutcome = struct {
789 accepted: bool,
790 others: u8,
791 reason_buf: [proto.end_reply_max_len]u8 = undefined,
792 reason_len: usize = 0,
793 pub fn reason(self: *const EndOutcome) []const u8 {
794 return self.reason_buf[0..self.reason_len];
795 }
796 };
797 ```
798
799 - `wall_picker.sessionRows(body: *PickerBody, h: *Host, w: Wall, host: usize, sel_row: usize, cols: u16) void`
800 - `wall_picker.pickAdd(w: Wall, host: usize, row: usize) ?usize` — the tile index added or zoomed to; null with a notice.
801 - `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.
802
803 - [ ] **Step 1: Write the failing filter tests**
804
805 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`):
806
807 ```zig
808 test "picker levels: Enter on a host opens its sessions, Esc backs out a level, Enter on a session closes with pick_enter" {
809 var f = PrefixFilter{};
810 var open = [_]u8{ detach_key, 's' };
811 try std.testing.expectEqual(PrefixFilter.Action.pick_open, f.feed(&open).action);
812 try std.testing.expectEqual(PrefixFilter.PickLevel.hosts, f.pick_level);
813
814 var enter = [_]u8{'\r'};
815 try std.testing.expectEqual(PrefixFilter.Action.pick_enter, f.feed(&enter).action);
816 try std.testing.expect(f.picking);
817 try std.testing.expectEqual(PrefixFilter.PickLevel.sessions, f.pick_level);
818
819 var esc = [_]u8{0x1b};
820 try std.testing.expectEqual(PrefixFilter.Action.pick_back, f.feed(&esc).action);
821 try std.testing.expect(f.picking);
822 try std.testing.expectEqual(PrefixFilter.PickLevel.hosts, f.pick_level);
823
824 _ = f.feed(&enter);
825 try std.testing.expectEqual(PrefixFilter.Action.pick_enter, f.feed(&enter).action);
826 try std.testing.expect(!f.picking);
827 try std.testing.expectEqual(PrefixFilter.PickLevel.hosts, f.pick_level);
828 }
829
830 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" {
831 var f = PrefixFilter{};
832 var open = [_]u8{ detach_key, 's' };
833 _ = f.feed(&open);
834 var x = [_]u8{'x'};
835 try std.testing.expectEqual(PrefixFilter.Action.pick_forget, f.feed(&x).action);
836 try std.testing.expect(!f.picking);
837
838 _ = f.feed(&open);
839 var enter = [_]u8{'\r'};
840 _ = f.feed(&enter);
841 const ended = f.feed(&x);
842 try std.testing.expectEqual(PrefixFilter.Action.pick_end, ended.action);
843 try std.testing.expectEqual(@as(usize, 0), ended.forward.len);
844 try std.testing.expect(f.picking); // the popup stays up to show the count or the end
845
846 var c = [_]u8{'c'};
847 try std.testing.expectEqual(PrefixFilter.Action.pick_birth, f.feed(&c).action);
848 try std.testing.expect(!f.picking);
849 try std.testing.expectEqual(PrefixFilter.PickLevel.hosts, f.pick_level);
850 }
851 ```
852
853 - [ ] **Step 2: Run to see them fail**
854
855 Run: `deps/zig/zig build test 2>&1 | grep -a "error:" | head -3`
856 Expected: `pick_level`, `PickLevel`, `pick_enter` undefined.
857
858 - [ ] **Step 3: Implement the filter**
859
860 In `src/tui/interact.zig`, `PrefixFilter`:
861
862 ```zig
863 pub const PickLevel = enum { hosts, sessions };
864 /// Which list the popup shows. The filter owns it because Enter and Esc
865 /// mean different things at each level, and a key must resolve to ONE
866 /// action without the wall's help.
867 pick_level: PickLevel = .hosts,
868 ```
869
870 Add to `Action`: `pick_enter`, `pick_back`, `pick_end`.
871
872 In the `if (self.picking)` switch:
873
874 ```zig
875 0x1b => {
876 const tail = buf[i + 1 ..];
877 if (arrowMove(tail)) |d|
878 return .{ .forward = buf[0..kept], .action = .{ .pick_move = d } };
879 if (tail.len > 0 and (tail[0] == '[' or tail[0] == 'O'))
880 return .{ .forward = buf[0..kept], .action = .none };
881 if (self.pick_level == .sessions) {
882 self.pick_level = .hosts;
883 return .{ .forward = buf[0..kept], .action = .pick_back };
884 }
885 self.picking = false;
886 return .{ .forward = buf[0..kept], .action = .pick_close };
887 },
888 '\r', '\n' => {
889 if (self.pick_level == .hosts) {
890 self.pick_level = .sessions;
891 return .{ .forward = buf[0..kept], .action = .pick_enter };
892 }
893 self.picking = false;
894 self.pick_level = .hosts;
895 return .{ .forward = buf[0..kept], .action = .pick_enter };
896 },
897 'c' => {
898 self.picking = false;
899 self.pick_level = .hosts;
900 return .{ .forward = buf[0..kept], .action = .pick_birth };
901 },
902 'x' => {
903 if (self.pick_level == .sessions)
904 return .{ .forward = buf[0..kept], .action = .pick_end };
905 self.picking = false;
906 return .{ .forward = buf[0..kept], .action = .pick_forget };
907 },
908 's', 0x03 => {
909 self.picking = false;
910 self.pick_level = .hosts;
911 return .{ .forward = buf[0..kept], .action = .pick_close };
912 },
913 ```
914
915 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`.
916
917 - [ ] **Step 4: Run the filter tests**
918
919 Run: `deps/zig/zig build test 2>&1 | grep -a "picker levels\|error:" | head -5`
920 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).
921
922 - [ ] **Step 5: Write the failing `endSession` test**
923
924 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):
925
926 ```zig
927 test "endSession: a held session refuses with the count, force ends it, and an unknown name is refused with the daemon's reason" {
928 // (fixture: a real daemon on a tmp socket, one session "0" with one
929 // client attached — same setup as birthSession's test above)
930 var first = try endSession(std.testing.allocator, .{ .sock = sock_path }, "0", false);
931 try std.testing.expect(!first.accepted);
932 try std.testing.expectEqual(@as(u8, 1), first.others);
933 var forced = try endSession(std.testing.allocator, .{ .sock = sock_path }, "0", true);
934 try std.testing.expect(forced.accepted);
935 var missing = try endSession(std.testing.allocator, .{ .sock = sock_path }, "nope", false);
936 try std.testing.expect(!missing.accepted);
937 try std.testing.expectEqualStrings(proto.end_reason.no_session, missing.reason());
938 }
939 ```
940
941 - [ ] **Step 6: Implement `endSession`**
942
943 In `src/client/client.zig`, after `birthSession`:
944
945 ```zig
946 pub const EndOutcome = struct {
947 accepted: bool,
948 others: u8,
949 reason_buf: [proto.end_reply_max_len]u8 = undefined,
950 reason_len: usize = 0,
951 pub fn reason(self: *const EndOutcome) []const u8 {
952 return self.reason_buf[0..self.reason_len];
953 }
954 };
955
956 /// `end_req` on a side connection of its own: the picker ends a session
957 /// that may have no pane on this wall, so there is no pump to ask
958 /// through. The daemon owns the two-step; this only carries `force`.
959 pub fn endSession(alloc: std.mem.Allocator, target: Target, name: []const u8, force: bool) !EndOutcome {
960 var tr = try Transport.open(alloc, target, null, -1, null);
961 defer tr.close();
962 var buf: [proto.end_req_max_len]u8 = undefined;
963 const req = proto.encodeEndReq(&buf, force, proto.wireName(name));
964 const deadline = std.time.milliTimestamp() + birth_budget_ms;
965 const f = roundTrip(&tr, alloc, .end_req, req, &.{ .end_reply, .exit_status }, deadline) catch |e| return switch (e) {
966 error.Timeout => error.Timeout,
967 else => error.Transport,
968 };
969 defer f.deinit(alloc);
970 if (f.type != .end_reply) return error.Refused;
971 const r = try proto.decodeEndReply(f.payload);
972 var out: EndOutcome = .{ .accepted = r.accepted, .others = r.others };
973 const n = @min(r.reason.len, out.reason_buf.len);
974 @memcpy(out.reason_buf[0..n], r.reason[0..n]);
975 out.reason_len = n;
976 return out;
977 }
978 ```
979
980 `proto.decodeEndReply` exists beside `encodeEndReply` (grep to confirm the name; if it is `parseEndReply`, use that).
981
982 - [ ] **Step 7: Run the client test**
983
984 Run: `deps/zig/zig build test 2>&1 | grep -a "endSession\|error:" | head -5`
985 Expected: pass.
986
987 - [ ] **Step 8: Write the failing picker tests**
988
989 Append to `src/tui/wall_test_picker.zig` (it has `fixture.testHost`, `fixture.setList`, and `pickerFrame` for painted rows):
990
991 ```zig
992 test "sessionRows: a host's sessions, marked when already on this wall, with the holder count when the daemon says" {
993 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
994 defer shared.tree.deinit();
995 var hosts_table = [_]Host{fixture.testHost(&shared, "box", "/b")};
996 fixture.setList(&hosts_table[0], "0\nwork\n# holds 0 2\n# holds work 0\n# mux 0.0.1-18");
997 var tiles = [_]Tile{fixture.claimBench(&shared, 0)};
998 tiles[0].host = 0;
999 tiles[0].r.session = "work";
1000 var present = [_]bool{true};
1001 const w = fixture.wallAll(std.testing.allocator, &tiles, &present, &shared);
1002 _ = w.hosts; // wallAll has no hosts; the rows take the table directly
1003
1004 var body = wall_picker.PickerBody{};
1005 wall_picker.sessionRows(&body, &hosts_table[0], fixture.wallOf(std.testing.allocator, &tiles, &present, w.live, &shared, &hosts_table), 0, 1, 80);
1006 try std.testing.expectEqual(@as(usize, 2), body.n);
1007 try std.testing.expect(std.mem.indexOf(u8, body.row(0), "0") != null);
1008 try std.testing.expect(std.mem.indexOf(u8, body.row(0), "2 clients") != null);
1009 try std.testing.expect(std.mem.indexOf(u8, body.row(1), "work") != null);
1010 try std.testing.expect(std.mem.indexOf(u8, body.row(1), "on this wall") != null);
1011 fixture.endPumps(&tiles);
1012 }
1013
1014 test "sessionRows: an old daemon's list shows no count rather than zero" {
1015 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
1016 defer shared.tree.deinit();
1017 var hosts_table = [_]Host{fixture.testHost(&shared, "box", "/b")};
1018 fixture.setList(&hosts_table[0], "0\n");
1019 var tiles: [1]Tile = undefined;
1020 var present = [_]bool{false};
1021 var live: usize = 0;
1022 var body = wall_picker.PickerBody{};
1023 wall_picker.sessionRows(&body, &hosts_table[0], fixture.wallOf(std.testing.allocator, &tiles, &present, &live, &shared, &hosts_table), 0, 0, 80);
1024 try std.testing.expectEqual(@as(usize, 1), body.n);
1025 try std.testing.expect(std.mem.indexOf(u8, body.row(0), "client") == null);
1026 }
1027
1028 test "pickAdd: a listed session becomes a pane once; a second add zooms to it" {
1029 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = false };
1030 defer shared.tree.deinit();
1031 var hosts_table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")};
1032 fixture.setList(&hosts_table[0], "0\nwork\n");
1033 var tiles: [wv.max_tiles]Tile = undefined;
1034 var present = [_]bool{false} ** wv.max_tiles;
1035 var live: usize = 0;
1036 const w = fixture.wallOf(std.testing.allocator, &tiles, &present, &live, &shared, &hosts_table);
1037
1038 const first = wall_picker.pickAdd(w, 0, 1).?;
1039 try std.testing.expectEqualStrings("work", tiles[first].r.session);
1040 try std.testing.expect(!tiles[first].creates);
1041 try std.testing.expectEqual(@as(usize, 1), wv.presentCount(w.livePresent()));
1042 const again = wall_picker.pickAdd(w, 0, 1).?;
1043 try std.testing.expectEqual(first, again);
1044 try std.testing.expectEqual(@as(usize, 1), wv.presentCount(w.livePresent()));
1045 try std.testing.expectEqual(first, shared.sel);
1046 fixture.endPumps(&tiles);
1047 }
1048 ```
1049
1050 - [ ] **Step 9: Implement the picker's session level**
1051
1052 In `src/tui/wall_picker.zig`:
1053
1054 ```zig
1055 /// The second level: one row per session the host's last answer named.
1056 /// "on this wall" when the layout already has it, and the holder count
1057 /// when the daemon is new enough to say (`proto.parseSessionsHolds`); an
1058 /// old daemon's rows carry no count rather than a zero that would read as
1059 /// "safe to end".
1060 pub fn sessionRows(body: *PickerBody, h: *Host, w: Wall, host: usize, sel_row: usize, cols: u16) void {
1061 body.n = 0;
1062 var list_buf: [proto.sessions_reply_max]u8 = undefined;
1063 const list = h.poll.snapshot(&list_buf);
1064 var it = proto.sessionsIter(list);
1065 var row: usize = 0;
1066 while (it.next()) |name| : (row += 1) {
1067 if (body.n >= wv.max_tiles) break;
1068 var state_buf: [48]u8 = undefined;
1069 var state: []const u8 = "";
1070 const on_wall = paneOf(w, host, name) != null;
1071 if (proto.parseSessionsHolds(list, name)) |n| {
1072 state = std.fmt.bufPrint(&state_buf, "{s}{d} client{s}", .{
1073 if (on_wall) "on this wall, " else "",
1074 n,
1075 if (n == 1) "" else "s",
1076 }) catch "";
1077 } else if (on_wall) state = "on this wall";
1078 body.host[body.n] = host;
1079 body.lens[body.n] = pickerRow(&body.text[body.n], body.n + 1, false, name, state, row == sel_row, cols).len;
1080 body.n += 1;
1081 }
1082 }
1083
1084 fn paneOf(w: Wall, host: usize, name: []const u8) ?usize {
1085 for (w.liveTiles(), w.livePresent(), 0..) |*t, p, i| {
1086 if (p and wall_host.ownedBy(t, host) and std.mem.eql(u8, proto.resolveName(t.r.session), name)) return i;
1087 }
1088 return null;
1089 }
1090
1091 fn sessionAt(h: *Host, row: usize, out: *[proto.session_name_max]u8) ?[]const u8 {
1092 var list_buf: [proto.sessions_reply_max]u8 = undefined;
1093 const list = h.poll.snapshot(&list_buf);
1094 var it = proto.sessionsIter(list);
1095 var i: usize = 0;
1096 while (it.next()) |name| : (i += 1) {
1097 if (i == row) {
1098 @memcpy(out[0..name.len], name);
1099 return out[0..name.len];
1100 }
1101 }
1102 return null;
1103 }
1104
1105 /// Enter on a session row: a pane for it, joined (never created), zoomed
1106 /// to. A session already on the wall is only zoomed to. The layout is
1107 /// written, because this is one of the three places a pane comes from.
1108 pub fn pickAdd(w: Wall, host: usize, row: usize) ?usize {
1109 if (host >= w.hosts.len) return null;
1110 const h = &w.hosts[host];
1111 var name_buf: [proto.session_name_max]u8 = undefined;
1112 const name = sessionAt(h, row, &name_buf) orelse {
1113 wv.setNotice(w.shared, "[no session on that row]");
1114 return null;
1115 };
1116 if (paneOf(w, host, name)) |at| {
1117 wv.setFocus(w.liveTiles(), w.shared, at);
1118 return at;
1119 }
1120 var target = h.spec.target;
1121 if (target == .hand) target.hand.asked = true;
1122 const anchor = wall_layout.anchorTile(w.livePresent(), w.shared.sel);
1123 const has_anchor = wv.presentCount(w.livePresent()) > 0;
1124 const at = wv.birthTile(w, .{
1125 .r = .{ .target = target, .label = "", .session = name, .agent = false },
1126 .from = anchor,
1127 .place = .beside_focus,
1128 .creates = false,
1129 .born_from = if (has_anchor) anchor else null,
1130 .host = host,
1131 .borrowed = true,
1132 }) orelse {
1133 wv.setNotice(w.shared, "[no room on the wall for another pane]");
1134 return null;
1135 };
1136 wv.spawnPump(&w.tiles[at]);
1137 wv.setFocus(w.liveTiles(), w.shared, at);
1138 wall_layout.persist(w);
1139 return at;
1140 }
1141
1142 /// `x` on a session row: the daemon's two-step, from a side connection.
1143 /// The first press on a session others hold is refused with the count and
1144 /// arms 3 s; a second press inside that window forces. Armed per host and
1145 /// name on `Shared`, so a press on a different row is a first press.
1146 pub fn pickEnd(w: Wall, host: usize, row: usize, now: i64) void {
1147 if (host >= w.hosts.len) return;
1148 const h = &w.hosts[host];
1149 var name_buf: [proto.session_name_max]u8 = undefined;
1150 const name = sessionAt(h, row, &name_buf) orelse return;
1151 const armed = w.shared.pick_end.armedFor(host, name, now);
1152 const out = client.endSession(w.alloc, h.spec.target, name, armed) catch |e| {
1153 var buf: [96]u8 = undefined;
1154 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]");
1155 return;
1156 };
1157 var buf: [128]u8 = undefined;
1158 if (out.accepted) {
1159 w.shared.pick_end.clear();
1160 wv.setNotice(w.shared, std.fmt.bufPrint(&buf, "[ending {s} on {s}]", .{ name, h.spec.spelling }) catch "[ending the session]");
1161 } else {
1162 w.shared.pick_end.arm(host, name, now + wv.end_arm_ms);
1163 wv.setNotice(w.shared, std.fmt.bufPrint(&buf, "[{s}: {d} other client{s} attached - x again within 3s to end anyway]", .{
1164 name, out.others, if (out.others == 1) "" else "s",
1165 }) catch "[others attached - x again to end anyway]");
1166 }
1167 h.poll.poke.store(true, .release);
1168 }
1169 ```
1170
1171 Add to `Shared` in `wallview.zig`:
1172
1173 ```zig
1174 /// The picker's end two-step, per host and name: a second `x` on the SAME
1175 /// row inside the window forces, any other row is a first press.
1176 pick_end: PickEnd = .{},
1177
1178 pub const PickEnd = struct {
1179 host: usize = 0,
1180 name: client.SessionName = .{},
1181 until: i64 = 0,
1182 pub fn armedFor(self: *const PickEnd, host: usize, name: []const u8, now: i64) bool {
1183 return now < self.until and self.host == host and std.mem.eql(u8, self.name.slice(), name);
1184 }
1185 pub fn arm(self: *PickEnd, host: usize, name: []const u8, until: i64) void {
1186 self.host = host;
1187 self.name = client.SessionName.of(name);
1188 self.until = until;
1189 }
1190 pub fn clear(self: *PickEnd) void {
1191 self.until = 0;
1192 }
1193 };
1194 ```
1195
1196 `end_arm_ms` must be `pub` in `wallview.zig`. `client.SessionName.of` exists (the hub uses it).
1197
1198 `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.
1199
1200 In `wallview.zig`'s picker arm of the keyboard loop:
1201
1202 ```zig
1203 .pick_enter => if (input.prefix.pick_level == .sessions) {
1204 // Enter at the host level just opened the list; nothing to do but paint.
1205 picker_row = 0;
1206 } else {
1207 // Enter at the session level closed the popup with a choice.
1208 birth_at = wall_picker.pickAdd(w, picker_sel, picker_row);
1209 },
1210 .pick_back => picker_row = 0,
1211 .pick_end => wall_picker.pickEnd(w, picker_sel, picker_row, std.time.milliTimestamp()),
1212 .pick_move => |d| if (input.prefix.pick_level == .sessions) {
1213 picker_row = wall_picker.rowStep(picker_row, d, wall_picker.sessionCount(&w.hosts[picker_sel]));
1214 } else picker_sel = wall_picker.pickerStep(w.hosts, picker_sel, d),
1215 .pick_select => |row| if (input.prefix.pick_level == .sessions) {
1216 picker_row = @min(row - 1, wall_picker.sessionCount(&w.hosts[picker_sel]) -| 1);
1217 } else if (wall_picker.pickerAt(w.hosts, row - 1)) |hi| picker_sel = hi,
1218 ```
1219
1220 with in `wall_picker.zig`:
1221
1222 ```zig
1223 pub fn sessionCount(h: *Host) usize {
1224 var list_buf: [proto.sessions_reply_max]u8 = undefined;
1225 var it = proto.sessionsIter(h.poll.snapshot(&list_buf));
1226 var n: usize = 0;
1227 while (it.next()) |_| n += 1;
1228 return n;
1229 }
1230
1231 pub fn rowStep(row: usize, d: i8, n: usize) usize {
1232 if (n == 0) return 0;
1233 if (d < 0) return if (row == 0) n - 1 else row - 1;
1234 return if (row + 1 >= n) 0 else row + 1;
1235 }
1236 ```
1237
1238 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.
1239
1240 - [ ] **Step 10: Run the gate**
1241
1242 Run: `make check 2>&1 | tail -3; echo rc=$?`
1243 Expected: `rc=0`.
1244
1245 - [ ] **Step 11: Commit**
1246
1247 ```bash
1248 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
1249 git commit -m "feat: the picker lists a host's sessions; Enter adds one, c births, x ends with the daemon's two-step"
1250 ```
1251
1252 ---
1253
1254 ### Task 7: The hub serves and writes the layout
1255
1256 **Files:**
1257 - Modify: `src/client/webhub.zig` (`Hub.init`, `applyList`, `spawn`, `json`)
1258 - Modify: `src/cli/webhub_main.zig` (reads the layout, hands leaves to `Hub.init`)
1259 - Test: `src/client/webhub.zig` (inline tests)
1260
1261 **Interfaces:**
1262 - `webhub.Leaf = struct { host: usize, session: []const u8 }`
1263 - `Hub.init(alloc, specs: []const client.HostSpec, leaves: []const Leaf) !Hub` — one tile per leaf, ids in leaf order.
1264 - `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.
1265 - `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.
1266 - `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.
1267 - `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.
1268
1269 - [ ] **Step 1: Write the failing hub tests**
1270
1271 Replace the test "hub: a listed name births a tile once; ids are birth order and never reused" in `src/client/webhub.zig` with:
1272
1273 ```zig
1274 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" {
1275 const alloc = std.testing.allocator;
1276 const specs = [_]client.HostSpec{
1277 .{ .spelling = "--sock /tmp/a", .target = .{ .sock = "/tmp/a" }, .poll_target = .{ .sock = "/tmp/a" } },
1278 .{ .spelling = "box", .target = .{ .sock = "/tmp/b" }, .poll_target = .{ .sock = "/tmp/b" } },
1279 };
1280 const leaves = [_]Leaf{ .{ .host = 1, .session = "0" }, .{ .host = 0, .session = "0" }, .{ .host = 0, .session = "b" } };
1281 var hub = try Hub.init(alloc, &specs, &leaves);
1282 defer hub.deinit();
1283 try std.testing.expectEqual(@as(usize, 3), hub.tiles.items.len);
1284 try std.testing.expectEqual(@as(u32, 0), hub.tiles.items[0].id);
1285 try std.testing.expectEqualStrings("box", specs[hub.tiles.items[0].host].spelling);
1286
1287 hub.applyList(0, "0\nb\nstranger\n", true);
1288 try std.testing.expectEqual(@as(usize, 3), hub.tiles.items.len);
1289
1290 hub.applyList(0, "0\n", true);
1291 hub.applyList(0, "0\n", true);
1292 try std.testing.expectEqual(@as(usize, 3), hub.tiles.items.len);
1293 try std.testing.expectEqual(TileState.gone, hub.tiles.items[2].state);
1294 hub.applyList(0, "0\nb\n", true);
1295 try std.testing.expect(hub.tiles.items[2].state != .gone);
1296
1297 const json = try hub.json(alloc);
1298 defer alloc.free(json);
1299 try std.testing.expectEqualStrings(
1300 \\[{"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"}]
1301 , json);
1302 }
1303
1304 test "readLeaves and appendLeaf: the layout round-trips through the hub, and a bad file is refused with its line" {
1305 const alloc = std.testing.allocator;
1306 const testtmp = @import("testtmp");
1307 var tmp = try testtmp.TmpDir.make();
1308 defer tmp.cleanup();
1309 var pb: [64]u8 = undefined;
1310 const path = try std.fmt.bufPrint(&pb, "{s}/layout", .{tmp.path()});
1311 const specs = [_]client.HostSpec{
1312 .{ .spelling = "--sock /tmp/a", .target = .{ .sock = "/tmp/a" }, .poll_target = .{ .sock = "/tmp/a" } },
1313 };
1314 // No file yet: the first append makes a one-leaf tree.
1315 try appendLeaf(alloc, path, &specs, null, .{ .host = 0, .session = "0" });
1316 try appendLeaf(alloc, path, &specs, .{ .host = 0, .session = "0" }, .{ .host = 0, .session = "b" });
1317 const leaves = try readLeaves(alloc, path, &specs);
1318 defer alloc.free(leaves);
1319 try std.testing.expectEqual(@as(usize, 2), leaves.len);
1320 try std.testing.expectEqualStrings("0", leaves[0].session);
1321 try std.testing.expectEqualStrings("b", leaves[1].session);
1322
1323 try std.fs.cwd().writeFile(.{ .sub_path = path, .data = "mux-layout 1\nleaf 0 nowhere#0\n" });
1324 try std.testing.expectError(error.BadLayout, readLeaves(alloc, path, &specs));
1325 }
1326 ```
1327
1328 If `TileState` has no `gone` variant, add one (the browser's `controlMessage` table gains a `gone` string: "session ended on its daemon").
1329
1330 - [ ] **Step 2: Run to see them fail**
1331
1332 Run: `deps/zig/zig build test 2>&1 | grep -a "error:" | head -3`
1333 Expected: `Leaf`, `readLeaves`, `appendLeaf` undefined.
1334
1335 - [ ] **Step 3: Implement**
1336
1337 In `src/client/webhub.zig`:
1338
1339 ```zig
1340 pub const Leaf = struct { host: usize, session: []const u8 };
1341
1342 /// The layout's leaves in tree order, each mapped to a spec index. The
1343 /// same strictness as the terminal wall: a leaf the hosts file cannot
1344 /// place, or one with no session, refuses the FILE, because a hub that
1345 /// silently served part of a wall would be a wall the user cannot see is
1346 /// short.
1347 pub fn readLeaves(alloc: std.mem.Allocator, path: []const u8, specs: []const client.HostSpec) ![]Leaf {
1348 const bytes = std.fs.cwd().readFileAlloc(alloc, path, 1024 * 1024) catch |e| switch (e) {
1349 error.FileNotFound => return alloc.alloc(Leaf, 0),
1350 else => return e,
1351 };
1352 defer alloc.free(bytes);
1353 var parsed = layout.parse(alloc, bytes) orelse return error.BadLayout;
1354 defer parsed.deinit(alloc);
1355 var out = std.ArrayListUnmanaged(Leaf){};
1356 errdefer {
1357 for (out.items) |l| alloc.free(l.session);
1358 out.deinit(alloc);
1359 }
1360 for (parsed.spellings.items) |sp| {
1361 const cut = std.mem.lastIndexOfScalar(u8, sp, '#') orelse return error.BadLayout;
1362 const sess = sp[cut + 1 ..];
1363 if (!proto.validSessionName(sess)) return error.BadLayout;
1364 const hi = for (specs, 0..) |s, i| {
1365 if (std.mem.eql(u8, s.spelling, sp[0..cut])) break i;
1366 } else return error.BadLayout;
1367 try out.append(alloc, .{ .host = hi, .session = try alloc.dupe(u8, sess) });
1368 }
1369 return out.toOwnedSlice(alloc);
1370 }
1371
1372 /// Read-modify-write over the atomic rename `hosts.saveBytes` does. Two
1373 /// writers in the same instant lose one update; the hosts file accepts
1374 /// the same, and the terminal wall re-reads on its next start.
1375 pub fn appendLeaf(alloc: std.mem.Allocator, path: []const u8, specs: []const client.HostSpec, beside: ?Leaf, new: Leaf) !void {
1376 var parsed: layout.ParsedLayout = blk: {
1377 const bytes = std.fs.cwd().readFileAlloc(alloc, path, 1024 * 1024) catch |e| switch (e) {
1378 error.FileNotFound => break :blk .{ .tree = layout.Tree.init(alloc) },
1379 else => return e,
1380 };
1381 defer alloc.free(bytes);
1382 break :blk layout.parse(alloc, bytes) orelse return error.BadLayout;
1383 };
1384 defer parsed.deinit(alloc);
1385 const new_sp = try std.fmt.allocPrint(alloc, "{s}#{s}", .{ specs[new.host].spelling, new.session });
1386 errdefer alloc.free(new_sp);
1387 const new_id: u8 = @intCast(parsed.spellings.items.len);
1388 if (new_id >= layout.max_leaves) return error.WallFull;
1389 var anchor: ?u8 = null;
1390 if (beside) |b| {
1391 for (parsed.spellings.items, 0..) |sp, i| {
1392 const cut = std.mem.lastIndexOfScalar(u8, sp, '#') orelse continue;
1393 if (std.mem.eql(u8, sp[0..cut], specs[b.host].spelling) and std.mem.eql(u8, sp[cut + 1 ..], b.session)) {
1394 anchor = @intCast(i);
1395 break;
1396 }
1397 }
1398 }
1399 if (parsed.tree.root == null) try parsed.tree.addFirst(new_id) else try parsed.tree.insert(anchor orelse 0, new_id);
1400 try parsed.spellings.append(alloc, new_sp);
1401 var buf = std.ArrayListUnmanaged(u8){};
1402 defer buf.deinit(alloc);
1403 try parsed.tree.serialize(parsed.spellings.items, parsed.focus, buf.writer(alloc));
1404 try hosts.saveBytes(path, buf.items);
1405 }
1406 ```
1407
1408 `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).
1409
1410 `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.
1411
1412 `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`.
1413
1414 `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`.
1415
1416 `json`: add `"state":"<tag>"` per tile from `@tagName(t.state)`.
1417
1418 In `src/cli/webhub_main.zig`, after `hosts.load`: `const layout_path = try hosts.layoutPath(arena);` then
1419
1420 ```zig
1421 const leaves = webhub.readLeaves(arena, layout_path, specs) catch |e| switch (e) {
1422 error.BadLayout => blk: {
1423 std.debug.print("mux web: layout ignored ({s}): not a wall this hosts file can place\n", .{layout_path});
1424 break :blk &.{};
1425 },
1426 else => return e,
1427 };
1428 var hub = try webhub.Hub.init(arena, specs, leaves);
1429 ```
1430
1431 and pass `layout_path` into the hub (`hub.layout_path = layout_path;` or an `init` parameter).
1432
1433 - [ ] **Step 4: Run the gate**
1434
1435 Run: `make check 2>&1 | tail -3; echo rc=$?`
1436 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).
1437
1438 - [ ] **Step 5: Commit**
1439
1440 ```bash
1441 git add src/client/webhub.zig src/cli/webhub_main.zig
1442 git commit -m "feat: the hub serves the layout's panes and writes a birth back into it"
1443 ```
1444
1445 ---
1446
1447 ### Task 8: New machine, the entry pane, and the end of `keeps_wall`
1448
1449 **Files:**
1450 - Modify: `src/tui/wallview.zig` (`run`'s startup; `Tile.keeps_wall`, `Birth.keeps_wall`, the `endAction` branch; `Entry`)
1451 - Modify: `src/tui/wall_picker.zig` (`pickBirth` no longer sets `keeps_wall`; it persists)
1452 - Modify: `src/tui/wall_test_wall.zig` (the `keeps_wall` tests)
1453
1454 **Interfaces:** none new. `keeps_wall` is deleted everywhere.
1455
1456 - [ ] **Step 1: Startup writes the wall it seated**
1457
1458 In `run`, after the entry tile is seated and any seed plan applied (the block ending in `shared.last_flat = init_flat;`), add:
1459
1460 ```zig
1461 // The wall as seated is the wall as written: a first `mux` on a machine
1462 // leaves a one-leaf layout behind, and `mux HOST` leaves its new pane in
1463 // the file. Before the keys loop, so a wall that dies at once still
1464 // persisted what it showed.
1465 wall_layout.persist(w);
1466 ```
1467
1468 placing it where `w` exists (the `Wall` value is built before the keys loop; put the call right after that construction).
1469
1470 - [ ] **Step 2: Delete `keeps_wall`**
1471
1472 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.
1473
1474 - [ ] **Step 3: Run the gate**
1475
1476 Run: `make check 2>&1 | tail -3; echo rc=$?`
1477 Expected: `rc=0`.
1478
1479 - [ ] **Step 4: Hand-run the new-machine path**
1480
1481 ```bash
1482 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
1483 mkdir -p $XDG_STATE_HOME $XDG_RUNTIME_DIR $XDG_CONFIG_HOME $XDG_CACHE_HOME $HOME
1484 deps/zig/zig build
1485 ./zig-out/bin/ptyclient --cols 100 --rows 30 --out /tmp/nm-$$/cap -- ./zig-out/bin/mux <<'EOF'
1486 expect \$ 10000
1487 send \x1cd
1488 EOF
1489 cat $XDG_STATE_HOME/mux/hosts; cat $XDG_STATE_HOME/mux/layout
1490 ./zig-out/bin/mux d stop
1491 ```
1492
1493 Expected: the hosts file holds one `--sock` line; the layout holds `leaf 0 --sock <path>#0` and nothing else.
1494
1495 - [ ] **Step 5: Commit**
1496
1497 ```bash
1498 git add src/tui/wallview.zig src/tui/wall_picker.zig src/tui/wall_test_wall.zig
1499 git commit -m "feat: a first mux writes its one-pane wall, and a refused birth needs no flag to leave the wall standing"
1500 ```
1501
1502 ---
1503
1504 ### Task 9: The e2e suite says the new model
1505
1506 **Files:**
1507 - 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`
1508
1509 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.
1510
1511 - [ ] **Step 1: Rewrite the legs that pin the old model**
1512
1513 By scenario name (the `ok "..."` line), each becomes:
1514
1515 `e2e_09_hosts.sh`
1516 - "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.
1517 - "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.
1518 - "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.
1519 - "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.
1520 - "the first mux on a machine starts the local daemon and writes it down" → add the layout assertion: one leaf, `--sock <default>#0`.
1521 - "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).
1522 - "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.
1523 - "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".
1524
1525 `e2e_12_panes.sh`
1526 - "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.
1527 - "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.
1528 - "a resized layout survives a detach/reattach round trip via the sidecar" → keep; the sidecar IS the wall now, nothing else changes.
1529 - "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).
1530
1531 `e2e_13_birth.sh`
1532 - "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.
1533 - "a new tile takes the lowest free digit, and the daemon takes the name back" → `c` in the picker instead of Enter; otherwise unchanged.
1534 - "a birth the daemon refuses paints [refused] and leaves the wall standing" → `c` instead of Enter.
1535
1536 `e2e_06_web.sh`
1537 - "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.
1538
1539 `e2e_07_wallcli.sh`
1540 - "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.
1541
1542 - [ ] **Step 2: Add the legs the spec names that no rewrite above covers**
1543
1544 In `e2e_09_hosts.sh`, after the picker leg:
1545
1546 ```sh
1547 # ---- two devices, one daemon: each wall is its own layout ----------------
1548 #
1549 # The whole point of the change: a second state dir against the SAME two
1550 # daemons starts with nothing but what it adds, and the first wall never
1551 # learns of it. Two XDG_STATE_HOMEs stand in for two machines.
1552 DEV2="${TMPDIR:-/tmp}/mux-e2e-dev2-$$"
1553 defer_rm "$DEV2"
1554 mkdir -p "$DEV2/mux"
1555 printf -- '--sock %s\n--sock %s\n' "$SOCKA" "$SOCKB" > "$DEV2/mux/hosts"
1556 # Device 1: a two-pane wall on A#0 and B#0, already running from the leg
1557 # above under $HSTATE (its layout has exactly those leaves).
1558 # Device 2: no layout. `mux` opens the picker on an empty wall; Enter on
1559 # host B, Enter on its second row adds B#1 and nothing else.
1560 XDG_STATE_HOME="$DEV2" XDG_RUNTIME_DIR="$HRUN" timeout 60 "$PTYCLIENT" --cols 100 --rows 30 \
1561 --out "$OUT.dev2.cap" --err "$OUT.dev2.cap.err" -- "$MUX" > "$OUT.dev2.pc" 2>&1 <<EOF
1562 expect no sessions on the wall 10000
1563 send \x1cs
1564 expect sessions 10000
1565 send 2
1566 send \r
1567 settle 400 10000
1568 send j
1569 send \r
1570 expect ${SOCKB##*/}#1 15000
1571 settle 1500 10000
1572 send \x1cd
1573 EOF
1574 grep -q "leaf 0 --sock $SOCKB#1" "$DEV2/mux/layout" || {
1575 echo "e2e FAIL: device 2's layout is not the one pane it added:"; cat "$DEV2/mux/layout"; exit 1; }
1576 grep -c "#0" "$OUT.dev2.cap" | grep -qx 0 || {
1577 echo "e2e FAIL: device 2 saw a pane it never added"; exit 1; }
1578 # Device 1 is unchanged: its layout still has two leaves and no B#1.
1579 [ "$(grep -c '^ leaf' "$HSTATE/mux/layout")" -eq 2 ] || {
1580 echo "e2e FAIL: device 1's layout changed under device 2's add:"; cat "$HSTATE/mux/layout"; exit 1; }
1581 ok "two walls on the same daemons are two layouts; neither learns of the other's panes"
1582 ```
1583
1584 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).
1585
1586 - [ ] **Step 3: Update the pins**
1587
1588 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.
1589
1590 - [ ] **Step 4: Run each group alone, then the whole suite**
1591
1592 ```bash
1593 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
1594 make e2e 2>&1 | tail -3
1595 ```
1596
1597 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)`.
1598
1599 - [ ] **Step 5: Mutation-check the two new oracles**
1600
1601 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.
1602
1603 - [ ] **Step 6: Commit**
1604
1605 ```bash
1606 git add test/
1607 git commit -m "test: the e2e suite pins the wall as the layout"
1608 ```
1609
1610 ---
1611
1612 ### Task 10: Docs say the new model
1613
1614 **Files:**
1615 - Modify: `README.md` (the "wall of hosts" paragraphs quoted in the spec's Problem section; the `x` key; the picker keys)
1616 - 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")
1617 - Modify: `docs/decisions.md` (a dated section)
1618
1619 - [ ] **Step 1: README**
1620
1621 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:
1622
1623 > 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.
1624 >
1625 > `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.
1626
1627 Update the key table's `x` row and the picker rows to match.
1628
1629 - [ ] **Step 2: CLAUDE.md**
1630
1631 Replace the four bullets named above with:
1632
1633 ```
1634 - **The layout is the wall; the poll grades it and adds nothing.**
1635 `$XDG_STATE_HOME/mux/layout` is authored intent: a pane tree whose
1636 leaves are `HOST#SESSION`, `HOST` a hosts-file line verbatim. Tiles come
1637 from it and from three doors only — the file on start, the picker, and a
1638 chord split — never from a daemon's `sessions_reply`; `mux HOST`'s entry
1639 pane goes through the same seat-then-`persist` path. The once-a-second
1640 poll binds a pending pane whose session the list names, marks `gone` one
1641 it does not, and vanishes a live pane whose shell ended. `persist` is
1642 the ONE save path, gated on `Shared.layout_path`, and every change to
1643 the pane set or tree calls it. A file that fails `seedLayout` — a host
1644 the hosts file lacks, a leaf without a session, a repeat, garbage — is
1645 reported with its line and treated as missing; a missing file is a wall
1646 of one local pane (session `0`) when there is an entry, and an empty
1647 wall that opens the picker when there is not. The hub reads and writes
1648 the same file (`webhub.readLeaves`, `webhub.appendLeaf`).
1649 - **Hosts and sessions live in the picker.** `Ctrl-\ s` is a MODE of
1650 `interact.PrefixFilter` with two levels (`pick_level`): hosts, then a
1651 host's sessions with `# holds` counts. Enter descends or adds, `c`
1652 births at `client.nextFreeName`, `x` forgets a host or ends a session
1653 (`client.endSession` on a side connection; the daemon's two-step, armed
1654 3 s per host and name in `Shared.pick_end`), `a` edits a spelling, Esc
1655 backs out. Tiles do not paint while it is open.
1656 - **`Ctrl-\ x` removes a pane and ends nothing.** `removePane` tells the
1657 pump to detach, vanishes the tile, and persists. Ending is the picker's.
1658 ```
1659
1660 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.
1661
1662 - [ ] **Step 3: decisions.md**
1663
1664 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.
1665
1666 - [ ] **Step 4: Gate and commit**
1667
1668 Run: `make check 2>&1 | tail -3; echo rc=$?` — the comment gate reads `CLAUDE.md`'s cited symbols; every backticked name above must exist.
1669
1670 ```bash
1671 git add README.md CLAUDE.md docs/decisions.md
1672 git commit -m "docs: the wall is the layout"
1673 ```
1674
1675 ---
1676
1677 ## Delivery
1678
1679 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.