docs/superpowers/plans/2026-09-01-gone-panes.md
Ref: Size: 35.3 KiB History
# Gone Panes Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 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.
**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.
**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).
**Spec:** `docs/superpowers/specs/2026-09-01-gone-panes-design.md` — read it first; every task below argues from it.
## Global Constraints
- Build/test ONLY via the vendored toolchain: `ZIG=deps/zig/zig`, or the `make` targets (`make build test check e2e`).
- `make check` must pass before every commit (fmt + unit tests + comment-claim refs). Capture `$?` before piping output.
- 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>`.
- Comments say *why*, not *how*; write them plainly. `zig build check` gates that a comment's cited symbols resolve.
- Test fixtures are PLURAL and off-origin by default (multiple panes, nonzero offsets). N=1 is an extra case, never the baseline.
- 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.
- 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.
- Unit tests print NOTHING on success — silence from `zig build test` (with exit 0) is a pass.
- Any hand-run rig exports isolated `XDG_STATE_HOME` and `XDG_RUNTIME_DIR` first, always.
## Code map (read-before-write anchors; line numbers measured 2026-09-01, re-grep before trusting)
- `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).
- `src/tui/wall_host.zig` — `dressSilent` (~296); `applyHostList` (~314); `planHostDiff` (~176).
- `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`.
- `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`.
---
### Task 1: Rename `Tile.gone` to `Tile.removed`
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.
**Files:**
- Modify: `src/tui/wallview.zig` (field decl ~261 with its doc comment, ~561, ~753; also the comment at ~439 and ~557 that name `gone`)
- Modify: `src/tui/wall_pump.zig` (~46, ~221, ~244, ~506)
- Modify: any test files the compiler names (`grep -rn "\.gone\b" src/tui/`)
**Interfaces:**
- Produces: `Tile.removed: std.atomic.Value(bool)` — same semantics, new name. Task 2's `.gone` state relies on this rename having landed.
- [ ] **Step 1: Rename mechanically**
`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.:
```zig
/// from `alive`: a tile the user removed, a dead one still narrates.
removed: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
```
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.
- [ ] **Step 2: Verify the gate**
Run: `make check 2>&1 | tail -5; echo rc=$?` (read the rc printed by echo, not tail's).
Expected: rc=0, no output from tests.
- [ ] **Step 3: Commit**
```bash
git add -A src/tui && git commit -m "refactor: the removed-tile flag is not called gone
The spec about to land (docs/superpowers/specs/2026-09-01-gone-panes-design.md)
adds a .gone TILE STATE meaning the pane stands and its session does not.
The atomic that meant the opposite - the tile itself has left the wall -
gives up the word first.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 2: `.gone` state; `planHostDiff` routes pending panes to `gones`
**Files:**
- Modify: `src/tui/wallview.zig` — `State` enum and `word()`
- Modify: `src/tui/wall_host.zig` — `planHostDiff` signature + body; `applyHostList` call site
- Test: `src/tui/wall_test_host.zig` — one new test; every existing `planHostDiff` call gains `&gones`
**Interfaces:**
- Consumes: `Tile.removed` from Task 1 (no direct use, but the name `.gone` is now free).
- 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`.
- [ ] **Step 1: Write the failing test**
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:
```zig
test "planHostDiff: a pending pane the reachable list disowns is gone, not vanished — and a later list still binds it" {
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
var tiles = fixture.diffFixture(&shared);
var present = [_]bool{ true, true, true };
// Panes 0 and 1 are sidecar restores: no pump ever ran, nothing dialed.
for (tiles[0..2]) |*t| {
t.pending = true;
t.state = .waiting;
t.alive.store(false, .release);
}
var births = BirthNames{};
var binds = TileIdxs{};
var vanish = TileIdxs{};
var gones = TileIdxs{};
// The reachable host answers with NEITHER saved session: both panes are
// gone-fodder, neither is vanish-fodder, and host 1's tile is untouched.
wall_host.planHostDiff(&tiles, &present, 3, 0, "", null, &births, &binds, &vanish, &gones);
try std.testing.expectEqual(@as(usize, 0), vanish.len);
try std.testing.expectEqual(@as(usize, 2), gones.len);
try std.testing.expectEqual(@as(usize, 0), gones.get(0));
try std.testing.expectEqual(@as(usize, 1), gones.get(1));
// Once dressed, a pane is not re-listed every second: the poll comes
// back every 1s and a repaint per poll would flicker the bar.
tiles[0].state = .gone;
tiles[1].state = .gone;
gones.len = 0;
wall_host.planHostDiff(&tiles, &present, 3, 0, "", null, &births, &binds, &vanish, &gones);
try std.testing.expectEqual(@as(usize, 0), gones.len);
try std.testing.expectEqual(@as(usize, 0), vanish.len);
// The dressing is reversible: a list that names session "a" binds pane 0
// (it stayed pending), and does not birth a twin.
wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &binds, &vanish, &gones);
try std.testing.expectEqual(@as(usize, 1), binds.len);
try std.testing.expectEqual(@as(usize, 0), binds.get(0));
try std.testing.expectEqual(@as(usize, 0), births.len);
}
```
Note `TileIdxs.get(i)` — mirror how existing tests read `vanish.get(0)`.
- [ ] **Step 2: Run to verify it fails for the right reason**
Run: `deps/zig/zig build test 2>&1 | tail -15`
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.)
- [ ] **Step 3: Implement**
In `src/tui/wallview.zig`, add to `State` (beside `.@"unreachable"`, keep the enum's doc-comment style) and to `word()`:
```zig
/// A pending pane whose REACHABLE host answered without its session:
/// the host said no, not nothing. The pane stands — its rect is the
/// user's saved layout, not the daemon's state — until Enter re-creates
/// the session in place or `x` dismisses it. Reversible like
/// `unreachable`: the tile stays pending, so a list that names the
/// session later binds it.
gone,
```
and in `word()`: `.gone => "gone",`.
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`):
```zig
pub fn planHostDiff(
tiles: []Tile,
present: []const bool,
live: usize,
host: usize,
list: []const u8,
self_name: ?[]const u8,
births: *BirthNames,
binds: *TileIdxs,
vanish: *TileIdxs,
gones: *TileIdxs,
) void {
```
```zig
if (keep) {
t.missed_once = false;
continue;
}
// A seeded pane the host itself disowns is GONE, never vanished:
// its rect is the user's authored layout, and collapsing it here is
// the one-second re-cut the seed exists to prevent. Listed only on
// the transition — the poll returns every second, and re-dressing
// a pane per poll would repaint its bar for nothing.
if (t.pending) {
if (t.state != .gone) gones.append(i);
continue;
}
if (t.alive.load(.acquire) and !t.missed_once) {
```
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).
Update every existing `planHostDiff` call in `src/tui/wall_test_host.zig` (six calls across four tests) to declare and pass `&gones`.
- [ ] **Step 4: Run tests to verify green**
Run: `deps/zig/zig build test 2>&1 | tail -5; echo rc=$?`
Expected: rc=0, silence. The pre-existing planHostDiff tests must still pass — they pin that LIVE tiles keep the grace-then-vanish path.
- [ ] **Step 5: Commit**
```bash
git add src/tui/wallview.zig src/tui/wall_host.zig src/tui/wall_test_host.zig
git commit -m "feat: a disowned seeded pane plans as gone, not vanish
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 3: `applyHostList` dresses gone panes; flap with `unreachable`; the focus notice
**Files:**
- Modify: `src/tui/wall_host.zig` — `applyHostList` acts on `gones`
- Modify: `src/tui/wallview.zig` — `setFocus` posts the notice; a `pub const gone_notice`
- Test: `src/tui/wall_test_host.zig`
**Interfaces:**
- Consumes: `planHostDiff(..., &gones)` and `.gone` from Task 2; `seedTile` (wallview ~852) to build pending panes in tests.
- 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.
- [ ] **Step 1: Write the failing test**
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):
```zig
test "applyHostList: a reachable host that lost its sessions dresses seeded panes gone and keeps them standing" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var shared: Shared = undefined;
fixture.stoppedWall(alloc, &shared);
var tiles: [4]Tile = undefined;
var present = [_]bool{false} ** 4;
var live: usize = 0;
defer fixture.endPumps(tiles[0..live]);
var table = [_]Host{fixture.testHost(&shared, "box", "/tmp/box.sock")};
// Two seeded panes, off-origin, distinct rects: the saved cut of a
// two-pane wall whose daemon rebooted overnight.
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);
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);
present[0] = true;
present[1] = true;
live = 2;
// The host is REACHABLE and answers empty: both panes dress gone, both
// stay present — the wall re-cuts nothing.
fixture.setList(&table[0], "");
wall_host.applyHostList(fixture.wallOf(alloc, &tiles, &present, &live, &shared, &table), 0);
try std.testing.expectEqual(wv.State.gone, tiles[0].state);
try std.testing.expectEqual(wv.State.gone, tiles[1].state);
try std.testing.expectEqual(@as(usize, 2), wv.presentCount(present[0..live]));
// The poll FAILS next: gone yields to unreachable (the host cannot
// answer, which is a different sentence than "the host said no")...
table[0].poll.reachable.store(false, .release);
wall_host.applyHostList(fixture.wallOf(alloc, &tiles, &present, &live, &shared, &table), 0);
try std.testing.expectEqual(wv.State.@"unreachable", tiles[0].state);
// ...and recovery without the session dresses gone again. Flap over,
// both panes still standing.
fixture.setList(&table[0], "");
wall_host.applyHostList(fixture.wallOf(alloc, &tiles, &present, &live, &shared, &table), 0);
try std.testing.expectEqual(wv.State.gone, tiles[0].state);
try std.testing.expectEqual(wv.State.gone, tiles[1].state);
try std.testing.expectEqual(@as(usize, 2), wv.presentCount(present[0..live]));
}
test "setFocus: landing on a gone pane says what the two keys are" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var shared: Shared = undefined;
fixture.stoppedWall(alloc, &shared);
var tiles: [4]Tile = undefined;
var present = [_]bool{false} ** 4;
var live: usize = 0;
defer fixture.endPumps(tiles[0..live]);
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);
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);
present[0] = true;
present[1] = true;
live = 2;
tiles[1].state = .gone;
wv.setFocus(tiles[0..live], &shared, 1);
try std.testing.expectEqualStrings(wv.gone_notice, shared.notice[0..shared.notice_len]);
}
```
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.
- [ ] **Step 2: Run to verify failure**
Run: `deps/zig/zig build test 2>&1 | tail -15`
Expected: first test fails on `expectEqual(.gone, …)` — state stays `.waiting` because `applyHostList` ignores `gones`; second fails on `gone_notice` not existing.
- [ ] **Step 3: Implement**
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):
```zig
if (gones.len > 0) {
w.shared.paint_mu.lock();
for (gones.items[0..gones.len]) |gi| w.liveTiles()[gi].state = .gone;
w.shared.paint_mu.unlock();
// A gone pane has no pump, so no doorbell can repaint its bar:
// the keyboard paints it here, the same hand that dressed it.
if (w.shared.labelRows() != 0) wv.paintDeadBarsLocked(w.liveTiles());
}
```
In `wallview.zig`, beside `setNoticeIdle`:
```zig
/// What a gone pane can do, said where the user is looking. Under the
/// 96-byte notice buffer; `setNoticeIdle` truncates silently past it.
pub const gone_notice: []const u8 = "[session gone - Enter starts it anew, x closes]";
```
and at the END of `setFocus` (read the body first; add after the focus has moved):
```zig
// A gone pane's bar says "gone"; this says what to do about it. Idle
// only: a refusal or an exit sentence already on screen outranks a hint.
if (tiles[next].state == .gone) setNoticeIdle(shared, gone_notice);
```
- [ ] **Step 4: Run tests to verify green**
Run: `deps/zig/zig build test 2>&1 | tail -5; echo rc=$?`
Expected: rc=0, silence.
- [ ] **Step 5: Commit**
```bash
git add src/tui/wall_host.zig src/tui/wallview.zig src/tui/wall_test_host.zig
git commit -m "feat: a reachable host's missing sessions dress their panes gone
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 4: Revive — Enter re-arms the tile in place; every other byte is eaten; `x` drops it
**Files:**
- 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)
- Test: `src/tui/wall_test_host.zig` (or `wall_test_wall.zig` if the reviewer prefers — keep all three new tests together either way)
**Interfaces:**
- Consumes: `.gone`, `seedTile`, `spawnPump`, `bindTile` (the model), `endKey` (~434).
- 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.
- [ ] **Step 1: Write the failing tests**
```zig
test "armRevive: Enter turns a gone pane into a creating attach in the same rect" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var shared: Shared = undefined;
fixture.stoppedWall(alloc, &shared);
var tiles: [2]Tile = undefined;
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);
tiles[0].state = .gone;
wv.armRevive(&tiles[0]);
// The tile is no longer a placeholder: it CREATES (the daemon has no
// such session - that is what gone means), and it does so exactly
// where it stood.
try std.testing.expect(!tiles[0].pending);
try std.testing.expect(tiles[0].creates);
try std.testing.expectEqual(wv.State.connecting, tiles[0].state);
try std.testing.expect(tiles[0].alive.load(.acquire));
try std.testing.expect(!tiles[0].pump_done.load(.acquire));
try std.testing.expectEqual(@as(u16, 41), tiles[0].rect.left);
try std.testing.expectEqualStrings("a", tiles[0].r.session);
}
test "keysToFocused: a gone pane eats every byte except Enter" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var shared: Shared = undefined;
fixture.stoppedWall(alloc, &shared);
var tiles: [2]Tile = undefined;
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);
tiles[0].state = .gone;
// Ordinary typing reaches no session and queues nowhere.
wv.keysToFocused(&tiles[0], "ls -la");
try std.testing.expectEqual(@as(usize, 0), tiles[0].in_len);
try std.testing.expect(tiles[0].pending);
// Enter revives. (`running` is false in this fixture, so the spawned
// pump exits at its gate; the transitions are armRevive's, pinned above.)
wv.keysToFocused(&tiles[0], "\r");
try std.testing.expect(!tiles[0].pending);
tiles[0].pump_done.store(true, .release);
}
test "endKey: x on a gone pane drops the tile locally" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var shared: Shared = undefined;
fixture.stoppedWall(alloc, &shared);
var tiles: [2]Tile = undefined;
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);
tiles[0].state = .gone;
try std.testing.expectEqual(wv.EndKey.drop, wv.endKey(&tiles[0], 0));
}
```
(`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.)
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.
- [ ] **Step 2: Run to verify failure**
Run: `deps/zig/zig build test 2>&1 | tail -15`
Expected: compile errors — no `armRevive`, no `keysToFocused`.
- [ ] **Step 3: Implement**
In `wallview.zig`, beside `bindTile` (read `bindTile`'s body first — `armRevive` is its shape with `creates = true`):
```zig
/// The state half of a revive, split from the thread so a test can pin the
/// transitions without racing a pump. `reviveTile` is the only production
/// caller.
pub fn armRevive(t: *Tile) void {
t.shared.paint_mu.lock();
t.pending = false;
t.creates = true;
t.state = .connecting;
t.shared.paint_mu.unlock();
t.end_seen = false;
// The user is looking at this pane - Enter was pressed IN it - so the
// claim is armed the way bindTile arms the saved focus.
if (t.idx == t.shared.sel) t.claim_pending.store(true, .release);
t.pump_done.store(false, .release);
t.alive.store(true, .release);
}
/// A gone pane, revived on the user's Enter: the same tile re-arms as a
/// CREATING attach - same host, same session name, same rect - so the
/// daemon makes the session anew where the old one stood. No tree edit and
/// no flatten: reviving re-cuts nothing, which is the promise gone panes
/// exist to keep.
pub fn reviveTile(t: *Tile) void {
armRevive(t);
spawnPump(t);
}
/// The one door for bytes aimed at the focused tile. A gone pane has no
/// pump, so no byte could reach a session - eaten here rather than queued
/// into `in` where they would greet the NEXT session this tile carries.
/// Enter is the pane's one verb.
pub fn keysToFocused(t: *Tile, keys: []const u8) void {
if (t.pending and t.state == .gone) {
if (std.mem.indexOfAny(u8, keys, "\r\n") != null) reviveTile(t);
return;
}
sendKeys(t, keys);
}
```
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.
- [ ] **Step 4: Run tests to verify green**
Run: `deps/zig/zig build test 2>&1 | tail -5; echo rc=$?`
Expected: rc=0, silence.
- [ ] **Step 5: Run `make check`, commit**
```bash
make check 2>&1 | tail -3; echo rc=$?
git add src/tui/wallview.zig src/tui/wall_test_host.zig
git commit -m "feat: Enter revives a gone pane in place, and x still drops it
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 5: E2E — the reboot leg
**Files:**
- 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`)
- 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)`)
**Interfaces:**
- 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).
- [ ] **Step 1: Write the leg (it must FAIL against a stale build and pass after `zig build`)**
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):
```sh
# ---- gone panes: a reboot keeps the cut ------------------------------
#
# The spec's scenario: three sessions, a saved 3-pane cut, and a daemon
# that comes back EMPTY. The wall must paint the saved cut and never move
# it: panes b and c dress `gone` instead of collapsing (the pre-gone-panes
# build re-cuts ~1s in - this leg fails there). Enter in pane b creates
# session b anew in the same rect; a later run x's pane c and only THAT
# collapses it.
GPSTATE="${TMPDIR:-/tmp}/mux-e2e-gone-state-$$"
defer_rm "$GPSTATE"
start_daemon "$SOCK62" "$OUT.gp.d" "gone-panes daemon never bound" --shell /bin/sh
D62PID=$DPID
# Three sessions - plural, so the fates can differ per pane.
pipe_mux "$OUT.gpa" "$OUT.gpa.err" env XDG_STATE_HOME="$GPSTATE" timeout 40 "$MUX" --sock "$SOCK62"
pipe_send 'printf "gp-%%s\\n" zero\n'
await_out "$OUT.gpa" "gp-zero" "gone-panes: session 0 marker"
pipe_detach
wait_grid "$SOCK62" "gp-zero" "gone-panes: session 0 marker"
pipe_mux "$OUT.gpb" "$OUT.gpb.err" env XDG_STATE_HOME="$GPSTATE" timeout 40 "$MUX" --sock "$SOCK62" --session b
pipe_send 'printf "gp-%%s\\n" bee\n'
await_out "$OUT.gpb" "gp-bee" "gone-panes: session b marker"
pipe_detach
wait_grid "$SOCK62" "gp-bee" "gone-panes: session b marker" b
pipe_mux "$OUT.gpc" "$OUT.gpc.err" env XDG_STATE_HOME="$GPSTATE" timeout 40 "$MUX" --sock "$SOCK62" --session c
pipe_send 'printf "gp-%%s\\n" sea\n'
await_out "$OUT.gpc" "gp-sea" "gone-panes: session c marker"
pipe_detach
wait_grid "$SOCK62" "gp-sea" "gone-panes: session c marker" c
mkdir -p "$GPSTATE/mux"
printf -- '--sock %s\n' "$SOCK62" > "$GPSTATE/mux/hosts"
no_saved_tree "$GPSTATE"
# Run 1: hydrate the 3-pane wall on a tty; detach saves the sidecar.
XDG_STATE_HOME="$GPSTATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 24 \
--out "$OUT.gpcap1" --err "$OUT.gpcap1.err" -- "$MUX" > "$OUT.gppc1" 2>&1 <<'EOF'
expect gp-zero 20000
settle 2500 20000
send \x1cd
waitexit 10000
EOF
grep -c '^ leaf' "$GPSTATE/mux/layout" | grep -qx 3 || {
echo "e2e FAIL: gone-panes: sidecar does not hold 3 panes:"
cat "$GPSTATE/mux/layout"; exit 1; }
# The reboot: same socket, fresh daemon, no sessions of ours.
assert_stopped "$SOCK62" "$D62PID" "gone-panes" "$OUT.gpstop"
start_daemon "$SOCK62" "$OUT.gp.d2" "gone-panes daemon 2 never bound" --shell /bin/sh
D62PID=$DPID
# Run 2: the saved cut must paint and STAY. `gone` must reach the grid.
# Enter in pane b (focus-right from the saved focus on pane 0) creates
# session b anew - its marker proves which daemon-side session exists.
XDG_STATE_HOME="$GPSTATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 24 \
--out "$OUT.gpcap2" --err "$OUT.gpcap2.err" -- "$MUX" > "$OUT.gppc2" 2>&1 <<'EOF'
expect gone 20000
settle 1500 20000
send \x1cl
settle 500 10000
send \r
settle 1500 20000
send printf 'gp-%s\n' reborn\n
expect gp-reborn 15000
settle 500 15000
send \x1cd
waitexit 10000
EOF
# The claim measured 2026-09-01: pre-gone-panes, the restored cut collapses
# on the first poll answer, which moves or removes a rail. The whole run 2
# capture must contain EXACTLY the saved cut's rail columns - compare the
# distinct-set against run 1's.
rails1=$(rail_cols "$OUT.gpcap1" | sort -n | uniq | tr '\n' ' ')
rails2=$(rail_cols "$OUT.gpcap2" | sort -n | uniq | tr '\n' ' ')
[ "$rails1" = "$rails2" ] || {
echo "e2e FAIL: gone-panes: rails moved across the reboot (run1: $rails1 run2: $rails2)"
cat "$OUT.gppc2"; exit 1; }
# The revived session is REAL and correctly named: the daemon's own grid
# for session b carries the marker; the entry session does not.
timeout 20 "$MUX" a capture --sock "$SOCK62" --session b > "$OUT.gprb" 2>&1
grep -q "gp-reborn" "$OUT.gprb" || {
echo "e2e FAIL: gone-panes: reborn marker not in session b:"
cat "$OUT.gprb"; exit 1; }
timeout 20 "$MUX" a capture --sock "$SOCK62" > "$OUT.gpra" 2>&1
! grep -q "gp-reborn" "$OUT.gpra" || {
echo "e2e FAIL: gone-panes: reborn marker leaked into the entry session"
exit 1; }
# Run 3: x on the still-gone pane c collapses it - on the KEYPRESS, not on
# a poll. Focus right twice from the saved focus to reach c.
XDG_STATE_HOME="$GPSTATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 24 \
--out "$OUT.gpcap3" --err "$OUT.gpcap3.err" -- "$MUX" > "$OUT.gppc3" 2>&1 <<'EOF'
expect gone 20000
settle 1500 20000
send \x1cl
settle 300 10000
send \x1cl
settle 300 10000
send \x1cx
settle 1500 20000
send \x1cd
waitexit 10000
EOF
# Two panes remain; the sidecar saved after run 3 must not name c.
! grep -q '#c$' "$GPSTATE/mux/layout" || {
echo "e2e FAIL: gone-panes: x did not dismiss pane c from the sidecar:"
cat "$GPSTATE/mux/layout"; exit 1; }
assert_stopped "$SOCK62" "$D62PID" "gone-panes" "$OUT.gpstop2"
D62PID=""
rm -rf "$GPSTATE"
ok "a rebooted daemon's saved panes wear gone; Enter revives in place, x dismisses"
```
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.
- [ ] **Step 2: RED — run the leg against a build WITHOUT tasks 2–4**
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):
```bash
git worktree add /tmp/gone-red <task-1-commit-sha>
cp -r "$(dirname "$(readlink -f deps/zig/zig)")" /tmp/gone-red/deps/zig 2>/dev/null || true # vendored toolchain is gitignored; copy it
cp test/e2e_12_panes.sh /tmp/gone-red/test/e2e_12_panes.sh # the NEW leg onto the OLD code
cd /tmp/gone-red && deps/zig/zig build && E2E_ONLY=12 make e2e 2>&1 | tail -8
cd - && git worktree remove --force /tmp/gone-red
```
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.
- [ ] **Step 3: GREEN — full build, full group**
```bash
deps/zig/zig build 2>&1 | tail -3
E2E_ONLY=12 make e2e 2>&1 | tail -5; echo rc=$?
```
Expected: rc=0, `ok` line for the new leg.
- [ ] **Step 4: Full gates**
```bash
make check 2>&1 | tail -3; echo rc=$?
make e2e 2>&1 | tail -3; echo rc=$?
```
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.
- [ ] **Step 5: Commit**
```bash
git add test/e2e_12_panes.sh
git commit -m "test: e2e - a rebooted daemon's panes wear gone and the cut never moves
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 6: Docs — the invariant says so
**Files:**
- Modify: `CLAUDE.md` — the hosts-file invariant bullet (the sentence about a saved pane on a dark host wearing `unreachable`)
- Modify: `docs/decisions.md` — append a dated entry
**Interfaces:** none; prose only.
- [ ] **Step 1: Edit CLAUDE.md**
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:
> 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.
Keep the bullet's existing sentences intact — they are load-bearing.
- [ ] **Step 2: Append to docs/decisions.md**
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:
> ## 2026-09-01 — gone panes: placeholders over auto-start
>
> Measured: after a daemon restart, the seeded wall painted its saved cut at
> byte 46 of the capture and collapsed it on the first poll answer (~1s) —
> the flash the seed was built to remove, surviving in the one path where a
> reachable host disowns a saved session. Chosen: dress those panes `gone`
> (reversible, pending stays set), Enter re-arms the same tile as a creating
> attach in its rect, `x` dismisses. Rejected: auto-starting shells (bends
> "nothing re-creates a session" without a keypress; layers on later as
> Enter-on-all), and keeping the collapse (the user's stated expectation is
> the wall they left). Spec: docs/superpowers/specs/2026-09-01-gone-panes-design.md.
- [ ] **Step 3: Gate and commit**
```bash
make check 2>&1 | tail -3; echo rc=$?
git add CLAUDE.md docs/decisions.md
git commit -m "docs: the gone-pane rule joins the wall invariant
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
## Final verification (after all tasks)
- [ ] `make ci 2>&1 | tail -5; echo rc=$?` — rc=0 (check + e2e + agent + throughput; this is the delivery gate).
- [ ] Autosquash any fixups so the history tells the feature's story: rename → planner → dressing → revive → e2e → docs.
- [ ] 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.