@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.
@@ -60,6 +60,11 @@ const NO_FRAME_RESTORE_MS = 6000;
60
60
  */
61
61
  const POST_RESTORE_VERIFY_MS = 900;
62
62
 
63
+ /** Lifetime cap on runtime desync heals (issue #11): a persistent misdiagnosis
64
+ * must not flicker the screen forever; after this many attempts the backstop
65
+ * stays quiet until the controller is recreated. Consumed on heal() entry. */
66
+ const HEAL_MAX_PER_LIFETIME = 5;
67
+
63
68
  /**
64
69
  * @typedef {Object} JiggleRetryControllerDeps
65
70
  * @property {(cols: number, rows: number) => void} sendResize - Resize the child PTY.
@@ -88,6 +93,8 @@ export function createJiggleRetryController(deps) {
88
93
  let chainTimer = null;
89
94
  /** @type {unknown | null} */
90
95
  let g1Timer = null;
96
+ /** Runtime heals spent (issue #11); never reset by start()/restoreAndStop(). */
97
+ let healCount = 0;
91
98
 
92
99
  function clearChainTimer() {
93
100
  if (chainTimer === null) return;
@@ -199,7 +206,10 @@ export function createJiggleRetryController(deps) {
199
206
 
200
207
  /**
201
208
  * Feed one socket output chunk. A clear wins over the re-arm when both
202
- * appear in one chunk. The first TUI frame restores the held size (the
209
+ * appear in one chunk — but frame cognition is still learned from that
210
+ * chunk: a clear only proves the child redraws, not that it isn't a TUI
211
+ * (screen-log replay bundles historical frames with clears, issue #11).
212
+ * The first TUI frame restores the held size (the
203
213
  * child is now rendering and will fullRender on the width delta) and
204
214
  * does NOT reschedule the chain; if G1 already released the hold before
205
215
  * the TUI booted, the frame instead re-arms a fresh hold so the running
@@ -212,14 +222,18 @@ export function createJiggleRetryController(deps) {
212
222
  const result = feedOutput(state, data, carry);
213
223
  state = result.state;
214
224
  carry = result.carry;
225
+ // Learn frame cognition unconditionally, BEFORE the clear branch: a
226
+ // clear-wins chunk must not swallow it (issue #11), and only the FIRST
227
+ // frame ever seen drives the re-arm/fast-path logic below.
228
+ const firstFrame = result.frameStartFound && !tuiFrameSeen;
229
+ if (result.frameStartFound) tuiFrameSeen = true;
215
230
  if (result.clearFound) {
216
231
  clearAllTimers();
217
232
  restoreIfHeld();
218
233
  state = stopRetry({ ...state, clearDetected: true });
219
234
  return;
220
235
  }
221
- if (result.frameStartFound && !tuiFrameSeen) {
222
- tuiFrameSeen = true;
236
+ if (firstFrame) {
223
237
  clearG1Timer();
224
238
  if (held) {
225
239
  restoreIfHeld(); // fast path: child is rendering, width delta now lands
@@ -242,6 +256,44 @@ export function createJiggleRetryController(deps) {
242
256
  }
243
257
  }
244
258
 
259
+ /**
260
+ * Runtime desync backstop (issue #11): re-arm the shrink-and-hold protocol
261
+ * mid-session. Unlike start(), tuiFrameSeen is preserved (the child has
262
+ * rendered), G1 is not armed (frames are flowing), and the budget is
263
+ * lifetime-capped so a misdiagnosis cannot flicker the screen forever.
264
+ * Consumes one budget slot on entry, including the tiny-terminal give-up.
265
+ * @param {number} cols
266
+ * @param {number} rows
267
+ * @returns {boolean} true when a heal hold was armed.
268
+ */
269
+ function heal(cols, rows) {
270
+ if (healCount >= HEAL_MAX_PER_LIFETIME) return false;
271
+ healCount++;
272
+ clearAllTimers();
273
+ if (held) {
274
+ // Unwind any live hold (e.g. a previous clear-less heal) first.
275
+ sendResize(originalCols, originalRows);
276
+ restored = true;
277
+ held = false;
278
+ }
279
+ state = createJiggleRetryState();
280
+ carry = "";
281
+ originalCols = cols;
282
+ originalRows = rows;
283
+ holdSize = resizeJiggleSize(cols, rows);
284
+ if (!holdSize) {
285
+ state = stopRetry(state);
286
+ held = false;
287
+ restored = true;
288
+ return false;
289
+ }
290
+ sendResize(holdSize.cols, holdSize.rows);
291
+ held = true;
292
+ restored = false;
293
+ scheduleNextRetry(); // G2 backoff re-shrinks while a renderer is seen but no clear follows
294
+ return true;
295
+ }
296
+
245
297
  /**
246
298
  * Restore the held size (if any) and stop all chain activity. Used by the
247
299
  * component on close/detach while the socket is still usable (G3).
@@ -272,8 +324,9 @@ export function createJiggleRetryController(deps) {
272
324
  return {
273
325
  start,
274
326
  feed,
327
+ heal,
275
328
  restoreAndStop,
276
329
  notifyExternalResize,
277
- getState: () => ({ ...state, held, tuiFrameSeen, originalCols, originalRows, holdSize }),
330
+ getState: () => ({ ...state, held, tuiFrameSeen, originalCols, originalRows, holdSize, healCount }),
278
331
  };
279
332
  }
@@ -70,3 +70,33 @@ export function createAttachOutputRenderScheduler(requestRender, delayMs = ATTAC
70
70
  },
71
71
  };
72
72
  }
73
+
74
+ /**
75
+ * Classify the PTY cursor's alignment for runtime desync detection (issue #11).
76
+ *
77
+ * Healthy idle pi: the child pi-tui parks the hardware cursor on the editor
78
+ * marker, whose cell is the inverse-video "fake cursor" — so the cursor cell
79
+ * itself is inverse. A desynced buffer leaves the cursor parked elsewhere
80
+ * (typically where the last differential write ended), on a non-inverse cell.
81
+ * Width-0 cells are CJK continuation cells and out-of-range columns sit past
82
+ * the line's cells; in both cases the meaningful attribute lives on the
83
+ * preceding cell, so we look left. Returns:
84
+ * "aligned" — cursor resolves to an inverse cell (healthy);
85
+ * "misaligned" — cursor resolves to a non-inverse cell (candidate desync;
86
+ * callers gate this with an output-quietness window);
87
+ * "unknown" — no cursor (scrolled out of the projected viewport) or no
88
+ * buffer line (defensive); never treat these as desync.
89
+ */
90
+ export function detectCursorDesync(buf, cursor) {
91
+ if (!cursor) return "unknown";
92
+ const line = buf.getLine(cursor.row);
93
+ if (!line) return "unknown";
94
+ let x = cursor.col;
95
+ let cell = line.getCell(x);
96
+ while ((!cell || cell.getWidth() === 0) && x > 0) {
97
+ x--;
98
+ cell = line.getCell(x);
99
+ }
100
+ if (!cell) return "misaligned";
101
+ return cell.isInverse() ? "aligned" : "misaligned";
102
+ }