dsh-ssh-tui 0.8.3 → 0.8.4-rc.2

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,415 @@
1
+ /**
2
+ * The input Reader: bytes in, framed input units out.
3
+ *
4
+ * This module owns everything between the wire and "here is what arrived":
5
+ *
6
+ * - **byte decoding** — a UTF-8 code point split across two reads is one
7
+ * character, not two replacement glyphs (`StringDecoder`)
8
+ * - **device-reply filtering** — the `CSI row;col R` answers the link probe asks
9
+ * for are not typing; the guard strips them and holds a possible tail for
10
+ * `INPUT_HOLD_MS` so `ESC [ 1` + `7;1R` cannot become `[17;1R` in the draft
11
+ * - **escape assembly** — a sequence may arrive in pieces (`ESC`, then `[A`;
12
+ * `ESC[`, then `5~`), so a read that could still grow is held for
13
+ * {@link ESCAPE_HOLD_MS} and released as what it turned out to be
14
+ * - **paste framing** — `ESC[200~ … ESC[201~` markers, the payload between them,
15
+ * and the console-without-bracketed-paste fallback (a burst carrying more than
16
+ * one line break in one read is a paste, not repeated Enter presses)
17
+ * - **unit classification** — whether a read is one complete terminal unit, a
18
+ * burst of typing, paste traffic, or a device reply (shape only: this module
19
+ * does not know which key does what)
20
+ *
21
+ * It owns **no** semantics. It does not look at `screen`, `dialog`, the composer or
22
+ * the workspace, does not consult the keymap, and never mutates owner state: the
23
+ * frames it emits are the input to the caller's routing, not a decision about it.
24
+ * That is the boundary the contract freezes (`docs/decisions/input-reader.md`,
25
+ * AD-21) — the same input bytes must produce the same frames, and the caller must
26
+ * be able to prove it (`tests/input-reader.test.mjs` compares the frames and the
27
+ * consumer calls against traces captured before this module existed).
28
+ *
29
+ * The two hold windows are deliberately separate and must not be merged:
30
+ * `INPUT_HOLD_MS` (30 ms, in the guard) guards a **reply** and hands the bytes back
31
+ * as input when the window passes; {@link ESCAPE_HOLD_MS} (60 ms, here) guards a
32
+ * **keystroke** and turns what it held into an Escape or into text. One is a filter
33
+ * with a fallback, the other an assembly step with two possible readings.
34
+ *
35
+ * @module dsh-ssh-tui/input-reader
36
+ */
37
+ import { StringDecoder } from 'node:string_decoder';
38
+ import { isEscapePrefix, parseCursorPositionReply } from './paint.js';
39
+ import { TerminalInputGuard } from './terminal-input.js';
40
+ /**
41
+ * How long a read that could still grow into a sequence is held.
42
+ *
43
+ * The window is what makes a lone `ESC` (the reader pressed Escape) different from
44
+ * the first byte of a sequence the terminal is still sending. W-1 moved the timer,
45
+ * not the number: changing it changes how Escape feels, and that is a separate
46
+ * decision with its own evidence.
47
+ */
48
+ export const ESCAPE_HOLD_MS = 60;
49
+ /** Whether `text` is exactly one complete terminal unit (shape, not meaning). */
50
+ const COMPLETE_CSI = /^\x1b\[[0-9;?<]*[ -/]*[@-~]$/u;
51
+ const COMPLETE_SS3 = /^\x1bO[@-~]$/u;
52
+ /** The same shapes, anchored at the start: one complete unit of a longer read. */
53
+ const COMPLETE_CSI_AT = /^\x1b\[[0-9;?<]*[ -/]*[@-~]/u;
54
+ const COMPLETE_SS3_AT = /^\x1bO[@-~]/u;
55
+ /**
56
+ * `ESC` plus one printable character: the Alt chord.
57
+ *
58
+ * Not `[ -~]` alone: a terminal can send Alt with any character it can encode, and the
59
+ * chord has to reach the Alt branch in one piece or its character leaks as text.
60
+ */
61
+ const ALT_CHORD_AT = /^\x1b(?!\[|O)[^\x00-\x1f\x7f]/u;
62
+ const PASTE_MARKERS = ['\x1b[200~', '\x1b[201~'];
63
+ /**
64
+ * How long a paste may sit with half a marker before the reader gives up on it.
65
+ *
66
+ * This is a watchdog, not a key hold: a paste is a stream, and its close marker may
67
+ * arrive in a second chunk after a slow terminal, a ConPTY or a loaded runner — on
68
+ * Windows CI the gap was over 200 ms, and expiring that early typed the marker's tail
69
+ * into the draft and left the session inside the paste. So the window is generous.
70
+ * When it does expire the reader does not just release the tail as payload, it
71
+ * **leaves the paste**: a paste that never closed must not keep the keyboard as its
72
+ * payload for the rest of the session.
73
+ */
74
+ export const PASTE_MARKER_HOLD_MS = 2_000;
75
+ /**
76
+ * The length of the longest suffix of `text` that is a *proper* prefix of a paste
77
+ * marker (0 when there is none) — what a read cut in half leaves behind.
78
+ */
79
+ function pasteMarkerSuffix(text) {
80
+ const longest = Math.max(...PASTE_MARKERS.map(marker => marker.length)) - 1;
81
+ for (let length = Math.min(longest, text.length); length > 0; length -= 1) {
82
+ const suffix = text.slice(text.length - length);
83
+ if (PASTE_MARKERS.some(marker => marker.startsWith(suffix)))
84
+ return length;
85
+ }
86
+ return 0;
87
+ }
88
+ export class InputReader {
89
+ options;
90
+ decoder = new StringDecoder('utf8');
91
+ guard;
92
+ /** A read that may still grow (escape assembly). */
93
+ held = '';
94
+ holdTimer;
95
+ /** Inside `ESC[200~ … ESC[201~`. */
96
+ inPaste = false;
97
+ /**
98
+ * The tail of a read that ended *inside* a paste marker (`…ESC[20`), waiting for
99
+ * its other half.
100
+ */
101
+ pasteTail = '';
102
+ pasteTailTimer;
103
+ constructor(options) {
104
+ this.options = options;
105
+ this.guard = new TerminalInputGuard(text => this.options.onRead(text), undefined,
106
+ // The filter must not mistake a paste's close marker for half a cursor reply.
107
+ { isPasteTraffic: text => this.isPasteTraffic(text) });
108
+ }
109
+ /** One read of bytes: decode it, drop device replies, hand the rest on. */
110
+ push(chunk) {
111
+ const decoded = this.decoder.write(chunk);
112
+ if (decoded === '')
113
+ return;
114
+ this.guard.push(decoded);
115
+ }
116
+ /** The read with any held prefix in front of it. The hold window is over. */
117
+ assemble(text) {
118
+ const combined = this.held + text;
119
+ this.held = '';
120
+ this.cancelHold();
121
+ return combined;
122
+ }
123
+ /**
124
+ * The frames of one read, whichever kind it is.
125
+ *
126
+ * A read is what the terminal happened to send, not what the reader pressed: a
127
+ * fast typist, a slow link or ConPTY can put several keys in one read. This is the
128
+ * one place that decides where one unit ends and the next begins; everything above
129
+ * it (Router, owners) sees complete units only, and nothing above it re-parses the
130
+ * bytes (AD-20 §4.4, §6).
131
+ */
132
+ read(text) {
133
+ const combined = this.pasteTail === '' ? text : `${this.pasteTail}${text}`;
134
+ if (this.pasteTail !== '') {
135
+ this.pasteTail = '';
136
+ this.cancelPasteTail();
137
+ }
138
+ if (!this.isPasteTraffic(combined))
139
+ return this.units(combined);
140
+ const { frames, tail } = this.framePaste(combined);
141
+ this.pasteTail = tail;
142
+ // Armed for the whole paste, not just for a held tail: a terminal can swallow the
143
+ // close marker outright (ConPTY drops a partial CSI), and a paste that never says
144
+ // it ended must not keep the keyboard as its payload.
145
+ if (this.inPaste || tail !== '')
146
+ this.armPasteTail();
147
+ else
148
+ this.cancelPasteTail();
149
+ return frames;
150
+ }
151
+ /**
152
+ * Frame one paste read, holding back a marker it cut in half.
153
+ *
154
+ * A terminal writes bytes, not reads: `ESC[201~` can arrive as `…ESC[20` and `1~`.
155
+ * Framed naively, the first read swallows `ESC[20` as payload and the second one
156
+ * carries no marker at all — so `inPaste` never clears and **every later key is
157
+ * pasted text**: Esc stops closing a dialog, `/exit` stops exiting. Found by
158
+ * `scripts/tui-paste-probe.mjs` on CI, where the pty chunked the write exactly
159
+ * there. The fix is the same rule the escape ladder already uses — a read that
160
+ * could still grow is held, and released as payload if its other half never comes.
161
+ */
162
+ framePaste(combined) {
163
+ const keep = pasteMarkerSuffix(combined);
164
+ if (keep === 0)
165
+ return { frames: this.frames(combined), tail: '' };
166
+ const body = combined.slice(0, combined.length - keep);
167
+ return { frames: this.frames(body), tail: combined.slice(combined.length - keep) };
168
+ }
169
+ armPasteTail() {
170
+ this.cancelPasteTail();
171
+ this.pasteTailTimer = setTimeout(() => {
172
+ this.pasteTailTimer = undefined;
173
+ const pending = this.pasteTail;
174
+ this.pasteTail = '';
175
+ // Its other half never came. Those bytes are what the reader pasted, and the
176
+ // paste they belonged to is over: leaving `inPaste` set here is what turned a
177
+ // slow (or swallowed) marker into a session that ignored every key.
178
+ if (pending !== '')
179
+ this.options.onFrame({ kind: this.inPaste ? 'paste' : 'literal', text: pending });
180
+ this.inPaste = false;
181
+ }, this.options.pasteMarkerHoldMs ?? PASTE_MARKER_HOLD_MS);
182
+ }
183
+ cancelPasteTail() {
184
+ if (this.pasteTailTimer !== undefined)
185
+ clearTimeout(this.pasteTailTimer);
186
+ this.pasteTailTimer = undefined;
187
+ }
188
+ /**
189
+ * One non-paste read, split into complete units.
190
+ *
191
+ * The rules, and why each one exists:
192
+ *
193
+ * - **A complete CSI/SS3 sequence is one unit.** `ESC [ 5 ~ ESC [ 5 ~` is two page
194
+ * keys, not one unnameable read (G-1). A sequence the read cuts short stays one
195
+ * piece: `hold()` has already claimed it if it could still grow, and a trailing
196
+ * `ESC [` must reach the hold again rather than be split into `ESC` + `[`.
197
+ * - **An Alt chord is one unit.** `ESC` plus exactly one printable character is
198
+ * Alt+that key, so `ESC 1 x` is the chord Alt+1 *and then* `x` — never the text
199
+ * `1x` (G-3), and never a chord that swallows the character behind it.
200
+ * - **A control byte is one unit, CR/LF pair as one Enter.** `\x10\x10` is two
201
+ * presses of a rebound key (G-2/G-8), and Windows' CRLF is one key.
202
+ * - **A run of ordinary characters is one unit.** Splitting typing per character
203
+ * would make a pasted `yes` answer a confirm dialog character by character,
204
+ * which is the boundary W-0 pinned (`yes-typed-at-dialog`). Keymap lookup on a
205
+ * whole run is still exactly what a run means: text.
206
+ * - **A burst the paste heuristic claims stays whole.** A console without
207
+ * bracketed paste sends a paste as ordinary bytes with line breaks in them;
208
+ * that read has to reach the paste path intact (`looksLikePasteBurst`).
209
+ */
210
+ units(text) {
211
+ if (InputReader.looksLikePasteBurst(text))
212
+ return [{ kind: 'burst', text }];
213
+ const frames = [];
214
+ let index = 0;
215
+ while (index < text.length) {
216
+ const char = text[index];
217
+ if (char === '\x1b') {
218
+ const rest = text.slice(index);
219
+ const sequence = COMPLETE_CSI_AT.exec(rest)?.[0] ?? COMPLETE_SS3_AT.exec(rest)?.[0];
220
+ if (sequence !== undefined) {
221
+ frames.push({ kind: 'key', sequence });
222
+ index += sequence.length;
223
+ continue;
224
+ }
225
+ // Something that could still grow into a sequence — `ESC [ 5` with its final
226
+ // byte in the next read, a mouse report cut in half — goes on as one piece, so
227
+ // the hold below claims exactly what it claimed before: splitting it here would
228
+ // turn the head of a sequence into an Alt chord and type its tail as text.
229
+ if (isEscapePrefix(rest)) {
230
+ frames.push({ kind: 'key', sequence: rest });
231
+ index += rest.length;
232
+ continue;
233
+ }
234
+ const chord = ALT_CHORD_AT.exec(rest)?.[0];
235
+ if (chord !== undefined) {
236
+ frames.push({ kind: 'key', sequence: chord });
237
+ index += chord.length;
238
+ continue;
239
+ }
240
+ // A lone ESC, or something that cannot grow: its own unit, and the hold decides.
241
+ frames.push({ kind: 'key', sequence: '\x1b' });
242
+ index += 1;
243
+ continue;
244
+ }
245
+ if (char === '\r' || char === '\n') {
246
+ const next = text[index + 1];
247
+ frames.push({ kind: 'key', sequence: char === '\n' ? '\n' : '\r' });
248
+ index += (char === '\r' && next === '\n') || (char === '\n' && next === '\r') ? 2 : 1;
249
+ continue;
250
+ }
251
+ if (char < ' ' || char === '\x7f') {
252
+ frames.push({ kind: 'key', sequence: char });
253
+ index += 1;
254
+ continue;
255
+ }
256
+ let end = index;
257
+ while (end < text.length && text[end] >= ' ' && text[end] !== '\x7f' && text[end] !== '\x1b')
258
+ end += 1;
259
+ frames.push({ kind: 'burst', text: text.slice(index, end) });
260
+ index = end;
261
+ }
262
+ return frames;
263
+ }
264
+ /**
265
+ * Whether a read is paste traffic: inside a paste, or carrying a marker.
266
+ *
267
+ * Asked before anything else, exactly as the inline check it replaces was: while
268
+ * inside a paste, CR/LF are literal characters rather than submit.
269
+ */
270
+ isPasteTraffic(combined) {
271
+ return this.inPaste || combined.includes('\x1b[200~') || combined.includes('\x1b[201~');
272
+ }
273
+ /**
274
+ * The frames of one paste read, in order: payload inside the markers as `paste`,
275
+ * everything outside them as `literal` (which is what the inline version typed
276
+ * directly, without a keymap lookup).
277
+ *
278
+ * Only complete markers are handled here: `framePaste` holds back a read that ends
279
+ * inside one, because a marker whose two halves land in two reads would otherwise
280
+ * leave the reader inside the paste for the rest of the session.
281
+ */
282
+ frames(combined) {
283
+ const frames = [];
284
+ let index = 0;
285
+ while (index < combined.length) {
286
+ if (combined.startsWith('\x1b[200~', index)) {
287
+ this.inPaste = true;
288
+ index += 6;
289
+ continue;
290
+ }
291
+ if (combined.startsWith('\x1b[201~', index)) {
292
+ this.inPaste = false;
293
+ index += 6;
294
+ continue;
295
+ }
296
+ let end = index;
297
+ while (end < combined.length
298
+ && !combined.startsWith('\x1b[200~', end)
299
+ && !combined.startsWith('\x1b[201~', end)) {
300
+ end += 1;
301
+ }
302
+ if (end > index) {
303
+ const part = combined.slice(index, end);
304
+ frames.push(this.inPaste ? { kind: 'paste', text: part } : { kind: 'literal', text: part });
305
+ index = end;
306
+ }
307
+ else {
308
+ index += 1;
309
+ }
310
+ }
311
+ return frames;
312
+ }
313
+ /**
314
+ * Keep a read that could still grow into a sequence.
315
+ * @returns true when it was held (the caller stops there), false when the read is
316
+ * complete as it stands.
317
+ */
318
+ hold(combined) {
319
+ if (!isEscapePrefix(combined))
320
+ return false;
321
+ this.held = combined;
322
+ this.cancelHold();
323
+ this.holdTimer = setTimeout(() => {
324
+ this.holdTimer = undefined;
325
+ const pending = this.held;
326
+ this.held = '';
327
+ this.releaseHold(pending);
328
+ }, ESCAPE_HOLD_MS);
329
+ return true;
330
+ }
331
+ /**
332
+ * Whether one read is a paste from a console that cannot say so.
333
+ *
334
+ * A burst carrying more than one line break is not a human pressing Enter twice —
335
+ * they cannot press it twice inside one read. This is the fallback's one rule, and
336
+ * it lives here so every caller applies the same one.
337
+ */
338
+ static looksLikePasteBurst(text) {
339
+ let breaks = 0;
340
+ for (let index = 0; index < text.length; index += 1) {
341
+ const char = text[index];
342
+ if (char === '\r' && text[index + 1] !== '\n')
343
+ breaks += 1;
344
+ else if (char === '\n' && text[index - 1] !== '\r')
345
+ breaks += 1;
346
+ if (breaks > 1)
347
+ return true;
348
+ }
349
+ return false;
350
+ }
351
+ /** Whether a complete read is a device reply rather than input. */
352
+ isDeviceReply(text) {
353
+ return parseCursorPositionReply(text) !== undefined;
354
+ }
355
+ /** Classify one complete read by shape. */
356
+ classify(text) {
357
+ if (this.isDeviceReply(text))
358
+ return { kind: 'reply', sequence: text };
359
+ if (text === '\x1b')
360
+ return { kind: 'key', sequence: text };
361
+ if (text.length === 1) {
362
+ const code = text.codePointAt(0) ?? 0;
363
+ return code < 0x20 || code === 0x7f ? { kind: 'key', sequence: text } : { kind: 'burst', text };
364
+ }
365
+ if (COMPLETE_CSI.test(text) || COMPLETE_SS3.test(text))
366
+ return { kind: 'key', sequence: text };
367
+ return { kind: 'burst', text };
368
+ }
369
+ /**
370
+ * Hand over a held reply tail now.
371
+ *
372
+ * The guard releases on its own window; this is for a caller that already knows
373
+ * the tail is stale — `scripts/bench-b2.mjs` drives keystrokes faster than the
374
+ * hold, and a measurement that waited for the window would be measuring the wait.
375
+ */
376
+ releaseReplyTail() {
377
+ this.guard.release();
378
+ }
379
+ /** Drop the holds and the reply filter (display detached, teardown). */
380
+ stop() {
381
+ this.cancelHold();
382
+ this.cancelPasteTail();
383
+ this.pasteTail = '';
384
+ this.guard.stop();
385
+ }
386
+ cancelHold() {
387
+ if (this.holdTimer !== undefined)
388
+ clearTimeout(this.holdTimer);
389
+ this.holdTimer = undefined;
390
+ }
391
+ /**
392
+ * What a held read turned out to be.
393
+ *
394
+ * Verbatim from the inline timer it replaces: a lone `ESC` is the Escape key, an
395
+ * `ESC O` without its final byte is Alt+O, an escape sequence that never completed
396
+ * is consumed silently (its `ESC` must not read as a cancel), and anything else is
397
+ * typed. The last branch is unreachable for the shapes `isEscapePrefix` accepts —
398
+ * it is kept because "unreachable today" is not a reason to change a release.
399
+ */
400
+ releaseHold(pending) {
401
+ if (pending === '\x1b') {
402
+ this.options.onFrame({ kind: 'escape' });
403
+ return;
404
+ }
405
+ if (pending === '\x1bO') {
406
+ this.options.onFrame({ kind: 'literal', text: 'O' });
407
+ return;
408
+ }
409
+ if (pending.startsWith('\x1b[') || pending.startsWith('\x1bO'))
410
+ return;
411
+ if (pending !== '')
412
+ this.options.onFrame({ kind: 'literal', text: pending });
413
+ }
414
+ }
415
+ //# sourceMappingURL=input-reader.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"input-reader.js","sourceRoot":"","sources":["../src/input-reader.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The InputRouter: one unit in, exactly one owner out.
3
+ *
4
+ * W-1 taught the TUI *what arrived* (`InputFrame`). This module answers the other
5
+ * question — *who takes it* — and it is the only place that may. The four owners
6
+ * (Screen, Surface, Composer, Workspace) each have an adapter in `tui.ts`; an
7
+ * adapter may read its own state, but it may not decide cross-owner ownership
8
+ * again, and it may not hand a unit on to the next owner when it does not use it
9
+ * (AD-20 §4.3, §4.5 F-1/F-6).
10
+ *
11
+ * The decision is a pure function of `(unit kind, unit, ownership snapshot)`: no
12
+ * app state is read or written, nothing is painted, no transcript row is pushed,
13
+ * and the snapshot is a plain object the caller keeps and overwrites, so a
14
+ * keystroke costs no allocation (W-2 is a hot-path migration).
15
+ *
16
+ * The rules are the ones `tui.ts` already applied inline, moved here verbatim —
17
+ * including one asymmetry that is **recorded debt, not a decision**:
18
+ *
19
+ * 1. `Ctrl+L` is handled by the Screen when a Screen is up and by the Composer
20
+ * otherwise; the repaint has two implementations and W-2 does not merge them
21
+ * (AD-20 §6: only a test that proves both owners repaint identically may).
22
+ * W-3b.2 owns that one.
23
+ *
24
+ * W-3b.1 closed the other asymmetry this module used to record:
25
+ * `SURFACE_COMPOSER_KEYS` sent `Home`/`End`/`Delete`/kitty-`Ctrl+Shift+C` to the
26
+ * composer while a Surface was up. The Surface owns them now, like every other unit,
27
+ * and its adapter decides what they mean — the field it borrowed, or nothing.
28
+ *
29
+ * @module dsh-ssh-tui/input-router
30
+ */
31
+ const PAGE_UP = '\x1b[5~';
32
+ const PAGE_DOWN = '\x1b[6~';
33
+ const ARROW_UP = '\x1b[A';
34
+ const ARROW_DOWN = '\x1b[B';
35
+ const SGR_MOUSE = /^\x1b\[<\d+;\d+;\d+[Mm]$/u;
36
+ export class InputRouter {
37
+ /** The one cross-owner decision. One unit in, one owner out. */
38
+ route(kind, unit, view) {
39
+ // A Screen replaces the workspace and owns the keyboard outright (AD-20 §4.3,
40
+ // priority 3). This single line is why no Screen event can reach the composer
41
+ // behind it: the owners below are never consulted.
42
+ if (view.screenKind !== undefined)
43
+ return 'screen';
44
+ // Paste is text: the field that owns the keyboard takes it. A Screen already
45
+ // terminated the paste above (it does not fall through to a field behind it), and
46
+ // a Surface without a field refuses it in its own adapter — the decision is still
47
+ // "the Surface", never the composer behind it (AD-20 §4.3, priority 1).
48
+ if (kind === 'paste')
49
+ return view.surfaceKind !== undefined ? 'surface' : 'composer';
50
+ // A Surface owns everything while it is up — including the composer's own editing
51
+ // sequences: `Home`/`End`/`Delete` and the kitty copy key are the Surface's units
52
+ // since W-3b.1, and its adapter edits the field it borrowed or drops them. The
53
+ // composer behind it is not the field on screen, so nothing here hands a unit to it
54
+ // by default.
55
+ if (view.surfaceKind !== undefined) {
56
+ if (kind === 'control' && unit === '\x0c')
57
+ return 'composer';
58
+ return 'surface';
59
+ }
60
+ return this.composerOrWorkspace(kind, unit, view);
61
+ }
62
+ /**
63
+ * With nothing layered over the workspace, the Composer owns text and its editing
64
+ * keys, and the Workspace owns the transcript: the mouse, the page keys, and the
65
+ * focus ring — which an arrow reaches only while the field is empty and there is a
66
+ * card to land on (otherwise the same arrow browses history, and history is the
67
+ * composer's).
68
+ */
69
+ composerOrWorkspace(kind, unit, view) {
70
+ if (kind !== 'sequence')
71
+ return 'composer';
72
+ if (SGR_MOUSE.test(unit))
73
+ return 'workspace';
74
+ if (unit === PAGE_UP || unit === PAGE_DOWN)
75
+ return 'workspace';
76
+ if (unit === ARROW_UP || unit === ARROW_DOWN) {
77
+ return view.composerEmpty && view.workspaceCards ? 'workspace' : 'composer';
78
+ }
79
+ return 'composer';
80
+ }
81
+ }
82
+ //# sourceMappingURL=input-router.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"input-router.js","sourceRoot":"","sources":["../src/input-router.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAoCH,MAAM,OAAO,GAAG,SAAS,CAAA;AACzB,MAAM,SAAS,GAAG,SAAS,CAAA;AAC3B,MAAM,QAAQ,GAAG,QAAQ,CAAA;AACzB,MAAM,UAAU,GAAG,QAAQ,CAAA;AAC3B,MAAM,SAAS,GAAG,2BAA2B,CAAA;AAE7C,MAAM,OAAO,WAAW;IACtB,gEAAgE;IAChE,KAAK,CAAC,IAAc,EAAE,IAAY,EAAE,IAAuB;QACzD,8EAA8E;QAC9E,8EAA8E;QAC9E,mDAAmD;QACnD,IAAI,IAAI,CAAC,UAAU,KAAK,SAAS;YAAE,OAAO,QAAQ,CAAA;QAClD,6EAA6E;QAC7E,kFAAkF;QAClF,kFAAkF;QAClF,wEAAwE;QACxE,IAAI,IAAI,KAAK,OAAO;YAAE,OAAO,IAAI,CAAC,WAAW,KAAK,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,UAAU,CAAA;QACpF,kFAAkF;QAClF,kFAAkF;QAClF,+EAA+E;QAC/E,oFAAoF;QACpF,cAAc;QACd,IAAI,IAAI,CAAC,WAAW,KAAK,SAAS,EAAE,CAAC;YACnC,IAAI,IAAI,KAAK,SAAS,IAAI,IAAI,KAAK,MAAM;gBAAE,OAAO,UAAU,CAAA;YAC5D,OAAO,SAAS,CAAA;QAClB,CAAC;QACD,OAAO,IAAI,CAAC,mBAAmB,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,CAAA;IACnD,CAAC;IAED;;;;;;OAMG;IACK,mBAAmB,CAAC,IAAc,EAAE,IAAY,EAAE,IAAuB;QAC/E,IAAI,IAAI,KAAK,UAAU;YAAE,OAAO,UAAU,CAAA;QAC1C,IAAI,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC;YAAE,OAAO,WAAW,CAAA;QAC5C,IAAI,IAAI,KAAK,OAAO,IAAI,IAAI,KAAK,SAAS;YAAE,OAAO,WAAW,CAAA;QAC9D,IAAI,IAAI,KAAK,QAAQ,IAAI,IAAI,KAAK,UAAU,EAAE,CAAC;YAC7C,OAAO,IAAI,CAAC,aAAa,IAAI,IAAI,CAAC,cAAc,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,UAAU,CAAA;QAC7E,CAAC;QACD,OAAO,UAAU,CAAA;IACnB,CAAC;CACF"}
@@ -255,10 +255,16 @@ export function duplicatePatchRows(rows) {
255
255
  }
256
256
  return duplicates;
257
257
  }
258
- /** Whether a patch already declares one roster row, by id or by module name. */
258
+ /**
259
+ * Whether a patch already addresses one roster row, by id or by module name.
260
+ *
261
+ * Every kind counts: an override or a disable targets the row just as an insert
262
+ * does. A repair must leave an intentional `- id: present` / `disabled: true`
263
+ * alone instead of mounting the same id a second time; the later entry would
264
+ * either collide with it or silently undo the user's choice.
265
+ */
259
266
  export function patchNamesRow(rows, row) {
260
- return rows.some(candidate => (candidate.kind === 'insert' && candidate.id === row.id)
261
- || candidate.name === row.name);
267
+ return rows.some(candidate => candidate.id === row.id || candidate.name === row.name);
262
268
  }
263
269
  /** The roster rows a patch does not declare yet. */
264
270
  export function missingRosterRows(rows, candidates = ROSTER_ROWS) {
@@ -287,12 +293,20 @@ function normalizeEnd(text) {
287
293
  * @param missing - the roster rows to mount; defaults to the 0.1.5 set.
288
294
  * @param generation - which host line the profile boots on.
289
295
  * @returns the new text, or `undefined` when nothing has to change.
296
+ * @throws when `existing` is not valid YAML; appending to a broken patch would
297
+ * make it longer without making it bootable.
290
298
  */
291
299
  export function planRosterRepair(existing, missing = ROSTER_ROWS, generation = 'legacy') {
292
300
  const analysis = analyzePatch(existing);
293
- const toAdd = analysis.parseable
294
- ? missing.filter(row => !patchNamesRow(analysis.rows, row))
295
- : [...missing];
301
+ // A patch the loader cannot parse is the one case where appending a fresh
302
+ // block is not a repair: `/mode fix` and the first-run repair would turn a
303
+ // user's malformed override into a file that still cannot boot, while
304
+ // claiming they had been fixed. Leave it to `/doctor`, which reports the
305
+ // parser error, and refuse the write rather than making the file longer.
306
+ if (!analysis.parseable) {
307
+ throw new Error(`profile patch does not parse; refusing to add rows: ${analysis.error ?? 'unknown YAML error'}`);
308
+ }
309
+ const toAdd = missing.filter(row => !patchNamesRow(analysis.rows, row));
296
310
  if (toAdd.length === 0)
297
311
  return undefined;
298
312
  const entry = `${rosterPatchHeader(generation)}${rosterEntriesText(toAdd)}`;
@@ -394,41 +408,73 @@ export function planDuplicateRepair(existing) {
394
408
  /**
395
409
  * Mount the roster in one profile, unless it is already composed.
396
410
  *
397
- * Idempotent: a profile whose patch already names the roster row (one bundling
398
- * `@deepseek-ai/dsh-web-app`, for example) is left untouched. The write is a
399
- * plain whole-file replace because the file is small and read once at boot; a
411
+ * Idempotent: a profile whose patch already addresses the roster row (one
412
+ * bundling `@deepseek-ai/dsh-web-app`, for example) is left untouched. An
413
+ * existing file is backed up before the small whole-file replace; a
400
414
  * half-written patch would only be seen by the next launch.
401
415
  * @param home - the harness home carrying `profiles/`.
402
416
  * @param profile - the profile to patch.
403
417
  * @param missing - the rows to mount; defaults to the 0.1.5 set.
404
418
  * @param generation - which host line the profile boots on.
419
+ * @param stamp - filename-safe backups stamp, injectable for tests.
405
420
  * @returns `present` when the row already exists, else `written`.
421
+ * @throws when the existing patch cannot be read, is not valid YAML, or cannot
422
+ * be backed up; a file that cannot be preserved is not overwritten.
406
423
  */
407
- export async function ensureRosterRows(home, profile, missing = ROSTER_ROWS, generation = 'legacy') {
424
+ export async function ensureRosterRows(home, profile, missing = ROSTER_ROWS, generation = 'legacy', stamp) {
408
425
  const path = rosterPatchPath(home, profile);
409
- let existing = PATCH_TEMPLATE;
426
+ let existing;
410
427
  try {
411
428
  existing = await readFile(path, 'utf8');
412
429
  }
413
- catch {
414
- // Missing file: start from the template the launcher would have written.
430
+ catch (error) {
431
+ // Only a missing file means "start from the template". EACCES, EISDIR,
432
+ // EIO and every other read failure must abort the repair: treating them as
433
+ // absence would overwrite a profile the process could not read.
434
+ if (!isEnoent(error)) {
435
+ throw new Error(`cannot read profile patch ${path}; refusing to repair: ${errorMessage(error)}`, { cause: error });
436
+ }
415
437
  }
416
- const repair = planRosterRepair(existing, missing, generation);
438
+ const repair = planRosterRepair(existing ?? PATCH_TEMPLATE, missing, generation);
417
439
  if (repair === undefined)
418
440
  return 'present';
419
- await mkdir(dirname(path), { recursive: true });
420
- await writeFile(path, repair.text, 'utf8');
441
+ if (existing === undefined) {
442
+ await mkdir(dirname(path), { recursive: true });
443
+ await writeFile(path, repair.text, 'utf8');
444
+ }
445
+ else {
446
+ // The same rule `/doctor --fix` follows: a file the user owns is backed up
447
+ // before it is replaced, and a failed backup aborts the write.
448
+ await writePatchWithBackup(path, repair.text, stamp);
449
+ }
421
450
  return 'written';
422
451
  }
452
+ /** A filesystem error's code, when it has one. */
453
+ function errorCode(error) {
454
+ return typeof error === 'object' && error !== null && 'code' in error
455
+ ? String(error.code)
456
+ : undefined;
457
+ }
458
+ /** A human-readable message for a caught filesystem error. */
459
+ function errorMessage(error) {
460
+ return error instanceof Error ? error.message : String(error);
461
+ }
462
+ /** Whether a read failure means the file is absent, and nothing else. */
463
+ function isEnoent(error) {
464
+ return errorCode(error) === 'ENOENT';
465
+ }
423
466
  /**
424
467
  * Write a repaired patch next to a copy of the previous content.
425
468
  *
426
- * `/doctor` changes a file the user owns, so the old text is kept beside it
427
- * before anything is replaced. A file that does not exist yet needs no backup.
469
+ * `/doctor` and the automatic repair change a file the user owns, so the old
470
+ * text is kept beside it before anything is replaced. A source that vanished
471
+ * between the read and the copy needs no backup; any other backup failure
472
+ * aborts before the write, leaving the original untouched.
428
473
  * @param path - the patch file to replace.
429
474
  * @param text - the new content.
430
475
  * @param stamp - filename-safe timestamp for the backup, injectable for tests.
431
476
  * @returns the backup path, or `undefined` when there was nothing to back up.
477
+ * @throws when the backup cannot be written; the destination is not touched.
432
478
  */
433
479
  export async function writePatchWithBackup(path, text, stamp = new Date().toISOString().replace(/[:.]/gu, '-')) {
434
480
  let backup;
@@ -436,7 +482,12 @@ export async function writePatchWithBackup(path, text, stamp = new Date().toISOS
436
482
  backup = `${path}.bak-${stamp}`;
437
483
  await copyFile(path, backup);
438
484
  }
439
- catch {
485
+ catch (error) {
486
+ if (!isEnoent(error)) {
487
+ throw new Error(`cannot back up profile patch ${path}; refusing to write: ${errorMessage(error)}`, { cause: error });
488
+ }
489
+ // The source disappeared after the caller read it. There is no user content
490
+ // left to preserve, so the repair may create the file fresh.
440
491
  backup = undefined;
441
492
  }
442
493
  await mkdir(dirname(path), { recursive: true });