@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.
- package/CHANGELOG.md +43 -0
- package/README.md +6 -3
- package/VERIFY.md +2 -1
- package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -1
- 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/plans/2026-09-10-reader-consistency.md +115 -0
- package/docs/superpowers/plans/2026-09-14-attach-cursor-dectcem-gate.md +469 -0
- package/docs/superpowers/plans/2026-09-14-attach-snapshot.md +92 -0
- package/docs/superpowers/plans/2026-09-14-host-meta-orphan-lock.md +771 -0
- package/docs/superpowers/plans/2026-09-14-issue-106-terminal-frame-cognition.md +299 -0
- package/docs/superpowers/plans/2026-09-14-issue-113-foreground-preview-race.md +609 -0
- package/docs/superpowers/plans/2026-09-14-terminal-model.md +145 -0
- package/docs/superpowers/plans/2026-09-15-coordinator-pipe-root-normalize.md +224 -0
- package/docs/superpowers/plans/2026-09-15-lease-publish-eprem-reclaim.md +341 -0
- package/docs/superpowers/plans/2026-09-18-detach-anchor-reporter-endpoint.md +875 -0
- package/docs/superpowers/plans/2026-09-20-control-lifecycle.md +116 -0
- package/docs/superpowers/plans/2026-09-20-issue-121-perf-gate-out-of-coverage.md +517 -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/docs/superpowers/specs/2026-09-14-attach-cursor-dectcem-gate-design.md +114 -0
- package/docs/superpowers/specs/2026-09-14-host-meta-orphan-lock-design.md +120 -0
- package/docs/superpowers/specs/2026-09-14-issue-106-terminal-frame-cognition-design.md +146 -0
- package/docs/superpowers/specs/2026-09-14-issue-113-foreground-preview-race-design.md +116 -0
- package/docs/superpowers/specs/2026-09-15-coordinator-pipe-root-normalize-design.md +84 -0
- package/docs/superpowers/specs/2026-09-15-lease-publish-eprem-reclaim-design.md +92 -0
- package/docs/superpowers/specs/2026-09-18-detach-anchor-reporter-endpoint-design.md +123 -0
- package/docs/superpowers/specs/2026-09-20-issue-121-perf-gate-out-of-coverage-design.md +204 -0
- package/package.json +3 -2
- package/runner/job-runner-legacy.mjs +68 -0
- package/runner/job-runner.mjs +371 -67
- package/runner/pty-runner-legacy.mjs +50 -0
- package/runner/pty-runner.mjs +685 -58
- package/runner/state-coordinator.mjs +429 -0
- package/runner/state-runner.mjs +90 -15
- package/scripts/run-perf-gate.mjs +40 -0
- package/src/commands/agent-board.ts +8 -8
- package/src/commands/attach-flow.ts +5 -5
- package/src/commands/bg.ts +2 -1
- package/src/core/control-protocol.mjs +482 -0
- 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/editor-state-reporter.mjs +11 -1
- package/src/core/foreground-preview-cache.mjs +117 -0
- package/src/core/host-protocol.mjs +24 -0
- package/src/core/launch.mjs +15 -0
- package/src/core/locks.mjs +68 -14
- package/src/core/paths.mjs +48 -0
- package/src/core/pid.mjs +32 -1
- package/src/core/pty-attach-jiggle-controller.mjs +83 -6
- package/src/core/pty-attach-reconnect.mjs +13 -6
- package/src/core/pty-attach-render.mjs +50 -0
- package/src/core/state-commands.mjs +699 -0
- package/src/core/status-consistency.mjs +98 -0
- package/src/core/store.mjs +59 -13
- package/src/core/terminal-attach-client.mjs +803 -0
- package/src/core/terminal-attach-protocol.mjs +252 -0
- package/src/core/terminal-model.mjs +222 -0
- package/src/core/terminal-snapshot.mjs +440 -0
- package/src/core/types.mjs +2 -0
- package/src/index.ts +12 -4
- package/src/runtime/service.mjs +694 -121
- package/src/ui/dashboard.ts +67 -92
- package/src/ui/pty-attach.ts +298 -72
- 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
|
+
}
|
package/src/core/types.mjs
CHANGED
|
@@ -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 {
|
|
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(
|
|
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() {} }));
|