@zhuxixi/pi-agent-board 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,917 @@
1
+ # Attach Runtime Desync Detect + Heal 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:** Detect runtime cursor desync (attach-settled, mid-session) via a 2s probe that checks the PTY cursor cell's inverse attribute, and self-heal by re-arming the shrink-and-hold jiggle protocol with rate limiting and a lifetime budget (issue #11).
6
+
7
+ **Architecture:** Three layers matching the existing codebase split: (1) pure classifier `detectCursorDesync()` in `src/core/pty-attach-render.mjs`; (2) protocol entry `heal()` on the existing jiggle controller in `src/core/pty-attach-jiggle-controller.mjs` reusing feed/clear detection and guards; (3) wiring in `src/ui/pty-attach.ts` — `lastOutputAt` timestamp, injectable `nowFn`, a `checkDesync()` method gated by 7 conditions, and a probe timer started at attach settle / stopped at close. Spec: `docs/superpowers/specs/2026-09-07-issue-11-attach-runtime-desync-heal-design.md`.
8
+
9
+ **Tech Stack:** Node.js ESM (.mjs), TypeScript component compiled via `--experimental-transform-types`, `node:test` + `node:assert/strict`, `@xterm/headless` in smoke tests only.
10
+
11
+ ## Global Constraints
12
+
13
+ - Parameter values (copy verbatim): `DESYNC_QUIET_MS = 1500`, `DESYNC_PROBE_INTERVAL_MS = 2000`, `HEAL_RATELIMIT_MS = 10000`, `HEAL_MAX_PER_LIFETIME = 5`.
14
+ - healBudget is consumed on **entry** to `heal()` (including the tiny-terminal give-up path); `start()`/`restoreAndStop()` never reset it.
15
+ - `heal()` preserves `tuiFrameSeen` and never arms G1; it must reuse `feed()`'s existing clear detection and the G3/G4/G5 guard behavior without changing `start()`'s semantics.
16
+ - `checkDesync()` gates, in order: (1) `!attaching && !closed`, (2) `tuiFrameSeen`, (3) `detectCursorDesync(...) === "misaligned"`, (4) `now - lastOutputAt > DESYNC_QUIET_MS`, (5) chain idle (`state.stopped && !held`), (6) `now - lastHealAt > HEAL_RATELIMIT_MS`, (7) `this.connected`.
17
+ - The probe is a self-contained timer (unref'd, cleared in `close()`), NOT hooked into the render path (render is event-driven and stops exactly when desync strikes).
18
+ - `lastOutputAt` is recorded synchronously in `pushOutput()` before `term.write`, never inside its async callback.
19
+ - Never commit to main; all work stays in this worktree branch. Commit messages use conventional commits. `git add` per-file, never `git add -A`.
20
+ - Test commands: `node --test test/pty-attach-render.test.mjs`, `node --test test/pty-attach-jiggle-controller.test.mjs`, `node --test test/pty-attach-desync-heal.test.mjs`, `node --test test/pty-attach-desync-health-e2e.test.mjs`, full suite `npm test`.
21
+
22
+ ---
23
+
24
+ ### Task 1: `detectCursorDesync` pure classifier (A1)
25
+
26
+ **Files:**
27
+ - Modify: `src/core/pty-attach-render.mjs` (append after `projectPtyCursor`)
28
+ - Test: `test/pty-attach-render.test.mjs` (append; add `detectCursorDesync` to the import list)
29
+
30
+ **Interfaces:**
31
+ - Consumes: the `{ row, col } | null` shape returned by the existing `projectPtyCursor(buf, start, height)` (same file).
32
+ - Produces: `detectCursorDesync(buf: { getLine(row): { length: number; getCell(x): { isInverse(): boolean; getWidth(): number } | undefined } | undefined }, cursor: { row: number; col: number } | null): "aligned" | "misaligned" | "unknown"` — consumed by Task 3's `checkDesync()`.
33
+
34
+ - [ ] **Step 1: Write the failing tests**
35
+
36
+ Append to `test/pty-attach-render.test.mjs` (extend the existing import statement with `detectCursorDesync`):
37
+
38
+ ```js
39
+ // --- issue #11: runtime desync classifier ---
40
+
41
+ function fakeDesyncBuf(cells) {
42
+ // cells: array of { inverse: boolean, width: number } | null (null = no cell at x)
43
+ return {
44
+ getLine(row) {
45
+ if (row !== 0) return undefined;
46
+ return {
47
+ length: cells.length,
48
+ getCell(x) {
49
+ const c = cells[x];
50
+ if (!c) return undefined;
51
+ return { isInverse: () => c.inverse, getWidth: () => c.width };
52
+ },
53
+ };
54
+ },
55
+ };
56
+ }
57
+
58
+ test("detectCursorDesync: null cursor (viewport-scrolled) is unknown", () => {
59
+ assert.equal(detectCursorDesync(fakeDesyncBuf([]), null), "unknown");
60
+ });
61
+
62
+ test("detectCursorDesync: cursor on an inverse cell is aligned", () => {
63
+ const buf = fakeDesyncBuf([{ inverse: false, width: 1 }, { inverse: true, width: 1 }]);
64
+ assert.equal(detectCursorDesync(buf, { row: 0, col: 1 }), "aligned");
65
+ });
66
+
67
+ test("detectCursorDesync: cursor on a non-inverse cell is misaligned", () => {
68
+ const buf = fakeDesyncBuf([{ inverse: true, width: 1 }, { inverse: false, width: 1 }]);
69
+ assert.equal(detectCursorDesync(buf, { row: 0, col: 1 }), "misaligned");
70
+ });
71
+
72
+ test("detectCursorDesync: cursor past line end falls back to the last inverse cell (aligned)", () => {
73
+ const buf = fakeDesyncBuf([{ inverse: false, width: 1 }, { inverse: true, width: 1 }]);
74
+ assert.equal(detectCursorDesync(buf, { row: 0, col: 99 }), "aligned");
75
+ });
76
+
77
+ test("detectCursorDesync: cursor on a width-0 CJK continuation falls back to the leading wide cell", () => {
78
+ // "你" occupies cols 0-1: col 0 width 2 inverse, col 1 width 0 (continuation)
79
+ const buf = fakeDesyncBuf([{ inverse: true, width: 2 }, { inverse: false, width: 0 }, { inverse: false, width: 1 }]);
80
+ assert.equal(detectCursorDesync(buf, { row: 0, col: 1 }), "aligned");
81
+ });
82
+
83
+ test("detectCursorDesync: fully empty line (no cells, no inverse) is misaligned", () => {
84
+ const buf = fakeDesyncBuf([]);
85
+ assert.equal(detectCursorDesync(buf, { row: 0, col: 0 }), "misaligned");
86
+ });
87
+
88
+ test("detectCursorDesync: missing buffer line is unknown", () => {
89
+ assert.equal(detectCursorDesync(fakeDesyncBuf([{ inverse: true, width: 1 }]), { row: 5, col: 0 }), "unknown");
90
+ });
91
+ ```
92
+
93
+ - [ ] **Step 2: Run tests to verify they fail**
94
+
95
+ Run: `cd /home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-11-attach-runtime-desync-heal && node --test test/pty-attach-render.test.mjs`
96
+ Expected: FAIL — `detectCursorDesync` is not exported (import error).
97
+
98
+ - [ ] **Step 3: Write the implementation**
99
+
100
+ Append to `src/core/pty-attach-render.mjs`:
101
+
102
+ ```js
103
+ /**
104
+ * Classify the PTY cursor's alignment for runtime desync detection (issue #11).
105
+ *
106
+ * Healthy idle pi: the child pi-tui parks the hardware cursor on the editor
107
+ * marker, whose cell is the inverse-video "fake cursor" — so the cursor cell
108
+ * itself is inverse. A desynced buffer leaves the cursor parked elsewhere
109
+ * (typically where the last differential write ended), on a non-inverse cell.
110
+ * Width-0 cells are CJK continuation cells and out-of-range columns sit past
111
+ * the line's cells; in both cases the meaningful attribute lives on the
112
+ * preceding cell, so we look left. Returns:
113
+ * "aligned" — cursor resolves to an inverse cell (healthy);
114
+ * "misaligned" — cursor resolves to a non-inverse cell (candidate desync;
115
+ * callers gate this with an output-quietness window);
116
+ * "unknown" — no cursor (scrolled out of the projected viewport) or no
117
+ * buffer line (defensive); never treat these as desync.
118
+ */
119
+ export function detectCursorDesync(buf, cursor) {
120
+ if (!cursor) return "unknown";
121
+ const line = buf.getLine(cursor.row);
122
+ if (!line) return "unknown";
123
+ let x = cursor.col;
124
+ let cell = line.getCell(x);
125
+ while ((!cell || cell.getWidth() === 0) && x > 0) {
126
+ x--;
127
+ cell = line.getCell(x);
128
+ }
129
+ if (!cell) return "misaligned";
130
+ return cell.isInverse() ? "aligned" : "misaligned";
131
+ }
132
+ ```
133
+
134
+ - [ ] **Step 4: Run tests to verify they pass**
135
+
136
+ Run: `node --test test/pty-attach-render.test.mjs`
137
+ Expected: PASS (all new + existing tests).
138
+
139
+ - [ ] **Step 5: Commit**
140
+
141
+ ```bash
142
+ git add src/core/pty-attach-render.mjs test/pty-attach-render.test.mjs
143
+ git commit -m "feat: detectCursorDesync three-state classifier for runtime desync (issue #11)"
144
+ ```
145
+
146
+ ---
147
+
148
+ ### Task 2: controller `heal()` + lifetime budget (A2)
149
+
150
+ **Files:**
151
+ - Modify: `src/core/pty-attach-jiggle-controller.mjs` (add `HEAL_MAX_PER_LIFETIME` const, `healCount` state, `heal()` function; extend `getState()` and the returned API)
152
+ - Test: `test/pty-attach-jiggle-controller.test.mjs` (append)
153
+
154
+ **Interfaces:**
155
+ - Consumes: existing `createJiggleRetryState`, `stopRetry`, `resizeJiggleSize`, `scheduleNextRetry`, `clearAllTimers`, `restoreIfHeld`, and module state (`held`, `restored`, `state`, `carry`, `tuiFrameSeen`, `originalCols/Rows`, `holdSize`).
156
+ - Produces: `heal(cols: number, rows: number): boolean` — `true` when a heal hold was armed, `false` when the budget is exhausted or no valid hold size exists. `getState()` additionally exposes `healCount: number`. Consumed by Task 3's `checkDesync()`.
157
+
158
+ - [ ] **Step 1: Write the failing tests**
159
+
160
+ Append to `test/pty-attach-jiggle-controller.test.mjs`:
161
+
162
+ ```js
163
+ // --- issue #11: runtime desync heal ---
164
+
165
+ function frameSeenController() {
166
+ const { controller, scheduler, resizes } = makeController();
167
+ controller.start(170, 36);
168
+ controller.feed("\x1b[?2026h"); // first TUI frame → restore via fast path
169
+ controller.feed("\x1b[2J"); // clear → chain done, runtime idle state
170
+ return { controller, scheduler, resizes };
171
+ }
172
+
173
+ test("heal(): re-arms shrink-and-hold, preserves tuiFrameSeen, no G1", () => {
174
+ const { controller, scheduler, resizes } = frameSeenController();
175
+ resizes.length = 0;
176
+ assert.equal(controller.heal(170, 36), true);
177
+ assert.deepEqual(resizes, [[169, 35]], "heal sends exactly one shrink");
178
+ assert.equal(controller.getState().held, true);
179
+ assert.equal(controller.getState().tuiFrameSeen, true, "heal must NOT reset tuiFrameSeen");
180
+ assert.equal(scheduler.findByDelay(6000), null, "heal must NOT arm G1");
181
+ assert.ok(scheduler.delays().length > 0, "chain (G2 backoff) is scheduled");
182
+ });
183
+
184
+ test("heal() then feed clear → restore + stop", () => {
185
+ const { controller, resizes } = frameSeenController();
186
+ resizes.length = 0;
187
+ controller.heal(170, 36);
188
+ controller.feed("redraw\x1b[2J\x1b[Hframe");
189
+ assert.deepEqual(resizes.slice(-1), [[170, 36]], "clear restores original size");
190
+ assert.equal(controller.getState().held, false);
191
+ assert.equal(controller.getState().clearDetected, true);
192
+ assert.equal(controller.getState().stopped, true);
193
+ });
194
+
195
+ test("heal() budget: 5 attempts max per controller lifetime", () => {
196
+ const { controller, resizes } = frameSeenController();
197
+ resizes.length = 0;
198
+ for (let i = 0; i < 5; i++) {
199
+ assert.equal(controller.heal(170, 36), true, `heal #${i + 1} succeeds`);
200
+ // resolve each heal with a clear so the next one starts idle
201
+ controller.feed("\x1b[2J");
202
+ }
203
+ resizes.length = 0;
204
+ assert.equal(controller.heal(170, 36), false, "6th heal rejected");
205
+ assert.deepEqual(resizes, [], "no resize sent after budget exhausted");
206
+ assert.equal(controller.getState().healCount, 5);
207
+ });
208
+
209
+ test("heal() budget is NOT reset by start() or restoreAndStop()", () => {
210
+ const { controller } = frameSeenController();
211
+ controller.heal(170, 36);
212
+ controller.feed("\x1b[2J");
213
+ controller.start(170, 36);
214
+ controller.feed("\x1b[2J");
215
+ controller.restoreAndStop();
216
+ assert.equal(controller.getState().healCount, 1);
217
+ });
218
+
219
+ test("heal() consumes budget even on tiny terminals (no valid hold size)", () => {
220
+ const { controller } = frameSeenController();
221
+ assert.equal(controller.heal(20, 5), false, "tiny terminal cannot hold");
222
+ assert.equal(controller.getState().healCount, 1, "budget consumed on entry");
223
+ });
224
+
225
+ test("heal() hold is cancelled by G4 notifyExternalResize", () => {
226
+ const { controller, resizes } = frameSeenController();
227
+ resizes.length = 0;
228
+ controller.heal(170, 36);
229
+ controller.notifyExternalResize(200, 50);
230
+ assert.equal(controller.getState().held, false);
231
+ assert.equal(controller.getState().stopped, true);
232
+ // G4 adopts the new size as original: a later heal restores to the new size
233
+ assert.equal(controller.heal(200, 50), true);
234
+ controller.feed("\x1b[2J");
235
+ assert.deepEqual(resizes.filter(([c]) => c === 200).length >= 1, true, "restore tracks the adopted size");
236
+ });
237
+
238
+ test("heal() while a previous heal is held: restores the old hold first", () => {
239
+ const { controller, resizes } = frameSeenController();
240
+ resizes.length = 0;
241
+ controller.heal(170, 36); // shrink #1
242
+ controller.heal(170, 36); // shrink #2 — must restore #1 first (no clear between)
243
+ assert.deepEqual(resizes, [[169, 35], [170, 36], [169, 35]], "second heal unwinds the first hold before re-shrinking");
244
+ assert.equal(controller.getState().healCount, 2);
245
+ });
246
+ ```
247
+
248
+ - [ ] **Step 2: Run tests to verify they fail**
249
+
250
+ Run: `node --test test/pty-attach-jiggle-controller.test.mjs`
251
+ Expected: FAIL — `controller.heal is not a function`.
252
+
253
+ - [ ] **Step 3: Write the implementation**
254
+
255
+ In `src/core/pty-attach-jiggle-controller.mjs`:
256
+
257
+ 3a. Add next to `POST_RESTORE_VERIFY_MS`:
258
+
259
+ ```js
260
+ /** Lifetime cap on runtime desync heals (issue #11): a persistent misdiagnosis
261
+ * must not flicker the screen forever; after this many attempts the backstop
262
+ * stays quiet until the controller is recreated. Consumed on heal() entry. */
263
+ const HEAL_MAX_PER_LIFETIME = 5;
264
+ ```
265
+
266
+ 3b. Add state next to `let g1Timer = null;`:
267
+
268
+ ```js
269
+ /** Runtime heals spent (issue #11); never reset by start()/restoreAndStop(). */
270
+ let healCount = 0;
271
+ ```
272
+
273
+ 3c. Add the method (place after `feed`, before `restoreAndStop`):
274
+
275
+ ```js
276
+ /**
277
+ * Runtime desync backstop (issue #11): re-arm the shrink-and-hold protocol
278
+ * mid-session. Unlike start(), tuiFrameSeen is preserved (the child has
279
+ * rendered), G1 is not armed (frames are flowing), and the budget is
280
+ * lifetime-capped so a misdiagnosis cannot flicker the screen forever.
281
+ * Consumes one budget slot on entry, including the tiny-terminal give-up.
282
+ * @param {number} cols
283
+ * @param {number} rows
284
+ * @returns {boolean} true when a heal hold was armed.
285
+ */
286
+ function heal(cols, rows) {
287
+ if (healCount >= HEAL_MAX_PER_LIFETIME) return false;
288
+ healCount++;
289
+ clearAllTimers();
290
+ if (held) {
291
+ // Unwind any live hold (e.g. a previous clear-less heal) first.
292
+ sendResize(originalCols, originalRows);
293
+ restored = true;
294
+ held = false;
295
+ }
296
+ state = createJiggleRetryState();
297
+ carry = "";
298
+ originalCols = cols;
299
+ originalRows = rows;
300
+ holdSize = resizeJiggleSize(cols, rows);
301
+ if (!holdSize) {
302
+ state = stopRetry(state);
303
+ held = false;
304
+ restored = true;
305
+ return false;
306
+ }
307
+ sendResize(holdSize.cols, holdSize.rows);
308
+ held = true;
309
+ restored = false;
310
+ scheduleNextRetry(); // G2 backoff re-shrinks while a renderer is seen but no clear follows
311
+ return true;
312
+ }
313
+ ```
314
+
315
+ 3d. Extend `getState()` and the return object:
316
+
317
+ ```js
318
+ getState: () => ({ ...state, held, tuiFrameSeen, originalCols, originalRows, holdSize, healCount }),
319
+ ```
320
+
321
+ and add `heal` to the returned API object (`return { start, feed, heal, restoreAndStop, notifyExternalResize, getState };`).
322
+
323
+ - [ ] **Step 4: Run tests to verify they pass**
324
+
325
+ Run: `node --test test/pty-attach-jiggle-controller.test.mjs`
326
+ Expected: PASS (all new + existing tests).
327
+
328
+ - [ ] **Step 5: Commit**
329
+
330
+ ```bash
331
+ git add src/core/pty-attach-jiggle-controller.mjs test/pty-attach-jiggle-controller.test.mjs
332
+ git commit -m "feat: jiggle controller heal() runtime backstop with lifetime budget (issue #11)"
333
+ ```
334
+
335
+ ---
336
+
337
+ ### Task 3: component wiring — probe timer, `checkDesync()`, 7 gates (A3)
338
+
339
+ **Files:**
340
+ - Modify: `src/ui/pty-attach.ts` (constants near `ATTACH_OUTPUT_RENDER_INTERVAL_MS` import; fields near `viewportTop` (~L152); `pushOutput` (~L1050); `finishAttachTransition` (~L514); `close()` (~L1100); import `detectCursorDesync`)
341
+ - Create: `test-support/desync-heal-smoke.ts`
342
+ - Create: `test/pty-attach-desync-heal.test.mjs`
343
+
344
+ **Interfaces:**
345
+ - Consumes: `detectCursorDesync` (Task 1), `controller.heal()` / `getState().healCount` (Task 2), existing `projectPtyCursor`, `bodyHeight()`, `bottomViewportTop()`, `clampViewportTop()`, `this.jiggleRetry`, `this.cols/rows`, `this.connected`, `this.attaching`, `this.send`.
346
+ - Produces (runtime-injectable for tests, same pattern detach-gate-smoke already uses): fields `lastOutputAt: number`, `lastHealAt: number`, `nowFn: () => number`, plus private methods `checkDesync(): void`, `startDesyncProbe(): void`, `stopDesyncProbe(): void`. Smoke tests call `checkDesync()` and read `jiggleRetry.getState()` via `(attach as unknown as {...})` casts.
347
+
348
+ - [ ] **Step 1: Write the failing smoke harness**
349
+
350
+ Create `test-support/desync-heal-smoke.ts` (modeled on `detach-gate-smoke.ts` — real `PtyAttachComponent`, fake tui, runtime injection of private fields):
351
+
352
+ ```ts
353
+ // Desync heal wiring harness (issue #11): construct PtyAttachComponent with a
354
+ // fake TUI, drive it through Pi-like buffer states via the real socket-data
355
+ // path (pushOutput + checkClearSequence), then verify the 7 gates of
356
+ // checkDesync() and the end-to-end heal loop:
357
+ // H1. healthy idle (cursor parked on inverse fake cursor) → no heal.
358
+ // H2. desync (cursor parked elsewhere) + quiet window → exactly one heal
359
+ // (shrink sent); immediate re-check is rate-limited + chain-idle-gated.
360
+ // H3. pre-settle (attaching) → no heal.
361
+ // H4. no TUI frame (tuiFrameSeen false) → no heal.
362
+ // H5. cursor scrolled out of viewport → no heal.
363
+ // H6. recent output (within DESYNC_QUIET_MS) → no heal.
364
+ // H7. heal loop: child redraws with clear + cursor back on the fake cursor
365
+ // → restore + no further heal.
366
+ // Timing/state facts the harness encodes (verified against the component and
367
+ // controller source):
368
+ // - checkDesync gate 7 needs this.connected — injected true (no real socket).
369
+ // - The controller must sit in runtime-idle state (tuiFrameSeen=true from a
370
+ // 2026h frame, then chain stopped by a detected \x1b[2J) — feed() learns
371
+ // the frame BEFORE the clear; once clearDetected it short-circuits, and
372
+ // within one chunk a clear wins over a frame start.
373
+ // - The projected window is bottom-anchored (bottomViewportTop), so frames
374
+ // sink 25 newlines first to land the cursor inside [start, start+height).
375
+ // Run via `node --experimental-transform-types` (TS parameter properties).
376
+ import { PtyAttachComponent } from "../src/ui/pty-attach.ts";
377
+
378
+ const ESC = "\x1b";
379
+ /** Leading clear: stops the controller chain (runtime-idle state). */
380
+ const CLEAR = `${ESC}[2J${ESC}[H`;
381
+ /** Push content + cursor to the buffer bottom so they land inside the
382
+ * bottom-anchored projection window (25 > 24-row viewport → 1 line scrollback). */
383
+ const SINK = "\n".repeat(25);
384
+ const tui = {
385
+ terminal: { rows: 24, cols: 80, columns: 80, write: () => {} },
386
+ requestRender: () => {},
387
+ };
388
+ const theme = { fg: (_c: string, t: string) => t, bold: (t: string) => t };
389
+ const keybindings = {} as never;
390
+
391
+ type Drivable = {
392
+ pushOutput: (data: string) => void;
393
+ checkClearSequence: (data: string) => void;
394
+ finishAttachTransition: () => void;
395
+ checkDesync: () => void;
396
+ attaching: boolean;
397
+ viewportTop: number | null;
398
+ jiggleRetry: { getState: () => { healCount: number; held: boolean; stopped: boolean; tuiFrameSeen: boolean }; feed: (data: string) => void };
399
+ };
400
+
401
+ function makeAttach() {
402
+ const attach = new PtyAttachComponent(
403
+ tui as never,
404
+ theme as never,
405
+ keybindings,
406
+ () => {},
407
+ { socketPath: "/no/such/socket", title: "desync" },
408
+ );
409
+ const sent: Array<Record<string, unknown>> = [];
410
+ (attach as unknown as { send: (msg: Record<string, unknown>) => void }).send = (msg) => sent.push(msg);
411
+ // Gate 7 needs a live connection; the fake socketPath would never connect.
412
+ (attach as unknown as { connected: boolean }).connected = true;
413
+ // Injectable clock: start at t=1_000_000 so "unset" (0) timestamps always look stale.
414
+ const clock = { now: 1_000_000 };
415
+ (attach as unknown as { nowFn: () => number }).nowFn = () => clock.now;
416
+ return { attach: attach as unknown as Drivable, sent, clock };
417
+ }
418
+
419
+ async function write(attach: Drivable, data: string): Promise<void> {
420
+ // Mirror the real socket-data path (onSocketData → pushOutput + checkClearSequence):
421
+ // checkClearSequence feeds the jiggle controller (2026h frame detection,
422
+ // clear detection) — without it tuiFrameSeen stays false and gates have no teeth.
423
+ attach.pushOutput(data);
424
+ attach.checkClearSequence(data);
425
+ await new Promise((r) => setTimeout(r, 20));
426
+ }
427
+
428
+ /** Bring the controller to the real runtime-idle state: 2026h frame seen
429
+ * first (tuiFrameSeen=true), then a detected clear (chain stopped). */
430
+ async function primeRuntime(attach: Drivable): Promise<void> {
431
+ await write(attach, `${ESC}[?2026h`);
432
+ await write(attach, CLEAR);
433
+ }
434
+
435
+ /** Healthy idle frame: bottom line carries the inverse fake cursor and the
436
+ * hardware cursor is parked ON it (CUP 24;10 == the inverse cell). */
437
+ const healthyFrame = `${SINK}> editor \u4f60\u597d${ESC}[7m ${ESC}[0m${ESC}[24;7H${ESC}[7m ${ESC}[0m${ESC}[24;7H`;
438
+ /** Desynced frame: same content, but the cursor ends parked on a PLAIN cell
439
+ * one row above the inverse fake cursor (where a garbled differential write
440
+ * left it). */
441
+ const desyncFrame = `${SINK}> editor \u4f60\u597d${ESC}[7m ${ESC}[0m${ESC}[24;7H${ESC}[7m ${ESC}[0m${ESC}[23;5H`;
442
+ /** Child response to a heal: fullRender-style clear + redraw + cursor parked
443
+ * back on the inverse fake cursor. */
444
+ const healResponse = `${ESC}[?2026h${ESC}[2J${ESC}[H${SINK}> editor \u4f60\u597d${ESC}[7m ${ESC}[0m${ESC}[24;7H${ESC}[7m ${ESC}[0m${ESC}[24;7H${ESC}[?2026l`;
445
+
446
+ async function main(): Promise<void> {
447
+ const out: Record<string, boolean> = {};
448
+
449
+ // H1: healthy idle — no heal
450
+ {
451
+ const { attach, sent, clock } = makeAttach();
452
+ await primeRuntime(attach);
453
+ await write(attach, healthyFrame);
454
+ attach.finishAttachTransition();
455
+ clock.now += 10_000; // all gates open — only gate 3 (aligned) can stop it
456
+ attach.checkDesync();
457
+ out.healthyIdleNoHeal = attach.jiggleRetry.getState().healCount === 0 && resizes(sent) === 0;
458
+ }
459
+
460
+ // H2: desync + quiet → exactly one heal; immediate re-check rate-limited + chain-idle-gated
461
+ {
462
+ const { attach, sent, clock } = makeAttach();
463
+ await primeRuntime(attach);
464
+ await write(attach, desyncFrame);
465
+ attach.finishAttachTransition();
466
+ clock.now += 10_000; // last output now older than DESYNC_QUIET_MS
467
+ attach.checkDesync();
468
+ const healed = attach.jiggleRetry.getState().healCount === 1 && resizes(sent) === 1;
469
+ attach.checkDesync(); // within 10s rate limit AND chain is active (held)
470
+ out.desyncHealsOnce = healed && attach.jiggleRetry.getState().healCount === 1 && resizes(sent) === 1;
471
+ }
472
+
473
+ // H3: pre-settle (attaching true) — no heal
474
+ {
475
+ const { attach, sent } = makeAttach();
476
+ await primeRuntime(attach);
477
+ await write(attach, desyncFrame);
478
+ // do NOT finishAttachTransition(); attaching is still true
479
+ attach.checkDesync();
480
+ out.preSettleNoHeal = attach.jiggleRetry.getState().healCount === 0 && resizes(sent) === 0;
481
+ }
482
+
483
+ // H4: no 2026h frame — no heal
484
+ {
485
+ const { attach, sent } = makeAttach();
486
+ // No primeRuntime (it would set tuiFrameSeen); chain is stopped via the
487
+ // clear so only gate 2 (no TUI frame) blocks the heal.
488
+ await write(attach, CLEAR + "plain shell output, no TUI frame");
489
+ attach.finishAttachTransition();
490
+ attach.checkDesync();
491
+ out.noFrameNoHeal = attach.jiggleRetry.getState().healCount === 0 && resizes(sent) === 0;
492
+ }
493
+
494
+ // H5: cursor scrolled out of the projected viewport — no heal
495
+ {
496
+ const { attach, sent, clock } = makeAttach();
497
+ await primeRuntime(attach);
498
+ // Establish scrollback well beyond the window (60 lines), leave the
499
+ // cursor on the bottom viewport row (absolute ≈ buf.length-1), then
500
+ // have the user scroll to the top: window [0,22) excludes the cursor.
501
+ let scroll = "";
502
+ for (let i = 0; i < 60; i++) scroll += `row ${i}\n`;
503
+ await write(attach, scroll + `${ESC}[24;5H`);
504
+ attach.finishAttachTransition();
505
+ (attach as unknown as { viewportTop: number | null }).viewportTop = 0; // user scrolled up
506
+ clock.now += 10_000; // all gates open — only the viewport (unknown) path can stop it
507
+ attach.checkDesync();
508
+ out.scrolledOutNoHeal = attach.jiggleRetry.getState().healCount === 0 && resizes(sent) === 0;
509
+ }
510
+
511
+ // H6: recent output (inside the quiet window) — no heal
512
+ {
513
+ const { attach, sent, clock } = makeAttach();
514
+ await primeRuntime(attach);
515
+ await write(attach, desyncFrame);
516
+ attach.finishAttachTransition();
517
+ clock.now += 100; // way less than DESYNC_QUIET_MS
518
+ attach.checkDesync();
519
+ out.recentOutputNoHeal = attach.jiggleRetry.getState().healCount === 0 && resizes(sent) === 0;
520
+ }
521
+
522
+ // H7: heal loop closes — child clears + parks cursor on the fake cursor again
523
+ {
524
+ const { attach, sent, clock } = makeAttach();
525
+ await primeRuntime(attach);
526
+ await write(attach, desyncFrame);
527
+ attach.finishAttachTransition();
528
+ clock.now += 10_000;
529
+ attach.checkDesync();
530
+ const shrinkSeen = attach.jiggleRetry.getState().healCount === 1;
531
+ // Child answers the shrink with a fullRender: clear + redraw + cursor on fake cursor.
532
+ await write(attach, healResponse);
533
+ const restored = attach.jiggleRetry.getState().held === false && attach.jiggleRetry.getState().stopped === true;
534
+ clock.now += 10_000; // next probe tick: aligned now, and budget intact
535
+ attach.checkDesync();
536
+ out.healLoopCloses = shrinkSeen && restored && attach.jiggleRetry.getState().healCount === 1 && resizes(sent) === 2; // heal shrink + controller restore
537
+ }
538
+
539
+ console.log(JSON.stringify(out));
540
+ const allOk = Object.values(out).every(Boolean);
541
+ if (!allOk) process.exitCode = 1;
542
+ }
543
+
544
+ void main();
545
+ ```
546
+
547
+ - [ ] **Step 2: Write the failing wrapper test**
548
+
549
+ Create `test/pty-attach-desync-heal.test.mjs` (same execFileSync pattern as `test/pty-attach-detach-gate.test.mjs`):
550
+
551
+ ```js
552
+ import assert from "node:assert/strict";
553
+ import { execFileSync } from "node:child_process";
554
+ import { fileURLToPath } from "node:url";
555
+ import { join } from "node:path";
556
+ import test from "node:test";
557
+
558
+ const ROOT_DIR = fileURLToPath(new URL("../", import.meta.url));
559
+ const SMOKE_SCRIPT = join(ROOT_DIR, "test-support", "desync-heal-smoke.ts");
560
+
561
+ // Issue #11: runtime desync detect + rate-limited heal — component wiring gates.
562
+ test("desync heal wiring: 7 gates + heal loop", () => {
563
+ const out = execFileSync(process.execPath, ["--experimental-transform-types", SMOKE_SCRIPT], {
564
+ encoding: "utf8",
565
+ timeout: 60_000,
566
+ });
567
+ const parsed = JSON.parse(out);
568
+ assert.equal(parsed.healthyIdleNoHeal, true, "H1 healthy idle must not heal");
569
+ assert.equal(parsed.desyncHealsOnce, true, "H2 desync must heal exactly once (rate limit + chain gate)");
570
+ assert.equal(parsed.preSettleNoHeal, true, "H3 pre-settle must not heal");
571
+ assert.equal(parsed.noFrameNoHeal, true, "H4 no TUI frame must not heal");
572
+ assert.equal(parsed.scrolledOutNoHeal, true, "H5 cursor out of viewport must not heal");
573
+ assert.equal(parsed.recentOutputNoHeal, true, "H6 recent output must not heal");
574
+ assert.equal(parsed.healLoopCloses, true, "H7 child clear must close the heal loop without a second heal");
575
+ });
576
+ ```
577
+
578
+ - [ ] **Step 3: Run tests to verify they fail**
579
+
580
+ Run: `node --test test/pty-attach-desync-heal.test.mjs`
581
+ Expected: FAIL — compile error (`checkDesync` / `nowFn` not on the component) or assertion failures.
582
+
583
+ - [ ] **Step 4: Write the implementation**
584
+
585
+ In `src/ui/pty-attach.ts`:
586
+
587
+ 4a. Extend the import from `../core/pty-attach-render.mjs` (~L10):
588
+
589
+ ```ts
590
+ import { createAttachOutputRenderScheduler, detectCursorDesync, nextAttachRender, projectPtyCursor, shouldScheduleAttachRenderForMessage } from "../core/pty-attach-render.mjs";
591
+ ```
592
+
593
+ 4b. Add constants near the other module consts (search `GRACEFUL_SOCKET_CLOSE_MS` / `ATTACH_SETTLE_MS`):
594
+
595
+ ```ts
596
+ /** Desync detection window: how long output must stay silent before a
597
+ * misaligned cursor counts as desync (issue #11). Streaming keeps the cursor
598
+ * on plain output cells legitimately; a healthy idle child re-parks it on the
599
+ * inverse fake-cursor cell on its last rendered frame. */
600
+ const DESYNC_QUIET_MS = 1500;
601
+ /** How often the post-settle probe runs checkDesync() (issue #11). */
602
+ const DESYNC_PROBE_INTERVAL_MS = 2000;
603
+ /** Minimum spacing between two runtime heals (issue #11). */
604
+ const HEAL_RATELIMIT_MS = 10000;
605
+ ```
606
+
607
+ 4c. Add fields near `private viewportTop: number | null = null;` (~L152):
608
+
609
+ ```ts
610
+ private lastOutputAt = 0;
611
+ private lastHealAt = 0;
612
+ private desyncProbeTimer: ReturnType<typeof setInterval> | null = null;
613
+ /** Injectable clock for desync gating (tests override this). */
614
+ private nowFn: () => number = () => Date.now();
615
+ ```
616
+
617
+ 4d. In `pushOutput()` (~L1050), record the timestamp synchronously (add as the second statement, right after the `if (data.length === 0) return;` guard, BEFORE `term.write`):
618
+
619
+ ```ts
620
+ this.lastOutputAt = this.nowFn();
621
+ ```
622
+
623
+ 4e. In `finishAttachTransition()` (~L514), add at the end (after `this.scheduleRender(true);`):
624
+
625
+ ```ts
626
+ this.startDesyncProbe();
627
+ ```
628
+
629
+ 4f. Add the probe + check methods (place after `finishAttachTransition`):
630
+
631
+ ```ts
632
+ private startDesyncProbe(): void {
633
+ this.stopDesyncProbe();
634
+ // The probe is deliberately NOT hooked into the render path: rendering is
635
+ // event-driven (socket output / keypress / resize) and stops exactly when
636
+ // desync strikes (output goes quiet). A self-contained timer is the only
637
+ // way an idle desynced screen gets its self-heal without user action.
638
+ this.desyncProbeTimer = setInterval(() => this.checkDesync(), DESYNC_PROBE_INTERVAL_MS);
639
+ this.desyncProbeTimer.unref?.();
640
+ }
641
+
642
+ private stopDesyncProbe(): void {
643
+ if (this.desyncProbeTimer) {
644
+ clearInterval(this.desyncProbeTimer);
645
+ this.desyncProbeTimer = null;
646
+ }
647
+ }
648
+
649
+ /**
650
+ * Runtime desync backstop (issue #11). Seven gates, cheapest first:
651
+ * settled+connected, child is a TUI (frame seen), misaligned cursor,
652
+ * output quiet, chain idle, heal rate limit. All pass → heal() re-arms
653
+ * shrink-and-hold; the child's fullRender clear then restores the size
654
+ * and repaints a consistent screen.
655
+ */
656
+ private checkDesync(): void {
657
+ if (this.closed || this.attaching || !this.connected) return; // gates 1, 7
658
+ const chain = this.jiggleRetry.getState();
659
+ if (!chain.tuiFrameSeen) return; // gate 2: shell/vim children never heal
660
+ if (!chain.stopped || chain.held) return; // gate 5: attach/heal chain active
661
+ const now = this.nowFn();
662
+ if (now - this.lastOutputAt <= DESYNC_QUIET_MS) return; // gate 4: streaming
663
+ if (now - this.lastHealAt <= HEAL_RATELIMIT_MS) return; // gate 6: rate limit
664
+ const height = this.bodyHeight();
665
+ this.clampViewportTop(height);
666
+ const start = this.viewportTop ?? this.bottomViewportTop(height);
667
+ const buf = this.term.buffer.active;
668
+ if (detectCursorDesync(buf, projectPtyCursor(buf, start, height)) !== "misaligned") return; // gate 3
669
+ this.lastHealAt = now;
670
+ this.jiggleRetry.heal(this.cols, this.rows);
671
+ }
672
+ ```
673
+
674
+ 4g. In `close()` (~L1100, next to the `attachSettleTimer` cleanup), add:
675
+
676
+ ```ts
677
+ this.stopDesyncProbe();
678
+ ```
679
+
680
+ - [ ] **Step 5: Run tests to verify they pass**
681
+
682
+ Run: `node --test test/pty-attach-desync-heal.test.mjs && node --test test/pty-attach-detach-gate.test.mjs`
683
+ Expected: PASS (new wiring test + existing detach-gate regression both green).
684
+
685
+ - [ ] **Step 6: Run the full suite to catch regressions**
686
+
687
+ Run: `npm test`
688
+ Expected: PASS.
689
+
690
+ - [ ] **Step 7: Commit**
691
+
692
+ ```bash
693
+ git add src/ui/pty-attach.ts test-support/desync-heal-smoke.ts test/pty-attach-desync-heal.test.mjs
694
+ git commit -m "feat: wire runtime desync probe with 7 gates into attach component (issue #11)"
695
+ ```
696
+
697
+ ---
698
+
699
+ ### Task 4: healthy-session E2E — no false heal (A4)
700
+
701
+ **Files:**
702
+ - Create: `test-support/fake-idle-tui-pi.mjs` (idle healthy child: TUI frame with inverse fake cursor + cursor parked on it, then silent; resize → full clear + repaint, mirroring pi-tui's widthChanged → fullRender(true))
703
+ - Create: `test-support/desync-health-e2e.ts` (real runner spawn + real `PtyAttachComponent` + fake tui; attach → settle → hold ≥3 probe ticks with the child idle → assert `healCount === 0`)
704
+ - Test: `test/pty-attach-desync-health-e2e.test.mjs` (execFileSync wrapper)
705
+
706
+ **Interfaces:**
707
+ - Consumes: `atomicWriteJson` + `P.*` paths (same setup as `test/pty-attach-cold-start-e2e.test.mjs`'s `spawnRunner`), real `runner/pty-runner.mjs` protocol, `PtyAttachComponent` opts `{ socketPath, title }`.
708
+ - Produces: standalone verification that a real runner + healthy idle TUI child never triggers a heal across the full attach lifecycle (spec A4).
709
+
710
+ - [ ] **Step 1: Write the fake idle child**
711
+
712
+ Create `test-support/fake-idle-tui-pi.mjs`:
713
+
714
+ ```js
715
+ #!/usr/bin/env node
716
+ /**
717
+ * Fake healthy IDLE pi for the desync-health E2E (issue #11).
718
+ *
719
+ * Boots fast (no artificial delay), renders exactly like a healthy pi-tui
720
+ * idle frame — an editor line whose cursor cell is INVERSE (the fake cursor)
721
+ * with the hardware cursor parked ON that cell — and then goes silent.
722
+ * Any resize away from the baseline triggers a fullRender-style clear +
723
+ * repaint (pi-tui widthChanged → fullRender(true)), re-parking the cursor.
724
+ * This is the child the desync probe must leave alone: aligned + quiet.
725
+ */
726
+ const baseline = [process.stdout.columns, process.stdout.rows];
727
+
728
+ function frame() {
729
+ // 2026h frame; editor line at row 2 col 5: inverse space = fake cursor;
730
+ // CUP to (2,5) parks the hardware cursor ON the inverse cell.
731
+ process.stdout.write(
732
+ "\x1b[?2026h\x1b[2J\x1b[Hready\n> editor line\x1b[2;5H\x1b[7m \x1b[0m\x1b[2;5H\x1b[?2026l",
733
+ );
734
+ }
735
+
736
+ process.stdout.on("resize", () => {
737
+ const c = process.stdout.columns;
738
+ const r = process.stdout.rows;
739
+ if (c === baseline[0] && r === baseline[1]) return;
740
+ baseline[0] = c;
741
+ baseline[1] = r;
742
+ frame();
743
+ });
744
+
745
+ frame();
746
+ process.on("SIGTERM", () => process.exit(0));
747
+ process.on("SIGINT", () => process.exit(0));
748
+ ```
749
+
750
+ - [ ] **Step 2: Write the E2E harness**
751
+
752
+ Create `test-support/desync-health-e2e.ts`:
753
+
754
+ ```ts
755
+ // Healthy-session E2E (issue #11, spec A4): real runner + fake idle TUI child +
756
+ // REAL PtyAttachComponent. The attach runs its full lifecycle (shrink-and-hold
757
+ // protocol, settle, probe). With a healthy child (cursor parked on the inverse
758
+ // fake cursor, then silent) the desync probe must never heal across ≥3 probe
759
+ // ticks. Run via `node --experimental-transform-types`.
760
+ import { mkdtempSync, rmSync, existsSync } from "node:fs";
761
+ import { createServer } from "node:net";
762
+ import { tmpdir } from "node:os";
763
+ import { join, resolve } from "node:path";
764
+ import { spawn } from "node:child_process";
765
+ import { once } from "node:events";
766
+ import { atomicWriteJson } from "../src/core/atomic.mjs";
767
+ import * as P from "../src/core/paths.mjs";
768
+ import { createView } from "../src/core/store.mjs";
769
+ import { PtyAttachComponent } from "../src/ui/pty-attach.ts";
770
+
771
+ const tui = {
772
+ terminal: { rows: 36, cols: 120, columns: 120, write: () => {} },
773
+ requestRender: () => {},
774
+ };
775
+ const theme = { fg: (_c: string, t: string) => t, bold: (t: string) => t };
776
+ const keybindings = {} as never;
777
+
778
+ async function main(): Promise<void> {
779
+ const root = mkdtempSync(join(tmpdir(), "desync-health-"));
780
+ const viewId = "e2ehealth";
781
+ let runner: ReturnType<typeof spawn> | null = null;
782
+ let attach: PtyAttachComponent | null = null;
783
+ try {
784
+ const meta = createView(root, { id: viewId, name: "health", cwd: process.cwd() });
785
+ atomicWriteJson(P.hostConfigPath(root, viewId), {
786
+ root,
787
+ viewId,
788
+ sessionFile: meta.sessionFile,
789
+ cwd: process.cwd(),
790
+ initialPrompt: null,
791
+ piCommand: process.execPath,
792
+ piArgsPrefix: [resolve("test-support/fake-idle-tui-pi.mjs")],
793
+ model: null,
794
+ tools: null,
795
+ env: {},
796
+ cols: 120,
797
+ rows: 36,
798
+ });
799
+ runner = spawn(process.execPath, [resolve("runner/pty-runner.mjs"), P.hostConfigPath(root, viewId)], {
800
+ stdio: ["ignore", "pipe", "pipe"],
801
+ });
802
+
803
+ const socketPath = P.controlSocketPath(root, viewId);
804
+ // Wait for the runner to create its control socket (same wait as cold-start E2E).
805
+ const deadline = Date.now() + 10_000;
806
+ while (!existsSync(socketPath) && Date.now() < deadline) {
807
+ await new Promise((r) => setTimeout(r, 50));
808
+ }
809
+
810
+ attach = new PtyAttachComponent(
811
+ tui as never,
812
+ theme as never,
813
+ keybindings,
814
+ () => {},
815
+ { socketPath, title: "health" },
816
+ );
817
+ void attach.render(120); // drive one render so resizeIfNeeded sends the initial size
818
+
819
+ // Settle + hold across ≥3 probe ticks (2s period) + quiet window (1.5s).
820
+ await new Promise((r) => setTimeout(r, 9_000));
821
+
822
+ const state = (attach as unknown as {
823
+ jiggleRetry: { getState: () => { healCount: number; held: boolean; stopped: boolean } };
824
+ }).jiggleRetry.getState();
825
+ const result = { healedNever: state.healCount === 0, chainDone: state.stopped === true, held: state.held === false };
826
+ console.log(JSON.stringify(result));
827
+ if (!Object.values(result).every(Boolean)) process.exitCode = 1;
828
+ } finally {
829
+ attach?.close();
830
+ runner?.kill("SIGTERM");
831
+ await new Promise((r) => setTimeout(r, 200));
832
+ rmSync(root, { recursive: true, force: true });
833
+ }
834
+ void createServer; // keep node:net import meaningful for parity with sibling harnesses
835
+ void once;
836
+ }
837
+
838
+ void main();
839
+ ```
840
+
841
+ Note: `P.controlSocketPath(root, viewId)` and `P.hostConfigPath(root, viewId)` are the exact helpers `test/pty-attach-cold-start-e2e.test.mjs` waits on (verified against `src/core/paths.mjs` L68 / cold-start E2E usage); `createView` is exported from `src/core/store.mjs` L420.
842
+
843
+ - [ ] **Step 3: Write the wrapper test**
844
+
845
+ Create `test/pty-attach-desync-health-e2e.test.mjs`:
846
+
847
+ ```js
848
+ import assert from "node:assert/strict";
849
+ import { execFileSync } from "node:child_process";
850
+ import { fileURLToPath } from "node:url";
851
+ import { join } from "node:path";
852
+ import test from "node:test";
853
+
854
+ const ROOT_DIR = fileURLToPath(new URL("../", import.meta.url));
855
+ const E2E_SCRIPT = join(ROOT_DIR, "test-support", "desync-health-e2e.ts");
856
+
857
+ // Issue #11 (A4): a real runner + healthy idle TUI child must never trigger a
858
+ // runtime desync heal across the full attach lifecycle.
859
+ test("desync health e2e: healthy idle session triggers no heal", () => {
860
+ const out = execFileSync(process.execPath, ["--experimental-transform-types", E2E_SCRIPT], {
861
+ encoding: "utf8",
862
+ timeout: 60_000,
863
+ });
864
+ const parsed = JSON.parse(out);
865
+ assert.equal(parsed.healedNever, true, "healCount must stay 0 on a healthy idle session");
866
+ assert.equal(parsed.chainDone, true, "attach chain must complete");
867
+ assert.equal(parsed.held, false || parsed.held === false, "no hold left armed");
868
+ });
869
+ ```
870
+
871
+ - [ ] **Step 4: Run the E2E to verify it fails-or-passes honestly**
872
+
873
+ Run: `node --test test/pty-attach-desync-health-e2e.test.mjs`
874
+ Expected: PASS. If it FAILS because the harness wiring (socket path helper names, runner protocol details) is off, fix the harness until it exercises the real attach lifecycle (observable via `chainDone: true`); if it fails because `healedNever` is false, the classifier/probe has a real false-positive — debug `detectCursorDesync` against the fake child's frame, not the harness.
875
+
876
+ - [ ] **Step 5: Commit**
877
+
878
+ ```bash
879
+ git add test-support/fake-idle-tui-pi.mjs test-support/desync-health-e2e.ts test/pty-attach-desync-health-e2e.test.mjs
880
+ git commit -m "test: healthy idle session never triggers desync heal (issue #11 A4)"
881
+ ```
882
+
883
+ ---
884
+
885
+ ### Task 5: full verification + spec acceptance sweep
886
+
887
+ **Files:**
888
+ - No new files; verification only.
889
+
890
+ **Interfaces:**
891
+ - Consumes: everything from Tasks 1-4.
892
+
893
+ - [ ] **Step 1: Full test suite**
894
+
895
+ Run: `cd /home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-11-attach-runtime-desync-heal && npm test`
896
+ Expected: PASS (568+ existing + 7 A1 + 7 A2 + 1 A3 + 1 A4 new tests).
897
+
898
+ - [ ] **Step 2: TypeScript check (whatever the repo uses)**
899
+
900
+ Run: `npm run verify 2>/dev/null || npx tsc --noEmit`
901
+ Expected: clean (use `npm run verify` if defined in package.json scripts; otherwise tsc).
902
+
903
+ - [ ] **Step 3: Spec acceptance sweep (fill the table into the PR description)**
904
+
905
+ Check off each spec acceptance ID against actual evidence:
906
+ - A1 → `node --test test/pty-attach-render.test.mjs` output (7 new cases)
907
+ - A2 → `node --test test/pty-attach-jiggle-controller.test.mjs` output (7 new cases)
908
+ - A3 → `node --test test/pty-attach-desync-heal.test.mjs` output (H1-H7)
909
+ - A4 → `node --test test/pty-attach-desync-health-e2e.test.mjs` output
910
+ - U1 → mark `pending (observational, post-merge)` — daily-use watching for spurious flicker; does not block merge per spec.
911
+
912
+ - [ ] **Step 4: Commit any residual fixes**
913
+
914
+ ```bash
915
+ git status --short
916
+ # fix and git add <specific files> if anything surfaced; otherwise nothing to commit
917
+ ```