@yolo-labs/yolobridge 0.10.0 → 0.12.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
+ }
@@ -191,13 +191,25 @@ export async function postHeartbeat(cfg, workspaceId, attachmentId) {
191
191
  const body = await postEvent(cfg, workspaceId, { attachmentId, type: 'heartbeat' });
192
192
  return Boolean(body?.recorded);
193
193
  }
194
- export async function postReadOutputReply(cfg, workspaceId, attachmentId, requestId, output, busy) {
194
+ export async function postReadOutputReply(cfg, workspaceId, attachmentId, requestId, output, busy, extra) {
195
195
  const body = await postEvent(cfg, workspaceId, {
196
196
  attachmentId,
197
197
  type: 'read-output-reply',
198
198
  requestId,
199
199
  output,
200
200
  busy,
201
+ ...(extra?.cols && extra?.rows ? { cols: extra.cols, rows: extra.rows } : {}),
202
+ ...(extra?.raw
203
+ ? {
204
+ raw: extra.raw.data,
205
+ epoch: extra.raw.epoch,
206
+ baseOffset: extra.raw.baseOffset,
207
+ endOffset: extra.raw.endOffset,
208
+ truncated: extra.raw.truncated,
209
+ ...(extra.raw.prologue ? { prologue: extra.raw.prologue } : {}),
210
+ }
211
+ : {}),
212
+ ...(extra?.screenDigest ? { screenDigest: extra.screenDigest } : {}),
201
213
  });
202
214
  return Boolean(body?.resolved);
203
215
  }
@@ -230,6 +242,9 @@ export async function postOutputChunk(cfg, workspaceId, attachmentId, chunk) {
230
242
  seq: chunk.seq,
231
243
  data: chunk.data,
232
244
  droppedBytes: chunk.droppedBytes,
245
+ ...(chunk.epoch ? { epoch: chunk.epoch } : {}),
246
+ ...(typeof chunk.startOffset === 'number' ? { startOffset: chunk.startOffset } : {}),
247
+ ...(chunk.cols && chunk.rows ? { cols: chunk.cols, rows: chunk.rows } : {}),
233
248
  });
234
249
  return Boolean(body?.relayed);
235
250
  }