@yolo-labs/yolobridge 0.11.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.
- package/dist/ansi-replay-state.js +661 -0
- package/dist/api-client.js +2 -0
- package/dist/attach-cmd.js +6 -0
- package/dist/local-agent.js +105 -23
- package/dist/screen-digest.js +129 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/api-client.js
CHANGED
|
@@ -206,8 +206,10 @@ export async function postReadOutputReply(cfg, workspaceId, attachmentId, reques
|
|
|
206
206
|
baseOffset: extra.raw.baseOffset,
|
|
207
207
|
endOffset: extra.raw.endOffset,
|
|
208
208
|
truncated: extra.raw.truncated,
|
|
209
|
+
...(extra.raw.prologue ? { prologue: extra.raw.prologue } : {}),
|
|
209
210
|
}
|
|
210
211
|
: {}),
|
|
212
|
+
...(extra?.screenDigest ? { screenDigest: extra.screenDigest } : {}),
|
|
211
213
|
});
|
|
212
214
|
return Boolean(body?.resolved);
|
|
213
215
|
}
|
package/dist/attach-cmd.js
CHANGED
|
@@ -882,8 +882,14 @@ export async function runAttachDaemon(deps) {
|
|
|
882
882
|
endOffset: raw.endOffset,
|
|
883
883
|
data: raw.data,
|
|
884
884
|
truncated: raw.truncated,
|
|
885
|
+
prologue: raw.prologue,
|
|
885
886
|
}
|
|
886
887
|
: undefined,
|
|
888
|
+
// The viewer's integrity check compares this against a
|
|
889
|
+
// digest of its OWN rendered screen; a mismatch that
|
|
890
|
+
// survives the false-positive guards is the only
|
|
891
|
+
// available proof that the two parsers have diverged.
|
|
892
|
+
screenDigest: raw?.screenDigest,
|
|
887
893
|
})
|
|
888
894
|
.catch((err) => noteConnection('degraded', {
|
|
889
895
|
detail: `read-output reply failed: ${err instanceof Error ? err.message : String(err)}`,
|
package/dist/local-agent.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
213
|
-
rawRing.baseOffset += oldestBytes;
|
|
228
|
+
dropFromFront(oldest, oldestBytes);
|
|
214
229
|
continue;
|
|
215
230
|
}
|
|
216
231
|
rawRing.chunks[0] = tail;
|
|
217
|
-
|
|
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,32 +257,85 @@ 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
|
-
|
|
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
|
|
294
|
+
data,
|
|
240
295
|
cols: current.cols,
|
|
241
296
|
rows: current.rows,
|
|
242
|
-
truncated
|
|
297
|
+
truncated,
|
|
298
|
+
// Only a truncated replay is missing state that predates it.
|
|
299
|
+
...(truncated ? { prologue: buildModePrologue(observedModes(current.term), rawRing.modes) } : {}),
|
|
300
|
+
screenDigest: screenDigest(current.term),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* The sticky modes readable from the daemon's own terminal's PUBLIC API.
|
|
305
|
+
*
|
|
306
|
+
* This is the authority for everything it covers: that `Terminal` consumed
|
|
307
|
+
* every byte of the session, so what it reports is not an inference from a
|
|
308
|
+
* screen, it is the parser's own answer. What it does NOT cover — the scroll
|
|
309
|
+
* region, cursor visibility and current SGR, none of which appear on `IModes`
|
|
310
|
+
* or `IBuffer` — comes from `TerminalModeTracker` instead, and
|
|
311
|
+
* `buildModePrologue` documents the split.
|
|
312
|
+
*/
|
|
313
|
+
function observedModes(term) {
|
|
314
|
+
return {
|
|
315
|
+
altScreen: term.buffer.active.type === 'alternate',
|
|
316
|
+
wraparound: term.modes.wraparoundMode,
|
|
317
|
+
origin: term.modes.originMode,
|
|
318
|
+
insert: term.modes.insertMode,
|
|
319
|
+
reverseWraparound: term.modes.reverseWraparoundMode,
|
|
243
320
|
};
|
|
244
321
|
}
|
|
245
322
|
export function primeRawStream(maxBytes = RAW_STREAM_PRIME_BYTES) {
|
|
246
323
|
const all = rawRing.chunks.join('');
|
|
247
324
|
const total = Buffer.byteLength(all, 'utf-8');
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
325
|
+
const naiveStart = total <= maxBytes
|
|
326
|
+
? rawRing.baseOffset
|
|
327
|
+
: rawRing.baseOffset + Buffer.byteLength(splitByUtf8Bytes(all, total - maxBytes).head, 'utf-8');
|
|
328
|
+
// Same hazard as the seed, one degree milder: `maxBytes` is a budget, not a
|
|
329
|
+
// boundary, so the cut lands on an arbitrary byte and can sit inside an
|
|
330
|
+
// escape sequence. Unlike the seed, the bytes BEFORE the cut are still
|
|
331
|
+
// retained, so the fix here loses nothing at all — back up to the last
|
|
332
|
+
// boundary where a fresh parser and the daemon's agree. The handful of extra
|
|
333
|
+
// bytes are deduplicated by the viewer's offset arithmetic anyway.
|
|
334
|
+
const groundStart = resolveGroundStart(all, rawRing.baseOffset, rawRing.baseScanner.state, naiveStart);
|
|
335
|
+
const data = groundStart > rawRing.baseOffset
|
|
336
|
+
? splitByUtf8Bytes(all, groundStart - rawRing.baseOffset).tail
|
|
337
|
+
: all;
|
|
338
|
+
return { epoch: rawRing.epoch, startOffset: groundStart, data };
|
|
257
339
|
}
|
|
258
340
|
/** The PTY's grid, which the tile must render at EXACTLY (it cannot be
|
|
259
341
|
* resized — the human at the keyboard is watching the same PTY). */
|
|
@@ -270,8 +352,8 @@ export function isLocalAgentBusy() {
|
|
|
270
352
|
return Date.now() - current.lastOutputAt < current.busyWindowMs;
|
|
271
353
|
}
|
|
272
354
|
/** Test seam: forget the ring and start a fresh epoch. */
|
|
273
|
-
export function __resetRawRing() {
|
|
274
|
-
rawRing = newRawRing();
|
|
355
|
+
export function __resetRawRing(rows = DEFAULT_ROWS) {
|
|
356
|
+
rawRing = newRawRing(rows);
|
|
275
357
|
}
|
|
276
358
|
/**
|
|
277
359
|
* Test seam: the daemon's OWN headless terminal — the thing a remote viewer
|
|
@@ -527,7 +609,7 @@ export function startLocalAgent(opts = {}) {
|
|
|
527
609
|
// that no longer exists, and offsets restart. The fresh `epoch` is what a
|
|
528
610
|
// viewer keyed to the old one sees, and it re-seeds rather than splicing two
|
|
529
611
|
// sessions' bytes together.
|
|
530
|
-
rawRing = newRawRing();
|
|
612
|
+
rawRing = newRawRing(rows);
|
|
531
613
|
const state = {
|
|
532
614
|
ptyProcess,
|
|
533
615
|
term,
|
|
@@ -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.
|
|
3
|
+
"version": "0.12.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",
|