@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.
- package/CHANGELOG.md +17 -0
- package/docs/superpowers/plans/2026-09-08-issue-11-attach-runtime-desync-heal.md +917 -0
- package/docs/superpowers/plans/2026-09-09-harden-runner-architecture.md +603 -0
- package/docs/superpowers/plans/2026-09-09-single-writer-completion.md +252 -0
- package/docs/superpowers/specs/2026-09-07-issue-11-attach-runtime-desync-heal-design.md +130 -0
- package/docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md +298 -0
- package/package.json +1 -1
- package/runner/job-runner-legacy.mjs +68 -0
- package/runner/job-runner.mjs +370 -67
- package/runner/pty-runner-legacy.mjs +50 -0
- package/runner/pty-runner.mjs +69 -31
- package/runner/state-coordinator.mjs +403 -0
- package/runner/state-runner.mjs +89 -15
- package/src/commands/bg.ts +2 -1
- package/src/core/coordinator-client.mjs +313 -0
- package/src/core/coordinator-journal.mjs +282 -0
- package/src/core/coordinator-protocol.mjs +12 -0
- package/src/core/launch.mjs +15 -0
- package/src/core/paths.mjs +16 -0
- package/src/core/pty-attach-jiggle-controller.mjs +57 -4
- package/src/core/pty-attach-render.mjs +30 -0
- package/src/core/state-commands.mjs +617 -0
- package/src/core/types.mjs +2 -0
- package/src/runtime/service.mjs +416 -111
- package/src/ui/dashboard.ts +77 -110
- package/src/ui/pty-attach.ts +63 -1
|
@@ -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
|
|
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 (
|
|
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
|
+
}
|