@zhuxixi/pi-agent-board 0.6.2 → 0.8.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.
Files changed (67) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +6 -3
  3. package/VERIFY.md +2 -1
  4. package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -1
  5. package/docs/superpowers/plans/2026-09-08-issue-11-attach-runtime-desync-heal.md +917 -0
  6. package/docs/superpowers/plans/2026-09-09-harden-runner-architecture.md +603 -0
  7. package/docs/superpowers/plans/2026-09-09-single-writer-completion.md +252 -0
  8. package/docs/superpowers/plans/2026-09-10-reader-consistency.md +115 -0
  9. package/docs/superpowers/plans/2026-09-14-attach-cursor-dectcem-gate.md +469 -0
  10. package/docs/superpowers/plans/2026-09-14-attach-snapshot.md +92 -0
  11. package/docs/superpowers/plans/2026-09-14-host-meta-orphan-lock.md +771 -0
  12. package/docs/superpowers/plans/2026-09-14-issue-106-terminal-frame-cognition.md +299 -0
  13. package/docs/superpowers/plans/2026-09-14-issue-113-foreground-preview-race.md +609 -0
  14. package/docs/superpowers/plans/2026-09-14-terminal-model.md +145 -0
  15. package/docs/superpowers/plans/2026-09-15-coordinator-pipe-root-normalize.md +224 -0
  16. package/docs/superpowers/plans/2026-09-15-lease-publish-eprem-reclaim.md +341 -0
  17. package/docs/superpowers/plans/2026-09-18-detach-anchor-reporter-endpoint.md +875 -0
  18. package/docs/superpowers/plans/2026-09-20-control-lifecycle.md +116 -0
  19. package/docs/superpowers/plans/2026-09-20-issue-121-perf-gate-out-of-coverage.md +517 -0
  20. package/docs/superpowers/specs/2026-09-07-issue-11-attach-runtime-desync-heal-design.md +130 -0
  21. package/docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md +298 -0
  22. package/docs/superpowers/specs/2026-09-14-attach-cursor-dectcem-gate-design.md +114 -0
  23. package/docs/superpowers/specs/2026-09-14-host-meta-orphan-lock-design.md +120 -0
  24. package/docs/superpowers/specs/2026-09-14-issue-106-terminal-frame-cognition-design.md +146 -0
  25. package/docs/superpowers/specs/2026-09-14-issue-113-foreground-preview-race-design.md +116 -0
  26. package/docs/superpowers/specs/2026-09-15-coordinator-pipe-root-normalize-design.md +84 -0
  27. package/docs/superpowers/specs/2026-09-15-lease-publish-eprem-reclaim-design.md +92 -0
  28. package/docs/superpowers/specs/2026-09-18-detach-anchor-reporter-endpoint-design.md +123 -0
  29. package/docs/superpowers/specs/2026-09-20-issue-121-perf-gate-out-of-coverage-design.md +204 -0
  30. package/package.json +3 -2
  31. package/runner/job-runner-legacy.mjs +68 -0
  32. package/runner/job-runner.mjs +371 -67
  33. package/runner/pty-runner-legacy.mjs +50 -0
  34. package/runner/pty-runner.mjs +685 -58
  35. package/runner/state-coordinator.mjs +429 -0
  36. package/runner/state-runner.mjs +90 -15
  37. package/scripts/run-perf-gate.mjs +40 -0
  38. package/src/commands/agent-board.ts +8 -8
  39. package/src/commands/attach-flow.ts +5 -5
  40. package/src/commands/bg.ts +2 -1
  41. package/src/core/control-protocol.mjs +482 -0
  42. package/src/core/coordinator-client.mjs +313 -0
  43. package/src/core/coordinator-journal.mjs +282 -0
  44. package/src/core/coordinator-protocol.mjs +12 -0
  45. package/src/core/editor-state-reporter.mjs +11 -1
  46. package/src/core/foreground-preview-cache.mjs +117 -0
  47. package/src/core/host-protocol.mjs +24 -0
  48. package/src/core/launch.mjs +15 -0
  49. package/src/core/locks.mjs +68 -14
  50. package/src/core/paths.mjs +48 -0
  51. package/src/core/pid.mjs +32 -1
  52. package/src/core/pty-attach-jiggle-controller.mjs +83 -6
  53. package/src/core/pty-attach-reconnect.mjs +13 -6
  54. package/src/core/pty-attach-render.mjs +50 -0
  55. package/src/core/state-commands.mjs +699 -0
  56. package/src/core/status-consistency.mjs +98 -0
  57. package/src/core/store.mjs +59 -13
  58. package/src/core/terminal-attach-client.mjs +803 -0
  59. package/src/core/terminal-attach-protocol.mjs +252 -0
  60. package/src/core/terminal-model.mjs +222 -0
  61. package/src/core/terminal-snapshot.mjs +440 -0
  62. package/src/core/types.mjs +2 -0
  63. package/src/index.ts +12 -4
  64. package/src/runtime/service.mjs +694 -121
  65. package/src/ui/dashboard.ts +67 -92
  66. package/src/ui/pty-attach.ts +298 -72
  67. package/src/core/pty-input.mjs +0 -47
@@ -0,0 +1,440 @@
1
+ import assert from "node:assert/strict";
2
+ import { createTerminalModel, defaultParserFactory, feedOutput, whenIdle } from "./terminal-model.mjs";
3
+
4
+ /**
5
+ * Terminal snapshot serialization for the canonical terminal model (issue #91,
6
+ * D2/D5). The versioned DTO is the project-owned snapshot contract — xterm.js
7
+ * private buffer objects never leave the module. A single full-redraw frame
8
+ * synthesis implementation serves both directions:
9
+ *
10
+ * - hydrate: frame → independent parser (equivalence-testable, A4/A5b)
11
+ * - wire route B: the same frame goes on the socket and the UI feeds its
12
+ * existing xterm verbatim (route decision evidence in the plan).
13
+ *
14
+ * The canonical state lives for the runner's lifetime only; snapshots are
15
+ * generated on demand from the live parser, never persisted.
16
+ */
17
+
18
+ export const TERMINAL_SNAPSHOT_VERSION = 1;
19
+ export const DEFAULT_SCROLLBACK_SNAPSHOT_CAP = 256;
20
+
21
+ /**
22
+ * Cell attribute flags (own wire encoding, documented here — deliberately not
23
+ * xterm.js internals).
24
+ */
25
+ export const CELL_FLAGS = {
26
+ BOLD: 1,
27
+ DIM: 2,
28
+ ITALIC: 4,
29
+ UNDERLINE: 8,
30
+ BLINK: 16,
31
+ INVERSE: 32,
32
+ INVISIBLE: 64,
33
+ STRIKETHROUGH: 128,
34
+ OVERLINE: 256,
35
+ };
36
+
37
+ /**
38
+ * Color encoding for DTO cells: -1 = default; 0-255 = palette index;
39
+ * RGB_MARKER | 0xRRGGBB = direct color (marker bit keeps it disjoint from
40
+ * palette indexes).
41
+ */
42
+ const DEFAULT_COLOR = -1;
43
+ const RGB_MARKER = 0x1000000;
44
+
45
+ /**
46
+ * @typedef {string | [ch: string, fg: number, bg: number, flags: number]} SnapshotCell
47
+ * A cell entry: plain string = char with default attributes; tuple = char with
48
+ * explicit color/attribute encoding; "" = zero-width continuation cell of a
49
+ * wide char (regenerated by the preceding wide char on hydrate, never emitted).
50
+ */
51
+
52
+ /**
53
+ * @typedef {object} TerminalSnapshotDto
54
+ * @property {1} version
55
+ * @property {"terminal_snapshot"} kind
56
+ * @property {number} cols
57
+ * @property {number} rows
58
+ * @property {number} snapshotSeq seq of the last chunk incorporated
59
+ * @property {{ x: number, y: number }} cursor viewport-relative; x may equal
60
+ * cols — the parser's wrap-pending state (captured faithfully, never clamped)
61
+ * @property {Record<string, boolean | string>} modes parser modes (full set)
62
+ * @property {SnapshotCell[][]} viewport rows of the viewport grid
63
+ * @property {SnapshotCell[][]} scrollback up to scrollbackCap lines directly
64
+ * above the viewport, oldest first
65
+ * @property {boolean} scrollbackTruncated true when more scrollback existed
66
+ * than the cap allowed
67
+ */
68
+
69
+ /**
70
+ * Capture one cell as a DTO entry. Blank default-attribute cells collapse to
71
+ * their char (keeps the DTO compact); everything else is an explicit tuple.
72
+ *
73
+ * @param {any} cell xterm.js IBufferCell
74
+ * @returns {SnapshotCell}
75
+ */
76
+ function captureCell(cell) {
77
+ if (cell.getWidth() === 0) return "";
78
+ const ch = cell.getChars() || " ";
79
+ const fg = cell.isFgDefault()
80
+ ? DEFAULT_COLOR
81
+ : cell.isFgRGB()
82
+ ? RGB_MARKER | (cell.getFgColor() & 0xffffff)
83
+ : cell.isFgPalette()
84
+ ? cell.getFgColor() & 0xff
85
+ : DEFAULT_COLOR;
86
+ const bg = cell.isBgDefault()
87
+ ? DEFAULT_COLOR
88
+ : cell.isBgRGB()
89
+ ? RGB_MARKER | (cell.getBgColor() & 0xffffff)
90
+ : cell.isBgPalette()
91
+ ? cell.getBgColor() & 0xff
92
+ : DEFAULT_COLOR;
93
+ let flags = 0;
94
+ if (cell.isBold()) flags |= CELL_FLAGS.BOLD;
95
+ if (cell.isDim()) flags |= CELL_FLAGS.DIM;
96
+ if (cell.isItalic()) flags |= CELL_FLAGS.ITALIC;
97
+ if (cell.isUnderline()) flags |= CELL_FLAGS.UNDERLINE;
98
+ if (cell.isBlink()) flags |= CELL_FLAGS.BLINK;
99
+ if (cell.isInverse()) flags |= CELL_FLAGS.INVERSE;
100
+ if (cell.isInvisible()) flags |= CELL_FLAGS.INVISIBLE;
101
+ if (cell.isStrikethrough()) flags |= CELL_FLAGS.STRIKETHROUGH;
102
+ if (cell.isOverline()) flags |= CELL_FLAGS.OVERLINE;
103
+ return flags === 0 && fg === DEFAULT_COLOR && bg === DEFAULT_COLOR ? ch : [ch, fg, bg, flags];
104
+ }
105
+
106
+ /**
107
+ * @param {any} buffer xterm.js IBuffer
108
+ * @param {number} lineIndex absolute buffer line
109
+ * @param {number} cols
110
+ * @returns {SnapshotCell[]}
111
+ */
112
+ function captureLine(buffer, lineIndex, cols) {
113
+ const line = buffer.getLine(lineIndex);
114
+ /** @type {SnapshotCell[]} */
115
+ const cells = new Array(cols);
116
+ if (!line) {
117
+ cells.fill(" ");
118
+ return cells;
119
+ }
120
+ for (let x = 0; x < cols; x++) cells[x] = captureCell(line.getCell(x));
121
+ return cells;
122
+ }
123
+
124
+ /**
125
+ * Snapshot the parser mode flags (full set exposed by @xterm/headless).
126
+ *
127
+ * @param {any} parser
128
+ */
129
+ function captureModes(parser) {
130
+ return { ...parser.modes };
131
+ }
132
+
133
+ /**
134
+ * Generate a versioned TerminalSnapshot DTO from the canonical model. Waits
135
+ * for all pending parser writes so the grid read is deterministic.
136
+ *
137
+ * @param {import("./terminal-model.mjs").TerminalModel} model
138
+ * @param {{ scrollbackCap?: number }} [opts]
139
+ * @returns {Promise<TerminalSnapshotDto>}
140
+ */
141
+ export async function captureTerminalSnapshot(model, { scrollbackCap = DEFAULT_SCROLLBACK_SNAPSHOT_CAP } = {}) {
142
+ await whenIdle(model);
143
+ const parser = /** @type {any} */ (model.parser);
144
+ const buffer = parser.buffer.active;
145
+ const cols = model.cols;
146
+ const rows = model.rows;
147
+ const baseY = buffer.baseY;
148
+ /** @type {SnapshotCell[][]} */
149
+ const viewport = [];
150
+ for (let y = 0; y < rows; y++) viewport.push(captureLine(buffer, baseY + y, cols));
151
+ const available = baseY;
152
+ const take = Math.min(scrollbackCap, available);
153
+ /** @type {SnapshotCell[][]} */
154
+ const scrollback = [];
155
+ for (let k = take; k >= 1; k--) scrollback.push(captureLine(buffer, baseY - k, cols));
156
+ return {
157
+ version: TERMINAL_SNAPSHOT_VERSION,
158
+ kind: "terminal_snapshot",
159
+ cols,
160
+ rows,
161
+ snapshotSeq: model.lastSeq,
162
+ cursor: { x: buffer.cursorX, y: buffer.cursorY },
163
+ modes: captureModes(parser),
164
+ viewport,
165
+ scrollback,
166
+ scrollbackTruncated: available > scrollbackCap,
167
+ };
168
+ }
169
+
170
+ /** @param {number} color @param {boolean} isFg @param {string[]} p */
171
+ function pushColor(p, color, isFg) {
172
+ if (color === DEFAULT_COLOR) {
173
+ p.push(isFg ? "39" : "49");
174
+ } else if (color >= RGB_MARKER) {
175
+ const rgb = color - RGB_MARKER;
176
+ p.push(`${isFg ? 38 : 48};2;${(rgb >> 16) & 255};${(rgb >> 8) & 255};${rgb & 255}`);
177
+ } else if (color <= 7) {
178
+ p.push(String((isFg ? 30 : 40) + color));
179
+ } else if (color <= 15) {
180
+ p.push(String((isFg ? 90 : 100) + (color - 8)));
181
+ } else {
182
+ p.push(`${isFg ? 38 : 48};5;${color}`);
183
+ }
184
+ }
185
+
186
+ /** @param {number} fg @param {number} bg @param {number} flags */
187
+ function sgrSequence(fg, bg, flags) {
188
+ /** @type {string[]} */
189
+ const p = ["0"];
190
+ if (flags & CELL_FLAGS.BOLD) p.push("1");
191
+ if (flags & CELL_FLAGS.DIM) p.push("2");
192
+ if (flags & CELL_FLAGS.ITALIC) p.push("3");
193
+ if (flags & CELL_FLAGS.UNDERLINE) p.push("4");
194
+ if (flags & CELL_FLAGS.BLINK) p.push("5");
195
+ if (flags & CELL_FLAGS.INVERSE) p.push("7");
196
+ if (flags & CELL_FLAGS.INVISIBLE) p.push("8");
197
+ if (flags & CELL_FLAGS.STRIKETHROUGH) p.push("9");
198
+ if (flags & CELL_FLAGS.OVERLINE) p.push("53");
199
+ pushColor(p, fg, true);
200
+ pushColor(p, bg, false);
201
+ return `\x1b[${p.join(";")}m`;
202
+ }
203
+
204
+ /**
205
+ * Emit one DTO row as run-length SGR + text. Emits every column position in
206
+ * order (wide-char continuation cells are skipped: the parser regenerates them
207
+ * when the wide char prints), so no intermediate positioning is needed within
208
+ * a row.
209
+ *
210
+ * @param {string[]} parts
211
+ * @param {SnapshotCell[]} cells
212
+ */
213
+ function emitRow(parts, cells) {
214
+ let runKey = null;
215
+ let runSgr = "";
216
+ let runText = "";
217
+ const flush = () => {
218
+ if (runText) parts.push(runSgr, runText);
219
+ runText = "";
220
+ };
221
+ for (const entry of cells) {
222
+ if (entry === "") continue;
223
+ const tuple = Array.isArray(entry);
224
+ const ch = tuple ? entry[0] : entry;
225
+ const fg = tuple ? entry[1] : DEFAULT_COLOR;
226
+ const bg = tuple ? entry[2] : DEFAULT_COLOR;
227
+ const flags = tuple ? entry[3] : 0;
228
+ const key = `${fg}/${bg}/${flags}`;
229
+ if (key !== runKey) {
230
+ flush();
231
+ runKey = key;
232
+ runSgr = sgrSequence(fg, bg, flags);
233
+ }
234
+ runText += ch;
235
+ }
236
+ flush();
237
+ }
238
+
239
+ /**
240
+ * Re-apply the captured parser modes. Bracketed paste, focus reporting, mouse
241
+ * tracking and synchronized output are restored so live output after the
242
+ * snapshot behaves like the original session.
243
+ *
244
+ * @param {Record<string, boolean | string>} m
245
+ */
246
+ function modesSequence(m) {
247
+ const dec = (n, v) => `\x1b[?${n}${v ? "h" : "l"}`;
248
+ let s = "";
249
+ s += dec(1, Boolean(m.applicationCursorKeysMode));
250
+ s += m.applicationKeypadMode ? "\x1b=" : "\x1b>";
251
+ s += dec(2004, Boolean(m.bracketedPasteMode));
252
+ s += m.insertMode ? "\x1b[4h" : "\x1b[4l";
253
+ switch (m.mouseTrackingMode) {
254
+ case "x10":
255
+ s += "\x1b[?9h";
256
+ break;
257
+ case "vt200":
258
+ s += "\x1b[?1000h";
259
+ break;
260
+ case "button":
261
+ s += "\x1b[?1002h";
262
+ break;
263
+ case "any":
264
+ s += "\x1b[?1003h";
265
+ break;
266
+ default:
267
+ s += "\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l";
268
+ }
269
+ s += dec(6, Boolean(m.originMode));
270
+ s += dec(45, Boolean(m.reverseWraparoundMode));
271
+ s += dec(7, Boolean(m.wraparoundMode));
272
+ s += dec(1004, Boolean(m.sendFocusMode));
273
+ s += dec(2026, Boolean(m.synchronizedOutputMode));
274
+ return s;
275
+ }
276
+
277
+ /**
278
+ * Park the cursor at the captured position. x === cols is the parser's
279
+ * wrap-pending state (Task-1 review ruling: captured faithfully, never
280
+ * clamped) — reproduced by re-emitting the target row: with autowrap on, the
281
+ * char landing in the last column re-arms pending. Insert mode must be off
282
+ * while re-emitting (it would shift instead of overwrite); wraparound and
283
+ * insert are restored after emission (mode sets never move the cursor).
284
+ *
285
+ * Origin mode is NEVER toggled here: headless v6 homes the cursor on DECOM
286
+ * set AND reset (observed), so a toggle after the park would re-home the
287
+ * just-parked cursor, and a toggle before it is unnecessary — the frame
288
+ * preamble guarantees a full scroll region (\x1b[r), under which
289
+ * origin-relative CUP addresses the same cell as absolute CUP, and origin
290
+ * mode does not affect character emission. The explicit ?6h re-assert keeps
291
+ * the park self-contained; it must stay before any CUP.
292
+ *
293
+ * @param {string[]} parts
294
+ * @param {TerminalSnapshotDto} dto
295
+ */
296
+ function parkCursor(parts, dto) {
297
+ const { x, y } = dto.cursor;
298
+ const row = dto.viewport[y] ?? [];
299
+ if (dto.modes.originMode) parts.push("\x1b[?6h");
300
+ if (x >= dto.cols) {
301
+ parts.push("\x1b[?7h\x1b[4l", `\x1b[${y + 1};1H`);
302
+ emitRow(parts, row);
303
+ if (dto.modes.wraparoundMode === false) parts.push("\x1b[?7l");
304
+ if (dto.modes.insertMode) parts.push("\x1b[4h");
305
+ return;
306
+ }
307
+ parts.push(`\x1b[${y + 1};${x + 1}H`);
308
+ }
309
+
310
+ /**
311
+ * Synthesize a full-redraw VT frame from a snapshot DTO (or directly from a
312
+ * model, which is captured first — one grid-walk implementation serves both).
313
+ * The frame is self-contained: safe to write into a DIRTY terminal (wire route
314
+ * B reuse), not just a fresh hydrate parser.
315
+ *
316
+ * Frame layout: clean slate (DECSTR + clear viewport/scrollback + home + full
317
+ * region + content-emission invariants: autowrap on, origin off, insert off) →
318
+ * scrollback lines pushed through the bottom-row scroll one by one → viewport
319
+ * rows via absolute CUP + SGR runs → captured modes → cursor park.
320
+ *
321
+ * @param {TerminalSnapshotDto | import("./terminal-model.mjs").TerminalModel} source
322
+ * @returns {Promise<string>}
323
+ */
324
+ export async function synthesizeFullRedrawFrame(source) {
325
+ /** @type {TerminalSnapshotDto} */
326
+ const dto =
327
+ /** @type {any} */ (source)?.kind === "terminal_snapshot"
328
+ ? /** @type {TerminalSnapshotDto} */ (source)
329
+ : await captureTerminalSnapshot(/** @type {any} */ (source));
330
+ const parts = [
331
+ // DECSTR resets modes/regions/SGR; 2J clears the viewport, 3J clears
332
+ // scrollback (ignored gracefully where unsupported); r resets the region;
333
+ // then force the invariants content emission relies on.
334
+ "\x1b[!p\x1b[2J\x1b[3J\x1b[H\x1b[r\x1b[?7h\x1b[?6l\x1b[4l",
335
+ ];
336
+ // Scrollback + viewport in ONE continuous top-down stream: total emissions
337
+ // = scrollback.length + rows, so exactly scrollback.length line feeds fire
338
+ // at the bottom row and the first `scrollback.length` emissions scroll into
339
+ // scrollback in order (verified P4 probe: CR LF after a full-width
340
+ // wrap-pending row advances exactly one line). The last viewport row gets
341
+ // no trailing newline (it would fire an extra scroll); the cursor park
342
+ // below positions the cursor afterwards.
343
+ for (const line of dto.scrollback) {
344
+ emitRow(parts, line);
345
+ parts.push("\r\n");
346
+ }
347
+ for (let y = 0; y < dto.rows - 1; y++) {
348
+ emitRow(parts, dto.viewport[y] ?? []);
349
+ parts.push("\r\n");
350
+ }
351
+ emitRow(parts, dto.viewport[dto.rows - 1] ?? []);
352
+ parts.push(modesSequence(dto.modes));
353
+ parkCursor(parts, dto);
354
+ return parts.join("");
355
+ }
356
+
357
+ /**
358
+ * Rebuild an independent terminal model from a snapshot DTO: the synthesized
359
+ * full-redraw frame is fed through a fresh parser. This is both the route-A
360
+ * hydrate engine and the route-B wire-encoding producer.
361
+ *
362
+ * @param {TerminalSnapshotDto} dto
363
+ * @param {typeof defaultParserFactory} [parserFactory]
364
+ * @returns {Promise<import("./terminal-model.mjs").TerminalModel>}
365
+ */
366
+ export async function hydrateTerminalSnapshot(dto, parserFactory = defaultParserFactory) {
367
+ assertSnapshotContract(dto);
368
+ const model = createTerminalModel({ cols: dto.cols, rows: dto.rows, parserFactory });
369
+ feedOutput(model, await synthesizeFullRedrawFrame(dto));
370
+ await whenIdle(model);
371
+ return model;
372
+ }
373
+
374
+ /**
375
+ * @param {TerminalSnapshotDto} dto
376
+ */
377
+ function assertSnapshotContract(dto) {
378
+ if (!dto || dto.kind !== "terminal_snapshot" || dto.version !== TERMINAL_SNAPSHOT_VERSION) {
379
+ throw new TypeError(
380
+ `unsupported terminal snapshot: kind=${/** @type {any} */ (dto)?.kind} version=${/** @type {any} */ (dto)?.version} (supported: v${TERMINAL_SNAPSHOT_VERSION})`,
381
+ );
382
+ }
383
+ if (!Number.isInteger(dto.cols) || dto.cols < 1 || !Number.isInteger(dto.rows) || dto.rows < 1) {
384
+ throw new TypeError(`invalid terminal snapshot dimensions: ${dto.cols}x${dto.rows}`);
385
+ }
386
+ }
387
+
388
+ /**
389
+ * First-difference-detail line comparison (deepEqual on 22k-cell arrays gives
390
+ * unusable diffs).
391
+ *
392
+ * @param {SnapshotCell[]} got
393
+ * @param {SnapshotCell[]} want
394
+ * @param {string} label
395
+ */
396
+ function assertLinesEqual(got, want, label) {
397
+ const same =
398
+ got.length === want.length && got.every((v, i) => JSON.stringify(v) === JSON.stringify(want[i]));
399
+ if (!same) {
400
+ const i = got.findIndex((v, j) => JSON.stringify(v) !== JSON.stringify(want[j]));
401
+ throw new Error(
402
+ `${label}: cell ${i} — got ${JSON.stringify(got[i])} want ${JSON.stringify(want[i])}\n got: ${JSON.stringify(got)}\n want: ${JSON.stringify(want)}`,
403
+ );
404
+ }
405
+ }
406
+
407
+ /**
408
+ * Equivalence walker for hydrate round-trip tests (A4/A5b): dimensions, modes,
409
+ * cursor (including wrap-pending x === cols), full viewport grid, and the
410
+ * scrollback lines directly above the viewport up to the cap.
411
+ *
412
+ * @param {import("./terminal-model.mjs").TerminalModel} modelA source model
413
+ * @param {import("./terminal-model.mjs").TerminalModel} modelB hydrated model
414
+ * @param {{ scrollbackCap?: number }} [opts]
415
+ */
416
+ export function assertSnapshotEquivalence(modelA, modelB, { scrollbackCap = DEFAULT_SCROLLBACK_SNAPSHOT_CAP } = {}) {
417
+ const bufA = /** @type {any} */ (modelA.parser).buffer.active;
418
+ const bufB = /** @type {any} */ (modelB.parser).buffer.active;
419
+ assert.equal(modelB.cols, modelA.cols, "cols mismatch");
420
+ assert.equal(modelB.rows, modelA.rows, "rows mismatch");
421
+ assert.deepEqual(captureModes(modelB.parser), captureModes(modelA.parser), "modes mismatch");
422
+ assert.equal(bufB.cursorX, bufA.cursorX, `cursor.x mismatch (wrap-pending must survive verbatim)`);
423
+ assert.equal(bufB.cursorY, bufA.cursorY, "cursor.y mismatch");
424
+ for (let y = 0; y < modelA.rows; y++) {
425
+ assertLinesEqual(
426
+ captureLine(bufB, bufB.baseY + y, modelB.cols),
427
+ captureLine(bufA, bufA.baseY + y, modelA.cols),
428
+ `viewport row ${y}`,
429
+ );
430
+ }
431
+ const expectedScrollback = Math.min(scrollbackCap, bufA.baseY);
432
+ assert.equal(bufB.baseY, expectedScrollback, "scrollback depth above viewport mismatch");
433
+ for (let k = 1; k <= expectedScrollback; k++) {
434
+ assertLinesEqual(
435
+ captureLine(bufB, bufB.baseY - k, modelB.cols),
436
+ captureLine(bufA, bufA.baseY - k, modelA.cols),
437
+ `scrollback line -${k}`,
438
+ );
439
+ }
440
+ }
@@ -120,6 +120,7 @@ export const GROUP_LABELS = {
120
120
  * @property {SteeringSummary} [steering] Compact plan/approval steering summary.
121
121
  * @property {CodeRefsSummary} [codeRefs] Compact issue/PR reference summary.
122
122
  * @property {AutoStateClassification|null} [autoState] Latest automatic terminal-state classification.
123
+ * @property {number} [materializedRevision] Coordinator-stamped monotonic revision (issue #91). Absent on legacy rows until the View State Coordinator first materializes the view.
123
124
  */
124
125
 
125
126
  /**
@@ -188,6 +189,7 @@ export const GROUP_LABELS = {
188
189
  * @property {EvidenceUsage|null} [usage]
189
190
  * @property {string|null} [stallReason]
190
191
  * @property {ReviewSummary|null} [evidenceSummary]
192
+ * @property {number} [materializedRevision] Coordinator-stamped monotonic revision shared with the view's state.json (issue #91). Absent on legacy rows until first coordinator materialization.
191
193
  * @property {AutoStateClassification|null} [autoState]
192
194
  */
193
195
 
package/src/index.ts CHANGED
@@ -9,7 +9,7 @@ import { fileURLToPath } from "node:url";
9
9
  import { createConnection } from "node:net";
10
10
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
11
11
  import { resolvePiInvocation } from "./core/invocation.mjs";
12
- import { controlSocketPathFor, defaultRoot } from "./core/paths.mjs";
12
+ import { defaultRoot, resolveControlEndpoint } from "./core/paths.mjs";
13
13
  import { listRows } from "./core/store.mjs";
14
14
  import { createService, envInt } from "./runtime/service.mjs";
15
15
  import { attachWarmHostSweeper } from "./core/warm-host-sweeper.mjs";
@@ -90,7 +90,7 @@ export default function piAgentBoard(pi: ExtensionAPI): void {
90
90
 
91
91
  const updateStatus = (ctx: ExtensionContext) => {
92
92
  try {
93
- serviceFor(ctx).reconcile();
93
+ void serviceFor(ctx).reconcile().catch(() => {});
94
94
  const rows = listRows(root);
95
95
  const needs = rows.filter((r) => r.state?.semanticState === "needs_input").length;
96
96
  const working = rows.filter((r) => r.alive).length;
@@ -115,14 +115,22 @@ export default function piAgentBoard(pi: ExtensionAPI): void {
115
115
  const { createEditorStateReporter } = await import("./core/editor-state-reporter.mjs");
116
116
  hostedEditorReporter = createEditorStateReporter({
117
117
  getEditorText: () => ctx.ui.getEditorText(),
118
- connect: () => createConnection(controlSocketPathFor(process.platform as "win32" | "linux" | "darwin", root, hostedViewId)),
118
+ connect: () => createConnection(resolveControlEndpoint({
119
+ // The runner injects the endpoint it actually bound (issue #103);
120
+ // falling back to the stable per-view address keeps older runners
121
+ // and legacy hosts working.
122
+ envSocketPath: process.env.AGENT_BOARD_CONTROL_SOCKET,
123
+ platform: process.platform as "win32" | "linux" | "darwin",
124
+ root,
125
+ viewId: hostedViewId,
126
+ })),
119
127
  });
120
128
  hostedEditorReporter.start();
121
129
  }
122
130
  updateStatus(ctx);
123
131
  if (event.reason === "startup" && !isHostedChild && pi.getFlag("agent-board") === true && ctx.hasUI) {
124
132
  const service = createService({ root, runnerScript: RUNNER_SCRIPT, ptyRunnerScript: PTY_RUNNER_SCRIPT, titleRunnerScript: TITLE_RUNNER_SCRIPT, autoStateRunnerScript: AUTO_STATE_RUNNER_SCRIPT, piCommand, piArgsPrefix, defaultCwd: ctx.cwd, availableModels: availableModelsFor(ctx) });
125
- service.reconcile();
133
+ void service.reconcile().catch(() => {});
126
134
  ctx.ui.setWorkingVisible(false);
127
135
  ctx.ui.setHeader(() => ({ render: () => [], invalidate() {} }));
128
136
  ctx.ui.setFooter(() => ({ render: () => [], invalidate() {} }));