5af4eb93
docs: the gone-panes implementation plan
a73x 2026-09-01 09:30
Commit message
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. | ||