@yolo-labs/yolobridge 0.11.0 → 0.13.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,661 @@
1
+ /**
2
+ * The two things a RAW-BYTE replay cannot get right on its own
3
+ * (docs/YOLOBRIDGE_PLAN.md, "Live terminal streaming").
4
+ *
5
+ * `local-agent.ts`'s ring gives a remote viewer the daemon's last
6
+ * `RAW_RING_MAX_BYTES` of PTY output to replay, and replaying bytes is exact
7
+ * where reconstructing state is not. But a ring is a WINDOW onto a byte
8
+ * stream, and a window has two edges that raw replay alone gets wrong:
9
+ *
10
+ * 1. **The window can open mid-escape-sequence.** The ring's `baseOffset`
11
+ * (and the stream prime's start point) is wherever the trim happened to
12
+ * land — an arbitrary byte. Land it inside `ESC [ 3 8 ; 5 ; 1 9 6 m` and
13
+ * the viewer's xterm parses `8;5;196m` as TEXT, prints it, and carries on
14
+ * with whatever SGR/mode state that left behind — possibly forever. This
15
+ * is not a rare case: escapes are a large fraction of a TUI's bytes.
16
+ *
17
+ * Fixed by `AnsiScanner`, an escape-state machine run over the SAME byte
18
+ * stream the terminal consumed, which records the absolute offsets at
19
+ * which the parser is in GROUND state. A replay that starts at a ground
20
+ * offset starts where a fresh parser and the daemon's parser agree.
21
+ *
22
+ * Deliberately NOT "scan back to the last `\n`": an OSC string (a window
23
+ * title, an OSC 8 hyperlink, an OSC 52 clipboard payload) can contain a
24
+ * newline, so the newline heuristic is wrong in exactly the cases that
25
+ * matter. This is exact.
26
+ *
27
+ * 2. **The window can have rolled past a STICKY MODE.** The escape that
28
+ * entered the alternate screen, set the scroll region (DECSTBM), hid the
29
+ * cursor or turned autowrap off may be older than the oldest retained
30
+ * byte. Replay cannot restore what it no longer holds, and every one of
31
+ * those changes where subsequent output LANDS.
32
+ *
33
+ * Fixed by `TerminalModeTracker`, which watches the same byte stream for
34
+ * exactly those sticky sequences and can emit a short, idempotent MODE
35
+ * PROLOGUE ahead of the replay tail. See `buildModePrologue` for the
36
+ * split between what is read from the daemon's own `Terminal` (the
37
+ * authority) and what this tracker supplies because the public API does
38
+ * not expose it.
39
+ *
40
+ * PURE. No terminal, no timers, no I/O — every function here is a fold over a
41
+ * string, which is what makes the whole thing testable byte by byte.
42
+ *
43
+ * ⚠️ UTF-8 BYTE OFFSETS, not UTF-16 code units. Every offset this module
44
+ * produces or consumes is an absolute UTF-8 byte position in the PTY's byte
45
+ * stream, because that is what the ring, the chunk protocol and the viewer's
46
+ * splice arithmetic are all measured in.
47
+ *
48
+ * KNOWN LIMIT, stated rather than hidden: 8-bit C1 control bytes (a bare 0x9B
49
+ * as CSI, 0x9D as OSC) are not recognised. In a UTF-8 PTY — which is what
50
+ * every agent this daemon runs produces — those byte values only ever occur
51
+ * as continuation bytes inside a multi-byte character, where treating them as
52
+ * ordinary text is the correct reading.
53
+ */
54
+ /** Cap on collected CSI parameter bytes. A real sequence is a few dozen bytes;
55
+ * this bounds what a hostile or corrupted stream can make us retain. Past the
56
+ * cap the parameters are truncated, which makes the sequence unrecognised —
57
+ * the safe failure, since an unrecognised sticky mode is simply not restored
58
+ * rather than restored wrongly. */
59
+ const MAX_CSI_PARAM_CHARS = 256;
60
+ /**
61
+ * A resumable escape-state machine over a UTF-8 byte stream.
62
+ *
63
+ * Resumable is the point: the ring's oldest retained byte is not the stream's
64
+ * first byte, so answering "is offset X ground?" requires knowing the parser
65
+ * state at the start of what is retained. `local-agent.ts` keeps one scanner
66
+ * fed with the bytes it TRIMS (giving the state at `baseOffset`) and one fed
67
+ * with every byte it PUSHES (driving the mode tracker).
68
+ */
69
+ export class AnsiScanner {
70
+ state = 'ground';
71
+ collected = '';
72
+ /** Start over from a known state — used to fork a scan from a saved
73
+ * position without replaying the stream that led to it. */
74
+ reset(state = 'ground') {
75
+ this.state = state;
76
+ this.collected = '';
77
+ }
78
+ /**
79
+ * Consume `text`, whose first byte sits at absolute offset `startOffset`.
80
+ * Returns the absolute offset one past its last byte.
81
+ *
82
+ * Iterates CODE POINTS while accounting in BYTES: every byte that can change
83
+ * parser state is ASCII, so a multi-byte character is always a single
84
+ * indivisible "printable" — which is exactly what makes it safe to walk the
85
+ * JS string the ring actually stores instead of encoding it to a Buffer.
86
+ */
87
+ feed(text, startOffset, sink) {
88
+ let offset = startOffset;
89
+ for (let i = 0; i < text.length;) {
90
+ const code = text.codePointAt(i);
91
+ const units = code > 0xffff ? 2 : 1;
92
+ const size = code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4;
93
+ // Reported BEFORE consuming: a replay that begins at this offset begins
94
+ // by parsing this code point, which is only safe from ground.
95
+ if (this.state === 'ground')
96
+ sink?.ground?.(offset);
97
+ this.step(code, sink);
98
+ offset += size;
99
+ i += units;
100
+ }
101
+ // The position after the last byte is a valid start too, when it is ground.
102
+ if (this.state === 'ground')
103
+ sink?.ground?.(offset);
104
+ return offset;
105
+ }
106
+ step(code, sink) {
107
+ switch (this.state) {
108
+ case 'ground':
109
+ if (code === 0x1b)
110
+ this.enterEsc();
111
+ return;
112
+ case 'esc':
113
+ this.stepEsc(code, sink);
114
+ return;
115
+ case 'esc-intermediate':
116
+ if (code >= 0x20 && code <= 0x2f)
117
+ return; // more intermediates
118
+ if (code === 0x1b)
119
+ return this.enterEsc();
120
+ // A final byte (or anything else) ends the sequence. Nothing here is
121
+ // a sticky mode, so no sink callback.
122
+ this.state = 'ground';
123
+ return;
124
+ case 'csi':
125
+ if (code === 0x1b)
126
+ return this.enterEsc();
127
+ // CAN / SUB abort a sequence in progress, everywhere.
128
+ if (code === 0x18 || code === 0x1a) {
129
+ this.state = 'ground';
130
+ return;
131
+ }
132
+ if (code >= 0x40 && code <= 0x7e) {
133
+ const params = this.collected;
134
+ this.collected = '';
135
+ this.state = 'ground';
136
+ sink?.csi?.(params, String.fromCharCode(code));
137
+ return;
138
+ }
139
+ if (code >= 0x20 && code <= 0x3f) {
140
+ if (this.collected.length < MAX_CSI_PARAM_CHARS) {
141
+ this.collected += String.fromCharCode(code);
142
+ }
143
+ return;
144
+ }
145
+ // A C0 control inside a CSI is EXECUTED and the sequence continues —
146
+ // so this is still not a ground boundary.
147
+ return;
148
+ case 'string':
149
+ if (code === 0x07) {
150
+ this.state = 'ground'; // BEL terminates an OSC
151
+ return;
152
+ }
153
+ if (code === 0x1b) {
154
+ this.state = 'string-esc';
155
+ return;
156
+ }
157
+ if (code === 0x18 || code === 0x1a) {
158
+ this.state = 'ground';
159
+ return;
160
+ }
161
+ // Everything else — INCLUDING `\n` — is string content. This is the
162
+ // whole reason "scan back to the last newline" is not a substitute.
163
+ return;
164
+ case 'string-esc':
165
+ if (code === 0x5c) {
166
+ this.state = 'ground'; // ST (`ESC \`)
167
+ return;
168
+ }
169
+ // The string ended and a fresh escape sequence began.
170
+ this.enterEsc();
171
+ this.stepEsc(code, sink);
172
+ return;
173
+ case 'single-shift':
174
+ // Exactly one character is shifted; whatever it was, we are back.
175
+ this.state = 'ground';
176
+ return;
177
+ }
178
+ }
179
+ enterEsc() {
180
+ this.state = 'esc';
181
+ this.collected = '';
182
+ }
183
+ stepEsc(code, sink) {
184
+ if (code === 0x1b)
185
+ return this.enterEsc();
186
+ if (code === 0x5b) {
187
+ // `[` — CSI
188
+ this.state = 'csi';
189
+ this.collected = '';
190
+ return;
191
+ }
192
+ // `]` OSC, `P` DCS, `X` SOS, `^` PM, `_` APC — all string sequences.
193
+ if (code === 0x5d || code === 0x50 || code === 0x58 || code === 0x5e || code === 0x5f) {
194
+ this.state = 'string';
195
+ return;
196
+ }
197
+ if (code === 0x4e || code === 0x4f) {
198
+ // `N` SS2 / `O` SS3
199
+ this.state = 'single-shift';
200
+ return;
201
+ }
202
+ if (code >= 0x20 && code <= 0x2f) {
203
+ this.state = 'esc-intermediate';
204
+ return;
205
+ }
206
+ if (code >= 0x30 && code <= 0x7e) {
207
+ this.state = 'ground';
208
+ sink?.esc?.(String.fromCharCode(code));
209
+ return;
210
+ }
211
+ // A control byte (or a stray continuation byte) after ESC: xterm executes
212
+ // it and abandons the sequence.
213
+ this.state = 'ground';
214
+ }
215
+ }
216
+ /**
217
+ * The absolute offset a replay of `text` should actually start at, given that
218
+ * the caller WANTS to start at `target`.
219
+ *
220
+ * Prefers the greatest ground offset **at or before** `target`, because
221
+ * starting earlier only ever replays a few extra bytes the viewer would have
222
+ * applied anyway. Falls back to the smallest ground offset AFTER `target` when
223
+ * nothing earlier is retained — which is the ring's own case: `baseOffset` is
224
+ * the oldest byte in existence, so a partial escape sitting on it can only be
225
+ * skipped, never completed.
226
+ *
227
+ * Returns the end of `text` when the whole retained window is inside one
228
+ * unterminated sequence (a multi-hundred-kilobyte OSC). An empty replay is the
229
+ * honest answer there: every byte in the window is a fragment of something the
230
+ * viewer cannot parse the same way the daemon did.
231
+ */
232
+ export function resolveGroundStart(text, textStartOffset, stateAtStart, target) {
233
+ const scanner = new AnsiScanner();
234
+ scanner.reset(stateAtStart);
235
+ let before = -1;
236
+ let after = -1;
237
+ const end = scanner.feed(text, textStartOffset, {
238
+ ground(offset) {
239
+ // Ground offsets arrive in increasing order, so the first one past the
240
+ // target is the smallest one past it.
241
+ if (offset <= target)
242
+ before = offset;
243
+ else if (after < 0)
244
+ after = offset;
245
+ },
246
+ });
247
+ if (before >= 0)
248
+ return before;
249
+ if (after >= 0)
250
+ return after;
251
+ return end;
252
+ }
253
+ export const DEFAULT_SGR = {
254
+ bold: false,
255
+ dim: false,
256
+ italic: false,
257
+ underline: false,
258
+ blink: false,
259
+ inverse: false,
260
+ invisible: false,
261
+ strikethrough: false,
262
+ fg: null,
263
+ bg: null,
264
+ };
265
+ export function sgrIsDefault(s) {
266
+ return (!s.bold && !s.dim && !s.italic && !s.underline && !s.blink &&
267
+ !s.inverse && !s.invisible && !s.strikethrough && s.fg === null && s.bg === null);
268
+ }
269
+ /**
270
+ * Watches the PTY byte stream for the sticky modes the daemon's own
271
+ * `Terminal` does not expose through its PUBLIC API.
272
+ *
273
+ * ⚠️ SCOPE, AND WHY THIS IS NOT "STATE INFERENCE". This does not look at a
274
+ * rendered screen and guess how it got that way — the thing the raw-replay
275
+ * design exists to eliminate. It reads the identical byte stream the
276
+ * authoritative parser read, and understands exactly four sequence families
277
+ * (DECSTBM, DECTCEM, the alternate-screen DEC private modes, and SGR) plus
278
+ * the two resets that clear them. Anything else it sees, it ignores.
279
+ *
280
+ * It exists only because `@xterm/headless`'s `IModes`/`IBuffer` expose neither
281
+ * the scroll region, nor cursor visibility, nor the current SGR attributes.
282
+ * Where the public API DOES expose a mode, `buildModePrologue` reads it from
283
+ * the terminal instead of from here — see that function.
284
+ */
285
+ export class TerminalModeTracker {
286
+ rows;
287
+ normalRegion;
288
+ altRegion;
289
+ altActive = false;
290
+ cursorHidden = false;
291
+ sgr = { ...DEFAULT_SGR };
292
+ constructor(rows) {
293
+ this.rows = Math.max(1, Math.floor(rows) || 1);
294
+ this.normalRegion = this.fullRegion();
295
+ this.altRegion = this.fullRegion();
296
+ }
297
+ /** Feed this to `AnsiScanner.feed`. */
298
+ sink = {
299
+ csi: (params, final) => this.onCsi(params, final),
300
+ esc: (final) => this.onEsc(final),
301
+ };
302
+ /** The scroll region in force on the named buffer. The caller passes the
303
+ * buffer the TERMINAL reports as active, so the two sources cannot drift
304
+ * into reporting a region for a screen that is not showing. */
305
+ regionFor(buffer) {
306
+ return buffer === 'alternate' ? this.altRegion : this.normalRegion;
307
+ }
308
+ /** DECTCEM: `CSI ? 25 l` hides, `CSI ? 25 h` shows. Default: visible. */
309
+ get isCursorHidden() {
310
+ return this.cursorHidden;
311
+ }
312
+ /** The SGR attributes the next printed character would be drawn with. */
313
+ get sgrState() {
314
+ return this.sgr;
315
+ }
316
+ /** Whether the tracker believes the alternate screen is active. Only used
317
+ * to route DECSTBM to the right buffer; the PROLOGUE takes the flag itself
318
+ * from the terminal. */
319
+ get isAltScreen() {
320
+ return this.altActive;
321
+ }
322
+ isFullRegion(region) {
323
+ return region.top === 1 && region.bottom === this.rows;
324
+ }
325
+ fullRegion() {
326
+ return { top: 1, bottom: this.rows };
327
+ }
328
+ onEsc(final) {
329
+ if (final === 'c')
330
+ this.hardReset(); // RIS
331
+ }
332
+ onCsi(params, final) {
333
+ // DECSTR (`CSI ! p`) — a soft reset.
334
+ if (final === 'p' && params === '!') {
335
+ this.softReset();
336
+ return;
337
+ }
338
+ const isPrivate = params.charCodeAt(0) === 0x3f; // '?'
339
+ const body = isPrivate ? params.slice(1) : params;
340
+ if (final === 'h' || final === 'l') {
341
+ // Only DEC PRIVATE modes are tracked here. The ANSI ones this prologue
342
+ // cares about (IRM) are readable from the terminal's public `modes`.
343
+ if (!isPrivate)
344
+ return;
345
+ const on = final === 'h';
346
+ for (const token of body.split(';')) {
347
+ const n = Number(token);
348
+ if (!Number.isFinite(n))
349
+ continue;
350
+ if (n === 25)
351
+ this.cursorHidden = !on;
352
+ else if (n === 1049 || n === 1047 || n === 47)
353
+ this.setAltScreen(on);
354
+ }
355
+ return;
356
+ }
357
+ if (final === 'r' && !isPrivate) {
358
+ this.setScrollRegion(body);
359
+ return;
360
+ }
361
+ if (final === 'm' && !isPrivate) {
362
+ this.applySgr(body);
363
+ }
364
+ }
365
+ /**
366
+ * Entering OR leaving the alternate screen resets the ALT buffer's scroll
367
+ * region to the full screen, while the normal buffer's survives the round
368
+ * trip. That is xterm's observed behaviour, and it is why the two regions
369
+ * are tracked separately rather than as one value.
370
+ */
371
+ setAltScreen(on) {
372
+ if (this.altActive === on)
373
+ return;
374
+ this.altActive = on;
375
+ this.altRegion = this.fullRegion();
376
+ }
377
+ /**
378
+ * DECSTBM. Absent parameters mean "the whole screen"; a bottom past the last
379
+ * row is clamped; a degenerate or inverted region is IGNORED and leaves the
380
+ * previous one in force — all three matching what xterm does with the same
381
+ * bytes.
382
+ */
383
+ setScrollRegion(body) {
384
+ const parts = body.split(';');
385
+ const rawTop = parts[0] ? Number(parts[0]) : 1;
386
+ const rawBottom = parts.length > 1 && parts[1] ? Number(parts[1]) : this.rows;
387
+ if (!Number.isFinite(rawTop) || !Number.isFinite(rawBottom))
388
+ return;
389
+ const top = rawTop < 1 ? 1 : Math.floor(rawTop);
390
+ const bottom = Math.floor(rawBottom > this.rows ? this.rows : rawBottom);
391
+ if (bottom <= top)
392
+ return;
393
+ const region = { top, bottom };
394
+ if (this.altActive)
395
+ this.altRegion = region;
396
+ else
397
+ this.normalRegion = region;
398
+ }
399
+ /** RIS (`ESC c`) — everything back to power-on. */
400
+ hardReset() {
401
+ this.normalRegion = this.fullRegion();
402
+ this.altRegion = this.fullRegion();
403
+ this.altActive = false;
404
+ this.cursorHidden = false;
405
+ this.sgr = { ...DEFAULT_SGR };
406
+ }
407
+ /** DECSTR (`CSI ! p`) — scroll region, cursor visibility and SGR reset; the
408
+ * active buffer is NOT switched. */
409
+ softReset() {
410
+ this.normalRegion = this.fullRegion();
411
+ this.altRegion = this.fullRegion();
412
+ this.cursorHidden = false;
413
+ this.sgr = { ...DEFAULT_SGR };
414
+ }
415
+ applySgr(body) {
416
+ const tokens = body === '' ? ['0'] : body.split(';');
417
+ for (let i = 0; i < tokens.length; i++) {
418
+ const token = tokens[i];
419
+ // Colon sub-parameters (`4:3` for curly underline, `38:5:196` for an
420
+ // indexed colour) are a single parameter as far as `;` splitting is
421
+ // concerned, so they are unpacked here rather than in the loop below.
422
+ if (token.includes(':')) {
423
+ this.applySgrSubParam(token);
424
+ continue;
425
+ }
426
+ const n = Number(token === '' ? '0' : token);
427
+ if (!Number.isFinite(n))
428
+ continue;
429
+ if (n === 38 || n === 48) {
430
+ i = this.applyExtendedColor(tokens, i, n === 38);
431
+ continue;
432
+ }
433
+ this.applySgrCode(n);
434
+ }
435
+ }
436
+ applySgrCode(n) {
437
+ switch (true) {
438
+ case n === 0:
439
+ this.sgr = { ...DEFAULT_SGR };
440
+ return;
441
+ case n === 1:
442
+ this.sgr = { ...this.sgr, bold: true };
443
+ return;
444
+ case n === 2:
445
+ this.sgr = { ...this.sgr, dim: true };
446
+ return;
447
+ case n === 3:
448
+ this.sgr = { ...this.sgr, italic: true };
449
+ return;
450
+ // 21 is doubly-underlined in xterm, not "bold off".
451
+ case n === 4 || n === 21:
452
+ this.sgr = { ...this.sgr, underline: true };
453
+ return;
454
+ case n === 5 || n === 6:
455
+ this.sgr = { ...this.sgr, blink: true };
456
+ return;
457
+ case n === 7:
458
+ this.sgr = { ...this.sgr, inverse: true };
459
+ return;
460
+ case n === 8:
461
+ this.sgr = { ...this.sgr, invisible: true };
462
+ return;
463
+ case n === 9:
464
+ this.sgr = { ...this.sgr, strikethrough: true };
465
+ return;
466
+ case n === 22:
467
+ this.sgr = { ...this.sgr, bold: false, dim: false };
468
+ return;
469
+ case n === 23:
470
+ this.sgr = { ...this.sgr, italic: false };
471
+ return;
472
+ case n === 24:
473
+ this.sgr = { ...this.sgr, underline: false };
474
+ return;
475
+ case n === 25:
476
+ this.sgr = { ...this.sgr, blink: false };
477
+ return;
478
+ case n === 27:
479
+ this.sgr = { ...this.sgr, inverse: false };
480
+ return;
481
+ case n === 28:
482
+ this.sgr = { ...this.sgr, invisible: false };
483
+ return;
484
+ case n === 29:
485
+ this.sgr = { ...this.sgr, strikethrough: false };
486
+ return;
487
+ case n === 39:
488
+ this.sgr = { ...this.sgr, fg: null };
489
+ return;
490
+ case n === 49:
491
+ this.sgr = { ...this.sgr, bg: null };
492
+ return;
493
+ case (n >= 30 && n <= 37) || (n >= 90 && n <= 97):
494
+ this.sgr = { ...this.sgr, fg: String(n) };
495
+ return;
496
+ case (n >= 40 && n <= 47) || (n >= 100 && n <= 107):
497
+ this.sgr = { ...this.sgr, bg: String(n) };
498
+ return;
499
+ default:
500
+ // Deliberately unhandled: 10-20 (fonts), 26/50-55 (proportional,
501
+ // framed, overlined), 58/59 (underline colour), 73-75 (super/sub).
502
+ // Not restoring an attribute we do not model is honest; restoring a
503
+ // guessed one is not. Divergence from an unmodelled attribute is what
504
+ // the viewer's integrity check exists to catch.
505
+ return;
506
+ }
507
+ }
508
+ /** `38:5:196`, `48:2::10:20:30`, `4:3`, … */
509
+ applySgrSubParam(token) {
510
+ const parts = token.split(':');
511
+ const head = Number(parts[0]);
512
+ if (!Number.isFinite(head))
513
+ return;
514
+ if (head === 4) {
515
+ // `4:0` is underline off; every other sub-value is some underline style.
516
+ this.sgr = { ...this.sgr, underline: parts[1] !== '0' };
517
+ return;
518
+ }
519
+ if (head === 38 || head === 48) {
520
+ // Re-spell in the semicolon form, dropping the colon form's empty
521
+ // colour-space slot (`38:2::r:g:b`).
522
+ const rest = parts.slice(1).filter((p) => p !== '');
523
+ if (rest.length === 0)
524
+ return;
525
+ const fragment = `${head};${rest.join(';')}`;
526
+ this.sgr = head === 38 ? { ...this.sgr, fg: fragment } : { ...this.sgr, bg: fragment };
527
+ return;
528
+ }
529
+ this.applySgrCode(head);
530
+ }
531
+ /**
532
+ * `38;5;n` (indexed) / `38;2;r;g;b` (direct), and the same for `48`.
533
+ * Returns the index of the LAST token consumed so the caller's loop resumes
534
+ * after the whole colour, never re-reading a channel value as a new
535
+ * attribute (which is how `38;2;1;7;0` would silently turn on inverse).
536
+ */
537
+ applyExtendedColor(tokens, i, isFg) {
538
+ const kind = Number(tokens[i + 1]);
539
+ let consumed;
540
+ if (kind === 5)
541
+ consumed = 2;
542
+ else if (kind === 2)
543
+ consumed = 4;
544
+ else
545
+ return i; // malformed — ignore the introducer and keep parsing
546
+ const slice = tokens.slice(i, i + consumed + 1);
547
+ if (slice.length < consumed + 1)
548
+ return tokens.length; // truncated
549
+ const fragment = slice.join(';');
550
+ this.sgr = isFg ? { ...this.sgr, fg: fragment } : { ...this.sgr, bg: fragment };
551
+ return i + consumed;
552
+ }
553
+ }
554
+ const ESC = '\x1b';
555
+ /**
556
+ * A short escape prologue that puts a fresh terminal into the daemon's sticky
557
+ * mode state, to be written IMMEDIATELY BEFORE a truncated raw replay tail.
558
+ *
559
+ * ⚠️ EMIT THIS ONLY WHEN THE REPLAY IS TRUNCATED. When the ring still holds
560
+ * the whole session the replay is self-contained by construction and a
561
+ * prologue is pure risk for no gain — it would be re-asserting state the tail
562
+ * is about to set anyway, and two of these sequences (DECOM, DECSTBM) move the
563
+ * cursor as a side effect.
564
+ *
565
+ * ONLY NON-DEFAULT MODES ARE EMITTED, for that same reason: a viewer's
566
+ * terminal has just been `reset()`, so it is already at power-on defaults, and
567
+ * `CSI ? 6 l` on an already-unset origin mode is not a no-op — it homes the
568
+ * cursor. When the daemon is in a wholly default state this returns `''`.
569
+ *
570
+ * WHAT IS RESTORED, and from where:
571
+ * - alternate screen ← the terminal (`buffer.active.type`)
572
+ * - autowrap (DECAWM) ← the terminal (`modes.wraparoundMode`)
573
+ * - origin mode (DECOM) ← the terminal (`modes.originMode`)
574
+ * - insert mode (IRM) ← the terminal (`modes.insertMode`)
575
+ * - reverse wraparound ← the terminal (`modes.reverseWraparoundMode`)
576
+ * - scroll region (DECSTBM) ← `TerminalModeTracker` (NOT on the public API)
577
+ * - cursor visibility ← `TerminalModeTracker` (NOT on the public API)
578
+ * - current SGR ← `TerminalModeTracker` (NOT on the public API)
579
+ *
580
+ * WHAT IS NOT RESTORED, deliberately:
581
+ * - the daemon's CURRENT cursor position. The tail replays bytes the daemon
582
+ * emitted in the PAST, from a cursor position that is also in the past;
583
+ * homing to where the cursor is NOW would put the tail's first relative
584
+ * move in the wrong place. The tail's own moves re-establish it.
585
+ * - the saved cursor (DECSC), character sets (SCS), tab stops, the palette
586
+ * (OSC 4), and the input-affecting modes (DECCKM, bracketed paste, mouse
587
+ * reporting) — the last group because this tile has no input path at all.
588
+ *
589
+ * ⚠️ THE ONE CURSOR MOVE THAT IS EMITTED, and why it is not the above. When a
590
+ * SCROLL REGION is in force, the replay must not begin OUTSIDE it. A region
591
+ * confines scrolling to a band of rows; the rows outside that band are static,
592
+ * so anything the replay writes there before it converges stays there
593
+ * FOREVER — the tail will never scroll it away or paint over it. And a fresh
594
+ * terminal starts at row 1, which under a region like `3;10` is exactly one of
595
+ * those static rows.
596
+ *
597
+ * So the prologue parks the cursor at the region's bottom-left. That is not a
598
+ * guess at where the cursor WAS: it is the one thing actually known about it —
599
+ * the output being replayed scrolled within this region, so it was produced
600
+ * with the cursor inside it. Bottom-left is the position from which the first
601
+ * line feed scrolls rather than descending through rows the daemon was not
602
+ * writing to. With no region in force (the common full-screen TUI) nothing is
603
+ * emitted, because then every row scrolls and residue cannot persist.
604
+ *
605
+ * Ordering is load-bearing: the alternate-screen switch comes FIRST because
606
+ * DECSTBM is per-buffer; DECOM comes before the cursor park because setting it
607
+ * homes the cursor; SGR comes LAST because no mode set touches it.
608
+ */
609
+ export function buildModePrologue(observed, tracker) {
610
+ let out = '';
611
+ if (observed.altScreen)
612
+ out += `${ESC}[?1049h`;
613
+ const region = tracker.regionFor(observed.altScreen ? 'alternate' : 'normal');
614
+ const scoped = !tracker.isFullRegion(region);
615
+ if (scoped)
616
+ out += `${ESC}[${region.top};${region.bottom}r`;
617
+ if (!observed.wraparound)
618
+ out += `${ESC}[?7l`;
619
+ if (observed.origin)
620
+ out += `${ESC}[?6h`;
621
+ if (observed.reverseWraparound)
622
+ out += `${ESC}[?45h`;
623
+ if (observed.insert)
624
+ out += `${ESC}[4h`;
625
+ if (tracker.isCursorHidden)
626
+ out += `${ESC}[?25l`;
627
+ if (scoped) {
628
+ // Bottom-left OF THE REGION — see the header. Under origin mode a CUP row
629
+ // is region-relative, so the same row has two spellings and using the
630
+ // wrong one would park the cursor outside the very band this exists to
631
+ // stay inside.
632
+ const row = observed.origin ? region.bottom - region.top + 1 : region.bottom;
633
+ out += `${ESC}[${row};1H`;
634
+ }
635
+ const sgr = tracker.sgrState;
636
+ if (!sgrIsDefault(sgr)) {
637
+ const codes = ['0'];
638
+ if (sgr.bold)
639
+ codes.push('1');
640
+ if (sgr.dim)
641
+ codes.push('2');
642
+ if (sgr.italic)
643
+ codes.push('3');
644
+ if (sgr.underline)
645
+ codes.push('4');
646
+ if (sgr.blink)
647
+ codes.push('5');
648
+ if (sgr.inverse)
649
+ codes.push('7');
650
+ if (sgr.invisible)
651
+ codes.push('8');
652
+ if (sgr.strikethrough)
653
+ codes.push('9');
654
+ if (sgr.fg)
655
+ codes.push(sgr.fg);
656
+ if (sgr.bg)
657
+ codes.push(sgr.bg);
658
+ out += `${ESC}[${codes.join(';')}m`;
659
+ }
660
+ return out;
661
+ }
@@ -199,6 +199,7 @@ export async function postReadOutputReply(cfg, workspaceId, attachmentId, reques
199
199
  output,
200
200
  busy,
201
201
  ...(extra?.cols && extra?.rows ? { cols: extra.cols, rows: extra.rows } : {}),
202
+ ...(extra?.usedRows ? { usedRows: extra.usedRows } : {}),
202
203
  ...(extra?.raw
203
204
  ? {
204
205
  raw: extra.raw.data,
@@ -206,8 +207,10 @@ export async function postReadOutputReply(cfg, workspaceId, attachmentId, reques
206
207
  baseOffset: extra.raw.baseOffset,
207
208
  endOffset: extra.raw.endOffset,
208
209
  truncated: extra.raw.truncated,
210
+ ...(extra.raw.prologue ? { prologue: extra.raw.prologue } : {}),
209
211
  }
210
212
  : {}),
213
+ ...(extra?.screenDigest ? { screenDigest: extra.screenDigest } : {}),
211
214
  });
212
215
  return Boolean(body?.resolved);
213
216
  }
@@ -243,6 +246,7 @@ export async function postOutputChunk(cfg, workspaceId, attachmentId, chunk) {
243
246
  ...(chunk.epoch ? { epoch: chunk.epoch } : {}),
244
247
  ...(typeof chunk.startOffset === 'number' ? { startOffset: chunk.startOffset } : {}),
245
248
  ...(chunk.cols && chunk.rows ? { cols: chunk.cols, rows: chunk.rows } : {}),
249
+ ...(chunk.usedRows ? { usedRows: chunk.usedRows } : {}),
246
250
  });
247
251
  return Boolean(body?.relayed);
248
252
  }
@@ -642,6 +642,7 @@ export async function runAttachDaemon(deps) {
642
642
  startOffset: batch.startOffset,
643
643
  cols: geometry?.cols,
644
644
  rows: geometry?.rows,
645
+ usedRows: geometry?.usedRows,
645
646
  });
646
647
  if (outputStream === session)
647
648
  session.seq += 1;
@@ -875,6 +876,7 @@ export async function runAttachDaemon(deps) {
875
876
  .postReadOutputReply(scopedCfg(), workspaceId, attachmentId, action.requestId, captured.output, captured.busy, {
876
877
  cols: raw?.cols ?? captured.cols,
877
878
  rows: raw?.rows ?? captured.rows,
879
+ usedRows: raw?.usedRows ?? captured.usedRows,
878
880
  raw: raw
879
881
  ? {
880
882
  epoch: raw.epoch,
@@ -882,8 +884,14 @@ export async function runAttachDaemon(deps) {
882
884
  endOffset: raw.endOffset,
883
885
  data: raw.data,
884
886
  truncated: raw.truncated,
887
+ prologue: raw.prologue,
885
888
  }
886
889
  : undefined,
890
+ // The viewer's integrity check compares this against a
891
+ // digest of its OWN rendered screen; a mismatch that
892
+ // survives the false-positive guards is the only
893
+ // available proof that the two parsers have diverged.
894
+ screenDigest: raw?.screenDigest,
887
895
  })
888
896
  .catch((err) => noteConnection('degraded', {
889
897
  detail: `read-output reply failed: ${err instanceof Error ? err.message : String(err)}`,
@@ -35,6 +35,9 @@ import { createRequire } from 'node:module';
35
35
  import { randomUUID } from 'node:crypto';
36
36
  import * as pty from 'node-pty';
37
37
  import { splitByUtf8Bytes } from './output-stream.js';
38
+ import { AnsiScanner, TerminalModeTracker, buildModePrologue, resolveGroundStart, } from './ansi-replay-state.js';
39
+ import { screenDigest } from './screen-digest.js';
40
+ export { screenDigest } from './screen-digest.js';
38
41
  // `@xterm/headless`'s published CJS bundle is a heavily minified/webpacked
39
42
  // single file — `cjs-module-lexer` (Node ESM's static CJS-named-export
40
43
  // detector) can't find `Terminal` on it, so a plain
@@ -172,8 +175,17 @@ export const RAW_RING_MAX_BYTES = 256 * 1024;
172
175
  * fatal anyway — it reads as a gap and costs one extra re-seed.
173
176
  */
174
177
  export const RAW_STREAM_PRIME_BYTES = 64 * 1024;
175
- function newRawRing() {
176
- return { epoch: randomUUID(), chunks: [], bytes: 0, baseOffset: 0, endOffset: 0 };
178
+ function newRawRing(rows = DEFAULT_ROWS) {
179
+ return {
180
+ epoch: randomUUID(),
181
+ chunks: [],
182
+ bytes: 0,
183
+ baseOffset: 0,
184
+ endOffset: 0,
185
+ baseScanner: new AnsiScanner(),
186
+ headScanner: new AnsiScanner(),
187
+ modes: new TerminalModeTracker(rows),
188
+ };
177
189
  }
178
190
  let rawRing = newRawRing();
179
191
  /** Append to the ring and return where the chunk landed. Synchronous and
@@ -182,6 +194,11 @@ let rawRing = newRawRing();
182
194
  function pushRaw(data) {
183
195
  const startOffset = rawRing.endOffset;
184
196
  const bytes = Buffer.byteLength(data, 'utf-8');
197
+ // Watch the bytes for sticky modes BEFORE they can be trimmed away. This is
198
+ // the only place that sees every byte of the session exactly once, which is
199
+ // what lets a truncated replay still be handed the alt-screen/scroll-region/
200
+ // cursor/SGR state that predates the ring (see `takeRawSeed`).
201
+ rawRing.headScanner.feed(data, startOffset, rawRing.modes.sink);
185
202
  rawRing.chunks.push(data);
186
203
  rawRing.bytes += bytes;
187
204
  rawRing.endOffset += bytes;
@@ -199,8 +216,7 @@ function trimRawRing() {
199
216
  const oldestBytes = Buffer.byteLength(oldest, 'utf-8');
200
217
  if (oldestBytes <= overflow) {
201
218
  rawRing.chunks.shift();
202
- rawRing.bytes -= oldestBytes;
203
- rawRing.baseOffset += oldestBytes;
219
+ dropFromFront(oldest, oldestBytes);
204
220
  continue;
205
221
  }
206
222
  const { head, tail } = splitByUtf8Bytes(oldest, overflow);
@@ -209,15 +225,28 @@ function trimRawRing() {
209
225
  // A single code point wider than the overflow — drop the chunk rather
210
226
  // than spin.
211
227
  rawRing.chunks.shift();
212
- rawRing.bytes -= oldestBytes;
213
- rawRing.baseOffset += oldestBytes;
228
+ dropFromFront(oldest, oldestBytes);
214
229
  continue;
215
230
  }
216
231
  rawRing.chunks[0] = tail;
217
- rawRing.bytes -= droppedBytes;
218
- rawRing.baseOffset += droppedBytes;
232
+ dropFromFront(head, droppedBytes);
219
233
  }
220
234
  }
235
+ /**
236
+ * Account for `text` (exactly `byteLength` UTF-8 bytes) leaving the front of
237
+ * the ring.
238
+ *
239
+ * The scanner feed is the load-bearing part: once these bytes are gone, the
240
+ * ONLY record that the new `baseOffset` sits (say) three bytes into an OSC
241
+ * string is the parser state this leaves behind. Without it, a later
242
+ * `takeRawSeed` would hand a viewer a replay that begins mid-sequence and the
243
+ * viewer's xterm would print the remainder as text.
244
+ */
245
+ function dropFromFront(text, byteLength) {
246
+ rawRing.baseScanner.feed(text, rawRing.baseOffset);
247
+ rawRing.bytes -= byteLength;
248
+ rawRing.baseOffset += byteLength;
249
+ }
221
250
  /**
222
251
  * Take the current replay tail plus the PTY's grid size, as ONE synchronous
223
252
  * observation.
@@ -228,39 +257,174 @@ function trimRawRing() {
228
257
  * ≥ `endOffset` and are therefore either spliced on cleanly or (if they were
229
258
  * already in `data`) trimmed away by the viewer — never lost, never applied
230
259
  * twice.
260
+ *
261
+ * ⚠️ CALL THIS ONLY AFTER THE TERMINAL HAS CAUGHT UP WITH THE RING. `prologue`
262
+ * and `screenDigest` are read from `current.term`, and that terminal is fed
263
+ * through an ASYNC write chain — so a seed taken mid-burst would describe the
264
+ * modes and the screen of a moment the ring has already moved past. The ring
265
+ * itself is written synchronously and is always current, which is exactly what
266
+ * makes the mismatch silent rather than obvious.
267
+ *
268
+ * `attach-cmd.ts` gets this right by construction: its `read-output` handler
269
+ * `await`s `captureOutput()` (which awaits the write chain) BEFORE calling
270
+ * this. Any new caller must do the same, and the tests do it deliberately.
231
271
  */
232
272
  export function takeRawSeed() {
233
273
  if (!current)
234
274
  return undefined;
275
+ const all = rawRing.chunks.join('');
276
+ // The ring's oldest retained byte is wherever the trim happened to land, so
277
+ // it can sit INSIDE an escape sequence. Starting there would feed a viewer's
278
+ // xterm the tail of a sequence it never saw the head of — which it renders
279
+ // as text and which can leave it in the wrong SGR or the wrong mode
280
+ // indefinitely. Nothing earlier than `baseOffset` still exists, so the only
281
+ // available correction is to skip forward to the next ground boundary; the
282
+ // few bytes discarded are a fragment no parser could have used.
283
+ const groundStart = resolveGroundStart(all, rawRing.baseOffset, rawRing.baseScanner.state, rawRing.baseOffset);
284
+ const data = groundStart > rawRing.baseOffset
285
+ ? splitByUtf8Bytes(all, groundStart - rawRing.baseOffset).tail
286
+ : all;
287
+ const truncated = rawRing.baseOffset > 0;
235
288
  return {
236
289
  epoch: rawRing.epoch,
237
- baseOffset: rawRing.baseOffset,
290
+ // The offset the replay ACTUALLY starts at, not the one the ring happens
291
+ // to hold — the viewer positions itself by these numbers.
292
+ baseOffset: groundStart,
238
293
  endOffset: rawRing.endOffset,
239
- data: rawRing.chunks.join(''),
294
+ data,
240
295
  cols: current.cols,
241
296
  rows: current.rows,
242
- truncated: rawRing.baseOffset > 0,
297
+ usedRows: computeUsedRows(current.term),
298
+ truncated,
299
+ // Only a truncated replay is missing state that predates it.
300
+ ...(truncated ? { prologue: buildModePrologue(observedModes(current.term), rawRing.modes) } : {}),
301
+ screenDigest: screenDigest(current.term),
302
+ };
303
+ }
304
+ /**
305
+ * The sticky modes readable from the daemon's own terminal's PUBLIC API.
306
+ *
307
+ * This is the authority for everything it covers: that `Terminal` consumed
308
+ * every byte of the session, so what it reports is not an inference from a
309
+ * screen, it is the parser's own answer. What it does NOT cover — the scroll
310
+ * region, cursor visibility and current SGR, none of which appear on `IModes`
311
+ * or `IBuffer` — comes from `TerminalModeTracker` instead, and
312
+ * `buildModePrologue` documents the split.
313
+ */
314
+ function observedModes(term) {
315
+ return {
316
+ altScreen: term.buffer.active.type === 'alternate',
317
+ wraparound: term.modes.wraparoundMode,
318
+ origin: term.modes.originMode,
319
+ insert: term.modes.insertMode,
320
+ reverseWraparound: term.modes.reverseWraparoundMode,
243
321
  };
244
322
  }
245
323
  export function primeRawStream(maxBytes = RAW_STREAM_PRIME_BYTES) {
246
324
  const all = rawRing.chunks.join('');
247
325
  const total = Buffer.byteLength(all, 'utf-8');
248
- if (total <= maxBytes) {
249
- return { epoch: rawRing.epoch, startOffset: rawRing.baseOffset, data: all };
326
+ const naiveStart = total <= maxBytes
327
+ ? rawRing.baseOffset
328
+ : rawRing.baseOffset + Buffer.byteLength(splitByUtf8Bytes(all, total - maxBytes).head, 'utf-8');
329
+ // Same hazard as the seed, one degree milder: `maxBytes` is a budget, not a
330
+ // boundary, so the cut lands on an arbitrary byte and can sit inside an
331
+ // escape sequence. Unlike the seed, the bytes BEFORE the cut are still
332
+ // retained, so the fix here loses nothing at all — back up to the last
333
+ // boundary where a fresh parser and the daemon's agree. The handful of extra
334
+ // bytes are deduplicated by the viewer's offset arithmetic anyway.
335
+ const groundStart = resolveGroundStart(all, rawRing.baseOffset, rawRing.baseScanner.state, naiveStart);
336
+ const data = groundStart > rawRing.baseOffset
337
+ ? splitByUtf8Bytes(all, groundStart - rawRing.baseOffset).tail
338
+ : all;
339
+ return { epoch: rawRing.epoch, startOffset: groundStart, data };
340
+ }
341
+ /**
342
+ * Is this cell blank — nothing a viewer could see in it?
343
+ *
344
+ * "Blank" is deliberately the SAME notion `serializeTerminalBuffer`'s
345
+ * right-trim uses: an unwritten or space cell whose PAINT is also default. A
346
+ * run of spaces carrying a background colour (a status bar, a selection, a
347
+ * progress bar's filled portion) is real, visible output, and calling it blank
348
+ * would crop it off the bottom of the screen.
349
+ */
350
+ function cellIsBlank(cell) {
351
+ const chars = cell.getChars();
352
+ if (chars !== '' && chars !== ' ')
353
+ return false;
354
+ return (!!cell.isBgDefault() && !cell.isInverse() && !cell.isUnderline() && !cell.isStrikethrough());
355
+ }
356
+ /**
357
+ * How many of the grid's `rows` the session is actually USING — the last row
358
+ * with anything visible on it (or the cursor's row), whichever is lower down.
359
+ *
360
+ * ⚠️ WHY THE DAEMON COMPUTES THIS AND NOT THE VIEWER. The tile only ever
361
+ * receives a byte stream; reconstructing "how much of the screen is in use"
362
+ * from it means re-implementing the parser's own bookkeeping. The daemon
363
+ * already holds the authoritative `@xterm/headless` buffer, so the answer is a
364
+ * read rather than an inference.
365
+ *
366
+ * ⚠️ ALTERNATE SCREEN ⇒ `rows`, ALWAYS. A full-screen TUI legitimately paints
367
+ * every row it was given, and plenty of those rows look blank by this
368
+ * predicate (a padded pane, an empty list body, the gap above a footer).
369
+ * Cropping there would clip live UI off the bottom of a viewer's tile, so the
370
+ * alternate screen simply opts out: the whole grid is in use by definition.
371
+ *
372
+ * COST: bounded by the number of TRAILING BLANK rows — the walk starts at the
373
+ * bottom and returns at the first row with content, so a busy screen costs one
374
+ * row's cells and a mostly-empty one costs `(rows − usedRows) × cols` cell
375
+ * reads with no allocation. Nothing is serialized: this is called on every
376
+ * relayed chunk and a screen dump per chunk would be exactly the cost the raw
377
+ * stream exists to avoid.
378
+ *
379
+ * Never returns 0 — a terminal is always showing at least one row, and a zero
380
+ * would divide straight through the viewer's scale computation.
381
+ */
382
+ export function computeUsedRows(term) {
383
+ const buffer = term.buffer.active;
384
+ const rows = Math.max(1, term.rows);
385
+ if (buffer.type === 'alternate')
386
+ return rows;
387
+ // The viewport's first line within the buffer. The daemon's terminal is
388
+ // never scrolled by a human (nobody scrolls it — it is fed and read
389
+ // programmatically), so the base IS the viewport.
390
+ const top = buffer.baseY;
391
+ // The cursor is the session's live edge even on a row that is still empty
392
+ // (a fresh prompt line, a cleared input box), so it is a floor.
393
+ let used = Math.min(rows, Math.max(1, buffer.cursorY + 1));
394
+ for (let y = rows - 1; y >= used; y--) {
395
+ const line = buffer.getLine(top + y);
396
+ if (!line)
397
+ continue;
398
+ let blank = true;
399
+ for (let x = 0; x < line.length; x++) {
400
+ const cell = line.getCell(x);
401
+ if (!cell)
402
+ continue;
403
+ if (!cellIsBlank(cell)) {
404
+ blank = false;
405
+ break;
406
+ }
407
+ }
408
+ if (!blank) {
409
+ used = y + 1;
410
+ break;
411
+ }
250
412
  }
251
- const { head, tail } = splitByUtf8Bytes(all, total - maxBytes);
252
- return {
253
- epoch: rawRing.epoch,
254
- startOffset: rawRing.baseOffset + Buffer.byteLength(head, 'utf-8'),
255
- data: tail,
256
- };
413
+ return used;
257
414
  }
258
415
  /** The PTY's grid, which the tile must render at EXACTLY (it cannot be
259
- * resized — the human at the keyboard is watching the same PTY). */
416
+ * resized — the human at the keyboard is watching the same PTY), plus how
417
+ * much of that grid is in USE.
418
+ *
419
+ * ⚠️ `usedRows` IS NOT A SECOND GEOMETRY. It never changes `cols`/`rows` and
420
+ * the viewer never resizes its terminal to it: a viewer's terminal that is
421
+ * not exactly `cols`×`rows` cannot replay the daemon's bytes (relative cursor
422
+ * moves and the scroll region are defined against the real grid). It exists
423
+ * so the viewer can decide how large to draw the text. */
260
424
  export function getLocalAgentGeometry() {
261
425
  if (!current)
262
426
  return undefined;
263
- return { cols: current.cols, rows: current.rows };
427
+ return { cols: current.cols, rows: current.rows, usedRows: computeUsedRows(current.term) };
264
428
  }
265
429
  /** The same recent-activity heuristic `captureLocalAgentOutput` reports, without
266
430
  * paying for a buffer serialization. */
@@ -270,8 +434,8 @@ export function isLocalAgentBusy() {
270
434
  return Date.now() - current.lastOutputAt < current.busyWindowMs;
271
435
  }
272
436
  /** Test seam: forget the ring and start a fresh epoch. */
273
- export function __resetRawRing() {
274
- rawRing = newRawRing();
437
+ export function __resetRawRing(rows = DEFAULT_ROWS) {
438
+ rawRing = newRawRing(rows);
275
439
  }
276
440
  /**
277
441
  * Test seam: the daemon's OWN headless terminal — the thing a remote viewer
@@ -527,7 +691,7 @@ export function startLocalAgent(opts = {}) {
527
691
  // that no longer exists, and offsets restart. The fresh `epoch` is what a
528
692
  // viewer keyed to the old one sees, and it re-seeds rather than splicing two
529
693
  // sessions' bytes together.
530
- rawRing = newRawRing();
694
+ rawRing = newRawRing(rows);
531
695
  const state = {
532
696
  ptyProcess,
533
697
  term,
@@ -790,5 +954,13 @@ export async function captureLocalAgentOutput() {
790
954
  // The grid the screen above was laid out FOR. A viewer that renders it at
791
955
  // any other width re-wraps every long line — the dump has no width of its
792
956
  // own, only the one the PTY composed it at.
793
- return { output, busy, cols: current.cols, rows: current.rows };
957
+ return {
958
+ output,
959
+ busy,
960
+ cols: current.cols,
961
+ rows: current.rows,
962
+ // How much of that grid is in use, so the viewer can scale the text to the
963
+ // rows that carry content instead of to a screenful of blank tail.
964
+ usedRows: computeUsedRows(current.term),
965
+ };
794
966
  }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * A cheap, comparable fingerprint of a terminal's VISIBLE SCREEN.
3
+ *
4
+ * ⚠️ MIRRORED IN THE WEBAPP as `webapp/lib/grid/terminal-screen-digest.ts`.
5
+ * The two must produce the SAME string for the same screen or the viewer's
6
+ * integrity check compares apples to oranges and either never fires or fires
7
+ * forever. Change them in the same commit; `screen-digest.test.ts` (here) and
8
+ * `terminal-screen-digest.test.ts` (there) pin the same fixture value from
9
+ * both sides.
10
+ *
11
+ * WHY IT EXISTS. Raw-byte replay makes the viewer's terminal a replica of the
12
+ * daemon's, and the offset arithmetic makes gaps detectable — but neither can
13
+ * PROVE that no byte sequence will ever desync the two parsers. Rather than
14
+ * claim a property that cannot be proved, the design guarantees a weaker one
15
+ * that can: divergence is DETECTED and corrected within a bounded time. This
16
+ * is the detector. The daemon reports the digest of its own screen alongside
17
+ * every poll; the viewer digests its own and compares.
18
+ *
19
+ * DESIGN NOTES, each of which is a bug avoided:
20
+ *
21
+ * - **Viewport only, addressed from `baseY`.** Not the scrollback: a viewer
22
+ * seeded from a rolled ring legitimately has less history than the daemon,
23
+ * and hashing that would report a permanent, uncorrectable mismatch. And
24
+ * `baseY` rather than `viewportY` so a reader who has scrolled up to read
25
+ * something does not look like corruption.
26
+ *
27
+ * - **The documented colour PREDICATES, not `getFgColorMode()`.** The
28
+ * typings call that number opaque ("can be used to perform quick
29
+ * comparisons of 2 cells") and point at `isFgRGB`/`isFgPalette`/
30
+ * `isFgDefault` instead. The two sides of this comparison are two
31
+ * different xterm builds — `@xterm/headless` in the daemon, `@xterm/xterm`
32
+ * in the browser — so relying on an opaque constant agreeing across them
33
+ * is exactly the assumption that would make this detector lie.
34
+ *
35
+ * - **A 32-bit FNV-1a folded in as we walk**, never a concatenated screen
36
+ * string. This runs on a poll tick over `cols × rows` cells; building a
37
+ * ~100 KB string per tick to throw away would be the expensive part.
38
+ *
39
+ * - **Includes the cursor.** Cursor drift is the defect raw replay was built
40
+ * to fix and the one a glyph-only comparison cannot see at all.
41
+ *
42
+ * NOT a security primitive: FNV-1a is a hash for detecting accidental
43
+ * divergence between two copies of a screen, not for resisting anyone.
44
+ */
45
+ /**
46
+ * Field separators, spelled as escapes rather than written as raw bytes.
47
+ *
48
+ * They must be characters that CANNOT occur in the data being folded, or a
49
+ * screen could collide with a different screen that happens to contain the
50
+ * separator as a glyph — a space would do exactly that, since a blank cell
51
+ * hashes as `' '`. NUL and SOH are never cell contents, so the field boundaries
52
+ * are unambiguous.
53
+ *
54
+ * ⚠️ Part of the cross-package fixture. Changing either value changes every
55
+ * digest, in both mirrors, and invalidates the pinned test value.
56
+ */
57
+ const SEP = '\u0000';
58
+ const FIELD = '\u0001';
59
+ const FNV_OFFSET_BASIS = 0x811c9dc5;
60
+ const FNV_PRIME = 0x01000193;
61
+ /** Fold a string into a running FNV-1a 32-bit hash. Operates on UTF-16 code
62
+ * units, which is fine: both sides hash the same JS strings. */
63
+ function fold(hash, text) {
64
+ let h = hash;
65
+ for (let i = 0; i < text.length; i++) {
66
+ h ^= text.charCodeAt(i);
67
+ h = Math.imul(h, FNV_PRIME);
68
+ }
69
+ return h >>> 0;
70
+ }
71
+ function flags(cell) {
72
+ return ((cell.isBold() ? 1 : 0) |
73
+ (cell.isDim() ? 2 : 0) |
74
+ (cell.isItalic() ? 4 : 0) |
75
+ (cell.isUnderline() ? 8 : 0) |
76
+ (cell.isInverse() ? 16 : 0) |
77
+ (cell.isStrikethrough() ? 32 : 0));
78
+ }
79
+ function colorKey(isDefault, isPalette, isRgb, color) {
80
+ // In default mode the colour NUMBER is meaningless (the typings say "should
81
+ // be 0"; the runtime reports -1), so it is normalised away — otherwise two
82
+ // identically-rendered default cells would hash differently.
83
+ if (isDefault)
84
+ return 'd';
85
+ if (isRgb)
86
+ return `r${color}`;
87
+ if (isPalette)
88
+ return `p${color}`;
89
+ return `?${color}`;
90
+ }
91
+ /**
92
+ * An 8-hex-character digest of the terminal's visible screen and cursor.
93
+ *
94
+ * Returns a stable string for a stable screen, and (with overwhelming
95
+ * likelihood) a different one for any screen that differs in a glyph, an
96
+ * attribute, a colour, the grid size, the active buffer, or the cursor.
97
+ */
98
+ export function screenDigest(term) {
99
+ const buffer = term.buffer.active;
100
+ let h = FNV_OFFSET_BASIS;
101
+ h = fold(h, `${term.cols}x${term.rows}|${buffer.type}|${buffer.cursorX},${buffer.cursorY}`);
102
+ for (let y = 0; y < term.rows; y++) {
103
+ const line = buffer.getLine(buffer.baseY + y);
104
+ if (!line) {
105
+ h = fold(h, `${SEP}~${SEP}`);
106
+ continue;
107
+ }
108
+ h = fold(h, SEP);
109
+ for (let x = 0; x < term.cols; x++) {
110
+ const cell = line.getCell(x);
111
+ if (!cell) {
112
+ h = fold(h, '~');
113
+ continue;
114
+ }
115
+ // Width 0 is the right half of a wide glyph; its content already came
116
+ // out of the width-2 cell before it. Hashing it too would double-count
117
+ // every CJK character and emoji.
118
+ const width = cell.getWidth();
119
+ if (width === 0)
120
+ continue;
121
+ const chars = cell.getChars();
122
+ h = fold(h, chars === '' ? ' ' : chars);
123
+ h = fold(h, `${FIELD}${width}${FIELD}${flags(cell)}${FIELD}`);
124
+ h = fold(h, colorKey(cell.isFgDefault(), cell.isFgPalette(), cell.isFgRGB(), cell.getFgColor()));
125
+ h = fold(h, colorKey(cell.isBgDefault(), cell.isBgPalette(), cell.isBgRGB(), cell.getBgColor()));
126
+ }
127
+ }
128
+ return (h >>> 0).toString(16).padStart(8, '0');
129
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yolo-labs/yolobridge",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "YoloBridge — local coding-agent daemon that attaches a user's own Claude Code/Codex session to a YOLO Studio workspace as a first-class tile (docs/YOLOBRIDGE_PLAN.md, build-order Phase 5).",
5
5
  "license": "MIT",
6
6
  "type": "module",