@adhdev/session-host-core 1.0.60-rc.66 → 1.0.60-rc.67
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/index.d.mts +41 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.js +99 -0
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +99 -0
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/buffer.ts +161 -0
package/src/buffer.ts
CHANGED
|
@@ -75,10 +75,171 @@ export class SessionRingBuffer {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
private trim(): void {
|
|
78
|
+
let evicted = false;
|
|
78
79
|
while (this.totalBytes > this.maxBytes && this.chunks.length > 1) {
|
|
79
80
|
const removed = this.chunks.shift();
|
|
80
81
|
if (!removed) break;
|
|
81
82
|
this.totalBytes -= removed.bytes;
|
|
83
|
+
evicted = true;
|
|
82
84
|
}
|
|
85
|
+
if (evicted) this.healHead();
|
|
83
86
|
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* TRIM-BOUNDARY repair. Eviction drops whole chunks, and a chunk boundary is
|
|
90
|
+
* a PTY read boundary — an arbitrary byte offset with no relationship to the
|
|
91
|
+
* structure of the stream. So the chunk that becomes the new oldest can begin
|
|
92
|
+
* partway through something the sender wrote atomically, and `snapshot()`
|
|
93
|
+
* joins from exactly there.
|
|
94
|
+
*
|
|
95
|
+
* The consumer is the browser terminal: "Load older terminal output" asks
|
|
96
|
+
* with `sinceSeq: 0`, which makes the daemon skip the emulator viewport and
|
|
97
|
+
* hand this raw text straight to xterm (see `mergeRuntimeSnapshot`). xterm
|
|
98
|
+
* then parses a stream that starts mid-token, which is how the reported
|
|
99
|
+
* screenshot got orphaned `.` and `5` glyphs floating above the output and a
|
|
100
|
+
* large blank band at the top:
|
|
101
|
+
*
|
|
102
|
+
* drop 5 chars -> "[HClaude Code v2.1.220…" the CSI introducer is gone,
|
|
103
|
+
* so `[H` prints literally
|
|
104
|
+
* drop 12 chars -> "2mClaude Code v2.1.220…" half an SGR prints literally
|
|
105
|
+
* drop 46 chars -> the leading `\x1b[2J\x1b[H` never arrives, so the screen
|
|
106
|
+
* is never cleared/homed and row placement collapses
|
|
107
|
+
* byte cut -> a torn 3-byte Hangul sequence decodes to U+FFFD
|
|
108
|
+
*
|
|
109
|
+
* So walk the head of the new oldest chunk forward to the first offset that
|
|
110
|
+
* is safe to start parsing at, and drop the partial prefix. Losing a few
|
|
111
|
+
* bytes of already-evicted context is strictly better than injecting literal
|
|
112
|
+
* garbage into the viewport.
|
|
113
|
+
*
|
|
114
|
+
* Two independent boundary classes have to be handled, and neither subsumes
|
|
115
|
+
* the other:
|
|
116
|
+
*
|
|
117
|
+
* 1. Character encoding. Chunks are JS strings, so a torn multi-byte UTF-8
|
|
118
|
+
* sequence has already decayed into U+FFFD (or, for astral characters, a
|
|
119
|
+
* lone surrogate) by the time it gets here. This mirrors the protection
|
|
120
|
+
* `createLineParser` grew for the IPC socket path in
|
|
121
|
+
* `ipc-line-parser-utf8.test.ts`; that layer can hold bytes back and
|
|
122
|
+
* re-join them because it owns both sides of the split, whereas here the
|
|
123
|
+
* other half is already gone, so dropping is the only repair available.
|
|
124
|
+
* 2. Escape sequences. A CSI/OSC/SS3 can be cut anywhere, and unlike the
|
|
125
|
+
* encoding case the leftover bytes are all perfectly printable — which is
|
|
126
|
+
* precisely why the corruption is visible rather than silent.
|
|
127
|
+
*/
|
|
128
|
+
private healHead(): void {
|
|
129
|
+
const head = this.chunks[0];
|
|
130
|
+
if (!head) return;
|
|
131
|
+
|
|
132
|
+
const repaired = stripDanglingPrefix(head.data);
|
|
133
|
+
if (repaired === head.data) return;
|
|
134
|
+
|
|
135
|
+
const bytes = Buffer.byteLength(repaired, 'utf8');
|
|
136
|
+
this.totalBytes -= head.bytes - bytes;
|
|
137
|
+
head.data = repaired;
|
|
138
|
+
head.bytes = bytes;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Final byte of a CSI (`ESC [ … X`) or SS3 sequence. */
|
|
143
|
+
const CSI_FINAL = /[\x40-\x7e]/;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The subset of CSI final bytes terminals actually emit: cursor movement
|
|
147
|
+
* (ABCDEFGHfd), erase (JK), scroll (STLM), insert/delete (PX@), SGR (m),
|
|
148
|
+
* device status (nc), mode set/reset (hl), save/restore cursor (su) and
|
|
149
|
+
* scroll region (r). Used only when the `[` introducer was itself evicted, so
|
|
150
|
+
* that a digit run followed by an arbitrary letter is not mistaken for a
|
|
151
|
+
* sequence — the whole CSI final range \x40-\x7e includes most of the
|
|
152
|
+
* alphabet and would swallow ordinary prose.
|
|
153
|
+
*/
|
|
154
|
+
const CSI_COMMON_FINAL = /[ABCDEFGHJKSTLMPX@mnchlsurdfgqit]/;
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Returns `text` with any leading fragment of a torn character or escape
|
|
158
|
+
* sequence removed. Returns `text` unchanged when the head is already a safe
|
|
159
|
+
* place to start parsing.
|
|
160
|
+
*/
|
|
161
|
+
function stripDanglingPrefix(text: string): string {
|
|
162
|
+
let i = 0;
|
|
163
|
+
|
|
164
|
+
// 1. Encoding damage. A torn multi-byte sequence survives as U+FFFD or as an
|
|
165
|
+
// unpaired surrogate; both are meaningless on their own.
|
|
166
|
+
while (i < text.length) {
|
|
167
|
+
const code = text.charCodeAt(i);
|
|
168
|
+
if (code === 0xfffd) { i += 1; continue; }
|
|
169
|
+
// High surrogate followed by a low surrogate is a complete astral
|
|
170
|
+
// character — keep it. Either half alone is debris.
|
|
171
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
172
|
+
const next = text.charCodeAt(i + 1);
|
|
173
|
+
if (next >= 0xdc00 && next <= 0xdfff) break;
|
|
174
|
+
i += 1;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (code >= 0xdc00 && code <= 0xdfff) { i += 1; continue; }
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 2. Escape-sequence damage. Only a *leading* fragment can be torn: anything
|
|
182
|
+
// at or after the first ESC still has its introducer, so it will parse.
|
|
183
|
+
// Scan to the first ESC (or to a control character that resynchronises the
|
|
184
|
+
// parser anyway) and drop everything before it — but only if what precedes
|
|
185
|
+
// it actually looks like the tail of a sequence rather than ordinary text,
|
|
186
|
+
// so a buffer that legitimately starts mid-line is left alone.
|
|
187
|
+
const rest = text.slice(i);
|
|
188
|
+
const danglingLength = danglingEscapeTailLength(rest);
|
|
189
|
+
return danglingLength > 0 ? rest.slice(danglingLength) : rest;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Length of the leading run that is the tail of a cut escape sequence, or 0
|
|
194
|
+
* when the text starts cleanly.
|
|
195
|
+
*
|
|
196
|
+
* A cut CSI leaves one of:
|
|
197
|
+
* `[2J…` `2J…` `J…` (introducer and/or parameters lost)
|
|
198
|
+
* and a cut OSC leaves parameter/payload text terminated by BEL or ST. What
|
|
199
|
+
* they have in common is that the *remaining* prefix is a run of parameter
|
|
200
|
+
* bytes ending at a final byte, all of it before the next ESC. Ordinary output
|
|
201
|
+
* only matches that shape when it happens to consist solely of parameter
|
|
202
|
+
* characters, so the scan stops at the first character that cannot appear in a
|
|
203
|
+
* sequence — a letter mid-word, a space, a newline — and reports 0.
|
|
204
|
+
*/
|
|
205
|
+
function danglingEscapeTailLength(text: string): number {
|
|
206
|
+
if (!text || text.charCodeAt(0) === 0x1b) return 0;
|
|
207
|
+
|
|
208
|
+
let i = 0;
|
|
209
|
+
// An orphaned `[` is the most common shape (the ESC alone was evicted).
|
|
210
|
+
const hasIntroducer = text[0] === '[' || text[0] === ']';
|
|
211
|
+
if (hasIntroducer) i = 1;
|
|
212
|
+
|
|
213
|
+
const start = i;
|
|
214
|
+
// Parameter bytes only: digits, `;`, `?`, `:` and the private markers.
|
|
215
|
+
// Deliberately NOT the intermediate bytes (space, `!`, `"`, `$`, `'`):
|
|
216
|
+
// including space made ordinary prose match — "2 files changed" scans `2`,
|
|
217
|
+
// ` `, then treats `f` as a CSI final and eats "2 f". Sequences that use
|
|
218
|
+
// intermediates are rare enough that leaving their tail in place is far
|
|
219
|
+
// cheaper than truncating real output.
|
|
220
|
+
while (i < text.length && /[0-9;?:<=>]/.test(text[i])) i += 1;
|
|
221
|
+
|
|
222
|
+
if (i >= text.length) return 0;
|
|
223
|
+
|
|
224
|
+
// OSC tail: ends at BEL or ST rather than a CSI final byte.
|
|
225
|
+
if (text[0] === ']') {
|
|
226
|
+
const bel = text.indexOf('\x07');
|
|
227
|
+
if (bel >= 0) return bel + 1;
|
|
228
|
+
return 0;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (!CSI_FINAL.test(text[i])) return 0;
|
|
232
|
+
|
|
233
|
+
// Require evidence of an actual sequence rather than a coincidence. With the
|
|
234
|
+
// orphaned `[` present the shape is already unambiguous. Without it, demand
|
|
235
|
+
// at least one parameter byte followed by one of the final bytes terminals
|
|
236
|
+
// actually emit — `32m` and `2J` are sequences; "J is a letter" has no
|
|
237
|
+
// parameter byte, and "2 files changed" ends its digit run at a space, which
|
|
238
|
+
// is not in the parameter class at all.
|
|
239
|
+
if (!hasIntroducer) {
|
|
240
|
+
if (i === start) return 0;
|
|
241
|
+
if (!CSI_COMMON_FINAL.test(text[i])) return 0;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return i + 1;
|
|
84
245
|
}
|