@aragon-agent/cli 0.6.26 → 0.6.28

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/CHANGELOG.md CHANGED
@@ -77,6 +77,57 @@ were written.
77
77
 
78
78
  ### Fixed
79
79
 
80
+ - **A large paste arrived as runs of digits the moment it was sent - on screen,
81
+ in the model's input, in the prompt history and in the session file.**
82
+ `sanitisePaste` deleted the C0/C1 *introducer byte* and kept the parameter
83
+ bytes, so `ESC[38;2;18;52;86m` reached the transcript as the literal text
84
+ `[38;2;18;52;86m`. The composer collapses a large paste into
85
+ `[Pasted text #N +20 lines]`, so nothing was visible until submit expanded
86
+ it: a measured 20-line coloured log produced 80 runs of digits, and the same
87
+ string was what `/context`, the ↑ history and the session checkpoint stored.
88
+ Cleaning is now SEQUENCE-based and has one owner (new
89
+ `input/control-sequences.ts`, ASCII-only, no new dependency): a complete
90
+ escape goes as a unit, a truncated one loses its introducer and parameters
91
+ but never the character after them, an unterminated OSC/DCS/APC/PM/SOS goes
92
+ to the end of its line and never across it, and bare text that merely looks
93
+ like a sequence body (`[38;2m` with no ESC) is left exactly as it is.
94
+ `tools/terminal-output.ts`'s `stripAnsi` becomes a thin wrapper over the same
95
+ syntax, so a tool row and a paste cannot drift apart (both directions are
96
+ pinned by a cross-check test, and `terminal-output.test.ts` is unchanged).
97
+ A **single-line** paste carrying ESC - which `classifyChunk` sent down the
98
+ keystroke path with no cleaning at all - is now framed and cleaned like any
99
+ other paste; measured on the real component that path either put a live ESC
100
+ plus `[38;2;18;52;86m` into the draft or threw an uncaught `TypeError` inside
101
+ Ink's key parser. win32-input-mode records inside a frame are translated
102
+ rather than deleted, so a `?9001h` paste yields its characters instead of 15
103
+ visible digits per key (or, with cleaning but no translation, nothing at all).
104
+ - **A pasted TAB-indented block or TSV table lined up differently before and
105
+ after sending.** One byte had three readings: the composer painted
106
+ four-column tab stops, the transcript handed the raw `\t` to the terminal
107
+ (eight), and the exit replay counted code units. New `ui/display-tab.ts`
108
+ expands a TAB by DISPLAY column (`string-width`, so a `CJK<TAB>X` line lands
109
+ where the terminal paints it); the composer's stop is the terminal's eight, and
110
+ `UserEntry`, `QueuedEntry` and the exit replay expand at paint time only -
111
+ the stored payload keeps its `\t`, so the model still receives the original
112
+ indentation. `wrappedRows` counts display columns too (with an ASCII fast
113
+ path, without which a 10 000-entry transcript timed out), because an estimate
114
+ that reads a TAB as one column keeps the row short while the painted row
115
+ grows - the under-estimate that file names as the unsafe direction.
116
+ - **`Ctrl+L` opened a window that failed with a PowerShell `ParserError` on
117
+ Windows.** The script handed to `-EncodedCommand` was the argv joined with
118
+ quotes but with no call operator, and PowerShell reads a quoted string in command
119
+ position as an *expression*: with node installed under `C:\Program Files\...`
120
+ the child was never created - `UnexpectedToken` at column 36 - while the parent
121
+ TUI still reported that the window opened. `powerShellScript` now emits
122
+ `& <argv...>`, which is the only form that runs a word of any shape as a
123
+ command. The failure fallback follows: it is now two labelled lines
124
+ (`PowerShell:` / `cmd.exe :`), because one line cannot be correct for both
125
+ parsers - and the `PowerShell:` line is byte-for-byte the script the window
126
+ receives. A leading `@` can no longer be left bare either, since `@name` is
127
+ PowerShell's splat operator and silently swallows an argument. New
128
+ `powershell-script-parse.test.ts` hands the generated scripts to the real
129
+ Windows PowerShell parser (and pins the old shape to its exact error), so this
130
+ class of defect has a guard instead of a green suite.
80
131
  - **A conversation saved mid-tool could be resumed into an illegal history.**
81
132
  A `tool_call` with no matching `tool_result` makes the next request fail. The
82
133
  checkpoint writer closes every dangling call on the copy it writes and re-checks
package/README.md CHANGED
@@ -539,7 +539,7 @@ completes, `Up` / `Down` moves the selection, `Esc` closes it.
539
539
  | `Ctrl+W`, `Alt+Backspace` | Delete the previous word |
540
540
  | `Ctrl+U` / `Ctrl+K` | Kill to line start / end |
541
541
  | `Up` / `Down` | 按视觉行移动;空草稿或正在浏览历史时,才在边界召回历史,避免覆盖新草稿 |
542
- | Paste | Inserted as-is up to 6 lines / 400 chars; larger collapses to `[Pasted text #1 +15 lines]`, which deletes as one unit and expands in full when you send. `--no-paste` restores the pre-0.6.3 behaviour |
542
+ | Paste | Inserted up to 6 lines / 400 chars; larger collapses to `[Pasted text #1 +15 lines]`, which deletes as one unit and expands in full when you send. Escape sequences are removed as sequences (so a coloured log arrives as its text, not as `[38;2;18;52;86m`), and a TAB is kept in the payload but laid out at the terminal's 8-column tab stops, so the draft and the echoed message line up. `--no-paste` restores the pre-0.6.3 behaviour |
543
543
  | `?` | Open help (empty input) |
544
544
 
545
545
  In **full-screen mode**, PgUp/PgDn, Shift+Up/Down and the right-edge scrollbar
@@ -1838,16 +1838,45 @@ The platform ladder, in order:
1838
1838
  The shell is `pwsh.exe` then `powershell.exe` on Windows, and `$SHELL` then `bash`
1839
1839
  then `/bin/sh` elsewhere. Windows receives the command as `-EncodedCommand` (base64
1840
1840
  of UTF-16LE), because that alphabet has no spaces, quotes or `cmd` metacharacters -
1841
- it survives `cmd` -> `start` -> PowerShell without a quoting argument.
1841
+ it survives `cmd` -> `start` -> PowerShell without a quoting argument. The payload
1842
+ that gets encoded is `& <argv...>`, and **the leading `&` is load-bearing**: in
1843
+ command position PowerShell reads a quoted string as an *expression*, so with a node
1844
+ install under `C:\Program Files\...` the script `'C:\Program Files\nodejs\node.exe'
1845
+ <script>` is a `ParserError ... UnexpectedToken` and no child is ever created - while
1846
+ the parent still reports that the window opened. `&` is PowerShell's call operator:
1847
+ it takes the next word as a command name whatever its shape.
1842
1848
 
1843
1849
  When no terminal can be opened - an SSH session, a container, a machine with no
1844
- graphical session - the command opens nothing and prints the fallback instead:
1850
+ graphical session - the command opens nothing and prints the fallback instead. On
1851
+ Windows that fallback is **two labelled lines**, because one line cannot be correct
1852
+ for both parsers (`& 'x'` is right for PowerShell and wrong for `cmd.exe`, which
1853
+ keeps the single quotes; `"x"` is right for `cmd.exe` and an expression to
1854
+ PowerShell):
1855
+
1856
+ ```
1857
+ No terminal window could be opened here. Run this in another terminal:
1858
+ PowerShell: & aragon --resume a4k2m9
1859
+ cmd.exe : aragon --resume a4k2m9
1860
+ ```
1861
+
1862
+ Copy the line that matches the terminal you opened. macOS and Linux keep the single
1863
+ line they have always printed:
1845
1864
 
1846
1865
  ```
1847
1866
  No terminal window could be opened here. Run this in another terminal:
1848
1867
  aragon --resume a4k2m9
1849
1868
  ```
1850
1869
 
1870
+ A `node <dist/cli.js>` build prints the same two lines with the full paths, quoted:
1871
+
1872
+ ```
1873
+ PowerShell: & 'C:\Program Files\nodejs\node.exe' C:\Users\me\AppData\Roaming\npm\node_modules\@aragon-agent\cli\dist\launcher.js --resume a4k2m9
1874
+ cmd.exe : "C:\Program Files\nodejs\node.exe" C:\Users\me\AppData\Roaming\npm\node_modules\@aragon-agent\cli\dist\launcher.js --resume a4k2m9
1875
+ ```
1876
+
1877
+ The `PowerShell:` line is byte-for-byte the script the new window receives, so the
1878
+ fallback can never drift from what the feature actually runs.
1879
+
1851
1880
  Two environment variables exist because neither belongs in a config file:
1852
1881
 
1853
1882
  | Variable | Effect |
@@ -2190,7 +2219,7 @@ env / `.env` → CLI flags**.
2190
2219
  | `planModeDefault` | `false` | Start every session in plan mode. `--no-plan` still overrides it for a single run. |
2191
2220
  | `planModeMaxAskRounds` | `4` | `ask_user` rounds allowed per user message, clamped to `[1, 10]`. The counter resets on your next message; steering does **not** reset it. |
2192
2221
  | `planModeHumanTimeoutMs` | `1800000` | Ceiling on a single question / plan wait, clamped to `[60000, 7200000]`. Thirty minutes is far outside "went to get coffee", but a ceiling has to exist so an unattended terminal cannot wedge a CI job forever. |
2193
- | `paste` | `true` | Recognise pasted text before Ink's key parser sees it: line breaks become buffer newlines instead of fifteen separate messages, control bytes are stripped instead of overwriting the row they land on, and a paste above 6 lines or 400 characters collapses to a one-line placeholder that expands when you send. `false` treats pasted bytes as keystrokes, which is what 0.6.2 did. |
2222
+ | `paste` | `true` | Recognise pasted text before Ink's key parser sees it: line breaks become buffer newlines instead of fifteen separate messages, escape SEQUENCES are removed whole (deleting only the introducer byte is what put `[38;2;18;52;86m` on screen and in the model's input) as are stray control bytes, and a paste above 6 lines or 400 characters collapses to a one-line placeholder that expands when you send. `false` treats pasted bytes as keystrokes, which is what 0.6.2 did. |
2194
2223
  | `historyEnabled` | `true` | Record submitted prompts to `prompt-history.jsonl` for `↑` recall. Setting it to `false` stops new writes; it does not delete what is already stored (that is `aragon history clear --yes`) and does not stop reading. See [Prompt history](#prompt-history). |
2195
2224
  | `team.enabled` | `true` | Master switch for team subagents. `false` means the `task` tool is never registered — `/team on` in such a session saves the setting for next launch and says so, rather than advertising a tool that is not there. |
2196
2225
  | `team.maxSubagents` | `5` | Subagents per dispatch, clamped to `[1, 10]`. The 10 is the hard ceiling and is enforced twice — on the config value and again on what the model asks for — so hand-editing this file cannot raise it. |
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Control-SEQUENCE removal for text that crosses a boundary where a terminal
3
+ * escape must never become content (paste-control-sequence-residue, fix A'-1).
4
+ *
5
+ * WHY A DELETE-THE-WHOLE-SEQUENCE RULE EXISTS. Deleting only the introducer
6
+ * byte turns an escape into visible text: `ESC [ 3 8 ; 2 m` loses its ESC and
7
+ * leaves `[38;2m`, which is a run of digits and semicolons somebody then reads
8
+ * on screen and sends to the model. The bytes were never content; the sequence
9
+ * has to go as a unit.
10
+ *
11
+ * WHY IT IS NOT `tools/terminal-output.ts`'s REGEXES MOVED HERE. Those four
12
+ * regexes answer "what does this look like" for a terminal row that is still
13
+ * being written, and they are fed text whose unfinished tail has ALREADY been
14
+ * cut off and parked in a carry by the caller (`sanitizeChunk`,
15
+ * `toDisplayRow`). Pasted text is one-shot: there is no next chunk, so a
16
+ * truncated sequence needs a rule of its own. Measured against the stream
17
+ * version of the same input, `ESC [ 3 8 ; 2 ; 1 8 ; 5 2 ; 8 6` followed by
18
+ * prose loses the first letter of the prose (`n`), and an unterminated OSC
19
+ * leaves its whole body behind.
20
+ *
21
+ * THE FOUR DECISIONS THIS MODULE ENCODES:
22
+ *
23
+ * 1. A sequence introduced by a real introducer byte (`0x1B`, `0x9B`, `0x9D`,
24
+ * ...) is removed as a unit; bare text that merely LOOKS like the inside of
25
+ * one (`[38;2;18;52;86m` typed or pasted without an ESC) is left exactly as
26
+ * it is, character for character.
27
+ * 2. A string-type sequence (OSC / DCS / SOS / PM / APC) runs to BEL or ST, and
28
+ * an unterminated one is removed UP TO THE END OF ITS LINE. It never crosses
29
+ * `\n` and never guesses at a terminator: a truncated sequence is the most
30
+ * common shape in a clipboard, and the only safe reading of it is "the rest
31
+ * of this line was never text".
32
+ * 3. A truncated CSI loses its introducer and its parameter bytes and NOTHING
33
+ * ELSE. `ESC [ 3 1 m` split mid-parameter is parameters, not prose, but the
34
+ * byte after them is the user's own character and must survive.
35
+ * 4. A C1 CSI (`0x9B`) with no parameter byte at all is treated as the stray
36
+ * control byte it is most likely to be rather than as a sequence, because a
37
+ * lone `0x9B` followed by one letter is otherwise indistinguishable from a
38
+ * non-UTF-8 file's stray byte followed by content -- and consuming that
39
+ * letter would be the very data loss this module exists to prevent.
40
+ *
41
+ * Pure, ASCII-only, React-free, Ink-free, and TOTAL: it never throws and it
42
+ * never reorders or mangles the bytes it keeps. No new dependency.
43
+ */
44
+ /**
45
+ * Remove every escape/control sequence, keeping all other bytes in order.
46
+ *
47
+ * KEEPS `\t`, `\n` AND `\r`: they are line structure, and `\t` inside pasted
48
+ * indentation is content the model is supposed to receive. Stray C0/C1 bytes
49
+ * (and DEL) are NOT this function's job -- they are removed by the caller that
50
+ * wants them gone, so one rule has one owner.
51
+ */
52
+ export declare function stripControlSequences(text: string): string;
53
+ /**
54
+ * True when `text` carries a sequence a KEYBOARD cannot physically produce.
55
+ *
56
+ * Used at the keystroke branch of the stdin filter: a single-line paste that
57
+ * carries ESC fails `classifyChunk`'s two rules (no line break, and
58
+ * `HAS_CONTROL_EXCEPT_TAB`) and would otherwise reach Ink as raw bytes --
59
+ * either typing `[38;2;18;52;86m` with a live ESC into the draft, or throwing
60
+ * an uncaught `TypeError` inside Ink's key parser.
61
+ *
62
+ * THREE THINGS MUST NOT MATCH, and the shapes below are why:
63
+ * - SGR mouse reports (`ESC [ < 0 ; 1 ; 2 m`) are stripped by `splitMouseEvents`
64
+ * BEFORE this test, and `<` is not a parameter byte, so the SGR rule cannot
65
+ * see them anyway.
66
+ * - Arrow / function / Home / End keys (`ESC [ A`, `ESC [ 1 ; 5 D`, `ESC [ 3 ~`,
67
+ * `ESC [ Z`) never end in `m`.
68
+ * - `Alt`+letter (`ESC` followed by one character) has no introducer body, so
69
+ * the string-type rule does not fire.
70
+ */
71
+ export declare function hasPasteOnlySequence(text: string): boolean;
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Control-SEQUENCE removal for text that crosses a boundary where a terminal
3
+ * escape must never become content (paste-control-sequence-residue, fix A'-1).
4
+ *
5
+ * WHY A DELETE-THE-WHOLE-SEQUENCE RULE EXISTS. Deleting only the introducer
6
+ * byte turns an escape into visible text: `ESC [ 3 8 ; 2 m` loses its ESC and
7
+ * leaves `[38;2m`, which is a run of digits and semicolons somebody then reads
8
+ * on screen and sends to the model. The bytes were never content; the sequence
9
+ * has to go as a unit.
10
+ *
11
+ * WHY IT IS NOT `tools/terminal-output.ts`'s REGEXES MOVED HERE. Those four
12
+ * regexes answer "what does this look like" for a terminal row that is still
13
+ * being written, and they are fed text whose unfinished tail has ALREADY been
14
+ * cut off and parked in a carry by the caller (`sanitizeChunk`,
15
+ * `toDisplayRow`). Pasted text is one-shot: there is no next chunk, so a
16
+ * truncated sequence needs a rule of its own. Measured against the stream
17
+ * version of the same input, `ESC [ 3 8 ; 2 ; 1 8 ; 5 2 ; 8 6` followed by
18
+ * prose loses the first letter of the prose (`n`), and an unterminated OSC
19
+ * leaves its whole body behind.
20
+ *
21
+ * THE FOUR DECISIONS THIS MODULE ENCODES:
22
+ *
23
+ * 1. A sequence introduced by a real introducer byte (`0x1B`, `0x9B`, `0x9D`,
24
+ * ...) is removed as a unit; bare text that merely LOOKS like the inside of
25
+ * one (`[38;2;18;52;86m` typed or pasted without an ESC) is left exactly as
26
+ * it is, character for character.
27
+ * 2. A string-type sequence (OSC / DCS / SOS / PM / APC) runs to BEL or ST, and
28
+ * an unterminated one is removed UP TO THE END OF ITS LINE. It never crosses
29
+ * `\n` and never guesses at a terminator: a truncated sequence is the most
30
+ * common shape in a clipboard, and the only safe reading of it is "the rest
31
+ * of this line was never text".
32
+ * 3. A truncated CSI loses its introducer and its parameter bytes and NOTHING
33
+ * ELSE. `ESC [ 3 1 m` split mid-parameter is parameters, not prose, but the
34
+ * byte after them is the user's own character and must survive.
35
+ * 4. A C1 CSI (`0x9B`) with no parameter byte at all is treated as the stray
36
+ * control byte it is most likely to be rather than as a sequence, because a
37
+ * lone `0x9B` followed by one letter is otherwise indistinguishable from a
38
+ * non-UTF-8 file's stray byte followed by content -- and consuming that
39
+ * letter would be the very data loss this module exists to prevent.
40
+ *
41
+ * Pure, ASCII-only, React-free, Ink-free, and TOTAL: it never throws and it
42
+ * never reorders or mangles the bytes it keeps. No new dependency.
43
+ */
44
+ /** ESC -- the introducer every terminal shares. */
45
+ const ESC = 0x1b;
46
+ /** LF. No rule in this module may consume it (decision 2). */
47
+ const LF = 0x0a;
48
+ /** BEL terminates an OSC. */
49
+ const BEL = 0x07;
50
+ /** `[` after ESC is CSI, and `\` after ESC is the two-byte ST. */
51
+ const BRACKET = 0x5b;
52
+ const BACKSLASH = 0x5c;
53
+ /** The ASCII second byte of each string-type sequence (`ESC ]`, `ESC P`, ...). */
54
+ const ESC_DCS = 0x50;
55
+ const ESC_SOS = 0x58;
56
+ const ESC_OSC = 0x5d;
57
+ const ESC_PM = 0x5e;
58
+ const ESC_APC = 0x5f;
59
+ /** C1 introducers, as written by a source that is not UTF-8 clean. */
60
+ const C1_CSI = 0x9b;
61
+ const C1_ST = 0x9c;
62
+ const C1_OSC = 0x9d;
63
+ const C1_DCS = 0x90;
64
+ const C1_SOS = 0x98;
65
+ const C1_PM = 0x9e;
66
+ const C1_APC = 0x9f;
67
+ /** Every byte that can START a sequence this module removes. */
68
+ const ANY_INTRODUCER = /[\u001b\u0090\u0098\u009b\u009d\u009e\u009f]/;
69
+ /**
70
+ * `CSI ... m` (SGR), in both encodings: `ESC [ digits ; m` and the single-byte
71
+ * C1 form `0x9B digits ; m`. NO KEY PRESS PRODUCES ONE, which is what makes it
72
+ * usable as the discriminator for an unframed paste in `stdin-filter.ts`.
73
+ */
74
+ const SGR_SEQUENCE = /\u001b\[[0-9;]*m|\u009b[0-9;]*m/;
75
+ /**
76
+ * A string-type introducer followed by at least one body byte. An `Alt`+key
77
+ * press is exactly two bytes, so "introducer plus a body" is already outside
78
+ * what a keyboard can emit.
79
+ */
80
+ const STRING_SEQUENCE = /(?:\u001b[\u0050\u0058\u005d\u005e\u005f]|[\u0090\u0098\u009d\u009e\u009f])[\s\S]/;
81
+ /** CSI final byte: `@` through `~`. */
82
+ function isFinalByte(code) {
83
+ return code >= 0x40 && code <= 0x7e;
84
+ }
85
+ /** CSI parameter byte: `0` through `?`. */
86
+ function isParamByte(code) {
87
+ return code >= 0x30 && code <= 0x3f;
88
+ }
89
+ /**
90
+ * CSI intermediate byte: `!` through `/`.
91
+ *
92
+ * SPACE (`0x20`) IS DELIBERATELY EXCLUDED (decision 3). The standard allows it,
93
+ * but a pasted sequence cut mid-parameter is followed by ` ` and then prose,
94
+ * and the first letter of that prose is a legal final byte -- so honouring the
95
+ * space would silently eat it. `CSI Ps SP q` (cursor style) is the only shape
96
+ * that gets worse, and no child process writes that to its stdout.
97
+ */
98
+ function isIntermediateByte(code) {
99
+ return code >= 0x21 && code <= 0x2f;
100
+ }
101
+ /** True for the C1 introducers that open a string, not a CSI. */
102
+ function isC1StringIntroducer(code) {
103
+ return code === C1_OSC || code === C1_DCS || code === C1_SOS || code === C1_PM || code === C1_APC;
104
+ }
105
+ /**
106
+ * End of the string-type sequence whose body starts at `from`.
107
+ *
108
+ * Stops AT the line break rather than past it, and reports "consumed to here"
109
+ * for a body that never terminates (decision 2).
110
+ */
111
+ function stringEnd(text, from) {
112
+ let i = from;
113
+ while (i < text.length) {
114
+ const code = text.charCodeAt(i);
115
+ if (code === BEL || code === C1_ST)
116
+ return i + 1;
117
+ if (code === LF)
118
+ return i;
119
+ if (code === ESC && text.charCodeAt(i + 1) === BACKSLASH)
120
+ return i + 2;
121
+ i += 1;
122
+ }
123
+ return i;
124
+ }
125
+ /**
126
+ * End of the CSI whose parameter run starts at `from`.
127
+ *
128
+ * `requireParams` implements decision 4: with no parameter byte at all, a C1
129
+ * CSI is only the introducer byte, and its "final byte" is content.
130
+ */
131
+ function csiEnd(text, from, requireParams) {
132
+ let i = from;
133
+ let sawParams = false;
134
+ while (i < text.length && isParamByte(text.charCodeAt(i))) {
135
+ sawParams = true;
136
+ i += 1;
137
+ }
138
+ if (requireParams && !sawParams)
139
+ return from;
140
+ while (i < text.length && isIntermediateByte(text.charCodeAt(i)))
141
+ i += 1;
142
+ if (i < text.length && isFinalByte(text.charCodeAt(i)))
143
+ return i + 1;
144
+ return i;
145
+ }
146
+ /** End of the sequence starting with ESC at `index` (decision 1 / 3). */
147
+ function escapeEnd(text, index) {
148
+ if (index + 1 >= text.length)
149
+ return index + 1;
150
+ const next = text.charCodeAt(index + 1);
151
+ if (next === BRACKET)
152
+ return csiEnd(text, index + 2, false);
153
+ if (next === ESC_OSC || next === ESC_DCS || next === ESC_SOS || next === ESC_PM
154
+ || next === ESC_APC) {
155
+ return stringEnd(text, index + 2);
156
+ }
157
+ if (next === LF)
158
+ return index + 1;
159
+ return index + 2;
160
+ }
161
+ /**
162
+ * End of the sequence starting at `index`, or `-1` when no sequence starts
163
+ * there.
164
+ */
165
+ function sequenceEnd(text, index) {
166
+ const code = text.charCodeAt(index);
167
+ if (code === ESC)
168
+ return escapeEnd(text, index);
169
+ if (code === C1_CSI)
170
+ return csiEnd(text, index + 1, true);
171
+ if (isC1StringIntroducer(code)) {
172
+ return index + 1 >= text.length ? index + 1 : stringEnd(text, index + 1);
173
+ }
174
+ return -1;
175
+ }
176
+ /**
177
+ * Remove every escape/control sequence, keeping all other bytes in order.
178
+ *
179
+ * KEEPS `\t`, `\n` AND `\r`: they are line structure, and `\t` inside pasted
180
+ * indentation is content the model is supposed to receive. Stray C0/C1 bytes
181
+ * (and DEL) are NOT this function's job -- they are removed by the caller that
182
+ * wants them gone, so one rule has one owner.
183
+ */
184
+ export function stripControlSequences(text) {
185
+ if (!ANY_INTRODUCER.test(text))
186
+ return text;
187
+ let out = '';
188
+ let kept = 0;
189
+ let i = 0;
190
+ while (i < text.length) {
191
+ const end = sequenceEnd(text, i);
192
+ if (end === -1) {
193
+ i += 1;
194
+ continue;
195
+ }
196
+ out += text.slice(kept, i);
197
+ i = end;
198
+ kept = end;
199
+ }
200
+ return kept === 0 ? text : out + text.slice(kept);
201
+ }
202
+ /**
203
+ * True when `text` carries a sequence a KEYBOARD cannot physically produce.
204
+ *
205
+ * Used at the keystroke branch of the stdin filter: a single-line paste that
206
+ * carries ESC fails `classifyChunk`'s two rules (no line break, and
207
+ * `HAS_CONTROL_EXCEPT_TAB`) and would otherwise reach Ink as raw bytes --
208
+ * either typing `[38;2;18;52;86m` with a live ESC into the draft, or throwing
209
+ * an uncaught `TypeError` inside Ink's key parser.
210
+ *
211
+ * THREE THINGS MUST NOT MATCH, and the shapes below are why:
212
+ * - SGR mouse reports (`ESC [ < 0 ; 1 ; 2 m`) are stripped by `splitMouseEvents`
213
+ * BEFORE this test, and `<` is not a parameter byte, so the SGR rule cannot
214
+ * see them anyway.
215
+ * - Arrow / function / Home / End keys (`ESC [ A`, `ESC [ 1 ; 5 D`, `ESC [ 3 ~`,
216
+ * `ESC [ Z`) never end in `m`.
217
+ * - `Alt`+letter (`ESC` followed by one character) has no introducer body, so
218
+ * the string-type rule does not fire.
219
+ */
220
+ export function hasPasteOnlySequence(text) {
221
+ return SGR_SEQUENCE.test(text) || STRING_SEQUENCE.test(text);
222
+ }
@@ -56,16 +56,28 @@ export declare function classifyChunk(text: string, minBurstChars?: number): 'pa
56
56
  * newline in pasted text, and a lone LF is dropped by Ink's key parser
57
57
  * entirely, so both have to be normalised here or a pasted line break either
58
58
  * submits the message or silently vanishes.
59
- * 2. Every C0 byte except `\n` and `\t` is removed. Ink writes `<Text>` children
60
- * to the terminal without escaping anything, so a surviving `\r` overwrites
61
- * the row it is drawn on and a surviving `\x1b` repaints the frame's colours.
62
- * 3. DEL and every C1 byte (U+0080..U+009F) go too, and that is NOT padding:
63
- * U+009B is the single-byte CSI and U+0085 is NEL, and a terminal that decodes
64
- * them acts on them exactly as it acts on `\x1b[`. A file pasted out of a
65
- * non-UTF-8 editor walks straight through a "strip C0" rule.
66
- * 4. TAB is KEPT. Indentation is content, and it is what the model receives.
59
+ * 2. Every escape SEQUENCE is removed AS A SEQUENCE (`stripControlSequences`).
60
+ * Deleting only the introducer byte is what produced the reported defect:
61
+ * `ESC [ 3 8 ; 2 ; 1 8 ; 5 2 ; 8 6 m` minus its ESC is `[38;2;18;52;86m`,
62
+ * a run of digits Ink then writes to the terminal as ordinary text -- and it
63
+ * reached the model, the prompt history and the session file too, because
64
+ * this function is the only cleaning the payload ever gets.
65
+ * 3. Every remaining C0 byte except `\n` and `\t` is removed. Ink writes
66
+ * `<Text>` children to the terminal without escaping anything, so a
67
+ * surviving `\r` overwrites the row it is drawn on and a surviving `ESC`
68
+ * repaints the frame colours. Sequences are gone by this point, so what is
69
+ * left for this step is a stray byte, never half of one.
70
+ * 4. DEL and every C1 byte (U+0080..U+009F) go too, and that is NOT padding:
71
+ * U+009B is the single-byte CSI and U+0085 is NEL, and a terminal that
72
+ * decodes them acts on them exactly as it acts on `ESC [`. A file pasted out
73
+ * of a non-UTF-8 editor walks straight through a "strip C0" rule.
74
+ * 5. TAB is KEPT. Indentation is content, and it is what the model receives.
75
+ * Expanding it is the RENDERERS' job (`ui/display-tab.ts`), so the model
76
+ * keeps the original indentation while the screen, the composer and the
77
+ * terminal all agree on what column a TAB lands on.
67
78
  *
68
79
  * Applied to PAYLOAD ONLY, never to keystrokes, so `Ctrl+A` and Escape keep
69
- * working. Non-ASCII text (CJK, emoji) is untouched.
80
+ * working. Non-ASCII text (CJK, emoji) is untouched. Idempotent, so the
81
+ * second pass in `ui/paste-frames.ts` stays a byte-identical no-op.
70
82
  */
71
83
  export declare function sanitisePaste(raw: string): string;
@@ -16,6 +16,7 @@
16
16
  * `DECSET 2004`. Both are about what a keyboard can physically emit.
17
17
  */
18
18
  import { PASTE_MIN_BURST_CHARS } from './limits.js';
19
+ import { stripControlSequences } from './control-sequences.js';
19
20
  /** `DECSET 2004` wraps a paste in these two markers. */
20
21
  export const PASTE_BEGIN_MARK = '\x1b[200~';
21
22
  export const PASTE_END_MARK = '\x1b[201~';
@@ -76,20 +77,31 @@ export function classifyChunk(text, minBurstChars = PASTE_MIN_BURST_CHARS) {
76
77
  * newline in pasted text, and a lone LF is dropped by Ink's key parser
77
78
  * entirely, so both have to be normalised here or a pasted line break either
78
79
  * submits the message or silently vanishes.
79
- * 2. Every C0 byte except `\n` and `\t` is removed. Ink writes `<Text>` children
80
- * to the terminal without escaping anything, so a surviving `\r` overwrites
81
- * the row it is drawn on and a surviving `\x1b` repaints the frame's colours.
82
- * 3. DEL and every C1 byte (U+0080..U+009F) go too, and that is NOT padding:
83
- * U+009B is the single-byte CSI and U+0085 is NEL, and a terminal that decodes
84
- * them acts on them exactly as it acts on `\x1b[`. A file pasted out of a
85
- * non-UTF-8 editor walks straight through a "strip C0" rule.
86
- * 4. TAB is KEPT. Indentation is content, and it is what the model receives.
80
+ * 2. Every escape SEQUENCE is removed AS A SEQUENCE (`stripControlSequences`).
81
+ * Deleting only the introducer byte is what produced the reported defect:
82
+ * `ESC [ 3 8 ; 2 ; 1 8 ; 5 2 ; 8 6 m` minus its ESC is `[38;2;18;52;86m`,
83
+ * a run of digits Ink then writes to the terminal as ordinary text -- and it
84
+ * reached the model, the prompt history and the session file too, because
85
+ * this function is the only cleaning the payload ever gets.
86
+ * 3. Every remaining C0 byte except `\n` and `\t` is removed. Ink writes
87
+ * `<Text>` children to the terminal without escaping anything, so a
88
+ * surviving `\r` overwrites the row it is drawn on and a surviving `ESC`
89
+ * repaints the frame colours. Sequences are gone by this point, so what is
90
+ * left for this step is a stray byte, never half of one.
91
+ * 4. DEL and every C1 byte (U+0080..U+009F) go too, and that is NOT padding:
92
+ * U+009B is the single-byte CSI and U+0085 is NEL, and a terminal that
93
+ * decodes them acts on them exactly as it acts on `ESC [`. A file pasted out
94
+ * of a non-UTF-8 editor walks straight through a "strip C0" rule.
95
+ * 5. TAB is KEPT. Indentation is content, and it is what the model receives.
96
+ * Expanding it is the RENDERERS' job (`ui/display-tab.ts`), so the model
97
+ * keeps the original indentation while the screen, the composer and the
98
+ * terminal all agree on what column a TAB lands on.
87
99
  *
88
100
  * Applied to PAYLOAD ONLY, never to keystrokes, so `Ctrl+A` and Escape keep
89
- * working. Non-ASCII text (CJK, emoji) is untouched.
101
+ * working. Non-ASCII text (CJK, emoji) is untouched. Idempotent, so the
102
+ * second pass in `ui/paste-frames.ts` stays a byte-identical no-op.
90
103
  */
91
104
  export function sanitisePaste(raw) {
92
- return raw
93
- .replace(/\r\n?/g, '\n')
105
+ return stripControlSequences(raw.replace(/\r\n?/g, '\n'))
94
106
  .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, '');
95
107
  }
@@ -33,8 +33,9 @@
33
33
  */
34
34
  import { PassThrough } from 'node:stream';
35
35
  import { MAX_PENDING_MOUSE_CHARS, splitMouseEvents, isMousePrefix, } from './mouse-events.js';
36
- import { ENTER_NEWLINE_FRAME, PASTE_ASSEMBLY_MAX_MS, PASTE_BURST_MS, PASTE_CLOSE, PASTE_MAX_BYTES, PASTE_OPEN, formatPasteSize, } from './limits.js';
36
+ import { ENTER_NEWLINE_FRAME, INDEX_KEY_FRAME, PASTE_ASSEMBLY_MAX_MS, PASTE_BURST_MS, PASTE_CLOSE, PASTE_MAX_BYTES, PASTE_OPEN, formatPasteSize, } from './limits.js';
37
37
  import { PASTE_BEGIN_MARK, PASTE_END_MARK, classifyChunk, sanitisePaste, trailingPastePrefixLength, } from './paste-parse.js';
38
+ import { hasPasteOnlySequence } from './control-sequences.js';
38
39
  import { splitEnterSequences, trailingEnterPrefixLength } from './enter-sequences.js';
39
40
  import { splitCsiUKeys, trailingCsiUPrefixLength, translateCsiUKey } from './csiu-keys.js';
40
41
  import { splitWin32KeySequences, trailingWin32PrefixLength, translateWin32Key, } from './win32-input-mode.js';
@@ -206,8 +207,8 @@ export function createStdinFilter(real, features, bridge, onWarn) {
206
207
  // The scan (section 5.1, and the ORDER below is the specification)
207
208
  // -----------------------------------------------------------------------
208
209
  /**
209
- * Text that reached the pass-through: Tier 2 classification, then mouse
210
- * stripping, then Ink.
210
+ * Text that reached the pass-through: a sequence a KEYBOARD cannot produce
211
+ * first (R-2), then Tier 2 classification, then mouse stripping, then Ink.
211
212
  *
212
213
  * MOUSE PARSING IS SUSPENDED FOR THE DURATION OF ANY PASTE BODY (I-3), burst
213
214
  * and bracketed alike. The Tier 2 population is precisely the terminals with
@@ -225,6 +226,23 @@ export function createStdinFilter(real, features, bridge, onWarn) {
225
226
  burstTimer = arm(flushBurst, PASTE_BURST_MS);
226
227
  return;
227
228
  }
229
+ // A SEQUENCE A KEYBOARD CANNOT PRODUCE IS A PASTE, FRAMED OR NOT (R-2).
230
+ //
231
+ // WHY THE CLASSIFIER BELOW IS NOT ENOUGH. `classifyChunk` needs either a
232
+ // line break or a chunk with no control byte but TAB, and a SINGLE-LINE
233
+ // paste carrying ESC satisfies neither -- so it travels the keystroke path
234
+ // with `sanitisePaste` never called. Measured on the real component that
235
+ // cost either a draft holding `[38;2;18;52;86m` ALONGSIDE A LIVE ESC (which
236
+ // then reached the model, the history and the session file) or an uncaught
237
+ // `TypeError` in Ink's parser, because `parse-keypress.js` maps `ESC [ 3 2 m`
238
+ // to a ctrl key whose `name` is undefined. Framing it here routes it through
239
+ // the ONE cleaning path every other paste already takes.
240
+ if (features.paste && hasPasteOnlySequence(text)) {
241
+ paste = { kind: 'burst', body: text, bytes: byteLength(text) };
242
+ burstTimer = clearTimer(burstTimer);
243
+ burstTimer = arm(flushBurst, PASTE_BURST_MS);
244
+ return;
245
+ }
228
246
  if (features.paste && classifyChunk(text) === 'paste') {
229
247
  paste = { kind: 'burst', body: text, bytes: byteLength(text) };
230
248
  burstTimer = clearTimer(burstTimer);
@@ -300,14 +318,47 @@ export function createStdinFilter(real, features, bridge, onWarn) {
300
318
  }
301
319
  }
302
320
  };
321
+ /**
322
+ * win32-input-mode records that arrived INSIDE a paste frame (R-4).
323
+ *
324
+ * DATA FIDELITY, NOT POLISH. Every character of a `?9001h` session arrives as
325
+ * `ESC [ vk ; sc ; char ; down ; mods ; rep _`, and once the frame opens
326
+ * nothing translated them: `sanitisePaste` then removed each record's ESC and
327
+ * left 15 visible characters of digits per key. Translating them is also what
328
+ * keeps the fix honest in the OTHER direction -- a whole-sequence stripper
329
+ * deletes such a record outright, so "clean" would have meant the user's
330
+ * characters silently vanishing. A record carrying a key FRAME (modified
331
+ * Enter, Ctrl+I) becomes the byte that frame stands for, because a frame
332
+ * inside a body is content and never a key.
333
+ */
334
+ const translateInPaste = (text) => {
335
+ if (!enhancedKeys || !text.includes('\x1b['))
336
+ return text;
337
+ let out = '';
338
+ for (const segment of splitWin32KeySequences(text)) {
339
+ if (segment.kind === 'text') {
340
+ out += segment.text;
341
+ continue;
342
+ }
343
+ const legacy = translateWin32Key(segment.record);
344
+ if (legacy === ENTER_NEWLINE_FRAME)
345
+ out += '\n';
346
+ else if (legacy === INDEX_KEY_FRAME)
347
+ out += '\t';
348
+ else
349
+ out += legacy;
350
+ }
351
+ return out;
352
+ };
303
353
  const appendBracketed = (text) => {
304
354
  if (paste.kind !== 'bracketed' || text.length === 0 || discardBracketed)
305
355
  return;
306
- const bytes = paste.bytes + byteLength(text);
356
+ const body = translateInPaste(text);
357
+ const bytes = paste.bytes + byteLength(body);
307
358
  const overflow = paste.overflow || bytes > PASTE_MAX_BYTES;
308
359
  paste = {
309
360
  kind: 'bracketed',
310
- body: overflow ? paste.body : paste.body + text,
361
+ body: overflow ? paste.body : paste.body + body,
311
362
  bytes,
312
363
  overflow,
313
364
  };
@@ -65,7 +65,10 @@ export interface TerminalPlanInput {
65
65
  /** New window title, ASCII, already truncated to the cell budget. */
66
66
  title: string;
67
67
  cwd: string;
68
- /** `joinCommandLine(command)` - the pasteable fallback shown to the user. */
68
+ /**
69
+ * `renderManualCommand(command, platform)`'s product: two lines on win32
70
+ * (`PowerShell:` / `cmd.exe :`), one line everywhere else.
71
+ */
69
72
  manual: string;
70
73
  }
71
74
  /** The env var that overrides the shell the new window runs (D-7). */
@@ -80,7 +83,22 @@ export declare const ARAGON_BRANCH_SHELL_ENV = "ARAGON_BRANCH_SHELL";
80
83
  * so there is exactly one place that number lives.
81
84
  */
82
85
  export declare function planTerminalLaunch(input: TerminalPlanInput): TerminalPlanResult;
83
- /** PowerShell: the whole command as one base64(UTF-16LE) blob (D-20). */
86
+ /**
87
+ * The PowerShell statement that runs the aragon command (D-1).
88
+ *
89
+ * THE LEADING `&` IS NOT DECORATION. In command position PowerShell reads a quoted
90
+ * string as an EXPRESSION, so `'C:\Program Files\nodejs\node.exe' <script>` is a
91
+ * parse error - "unexpected token ..." at column 36 - and the child is never
92
+ * created, while the parent still believes the window opened (K-1: `wt`/`start`
93
+ * forward and exit). That is exactly the report this branch exists to fix.
94
+ * `&` is the call operator: it takes the following word as a command name whatever
95
+ * its shape, and it does not change name resolution (`& 'npm.cmd'` still finds
96
+ * `npm` through PATH/PATHEXT, appendix B-8).
97
+ *
98
+ * `posixScript` MUST NOT be given the same treatment: in sh `&` means run in the
99
+ * background, so the window would reach the trailing `exec <shell> -i` immediately
100
+ * and the branch would start behind a prompt that looks idle (I-3 / K-1).
101
+ */
84
102
  export declare function powerShellScript(parts: readonly string[]): string;
85
103
  /** Bare words stay bare; everything else becomes a single-quoted literal. */
86
104
  export declare function powerShellQuote(value: string): string;