a73x

5af4eb93

docs: the gone-panes implementation plan

a73x   2026-09-01 09:30

Commit message
docs: the gone-panes implementation plan

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

docs/superpowers/plans/2026-09-01-gone-panes.md
Old New
@@ -0,0 +1,709 @@
1 # Gone Panes 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:** A sidecar-restored pane whose reachable host does not name its session stays in its rect dressed `gone` (Enter re-creates the session in place, `x` dismisses), instead of collapsing the wall ~1s after the saved cut paints.
6
7 **Architecture:** One arrow changes in `wall_host.planHostDiff` — a pending pane the reachable list does not name goes to a new `gones` out-list instead of `vanish`. `applyHostList` dresses those tiles `.gone` under `paint_mu` (mirroring `dressSilent`), which triggers no relayout. A new `wallview.reviveTile` re-arms a gone tile as a CREATING attach in its existing rect (mirroring `bindTile`); the keyboard loop routes bytes aimed at a gone pane through a filter that eats everything but Enter.
8
9 **Tech Stack:** Zig 0.15.2 (vendored: `deps/zig/zig` — system zig will NOT build this; `make` targets already point at it). Unit tests via `zig build test`; e2e via `make e2e` (`E2E_ONLY=12` runs just the panes group).
10
11 **Spec:** `docs/superpowers/specs/2026-09-01-gone-panes-design.md` — read it first; every task below argues from it.
12
13 ## Global Constraints
14
15 - Build/test ONLY via the vendored toolchain: `ZIG=deps/zig/zig`, or the `make` targets (`make build test check e2e`).
16 - `make check` must pass before every commit (fmt + unit tests + comment-claim refs). Capture `$?` before piping output.
17 - Commit subject is `type: what changed`; types: `feat` `fix` `refactor` `test` `docs` `build` `chore`; NO parenthesised scope. Every commit ends with the trailer: `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>`.
18 - Comments say *why*, not *how*; write them plainly. `zig build check` gates that a comment's cited symbols resolve.
19 - Test fixtures are PLURAL and off-origin by default (multiple panes, nonzero offsets). N=1 is an extra case, never the baseline.
20 - A unit test that writes to fd 1 wedges `zig build test` silently. Never print from tests. A `zig build test` that hangs with no output is this.
21 - Never `cat` `src/server/server.zig`, `src/tui/interact.zig`, or `docs/decisions.md` — use `grep -n` then `sed -n 'A,Bp'`. `src/tui/wallview.zig` is ~4.9k lines: read windows, not the file.
22 - Unit tests print NOTHING on success — silence from `zig build test` (with exit 0) is a pass.
23 - Any hand-run rig exports isolated `XDG_STATE_HOME` and `XDG_RUNTIME_DIR` first, always.
24
25 ## Code map (read-before-write anchors; line numbers measured 2026-09-01, re-grep before trusting)
26
27 - `src/tui/wallview.zig` — `State` enum (~line 43) and its `word()`; `Tile.gone` atomic (~261, renamed by Task 1); `endKey` (~434); `paintDeadBarsLocked` (~559); `setNoticeIdle` (~637, notice buffer is 96 bytes); `sendKeys` (~659); `setFocus` (~681); `seedTile` (~852); `spawnPump` (~862); `bindTile` (~931); the keyboard loop's three `sendKeys` call sites (~1902, ~2024, ~2030).
28 - `src/tui/wall_host.zig` — `dressSilent` (~296); `applyHostList` (~314); `planHostDiff` (~176).
29 - `src/tui/wall_test_host.zig` — `planHostDiff` tests (top of file) and `applyHostList` tests (~194 on); `src/tui/wall_test_harness.zig` — `wallOf`, `stoppedWall`, `testHost`, `setList`, `diffFixture`, `endPumps`.
30 - `test/e2e_12_panes.sh` — the seed legs (~560–770) are the pattern for the new leg; `test/e2e_lib.sh` — `start_daemon`, `pipe_mux`/`pipe_send`/`pipe_detach`, `await_out`, `wait_grid`, `rail_cols`, `assert_stopped`.
31
32 ---
33
34 ### Task 1: Rename `Tile.gone` to `Tile.removed`
35
36 The atomic `Tile.gone` means "this tile has been removed from the wall" — the OPPOSITE standing of the `.gone` state the spec adds. Shipping both spellings would make `t.gone` and `t.state == .gone` contradict each other in the same file. Rename first, behavior-free.
37
38 **Files:**
39 - Modify: `src/tui/wallview.zig` (field decl ~261 with its doc comment, ~561, ~753; also the comment at ~439 and ~557 that name `gone`)
40 - Modify: `src/tui/wall_pump.zig` (~46, ~221, ~244, ~506)
41 - Modify: any test files the compiler names (`grep -rn "\.gone\b" src/tui/`)
42
43 **Interfaces:**
44 - Produces: `Tile.removed: std.atomic.Value(bool)` — same semantics, new name. Task 2's `.gone` state relies on this rename having landed.
45
46 - [ ] **Step 1: Rename mechanically**
47
48 `grep -rn "\.gone\b" src/tui/` and rename every `Tile` field access and the declaration to `removed`. Keep the field's doc comment but re-word its first line to use the new name, e.g.:
49
50 ```zig
51 /// from `alive`: a tile the user removed, a dead one still narrates.
52 removed: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
53 ```
54
55 Do NOT touch `src/client/client.zig:1550` (`"{s}.gone"` is a socket-path suffix, unrelated). Update the prose comments at wallview ~439 ("polls `gone`") and ~557 ("`gone` rather than `present`") to say `removed` — `zig build check` gates comment symbol references, so a stale `gone` citation fails the build.
56
57 - [ ] **Step 2: Verify the gate**
58
59 Run: `make check 2>&1 | tail -5; echo rc=$?` (read the rc printed by echo, not tail's).
60 Expected: rc=0, no output from tests.
61
62 - [ ] **Step 3: Commit**
63
64 ```bash
65 git add -A src/tui && git commit -m "refactor: the removed-tile flag is not called gone
66
67 The spec about to land (docs/superpowers/specs/2026-09-01-gone-panes-design.md)
68 adds a .gone TILE STATE meaning the pane stands and its session does not.
69 The atomic that meant the opposite - the tile itself has left the wall -
70 gives up the word first.
71
72 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
73 ```
74
75 ---
76
77 ### Task 2: `.gone` state; `planHostDiff` routes pending panes to `gones`
78
79 **Files:**
80 - Modify: `src/tui/wallview.zig` — `State` enum and `word()`
81 - Modify: `src/tui/wall_host.zig` — `planHostDiff` signature + body; `applyHostList` call site
82 - Test: `src/tui/wall_test_host.zig` — one new test; every existing `planHostDiff` call gains `&gones`
83
84 **Interfaces:**
85 - Consumes: `Tile.removed` from Task 1 (no direct use, but the name `.gone` is now free).
86 - Produces: `wallview.State.gone` (word `"gone"`); `planHostDiff(tiles, present, live, host, list, self_name, births: *BirthNames, binds: *TileIdxs, vanish: *TileIdxs, gones: *TileIdxs)`. Task 3 relies on `gones` carrying tile indexes to dress; Task 4 relies on `.gone`.
87
88 - [ ] **Step 1: Write the failing test**
89
90 Add to `src/tui/wall_test_host.zig`, after the existing grace test (~line 92). `diffFixture` gives three tiles: sessions `a`,`b` on host 0 and `a` on host 1 — panes 0 and 1 become seeded restores here, tile 2 stays live on the other host, so the test is plural in panes AND in hosts:
91
92 ```zig
93 test "planHostDiff: a pending pane the reachable list disowns is gone, not vanished — and a later list still binds it" {
94 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
95 var tiles = fixture.diffFixture(&shared);
96 var present = [_]bool{ true, true, true };
97 // Panes 0 and 1 are sidecar restores: no pump ever ran, nothing dialed.
98 for (tiles[0..2]) |*t| {
99 t.pending = true;
100 t.state = .waiting;
101 t.alive.store(false, .release);
102 }
103
104 var births = BirthNames{};
105 var binds = TileIdxs{};
106 var vanish = TileIdxs{};
107 var gones = TileIdxs{};
108 // The reachable host answers with NEITHER saved session: both panes are
109 // gone-fodder, neither is vanish-fodder, and host 1's tile is untouched.
110 wall_host.planHostDiff(&tiles, &present, 3, 0, "", null, &births, &binds, &vanish, &gones);
111 try std.testing.expectEqual(@as(usize, 0), vanish.len);
112 try std.testing.expectEqual(@as(usize, 2), gones.len);
113 try std.testing.expectEqual(@as(usize, 0), gones.get(0));
114 try std.testing.expectEqual(@as(usize, 1), gones.get(1));
115
116 // Once dressed, a pane is not re-listed every second: the poll comes
117 // back every 1s and a repaint per poll would flicker the bar.
118 tiles[0].state = .gone;
119 tiles[1].state = .gone;
120 gones.len = 0;
121 wall_host.planHostDiff(&tiles, &present, 3, 0, "", null, &births, &binds, &vanish, &gones);
122 try std.testing.expectEqual(@as(usize, 0), gones.len);
123 try std.testing.expectEqual(@as(usize, 0), vanish.len);
124
125 // The dressing is reversible: a list that names session "a" binds pane 0
126 // (it stayed pending), and does not birth a twin.
127 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &binds, &vanish, &gones);
128 try std.testing.expectEqual(@as(usize, 1), binds.len);
129 try std.testing.expectEqual(@as(usize, 0), binds.get(0));
130 try std.testing.expectEqual(@as(usize, 0), births.len);
131 }
132 ```
133
134 Note `TileIdxs.get(i)` — mirror how existing tests read `vanish.get(0)`.
135
136 - [ ] **Step 2: Run to verify it fails for the right reason**
137
138 Run: `deps/zig/zig build test 2>&1 | tail -15`
139 Expected: compile errors — `planHostDiff` takes 9 arguments, and `State` has no member named `gone`. (In Zig the RED step is usually a compile error naming the missing feature; that is the correct failure.)
140
141 - [ ] **Step 3: Implement**
142
143 In `src/tui/wallview.zig`, add to `State` (beside `.@"unreachable"`, keep the enum's doc-comment style) and to `word()`:
144
145 ```zig
146 /// A pending pane whose REACHABLE host answered without its session:
147 /// the host said no, not nothing. The pane stands — its rect is the
148 /// user's saved layout, not the daemon's state — until Enter re-creates
149 /// the session in place or `x` dismisses it. Reversible like
150 /// `unreachable`: the tile stays pending, so a list that names the
151 /// session later binds it.
152 gone,
153 ```
154
155 and in `word()`: `.gone => "gone",`.
156
157 In `src/tui/wall_host.zig`, add the parameter and the routing. The pending check goes right after the `keep` continue, BEFORE the live-pump grace (a pending pane's `alive` is false and would fall through to `vanish`):
158
159 ```zig
160 pub fn planHostDiff(
161 tiles: []Tile,
162 present: []const bool,
163 live: usize,
164 host: usize,
165 list: []const u8,
166 self_name: ?[]const u8,
167 births: *BirthNames,
168 binds: *TileIdxs,
169 vanish: *TileIdxs,
170 gones: *TileIdxs,
171 ) void {
172 ```
173
174 ```zig
175 if (keep) {
176 t.missed_once = false;
177 continue;
178 }
179 // A seeded pane the host itself disowns is GONE, never vanished:
180 // its rect is the user's authored layout, and collapsing it here is
181 // the one-second re-cut the seed exists to prevent. Listed only on
182 // the transition — the poll returns every second, and re-dressing
183 // a pane per poll would repaint its bar for nothing.
184 if (t.pending) {
185 if (t.state != .gone) gones.append(i);
186 continue;
187 }
188 if (t.alive.load(.acquire) and !t.missed_once) {
189 ```
190
191 In `applyHostList`, declare `var gones = TileIdxs{};` beside the other three and pass it (the dressing itself is Task 3 — for THIS task only thread the parameter through so the build stands; do not act on `gones` yet).
192
193 Update every existing `planHostDiff` call in `src/tui/wall_test_host.zig` (six calls across four tests) to declare and pass `&gones`.
194
195 - [ ] **Step 4: Run tests to verify green**
196
197 Run: `deps/zig/zig build test 2>&1 | tail -5; echo rc=$?`
198 Expected: rc=0, silence. The pre-existing planHostDiff tests must still pass — they pin that LIVE tiles keep the grace-then-vanish path.
199
200 - [ ] **Step 5: Commit**
201
202 ```bash
203 git add src/tui/wallview.zig src/tui/wall_host.zig src/tui/wall_test_host.zig
204 git commit -m "feat: a disowned seeded pane plans as gone, not vanish
205
206 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
207 ```
208
209 ---
210
211 ### Task 3: `applyHostList` dresses gone panes; flap with `unreachable`; the focus notice
212
213 **Files:**
214 - Modify: `src/tui/wall_host.zig` — `applyHostList` acts on `gones`
215 - Modify: `src/tui/wallview.zig` — `setFocus` posts the notice; a `pub const gone_notice`
216 - Test: `src/tui/wall_test_host.zig`
217
218 **Interfaces:**
219 - Consumes: `planHostDiff(..., &gones)` and `.gone` from Task 2; `seedTile` (wallview ~852) to build pending panes in tests.
220 - Produces: `wallview.gone_notice: []const u8` = `"[session gone - Enter starts it anew, x closes]"` (must stay under the 96-byte notice buffer). Task 5's e2e may grep the bar word only, not this text.
221
222 - [ ] **Step 1: Write the failing test**
223
224 Model on the `applyHostList` tests at `src/tui/wall_test_host.zig:194` (arena + `stoppedWall` + `testHost` + `setList` + `endPumps`). Look at `src/tui/wall_test_host.zig:660` for how a test seeds pending panes with `wv.seedTile` (it passes an owned `Resolved` and a rect; copy that shape, but give the two panes real distinct rects at nonzero offsets):
225
226 ```zig
227 test "applyHostList: a reachable host that lost its sessions dresses seeded panes gone and keeps them standing" {
228 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
229 defer arena.deinit();
230 const alloc = arena.allocator();
231 var shared: Shared = undefined;
232 fixture.stoppedWall(alloc, &shared);
233 var tiles: [4]Tile = undefined;
234 var present = [_]bool{false} ** 4;
235 var live: usize = 0;
236 defer fixture.endPumps(tiles[0..live]);
237 var table = [_]Host{fixture.testHost(&shared, "box", "/tmp/box.sock")};
238
239 // Two seeded panes, off-origin, distinct rects: the saved cut of a
240 // two-pane wall whose daemon rebooted overnight.
241 try wv.seedTile(&tiles[0], .{ .target = .{ .sock = "/tmp/box.sock" }, .label = "box#a", .session = "a" }, .{ .top = 1, .left = 0, .rows = 23, .cols = 40 }, &shared, 0, 0);
242 try wv.seedTile(&tiles[1], .{ .target = .{ .sock = "/tmp/box.sock" }, .label = "box#b", .session = "b" }, .{ .top = 1, .left = 41, .rows = 23, .cols = 39 }, &shared, 1, 0);
243 present[0] = true;
244 present[1] = true;
245 live = 2;
246
247 // The host is REACHABLE and answers empty: both panes dress gone, both
248 // stay present — the wall re-cuts nothing.
249 fixture.setList(&table[0], "");
250 wall_host.applyHostList(fixture.wallOf(alloc, &tiles, &present, &live, &shared, &table), 0);
251 try std.testing.expectEqual(wv.State.gone, tiles[0].state);
252 try std.testing.expectEqual(wv.State.gone, tiles[1].state);
253 try std.testing.expectEqual(@as(usize, 2), wv.presentCount(present[0..live]));
254
255 // The poll FAILS next: gone yields to unreachable (the host cannot
256 // answer, which is a different sentence than "the host said no")...
257 table[0].poll.reachable.store(false, .release);
258 wall_host.applyHostList(fixture.wallOf(alloc, &tiles, &present, &live, &shared, &table), 0);
259 try std.testing.expectEqual(wv.State.@"unreachable", tiles[0].state);
260
261 // ...and recovery without the session dresses gone again. Flap over,
262 // both panes still standing.
263 fixture.setList(&table[0], "");
264 wall_host.applyHostList(fixture.wallOf(alloc, &tiles, &present, &live, &shared, &table), 0);
265 try std.testing.expectEqual(wv.State.gone, tiles[0].state);
266 try std.testing.expectEqual(wv.State.gone, tiles[1].state);
267 try std.testing.expectEqual(@as(usize, 2), wv.presentCount(present[0..live]));
268 }
269
270 test "setFocus: landing on a gone pane says what the two keys are" {
271 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
272 defer arena.deinit();
273 const alloc = arena.allocator();
274 var shared: Shared = undefined;
275 fixture.stoppedWall(alloc, &shared);
276 var tiles: [4]Tile = undefined;
277 var present = [_]bool{false} ** 4;
278 var live: usize = 0;
279 defer fixture.endPumps(tiles[0..live]);
280 try wv.seedTile(&tiles[0], .{ .target = .{ .sock = "/tmp/box.sock" }, .label = "box#a", .session = "a" }, .{ .top = 1, .left = 0, .rows = 23, .cols = 40 }, &shared, 0, 0);
281 try wv.seedTile(&tiles[1], .{ .target = .{ .sock = "/tmp/box.sock" }, .label = "box#b", .session = "b" }, .{ .top = 1, .left = 41, .rows = 23, .cols = 39 }, &shared, 1, 0);
282 present[0] = true;
283 present[1] = true;
284 live = 2;
285 tiles[1].state = .gone;
286
287 wv.setFocus(tiles[0..live], &shared, 1);
288 try std.testing.expectEqualStrings(wv.gone_notice, shared.notice[0..shared.notice_len]);
289 }
290 ```
291
292 If `seedTile`'s `Resolved` labels/sessions must be allocator-owned (check how `wall_test_host.zig:660` builds `owned`), mirror that site exactly rather than passing literals.
293
294 - [ ] **Step 2: Run to verify failure**
295
296 Run: `deps/zig/zig build test 2>&1 | tail -15`
297 Expected: first test fails on `expectEqual(.gone, …)` — state stays `.waiting` because `applyHostList` ignores `gones`; second fails on `gone_notice` not existing.
298
299 - [ ] **Step 3: Implement**
300
301 In `applyHostList` (reachable branch), after the binds loop and BEFORE the vanish loop, dress under `paint_mu` and repaint dead bars the way `dressSilent` does — and do NOT set `changed` (no relayout is the feature):
302
303 ```zig
304 if (gones.len > 0) {
305 w.shared.paint_mu.lock();
306 for (gones.items[0..gones.len]) |gi| w.liveTiles()[gi].state = .gone;
307 w.shared.paint_mu.unlock();
308 // A gone pane has no pump, so no doorbell can repaint its bar:
309 // the keyboard paints it here, the same hand that dressed it.
310 if (w.shared.labelRows() != 0) wv.paintDeadBarsLocked(w.liveTiles());
311 }
312 ```
313
314 In `wallview.zig`, beside `setNoticeIdle`:
315
316 ```zig
317 /// What a gone pane can do, said where the user is looking. Under the
318 /// 96-byte notice buffer; `setNoticeIdle` truncates silently past it.
319 pub const gone_notice: []const u8 = "[session gone - Enter starts it anew, x closes]";
320 ```
321
322 and at the END of `setFocus` (read the body first; add after the focus has moved):
323
324 ```zig
325 // A gone pane's bar says "gone"; this says what to do about it. Idle
326 // only: a refusal or an exit sentence already on screen outranks a hint.
327 if (tiles[next].state == .gone) setNoticeIdle(shared, gone_notice);
328 ```
329
330 - [ ] **Step 4: Run tests to verify green**
331
332 Run: `deps/zig/zig build test 2>&1 | tail -5; echo rc=$?`
333 Expected: rc=0, silence.
334
335 - [ ] **Step 5: Commit**
336
337 ```bash
338 git add src/tui/wall_host.zig src/tui/wallview.zig src/tui/wall_test_host.zig
339 git commit -m "feat: a reachable host's missing sessions dress their panes gone
340
341 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
342 ```
343
344 ---
345
346 ### Task 4: Revive — Enter re-arms the tile in place; every other byte is eaten; `x` drops it
347
348 **Files:**
349 - Modify: `src/tui/wallview.zig` — `armRevive` + `reviveTile` (beside `bindTile` ~931); a `keysToFocused` wrapper; the keyboard loop's `sendKeys` call sites (~1902, ~2024, ~2030 — re-grep `sendKeys(` to find them, line numbers drift)
350 - Test: `src/tui/wall_test_host.zig` (or `wall_test_wall.zig` if the reviewer prefers — keep all three new tests together either way)
351
352 **Interfaces:**
353 - Consumes: `.gone`, `seedTile`, `spawnPump`, `bindTile` (the model), `endKey` (~434).
354 - Produces: `wallview.armRevive(t: *Tile) void` (state transitions, no thread — the testable half); `wallview.reviveTile(t: *Tile) void` = `armRevive` + `spawnPump`; `wallview.keysToFocused(t: *Tile, keys: []const u8) void`. Task 5's e2e presses the real keys.
355
356 - [ ] **Step 1: Write the failing tests**
357
358 ```zig
359 test "armRevive: Enter turns a gone pane into a creating attach in the same rect" {
360 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
361 defer arena.deinit();
362 const alloc = arena.allocator();
363 var shared: Shared = undefined;
364 fixture.stoppedWall(alloc, &shared);
365 var tiles: [2]Tile = undefined;
366 try wv.seedTile(&tiles[0], .{ .target = .{ .sock = "/tmp/box.sock" }, .label = "box#a", .session = "a" }, .{ .top = 1, .left = 41, .rows = 23, .cols = 39 }, &shared, 0, 0);
367 tiles[0].state = .gone;
368
369 wv.armRevive(&tiles[0]);
370
371 // The tile is no longer a placeholder: it CREATES (the daemon has no
372 // such session - that is what gone means), and it does so exactly
373 // where it stood.
374 try std.testing.expect(!tiles[0].pending);
375 try std.testing.expect(tiles[0].creates);
376 try std.testing.expectEqual(wv.State.connecting, tiles[0].state);
377 try std.testing.expect(tiles[0].alive.load(.acquire));
378 try std.testing.expect(!tiles[0].pump_done.load(.acquire));
379 try std.testing.expectEqual(@as(u16, 41), tiles[0].rect.left);
380 try std.testing.expectEqualStrings("a", tiles[0].r.session);
381 }
382
383 test "keysToFocused: a gone pane eats every byte except Enter" {
384 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
385 defer arena.deinit();
386 const alloc = arena.allocator();
387 var shared: Shared = undefined;
388 fixture.stoppedWall(alloc, &shared);
389 var tiles: [2]Tile = undefined;
390 try wv.seedTile(&tiles[0], .{ .target = .{ .sock = "/tmp/box.sock" }, .label = "box#a", .session = "a" }, .{ .top = 1, .left = 0, .rows = 23, .cols = 40 }, &shared, 0, 0);
391 tiles[0].state = .gone;
392
393 // Ordinary typing reaches no session and queues nowhere.
394 wv.keysToFocused(&tiles[0], "ls -la");
395 try std.testing.expectEqual(@as(usize, 0), tiles[0].in_len);
396 try std.testing.expect(tiles[0].pending);
397
398 // Enter revives. (`running` is false in this fixture, so the spawned
399 // pump exits at its gate; the transitions are armRevive's, pinned above.)
400 wv.keysToFocused(&tiles[0], "\r");
401 try std.testing.expect(!tiles[0].pending);
402 tiles[0].pump_done.store(true, .release);
403 }
404
405 test "endKey: x on a gone pane drops the tile locally" {
406 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
407 defer arena.deinit();
408 const alloc = arena.allocator();
409 var shared: Shared = undefined;
410 fixture.stoppedWall(alloc, &shared);
411 var tiles: [2]Tile = undefined;
412 try wv.seedTile(&tiles[0], .{ .target = .{ .sock = "/tmp/box.sock" }, .label = "box#a", .session = "a" }, .{ .top = 1, .left = 0, .rows = 23, .cols = 40 }, &shared, 0, 0);
413 tiles[0].state = .gone;
414 try std.testing.expectEqual(wv.EndKey.drop, wv.endKey(&tiles[0], 0));
415 }
416 ```
417
418 (`EndKey.drop` is a bare union variant — if `expectEqual` balks at the union, compare tags: `try std.testing.expectEqual(std.meta.Tag(wv.EndKey).drop, std.meta.activeTag(wv.endKey(&tiles[0], 0)));`. The `endKey` test may pass immediately since `.drop` already covers `!alive` — that is fine, it is a PIN on behavior the spec now depends on; note it in the test comment.)
419
420 The `keysToFocused("\r")` case spawns a real pump thread. `stoppedWall` sets `shared.running = false`, and `wall_pump.pumpTile`'s loop gates on `running`, so the thread exits promptly; the `pump_done.store(true)` at the test's end is belt-and-braces for `endPumps`-less cleanup. If the test flakes on thread teardown, split: assert Enter's effect via `armRevive` only and have `keysToFocused` take a comptime-injected spawn function — but try the simple form first.
421
422 - [ ] **Step 2: Run to verify failure**
423
424 Run: `deps/zig/zig build test 2>&1 | tail -15`
425 Expected: compile errors — no `armRevive`, no `keysToFocused`.
426
427 - [ ] **Step 3: Implement**
428
429 In `wallview.zig`, beside `bindTile` (read `bindTile`'s body first — `armRevive` is its shape with `creates = true`):
430
431 ```zig
432 /// The state half of a revive, split from the thread so a test can pin the
433 /// transitions without racing a pump. `reviveTile` is the only production
434 /// caller.
435 pub fn armRevive(t: *Tile) void {
436 t.shared.paint_mu.lock();
437 t.pending = false;
438 t.creates = true;
439 t.state = .connecting;
440 t.shared.paint_mu.unlock();
441 t.end_seen = false;
442 // The user is looking at this pane - Enter was pressed IN it - so the
443 // claim is armed the way bindTile arms the saved focus.
444 if (t.idx == t.shared.sel) t.claim_pending.store(true, .release);
445 t.pump_done.store(false, .release);
446 t.alive.store(true, .release);
447 }
448
449 /// A gone pane, revived on the user's Enter: the same tile re-arms as a
450 /// CREATING attach - same host, same session name, same rect - so the
451 /// daemon makes the session anew where the old one stood. No tree edit and
452 /// no flatten: reviving re-cuts nothing, which is the promise gone panes
453 /// exist to keep.
454 pub fn reviveTile(t: *Tile) void {
455 armRevive(t);
456 spawnPump(t);
457 }
458
459 /// The one door for bytes aimed at the focused tile. A gone pane has no
460 /// pump, so no byte could reach a session - eaten here rather than queued
461 /// into `in` where they would greet the NEXT session this tile carries.
462 /// Enter is the pane's one verb.
463 pub fn keysToFocused(t: *Tile, keys: []const u8) void {
464 if (t.pending and t.state == .gone) {
465 if (std.mem.indexOfAny(u8, keys, "\r\n") != null) reviveTile(t);
466 return;
467 }
468 sendKeys(t, keys);
469 }
470 ```
471
472 Then route the keyboard loop's tile-bound forwards through it: `grep -n "sendKeys(" src/tui/wallview.zig` — the three call sites inside the keyboard loop (zoom-filter forward ~1902, the two mouse-segment sends ~2024/~2030) become `keysToFocused(...)` with the same arguments. Leave `sendKeys` itself and any non-keyboard callers untouched.
473
474 - [ ] **Step 4: Run tests to verify green**
475
476 Run: `deps/zig/zig build test 2>&1 | tail -5; echo rc=$?`
477 Expected: rc=0, silence.
478
479 - [ ] **Step 5: Run `make check`, commit**
480
481 ```bash
482 make check 2>&1 | tail -3; echo rc=$?
483 git add src/tui/wallview.zig src/tui/wall_test_host.zig
484 git commit -m "feat: Enter revives a gone pane in place, and x still drops it
485
486 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
487 ```
488
489 ---
490
491 ### Task 5: E2E — the reboot leg
492
493 **Files:**
494 - Modify: `test/e2e_12_panes.sh` (new leg at the end, after the layout-heal legs; new `SOCK62` decl at the top beside `SOCK59`–`SOCK61`)
495 - Possibly modify: the pinned convergence count if you add an `assert_converged_pty` (grep `CONV_COUNT` under `test/` and bump it; the runner prints `e2e OK (N scenarios, M convergence points)`)
496
497 **Interfaces:**
498 - Consumes: everything shipped in Tasks 2–4, through the real binaries. Helpers: `start_daemon`, `pipe_mux`/`pipe_send`/`pipe_detach`, `await_out`, `wait_grid`, `no_saved_tree`, `assert_stopped`, `rail_cols`, `defer_rm`, `ok` (read `test/e2e_lib.sh` for each before use; mirror the layout-restore leg at `test/e2e_12_panes.sh:560` throughout).
499
500 - [ ] **Step 1: Write the leg (it must FAIL against a stale build and pass after `zig build`)**
501
502 Shape (adapt spellings to the lib's actual helpers — the layout-restore leg is the canonical example; markers use the leg's own `gp-` prefix):
503
504 ```sh
505 # ---- gone panes: a reboot keeps the cut ------------------------------
506 #
507 # The spec's scenario: three sessions, a saved 3-pane cut, and a daemon
508 # that comes back EMPTY. The wall must paint the saved cut and never move
509 # it: panes b and c dress `gone` instead of collapsing (the pre-gone-panes
510 # build re-cuts ~1s in - this leg fails there). Enter in pane b creates
511 # session b anew in the same rect; a later run x's pane c and only THAT
512 # collapses it.
513 GPSTATE="${TMPDIR:-/tmp}/mux-e2e-gone-state-$$"
514 defer_rm "$GPSTATE"
515 start_daemon "$SOCK62" "$OUT.gp.d" "gone-panes daemon never bound" --shell /bin/sh
516 D62PID=$DPID
517
518 # Three sessions - plural, so the fates can differ per pane.
519 pipe_mux "$OUT.gpa" "$OUT.gpa.err" env XDG_STATE_HOME="$GPSTATE" timeout 40 "$MUX" --sock "$SOCK62"
520 pipe_send 'printf "gp-%%s\\n" zero\n'
521 await_out "$OUT.gpa" "gp-zero" "gone-panes: session 0 marker"
522 pipe_detach
523 wait_grid "$SOCK62" "gp-zero" "gone-panes: session 0 marker"
524 pipe_mux "$OUT.gpb" "$OUT.gpb.err" env XDG_STATE_HOME="$GPSTATE" timeout 40 "$MUX" --sock "$SOCK62" --session b
525 pipe_send 'printf "gp-%%s\\n" bee\n'
526 await_out "$OUT.gpb" "gp-bee" "gone-panes: session b marker"
527 pipe_detach
528 wait_grid "$SOCK62" "gp-bee" "gone-panes: session b marker" b
529 pipe_mux "$OUT.gpc" "$OUT.gpc.err" env XDG_STATE_HOME="$GPSTATE" timeout 40 "$MUX" --sock "$SOCK62" --session c
530 pipe_send 'printf "gp-%%s\\n" sea\n'
531 await_out "$OUT.gpc" "gp-sea" "gone-panes: session c marker"
532 pipe_detach
533 wait_grid "$SOCK62" "gp-sea" "gone-panes: session c marker" c
534
535 mkdir -p "$GPSTATE/mux"
536 printf -- '--sock %s\n' "$SOCK62" > "$GPSTATE/mux/hosts"
537 no_saved_tree "$GPSTATE"
538
539 # Run 1: hydrate the 3-pane wall on a tty; detach saves the sidecar.
540 XDG_STATE_HOME="$GPSTATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 24 \
541 --out "$OUT.gpcap1" --err "$OUT.gpcap1.err" -- "$MUX" > "$OUT.gppc1" 2>&1 <<'EOF'
542 expect gp-zero 20000
543 settle 2500 20000
544 send \x1cd
545 waitexit 10000
546 EOF
547 grep -c '^ leaf' "$GPSTATE/mux/layout" | grep -qx 3 || {
548 echo "e2e FAIL: gone-panes: sidecar does not hold 3 panes:"
549 cat "$GPSTATE/mux/layout"; exit 1; }
550
551 # The reboot: same socket, fresh daemon, no sessions of ours.
552 assert_stopped "$SOCK62" "$D62PID" "gone-panes" "$OUT.gpstop"
553 start_daemon "$SOCK62" "$OUT.gp.d2" "gone-panes daemon 2 never bound" --shell /bin/sh
554 D62PID=$DPID
555
556 # Run 2: the saved cut must paint and STAY. `gone` must reach the grid.
557 # Enter in pane b (focus-right from the saved focus on pane 0) creates
558 # session b anew - its marker proves which daemon-side session exists.
559 XDG_STATE_HOME="$GPSTATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 24 \
560 --out "$OUT.gpcap2" --err "$OUT.gpcap2.err" -- "$MUX" > "$OUT.gppc2" 2>&1 <<'EOF'
561 expect gone 20000
562 settle 1500 20000
563 send \x1cl
564 settle 500 10000
565 send \r
566 settle 1500 20000
567 send printf 'gp-%s\n' reborn\n
568 expect gp-reborn 15000
569 settle 500 15000
570 send \x1cd
571 waitexit 10000
572 EOF
573
574 # The claim measured 2026-09-01: pre-gone-panes, the restored cut collapses
575 # on the first poll answer, which moves or removes a rail. The whole run 2
576 # capture must contain EXACTLY the saved cut's rail columns - compare the
577 # distinct-set against run 1's.
578 rails1=$(rail_cols "$OUT.gpcap1" | sort -n | uniq | tr '\n' ' ')
579 rails2=$(rail_cols "$OUT.gpcap2" | sort -n | uniq | tr '\n' ' ')
580 [ "$rails1" = "$rails2" ] || {
581 echo "e2e FAIL: gone-panes: rails moved across the reboot (run1: $rails1 run2: $rails2)"
582 cat "$OUT.gppc2"; exit 1; }
583 # The revived session is REAL and correctly named: the daemon's own grid
584 # for session b carries the marker; the entry session does not.
585 timeout 20 "$MUX" a capture --sock "$SOCK62" --session b > "$OUT.gprb" 2>&1
586 grep -q "gp-reborn" "$OUT.gprb" || {
587 echo "e2e FAIL: gone-panes: reborn marker not in session b:"
588 cat "$OUT.gprb"; exit 1; }
589 timeout 20 "$MUX" a capture --sock "$SOCK62" > "$OUT.gpra" 2>&1
590 ! grep -q "gp-reborn" "$OUT.gpra" || {
591 echo "e2e FAIL: gone-panes: reborn marker leaked into the entry session"
592 exit 1; }
593
594 # Run 3: x on the still-gone pane c collapses it - on the KEYPRESS, not on
595 # a poll. Focus right twice from the saved focus to reach c.
596 XDG_STATE_HOME="$GPSTATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 24 \
597 --out "$OUT.gpcap3" --err "$OUT.gpcap3.err" -- "$MUX" > "$OUT.gppc3" 2>&1 <<'EOF'
598 expect gone 20000
599 settle 1500 20000
600 send \x1cl
601 settle 300 10000
602 send \x1cl
603 settle 300 10000
604 send \x1cx
605 settle 1500 20000
606 send \x1cd
607 waitexit 10000
608 EOF
609 # Two panes remain; the sidecar saved after run 3 must not name c.
610 ! grep -q '#c$' "$GPSTATE/mux/layout" || {
611 echo "e2e FAIL: gone-panes: x did not dismiss pane c from the sidecar:"
612 cat "$GPSTATE/mux/layout"; exit 1; }
613 assert_stopped "$SOCK62" "$D62PID" "gone-panes" "$OUT.gpstop2"
614 D62PID=""
615 rm -rf "$GPSTATE"
616 ok "a rebooted daemon's saved panes wear gone; Enter revives in place, x dismisses"
617 ```
618
619 Two soft spots to resolve while writing it, by reading the neighbors: (a) `expect gone` matches the BAR word — confirm the bar text renders the state word raw in the capture (the layout-restore leg greps bar glyphs; if `expect` needs plain text, `expect gone` works because the bar writes it as plain cells); (b) the focus-walk keys — the layout-restore leg uses `send \x1cl` for focus-right; confirm against `keymap` before trusting, and use whatever chord that leg used. If run 3's `\x1cx` on a gone pane needs the two-press arm (it must NOT: `endKey` returns `.drop` for a pump-less tile, which acts on the first press), and the tile survives, that is a product bug this leg just caught — stop and report, do not weaken the assertion.
620
621 - [ ] **Step 2: RED — run the leg against a build WITHOUT tasks 2–4**
622
623 Tasks 2–4 are commits by now, so the pre-feature build lives in a scratch worktree (its own `.zig-cache` — NEVER share zig caches across worktrees, a shared cache has GC'd binaries under a running e2e):
624
625 ```bash
626 git worktree add /tmp/gone-red <task-1-commit-sha>
627 cp -r "$(dirname "$(readlink -f deps/zig/zig)")" /tmp/gone-red/deps/zig 2>/dev/null || true # vendored toolchain is gitignored; copy it
628 cp test/e2e_12_panes.sh /tmp/gone-red/test/e2e_12_panes.sh # the NEW leg onto the OLD code
629 cd /tmp/gone-red && deps/zig/zig build && E2E_ONLY=12 make e2e 2>&1 | tail -8
630 cd - && git worktree remove --force /tmp/gone-red
631 ```
632 Expected: the new leg FAILS — rails differ across the reboot (the collapse). This is the watch-it-fail step for an e2e; do not skip it, and watch it fail on THIS assertion, not on a typo earlier in the leg.
633
634 - [ ] **Step 3: GREEN — full build, full group**
635
636 ```bash
637 deps/zig/zig build 2>&1 | tail -3
638 E2E_ONLY=12 make e2e 2>&1 | tail -5; echo rc=$?
639 ```
640 Expected: rc=0, `ok` line for the new leg.
641
642 - [ ] **Step 4: Full gates**
643
644 ```bash
645 make check 2>&1 | tail -3; echo rc=$?
646 make e2e 2>&1 | tail -3; echo rc=$?
647 ```
648 Expected: both rc=0. The e2e summary line's scenario count grows by one; if you used `assert_converged_pty`, the convergence count grew too and its pin must match.
649
650 - [ ] **Step 5: Commit**
651
652 ```bash
653 git add test/e2e_12_panes.sh
654 git commit -m "test: e2e - a rebooted daemon's panes wear gone and the cut never moves
655
656 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
657 ```
658
659 ---
660
661 ### Task 6: Docs — the invariant says so
662
663 **Files:**
664 - Modify: `CLAUDE.md` — the hosts-file invariant bullet (the sentence about a saved pane on a dark host wearing `unreachable`)
665 - Modify: `docs/decisions.md` — append a dated entry
666
667 **Interfaces:** none; prose only.
668
669 - [ ] **Step 1: Edit CLAUDE.md**
670
671 In the bullet `**The wall file lists DAEMONS; tiles are their live sessions.**`, find `a saved pane on a host that answers nothing stays, wearing `unreachable`, for as long as the box is dark` and extend the thought right after it with:
672
673 > A saved pane whose REACHABLE host answers without its session stays too, wearing `gone` — the host said no, not nothing — until Enter re-creates the session in that pane's own rect or `x` dismisses it; every other key at a gone pane is eaten. A wall of only gone panes is not empty.
674
675 Keep the bullet's existing sentences intact — they are load-bearing.
676
677 - [ ] **Step 2: Append to docs/decisions.md**
678
679 Match the file's entry format (`grep -n "^## 2026-08" docs/decisions.md | tail -3` and `sed -n` a recent entry to copy its shape — do not `cat` the file). Content:
680
681 > ## 2026-09-01 — gone panes: placeholders over auto-start
682 >
683 > Measured: after a daemon restart, the seeded wall painted its saved cut at
684 > byte 46 of the capture and collapsed it on the first poll answer (~1s) —
685 > the flash the seed was built to remove, surviving in the one path where a
686 > reachable host disowns a saved session. Chosen: dress those panes `gone`
687 > (reversible, pending stays set), Enter re-arms the same tile as a creating
688 > attach in its rect, `x` dismisses. Rejected: auto-starting shells (bends
689 > "nothing re-creates a session" without a keypress; layers on later as
690 > Enter-on-all), and keeping the collapse (the user's stated expectation is
691 > the wall they left). Spec: docs/superpowers/specs/2026-09-01-gone-panes-design.md.
692
693 - [ ] **Step 3: Gate and commit**
694
695 ```bash
696 make check 2>&1 | tail -3; echo rc=$?
697 git add CLAUDE.md docs/decisions.md
698 git commit -m "docs: the gone-pane rule joins the wall invariant
699
700 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
701 ```
702
703 ---
704
705 ## Final verification (after all tasks)
706
707 - [ ] `make ci 2>&1 | tail -5; echo rc=$?` — rc=0 (check + e2e + agent + throughput; this is the delivery gate).
708 - [ ] Autosquash any fixups so the history tells the feature's story: rename → planner → dressing → revive → e2e → docs.
709 - [ ] Demo, not a menu: an isolated-XDG hand rig (mirror `/tmp/flash-demo.sh` from the 2026-09-01 investigation — sessions, detach, `mux d stop`, fresh daemon, reattach on a pty) showing the cut standing with `gone` panes and an Enter revive. Report what the render oracle shows.