@aragon-agent/cli 0.6.3 → 0.6.4
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 +105 -0
- package/README.md +95 -2
- package/dist/agent/controller.d.ts +119 -0
- package/dist/agent/controller.js +275 -1
- package/dist/agent/reducer.d.ts +69 -0
- package/dist/agent/reducer.js +100 -0
- package/dist/agent/system-prompt.d.ts +14 -0
- package/dist/agent/system-prompt.js +2 -0
- package/dist/cli.js +84 -22
- package/dist/commands/builtins.js +92 -1
- package/dist/compaction/child.js +5 -0
- package/dist/compaction/command.js +14 -1
- package/dist/compaction/compactor.d.ts +34 -0
- package/dist/compaction/compactor.js +158 -37
- package/dist/compaction/types.d.ts +36 -0
- package/dist/compaction/wiring.d.ts +9 -0
- package/dist/compaction/wiring.js +22 -1
- package/dist/config/env.d.ts +1 -1
- package/dist/config/env.js +48 -1
- package/dist/config/load.d.ts +11 -0
- package/dist/config/load.js +39 -1
- package/dist/config/schema.d.ts +85 -0
- package/dist/config/schema.js +33 -0
- package/dist/config/store.js +21 -1
- package/dist/input/limits.d.ts +72 -0
- package/dist/input/limits.js +63 -0
- package/dist/input/paste-parse.d.ts +71 -0
- package/dist/input/paste-parse.js +95 -0
- package/dist/input/stdin-filter.d.ts +81 -0
- package/dist/input/stdin-filter.js +379 -0
- package/dist/logging/install.d.ts +2 -0
- package/dist/logging/install.js +53 -1
- package/dist/proc/classify.d.ts +52 -0
- package/dist/proc/classify.js +143 -0
- package/dist/proc/kill-tree.d.ts +42 -0
- package/dist/proc/kill-tree.js +119 -0
- package/dist/proc/limits.d.ts +84 -0
- package/dist/proc/limits.js +84 -0
- package/dist/proc/log-ring.d.ts +68 -0
- package/dist/proc/log-ring.js +102 -0
- package/dist/proc/prompt.d.ts +24 -0
- package/dist/proc/prompt.js +49 -0
- package/dist/proc/readiness.d.ts +102 -0
- package/dist/proc/readiness.js +219 -0
- package/dist/proc/supervisor.d.ts +103 -0
- package/dist/proc/supervisor.js +465 -0
- package/dist/proc/types.d.ts +175 -0
- package/dist/proc/types.js +15 -0
- package/dist/session/persist.js +36 -1
- package/dist/tools/bash-tool.d.ts +51 -2
- package/dist/tools/bash-tool.js +321 -51
- package/dist/tools/fs-tools.d.ts +17 -0
- package/dist/tools/index.d.ts +55 -2
- package/dist/tools/index.js +39 -1
- package/dist/tools/proc-tools.d.ts +25 -0
- package/dist/tools/proc-tools.js +102 -0
- package/dist/ui/App.d.ts +15 -2
- package/dist/ui/App.js +256 -7
- package/dist/ui/Composer.d.ts +54 -1
- package/dist/ui/Composer.js +50 -6
- package/dist/ui/PromptInput.d.ts +49 -3
- package/dist/ui/PromptInput.js +184 -52
- package/dist/ui/StatusBar.d.ts +19 -0
- package/dist/ui/StatusBar.js +5 -2
- package/dist/ui/Transcript.js +29 -1
- package/dist/ui/composer-limits.d.ts +31 -0
- package/dist/ui/composer-limits.js +50 -0
- package/dist/ui/composer-rows.d.ts +73 -0
- package/dist/ui/composer-rows.js +201 -0
- package/dist/ui/editor-reducer.d.ts +37 -0
- package/dist/ui/editor-reducer.js +88 -12
- package/dist/ui/entries/ServiceCard.d.ts +66 -0
- package/dist/ui/entries/ServiceCard.js +116 -0
- package/dist/ui/entries/UserEntry.d.ts +17 -3
- package/dist/ui/entries/UserEntry.js +34 -5
- package/dist/ui/layout/budget.d.ts +34 -6
- package/dist/ui/layout/budget.js +32 -7
- package/dist/ui/layout/virtual-window.js +51 -1
- package/dist/ui/overlays/HelpOverlay.js +3 -0
- package/dist/ui/overlays/PlanReviewOverlay.js +10 -2
- package/dist/ui/overlays/QuestionOverlay.js +5 -1
- package/dist/ui/overlays/SettingsScreen.js +14 -1
- package/dist/ui/paste-frames.d.ts +87 -0
- package/dist/ui/paste-frames.js +112 -0
- package/dist/ui/paste-tokens.d.ts +90 -0
- package/dist/ui/paste-tokens.js +147 -0
- package/dist/ui/screen.d.ts +16 -0
- package/dist/ui/screen.js +45 -2
- package/dist/ui/selection/selection-controller.d.ts +1 -1
- package/dist/ui/transcript-text.js +49 -0
- package/dist/ui/use-wheel-routing.d.ts +1 -1
- package/package.json +2 -2
- package/dist/input/stdin-mouse-filter.d.ts +0 -47
- package/dist/input/stdin-mouse-filter.js +0 -153
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stdin filter (mouse-wheel-region-routing section 4.2 / 5.2, extended by
|
|
3
|
+
* tui-paste-handling section 5.1).
|
|
4
|
+
*
|
|
5
|
+
* WHY A WRAPPER AND NOT A SECOND LISTENER. Ink 5.2.1 owns stdin in a way that
|
|
6
|
+
* forbids a second reader: `components/App.js` calls `setEncoding('utf8')`,
|
|
7
|
+
* `ref()`, `setRawMode(true)`, subscribes to `'readable'` and drains with
|
|
8
|
+
* `read()`. Attaching our own `'data'` listener to the same `process.stdin`
|
|
9
|
+
* would switch the stream into flowing mode and RACE Ink's `read()` for chunks
|
|
10
|
+
* -- some keystrokes would reach Ink, some would not, non-deterministically
|
|
11
|
+
* (D-3). So the filter REPLACES the stream Ink is given: it reads the real
|
|
12
|
+
* stdin, strips every mouse report, frames every paste, and writes the
|
|
13
|
+
* remainder into a `PassThrough` that carries the surface Ink requires.
|
|
14
|
+
*
|
|
15
|
+
* ONE CHANNEL, NOT TWO, AND THAT NOW COVERS PASTES AS WELL (D-2 / D-3). A second
|
|
16
|
+
* chained filter would need a second `PassThrough` and would reopen the ordering
|
|
17
|
+
* hazard this note closes; a side-channel paste emit would apply the payload
|
|
18
|
+
* BEFORE text that preceded it in the same chunk, because `wrapper.write` is
|
|
19
|
+
* asynchronous and a synchronous emit is not. The paste therefore travels
|
|
20
|
+
* INLINE, wrapped in `PASTE_OPEN` / `PASTE_CLOSE` (I-5).
|
|
21
|
+
*
|
|
22
|
+
* ORDERING IS AN INVARIANT, NOT A CONVENTION (I-8). The caller must build this
|
|
23
|
+
* filter BEFORE anything writes `\x1b[?1000h` or `\x1b[?2004h`. Reporting or
|
|
24
|
+
* bracketed paste enabled while Ink still holds the raw stream does not degrade
|
|
25
|
+
* the feature -- it types `[<0;12;5M` or `[200~` into the user's message, which
|
|
26
|
+
* is strictly worse than the bug being fixed.
|
|
27
|
+
*
|
|
28
|
+
* THE HANDLE ANSWERS EXACTLY ONE QUESTION: "is a stream wrapped?" (I-11 / D-17).
|
|
29
|
+
* It stopped being the answer to "is the mouse on?" the moment paste could build
|
|
30
|
+
* a filter on its own -- `paste` defaults to `true`, so `aragon --no-mouse`
|
|
31
|
+
* builds one. `cli.tsx` reads its own `mouseOn` / `pasteOn` booleans; this module
|
|
32
|
+
* is told which features it is running and never derives them.
|
|
33
|
+
*/
|
|
34
|
+
import { PassThrough } from 'node:stream';
|
|
35
|
+
import { MAX_PENDING_MOUSE_CHARS, splitMouseEvents, isMousePrefix, } from './mouse-events.js';
|
|
36
|
+
import { PASTE_ASSEMBLY_MAX_MS, PASTE_BURST_MS, PASTE_CLOSE, PASTE_MAX_BYTES, PASTE_OPEN, formatPasteSize, } from './limits.js';
|
|
37
|
+
import { PASTE_BEGIN_MARK, PASTE_END_MARK, classifyChunk, sanitisePaste, trailingPastePrefixLength, } from './paste-parse.js';
|
|
38
|
+
/**
|
|
39
|
+
* How long a possible-prefix tail is held before being flushed as text.
|
|
40
|
+
*
|
|
41
|
+
* This is what stops a lone `\x1b` -- the Esc key, which aborts a run -- from
|
|
42
|
+
* being held hostage waiting for a `[` that will never arrive. Worst-case Esc
|
|
43
|
+
* latency becomes 12 ms, and only for the keystroke that is literally a bare
|
|
44
|
+
* ESC; every other key resolves both prefix tests to false immediately.
|
|
45
|
+
*/
|
|
46
|
+
const PENDING_FLUSH_MS = 12;
|
|
47
|
+
/**
|
|
48
|
+
* Ceiling on the held-back tail, shared by both prefix families.
|
|
49
|
+
*
|
|
50
|
+
* NO CHANGE IS NEEDED HERE FOR PASTE (P2-5). `MAX_PENDING_MOUSE_CHARS` is 32 and
|
|
51
|
+
* the longest paste marker is 6, so 32 already covers both. v1 of the design said
|
|
52
|
+
* the ceiling "becomes `max(MAX_PENDING_MOUSE_CHARS, PASTE_BEGIN_MARK.length)`",
|
|
53
|
+
* which is the same number written in a way that invites someone to edit the
|
|
54
|
+
* constant.
|
|
55
|
+
*
|
|
56
|
+
* TAKEN FROM THE MODULE THAT OWNS IT rather than written again: this file already
|
|
57
|
+
* imports from `mouse-events.js`, so there is no cycle to dodge here and no
|
|
58
|
+
* reason for a second copy of a number whose only justification is that it equals
|
|
59
|
+
* that one.
|
|
60
|
+
*/
|
|
61
|
+
const MAX_PENDING_CHARS = MAX_PENDING_MOUSE_CHARS;
|
|
62
|
+
/** Assign a method onto the PassThrough without widening its public type. */
|
|
63
|
+
function define(target, name, value) {
|
|
64
|
+
Object.defineProperty(target, name, { value, writable: true, configurable: true });
|
|
65
|
+
}
|
|
66
|
+
const byteLength = (s) => Buffer.byteLength(s, 'utf8');
|
|
67
|
+
/**
|
|
68
|
+
* Length of the trailing run of `text` that is a proper prefix of a mouse
|
|
69
|
+
* report, or 0. Shortest match wins, mirroring `trailingPastePrefixLength`.
|
|
70
|
+
*
|
|
71
|
+
* MEASURED SEPARATELY FROM THE PASTE FAMILY, and the longer hold wins.
|
|
72
|
+
* `isMousePrefix` does not match `\x1b[2`, which IS a prefix of both paste
|
|
73
|
+
* markers, so a single combined test would leak half a marker into the draft.
|
|
74
|
+
*/
|
|
75
|
+
function trailingMousePrefixLength(text) {
|
|
76
|
+
const max = Math.min(text.length, MAX_PENDING_CHARS);
|
|
77
|
+
for (let k = 1; k <= max; k += 1) {
|
|
78
|
+
if (isMousePrefix(text.slice(text.length - k)))
|
|
79
|
+
return k;
|
|
80
|
+
}
|
|
81
|
+
return 0;
|
|
82
|
+
}
|
|
83
|
+
export function createStdinFilter(real, features, bridge, onWarn) {
|
|
84
|
+
const wrapper = new PassThrough();
|
|
85
|
+
const listeners = new Set();
|
|
86
|
+
let pending = '';
|
|
87
|
+
let flushTimer = null;
|
|
88
|
+
let burstTimer = null;
|
|
89
|
+
let assemblyTimer = null;
|
|
90
|
+
let disposed = false;
|
|
91
|
+
let paste = { kind: 'idle' };
|
|
92
|
+
// --- The surface Ink reads off the stream it is handed. ------------------
|
|
93
|
+
// `isRawModeSupported()` reads `props.stdin.isTTY` (App.js:34), so this has
|
|
94
|
+
// to be a DELEGATING GETTER rather than a snapshot: a stdin that is not a TTY
|
|
95
|
+
// must still report so through the wrapper, or Ink throws instead of falling
|
|
96
|
+
// back.
|
|
97
|
+
Object.defineProperty(wrapper, 'isTTY', {
|
|
98
|
+
get: () => real.isTTY,
|
|
99
|
+
configurable: true,
|
|
100
|
+
});
|
|
101
|
+
define(wrapper, 'setRawMode', (mode) => {
|
|
102
|
+
real.setRawMode?.(mode);
|
|
103
|
+
return wrapper;
|
|
104
|
+
});
|
|
105
|
+
define(wrapper, 'ref', () => {
|
|
106
|
+
real.ref?.();
|
|
107
|
+
return wrapper;
|
|
108
|
+
});
|
|
109
|
+
define(wrapper, 'unref', () => {
|
|
110
|
+
real.unref?.();
|
|
111
|
+
return wrapper;
|
|
112
|
+
});
|
|
113
|
+
const emit = (event) => {
|
|
114
|
+
for (const listener of [...listeners])
|
|
115
|
+
listener(event);
|
|
116
|
+
};
|
|
117
|
+
const clearTimer = (t) => {
|
|
118
|
+
if (t)
|
|
119
|
+
clearTimeout(t);
|
|
120
|
+
return null;
|
|
121
|
+
};
|
|
122
|
+
/** `.unref()` so a held tail can never keep the process alive on its own. */
|
|
123
|
+
const arm = (fn, ms) => {
|
|
124
|
+
const t = setTimeout(fn, ms);
|
|
125
|
+
t.unref?.();
|
|
126
|
+
return t;
|
|
127
|
+
};
|
|
128
|
+
// -----------------------------------------------------------------------
|
|
129
|
+
// Paste emission
|
|
130
|
+
// -----------------------------------------------------------------------
|
|
131
|
+
/**
|
|
132
|
+
* Write one frame, or refuse the paste and say so.
|
|
133
|
+
*
|
|
134
|
+
* D-11: above `PASTE_MAX_BYTES` the body is DROPPED and the user gets a toast
|
|
135
|
+
* naming the size and the limit. Truncating would lose their data and look
|
|
136
|
+
* like success. The body stops being accumulated the moment it goes over
|
|
137
|
+
* (`overflow`), so memory stays bounded while the rest of the payload is still
|
|
138
|
+
* being consumed -- consuming it is not optional, because the alternative is
|
|
139
|
+
* two megabytes of the user's file arriving as keystrokes.
|
|
140
|
+
*/
|
|
141
|
+
const emitPaste = (body, bytes, overflow) => {
|
|
142
|
+
if (disposed)
|
|
143
|
+
return;
|
|
144
|
+
if (overflow || bytes > PASTE_MAX_BYTES) {
|
|
145
|
+
const text = `Paste too large (${formatPasteSize(bytes)}); limit is ` +
|
|
146
|
+
`${formatPasteSize(PASTE_MAX_BYTES)}. Nothing was inserted.`;
|
|
147
|
+
onWarn?.(text);
|
|
148
|
+
bridge?.notify?.('warn', text);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const payload = sanitisePaste(body);
|
|
152
|
+
if (payload.length === 0)
|
|
153
|
+
return;
|
|
154
|
+
// ONE `write()` call, at the position the paste occupied in the stream.
|
|
155
|
+
wrapper.write(PASTE_OPEN + payload + PASTE_CLOSE);
|
|
156
|
+
};
|
|
157
|
+
const flushBurst = () => {
|
|
158
|
+
burstTimer = clearTimer(burstTimer);
|
|
159
|
+
if (paste.kind !== 'burst')
|
|
160
|
+
return;
|
|
161
|
+
const { body, bytes } = paste;
|
|
162
|
+
paste = { kind: 'idle' };
|
|
163
|
+
emitPaste(body, bytes, false);
|
|
164
|
+
};
|
|
165
|
+
const flushBracketed = (reason) => {
|
|
166
|
+
assemblyTimer = clearTimer(assemblyTimer);
|
|
167
|
+
if (paste.kind !== 'bracketed')
|
|
168
|
+
return;
|
|
169
|
+
const { body, bytes, overflow } = paste;
|
|
170
|
+
paste = { kind: 'idle' };
|
|
171
|
+
if (reason === 'timeout') {
|
|
172
|
+
// I-4: an unterminated marker must never wedge input. Emitting what
|
|
173
|
+
// arrived is strictly better than holding the keyboard hostage.
|
|
174
|
+
onWarn?.('bracketed paste never terminated; flushing what arrived');
|
|
175
|
+
}
|
|
176
|
+
emitPaste(body, bytes, overflow);
|
|
177
|
+
};
|
|
178
|
+
// -----------------------------------------------------------------------
|
|
179
|
+
// The scan (section 5.1, and the ORDER below is the specification)
|
|
180
|
+
// -----------------------------------------------------------------------
|
|
181
|
+
/**
|
|
182
|
+
* Text that reached the pass-through: Tier 2 classification, then mouse
|
|
183
|
+
* stripping, then Ink.
|
|
184
|
+
*
|
|
185
|
+
* MOUSE PARSING IS SUSPENDED FOR THE DURATION OF ANY PASTE BODY (I-3), burst
|
|
186
|
+
* and bracketed alike. The Tier 2 population is precisely the terminals with
|
|
187
|
+
* no bracketed paste, and the likeliest thing such a user pastes into a TUI
|
|
188
|
+
* dev tool is a terminal log full of `\x1b[<0;12;5M`-shaped bytes. Eating
|
|
189
|
+
* those would corrupt their data silently. v1 covered only Tier 1, which left
|
|
190
|
+
* the loss in place for exactly the terminals Tier 2 exists to serve (P1-7).
|
|
191
|
+
*/
|
|
192
|
+
const handleOutsidePaste = (text, holdTail) => {
|
|
193
|
+
if (text.length === 0)
|
|
194
|
+
return;
|
|
195
|
+
if (paste.kind === 'burst') {
|
|
196
|
+
paste = { kind: 'burst', body: paste.body + text, bytes: paste.bytes + byteLength(text) };
|
|
197
|
+
burstTimer = clearTimer(burstTimer);
|
|
198
|
+
burstTimer = arm(flushBurst, PASTE_BURST_MS);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (features.paste && classifyChunk(text) === 'paste') {
|
|
202
|
+
paste = { kind: 'burst', body: text, bytes: byteLength(text) };
|
|
203
|
+
burstTimer = clearTimer(burstTimer);
|
|
204
|
+
burstTimer = arm(flushBurst, PASTE_BURST_MS);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
// Step 0: with `mouse` false this degenerates to "pass the text on", which
|
|
208
|
+
// is byte-identical to the unwrapped stream.
|
|
209
|
+
if (!features.mouse) {
|
|
210
|
+
wrapper.write(text);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const split = splitMouseEvents(text);
|
|
214
|
+
if (split.text.length > 0)
|
|
215
|
+
wrapper.write(split.text);
|
|
216
|
+
for (const event of split.events)
|
|
217
|
+
emit(event);
|
|
218
|
+
if (split.pending.length === 0)
|
|
219
|
+
return;
|
|
220
|
+
// `holdTail` is false when a paste marker follows this run in the SAME
|
|
221
|
+
// chunk: no later byte can complete the mouse prefix, so holding it would
|
|
222
|
+
// reorder it behind the paste. Appending (never prepending) is what keeps
|
|
223
|
+
// `pending` in stream order when both prefix families fire at once.
|
|
224
|
+
if (holdTail)
|
|
225
|
+
pending += split.pending;
|
|
226
|
+
else
|
|
227
|
+
wrapper.write(split.pending);
|
|
228
|
+
};
|
|
229
|
+
const appendBracketed = (text) => {
|
|
230
|
+
if (paste.kind !== 'bracketed' || text.length === 0)
|
|
231
|
+
return;
|
|
232
|
+
const bytes = paste.bytes + byteLength(text);
|
|
233
|
+
const overflow = paste.overflow || bytes > PASTE_MAX_BYTES;
|
|
234
|
+
paste = {
|
|
235
|
+
kind: 'bracketed',
|
|
236
|
+
body: overflow ? paste.body : paste.body + text,
|
|
237
|
+
bytes,
|
|
238
|
+
overflow,
|
|
239
|
+
};
|
|
240
|
+
};
|
|
241
|
+
const feed = (raw) => {
|
|
242
|
+
let rest = raw;
|
|
243
|
+
while (rest.length > 0) {
|
|
244
|
+
if (paste.kind === 'bracketed') {
|
|
245
|
+
const end = rest.indexOf(PASTE_END_MARK);
|
|
246
|
+
if (end === -1) {
|
|
247
|
+
// Hold a trailing strict prefix of the END marker for the next chunk.
|
|
248
|
+
// NO 12 ms FLUSH TIMER HERE: inside a body every byte is payload, so
|
|
249
|
+
// nothing is being kept from the keyboard, and `assemblyTimer` is the
|
|
250
|
+
// bound that matters (I-4).
|
|
251
|
+
const hold = trailingPastePrefixLength(rest);
|
|
252
|
+
appendBracketed(rest.slice(0, rest.length - hold));
|
|
253
|
+
pending = rest.slice(rest.length - hold);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
appendBracketed(rest.slice(0, end));
|
|
257
|
+
flushBracketed('end');
|
|
258
|
+
rest = rest.slice(end + PASTE_END_MARK.length);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
// idle | burst. BOTH markers are consumed unconditionally, in every mode
|
|
262
|
+
// and whether or not this process enabled DEC 2004 (I-16): something else
|
|
263
|
+
// may have left it on, and a stray END typed into the draft is the same
|
|
264
|
+
// failure arriving from outside.
|
|
265
|
+
const begin = rest.indexOf(PASTE_BEGIN_MARK);
|
|
266
|
+
const strayEnd = rest.indexOf(PASTE_END_MARK);
|
|
267
|
+
const first = begin === -1 ? strayEnd : strayEnd === -1 ? begin : Math.min(begin, strayEnd);
|
|
268
|
+
if (first === -1) {
|
|
269
|
+
const keep = Math.max(trailingPastePrefixLength(rest), features.mouse ? trailingMousePrefixLength(rest) : 0);
|
|
270
|
+
const tail = rest.slice(rest.length - keep);
|
|
271
|
+
handleOutsidePaste(rest.slice(0, rest.length - keep), true);
|
|
272
|
+
pending += tail;
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
handleOutsidePaste(rest.slice(0, first), false);
|
|
276
|
+
if (begin === -1 || first < begin) {
|
|
277
|
+
rest = rest.slice(first + PASTE_END_MARK.length);
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
// P2-4: the burst is flushed FIRST, so two bodies can never be
|
|
281
|
+
// concatenated into one token out of order.
|
|
282
|
+
flushBurst();
|
|
283
|
+
paste = { kind: 'bracketed', body: '', bytes: 0, overflow: false };
|
|
284
|
+
assemblyTimer = clearTimer(assemblyTimer);
|
|
285
|
+
assemblyTimer = arm(() => flushBracketed('timeout'), PASTE_ASSEMBLY_MAX_MS);
|
|
286
|
+
rest = rest.slice(first + PASTE_BEGIN_MARK.length);
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
const flushPending = () => {
|
|
290
|
+
flushTimer = clearTimer(flushTimer);
|
|
291
|
+
if (pending.length === 0 || disposed)
|
|
292
|
+
return;
|
|
293
|
+
const text = pending;
|
|
294
|
+
pending = '';
|
|
295
|
+
// A tail that never completed is ordinary text; it goes through the same
|
|
296
|
+
// scan so a burst already open keeps coalescing it.
|
|
297
|
+
handleOutsidePaste(text, false);
|
|
298
|
+
};
|
|
299
|
+
const onData = (chunk) => {
|
|
300
|
+
if (disposed)
|
|
301
|
+
return;
|
|
302
|
+
flushTimer = clearTimer(flushTimer);
|
|
303
|
+
const decoded = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
304
|
+
const carried = pending;
|
|
305
|
+
pending = '';
|
|
306
|
+
feed(carried + decoded);
|
|
307
|
+
if (paste.kind === 'bracketed' || pending.length === 0)
|
|
308
|
+
return;
|
|
309
|
+
if (pending.length > MAX_PENDING_CHARS) {
|
|
310
|
+
// A malformed sequence that looks like a prefix forever must never wedge
|
|
311
|
+
// input: past the ceiling the tail becomes ordinary text.
|
|
312
|
+
const text = pending;
|
|
313
|
+
pending = '';
|
|
314
|
+
handleOutsidePaste(text, false);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
flushTimer = arm(flushPending, PENDING_FLUSH_MS);
|
|
318
|
+
};
|
|
319
|
+
// P2-7: a closed stdin must still terminate Ink's reader, or it waits forever
|
|
320
|
+
// on a stream that will never produce another byte.
|
|
321
|
+
const onEnd = () => {
|
|
322
|
+
flushBracketed('timeout');
|
|
323
|
+
flushBurst();
|
|
324
|
+
flushPending();
|
|
325
|
+
if (!wrapper.writableEnded)
|
|
326
|
+
wrapper.end();
|
|
327
|
+
};
|
|
328
|
+
// `setEncoding('utf8')` on the REAL stream so Node's StringDecoder handles
|
|
329
|
+
// multi-byte boundaries for us -- the scan works on characters, and a UTF-8
|
|
330
|
+
// sequence split across two chunks would otherwise become two mojibake
|
|
331
|
+
// characters before it ever reached `splitMouseEvents`.
|
|
332
|
+
real.setEncoding?.('utf8');
|
|
333
|
+
real.on('data', onData);
|
|
334
|
+
real.once('end', onEnd);
|
|
335
|
+
real.once('close', onEnd);
|
|
336
|
+
return {
|
|
337
|
+
stdin: wrapper,
|
|
338
|
+
source: {
|
|
339
|
+
subscribe(listener) {
|
|
340
|
+
listeners.add(listener);
|
|
341
|
+
return () => listeners.delete(listener);
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
dispose() {
|
|
345
|
+
if (disposed)
|
|
346
|
+
return;
|
|
347
|
+
disposed = true;
|
|
348
|
+
flushTimer = clearTimer(flushTimer);
|
|
349
|
+
burstTimer = clearTimer(burstTimer);
|
|
350
|
+
assemblyTimer = clearTimer(assemblyTimer);
|
|
351
|
+
real.removeListener('data', onData);
|
|
352
|
+
real.removeListener('end', onEnd);
|
|
353
|
+
real.removeListener('close', onEnd);
|
|
354
|
+
listeners.clear();
|
|
355
|
+
// I-7: a live `'data'` listener on stdin keeps the event loop alive, and
|
|
356
|
+
// `aragon` would hang on exit instead of returning to the shell.
|
|
357
|
+
real.pause?.();
|
|
358
|
+
real.unref?.();
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Build a filter, or `null` if construction throws.
|
|
364
|
+
*
|
|
365
|
+
* The `null` return is the whole point (P0-1): a construction failure must
|
|
366
|
+
* degrade to "the wheel is inert and pastes are keystrokes" -- a supported,
|
|
367
|
+
* tested rung of the fail-safe ladder -- rather than leaving `?1000h` or `?2004h`
|
|
368
|
+
* on with no filter in front of Ink. `cli.tsx` derives BOTH `mouseOn` and
|
|
369
|
+
* `pasteOn` from the returned handle for exactly that reason.
|
|
370
|
+
*/
|
|
371
|
+
export function tryCreateStdinFilter(real, features, onError, bridge) {
|
|
372
|
+
try {
|
|
373
|
+
return createStdinFilter(real, features, bridge, onError);
|
|
374
|
+
}
|
|
375
|
+
catch (err) {
|
|
376
|
+
onError?.(err instanceof Error ? err.message : String(err));
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
@@ -71,6 +71,8 @@ export declare function setScreenRestore(fn: () => void): void;
|
|
|
71
71
|
* `process.on('SIGINT')` added later never runs at all (P0-1).
|
|
72
72
|
*/
|
|
73
73
|
export declare function setSignalTerminator(fn: (signo: number) => void): void;
|
|
74
|
+
/** Register a synchronous hook to run before the signal terminator. */
|
|
75
|
+
export declare function addSignalHook(fn: () => void): () => void;
|
|
74
76
|
/** Tests only: undo the hook swaps so cases cannot leak into each other. */
|
|
75
77
|
export declare function resetProcessHooksForTest(): void;
|
|
76
78
|
export interface PendingNote {
|
package/dist/logging/install.js
CHANGED
|
@@ -91,10 +91,52 @@ export function setScreenRestore(fn) {
|
|
|
91
91
|
export function setSignalTerminator(fn) {
|
|
92
92
|
signalTerminator = fn;
|
|
93
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* Work to run BEFORE whatever `signalTerminator` does, on every signal and on a
|
|
96
|
+
* fatal crash (background-service-supervision §3.6 / P1-2).
|
|
97
|
+
*
|
|
98
|
+
* AN APPEND-ONLY LIST, NOT A SECOND SINGLE SLOT, AND THAT IS THE WHOLE POINT.
|
|
99
|
+
* `setSignalTerminator` is a SINGLE SLOT that `cli.tsx` already claims for the
|
|
100
|
+
* alternate-screen restore; registering a second terminator from anywhere else
|
|
101
|
+
* would REPLACE it, which that file's own comment names as the bug that leaves
|
|
102
|
+
* the user staring at a blank alternate screen. So a subsystem that needs to run
|
|
103
|
+
* on the way out — the process supervisor, which must reap its children — adds a
|
|
104
|
+
* hook instead, and nothing about the screen path changes.
|
|
105
|
+
*
|
|
106
|
+
* IT ALSO RUNS FROM `handleFatal`, so a crash reaps too. And it is deliberately
|
|
107
|
+
* SYNCHRONOUS: `signalTerminator` calls `process.exit()` synchronously and
|
|
108
|
+
* `process.on('exit')` permits only synchronous work, so a hook that awaited
|
|
109
|
+
* anything would simply not finish (I-9).
|
|
110
|
+
*
|
|
111
|
+
* A THROWING HOOK MUST NOT STOP THE EXIT. Each one is wrapped, for the reason
|
|
112
|
+
* `handleFatal`'s own two wrapped steps record: a failure on the way out must
|
|
113
|
+
* never be the reason the process fails to leave.
|
|
114
|
+
*/
|
|
115
|
+
const signalHooks = [];
|
|
116
|
+
/** Register a synchronous hook to run before the signal terminator. */
|
|
117
|
+
export function addSignalHook(fn) {
|
|
118
|
+
signalHooks.push(fn);
|
|
119
|
+
return () => {
|
|
120
|
+
const at = signalHooks.indexOf(fn);
|
|
121
|
+
if (at >= 0)
|
|
122
|
+
signalHooks.splice(at, 1);
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function runSignalHooks() {
|
|
126
|
+
for (const hook of [...signalHooks]) {
|
|
127
|
+
try {
|
|
128
|
+
hook();
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// A hook that throws must not stop the exit; see the note above.
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
94
135
|
/** Tests only: undo the hook swaps so cases cannot leak into each other. */
|
|
95
136
|
export function resetProcessHooksForTest() {
|
|
96
137
|
screenRestore = () => { };
|
|
97
138
|
signalTerminator = null;
|
|
139
|
+
signalHooks.length = 0;
|
|
98
140
|
}
|
|
99
141
|
/**
|
|
100
142
|
* Resolve the BOOTSTRAP level.
|
|
@@ -189,12 +231,18 @@ function installProcessHooks(logger, port) {
|
|
|
189
231
|
handleFatal(logger, port, 'unhandled_rejection', reason);
|
|
190
232
|
});
|
|
191
233
|
// Normal exit. Only synchronous work is possible here, and the whole write
|
|
192
|
-
// path is synchronous precisely so this can be one line
|
|
234
|
+
// path is synchronous precisely so this can be one line — plus the hooks,
|
|
235
|
+
// which are synchronous for exactly that reason (I-9).
|
|
193
236
|
port.on('exit', () => {
|
|
237
|
+
runSignalHooks();
|
|
194
238
|
logger.flushSync();
|
|
195
239
|
});
|
|
196
240
|
for (const [name, signo] of Object.entries(SIGNAL_NUMBERS)) {
|
|
197
241
|
port.on(name, () => {
|
|
242
|
+
// BEFORE the flush and before the terminator: the terminator exits the
|
|
243
|
+
// process, so anything that has to happen on the way out has to happen
|
|
244
|
+
// first, and a child process left running outlives its parent.
|
|
245
|
+
runSignalHooks();
|
|
198
246
|
logger.flushSync();
|
|
199
247
|
// Exiting is NOT optional here: merely having a signal listener stops Node
|
|
200
248
|
// from terminating by default, so skipping this would turn Ctrl+C into
|
|
@@ -219,6 +267,10 @@ function installProcessHooks(logger, port) {
|
|
|
219
267
|
*/
|
|
220
268
|
function handleFatal(logger, port, msg, err) {
|
|
221
269
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
270
|
+
// FIRST, and outside the try below: a crash must still reap the children it
|
|
271
|
+
// spawned, and it must do so before the screen restore and the stack write,
|
|
272
|
+
// both of which can themselves fail (R-10).
|
|
273
|
+
runSignalHooks();
|
|
222
274
|
try {
|
|
223
275
|
logger.error('cli', msg, { message: error.message, stack: error.stack });
|
|
224
276
|
logger.flushSync();
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is this command one that does not exit on its own?
|
|
3
|
+
* (background-service-supervision §3.5.)
|
|
4
|
+
*
|
|
5
|
+
* PURE. No process, no filesystem, no clock — so the whole table below is
|
|
6
|
+
* enumerable by a test and readable by a reviewer in one screen.
|
|
7
|
+
*
|
|
8
|
+
* ASCII ONLY: `src/proc/**` is inside the glyph scanner's scope.
|
|
9
|
+
*
|
|
10
|
+
* A CONSERVATIVE ALLOWLIST OVER THE LEADING TOKEN AND SCRIPT NAME, NEVER A VERB
|
|
11
|
+
* HEURISTIC (R-1). The cost of a false positive is real: a build that is
|
|
12
|
+
* backgrounded returns before it finished, and the model reads a half-empty log
|
|
13
|
+
* as success. So `npm run build` and `npm test` must not match, and a chained
|
|
14
|
+
* command must not match at all — a chain's TAIL is what the model actually
|
|
15
|
+
* wants the result of.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* The classifier's table, exported so `classify.test.ts` can enumerate it and a
|
|
19
|
+
* reviewer can read it whole.
|
|
20
|
+
*
|
|
21
|
+
* Each pattern is anchored at the start of the (trimmed, env-prefix-stripped)
|
|
22
|
+
* command. `\b`-style boundaries are spelled explicitly because a bare `vite`
|
|
23
|
+
* must not match `vitest`.
|
|
24
|
+
*/
|
|
25
|
+
export declare const LONG_RUNNING_PATTERNS: readonly RegExp[];
|
|
26
|
+
/**
|
|
27
|
+
* Does the command contain a TOP-LEVEL `&&`, `||`, `;` or `|`?
|
|
28
|
+
*
|
|
29
|
+
* "Top level" means outside quotes: `echo "a && b"` is one command. Anything
|
|
30
|
+
* quoted is skipped, which is enough for the judgement being made here — this
|
|
31
|
+
* decides whether to REFUSE to background, so an over-eager reading is the safe
|
|
32
|
+
* direction.
|
|
33
|
+
*/
|
|
34
|
+
export declare function hasTopLevelChain(command: string): boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Would this command sit there rather than exiting?
|
|
37
|
+
*
|
|
38
|
+
* FALSE FOR ANYTHING CHAINED, unconditionally and before the table is consulted.
|
|
39
|
+
*/
|
|
40
|
+
export declare function looksLongRunning(command: string): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* The port the command asks for, when it names one.
|
|
43
|
+
*
|
|
44
|
+
* Used ONLY to enable the probe detector, so a miss is free (the URL detector
|
|
45
|
+
* still runs) and a wrong hit is bounded by the pre-flight probe: a port that
|
|
46
|
+
* already answers before we spawn disables the detector entirely rather than
|
|
47
|
+
* declaring every launch instantly ready.
|
|
48
|
+
*
|
|
49
|
+
* `--port N`, `--port=N`, `-p N`, `PORT=N`, and a `:N` suffix on a host in the
|
|
50
|
+
* command text (`php -S 127.0.0.1:8080`).
|
|
51
|
+
*/
|
|
52
|
+
export declare function extractPortHint(command: string): number | undefined;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is this command one that does not exit on its own?
|
|
3
|
+
* (background-service-supervision §3.5.)
|
|
4
|
+
*
|
|
5
|
+
* PURE. No process, no filesystem, no clock — so the whole table below is
|
|
6
|
+
* enumerable by a test and readable by a reviewer in one screen.
|
|
7
|
+
*
|
|
8
|
+
* ASCII ONLY: `src/proc/**` is inside the glyph scanner's scope.
|
|
9
|
+
*
|
|
10
|
+
* A CONSERVATIVE ALLOWLIST OVER THE LEADING TOKEN AND SCRIPT NAME, NEVER A VERB
|
|
11
|
+
* HEURISTIC (R-1). The cost of a false positive is real: a build that is
|
|
12
|
+
* backgrounded returns before it finished, and the model reads a half-empty log
|
|
13
|
+
* as success. So `npm run build` and `npm test` must not match, and a chained
|
|
14
|
+
* command must not match at all — a chain's TAIL is what the model actually
|
|
15
|
+
* wants the result of.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* The classifier's table, exported so `classify.test.ts` can enumerate it and a
|
|
19
|
+
* reviewer can read it whole.
|
|
20
|
+
*
|
|
21
|
+
* Each pattern is anchored at the start of the (trimmed, env-prefix-stripped)
|
|
22
|
+
* command. `\b`-style boundaries are spelled explicitly because a bare `vite`
|
|
23
|
+
* must not match `vitest`.
|
|
24
|
+
*/
|
|
25
|
+
export const LONG_RUNNING_PATTERNS = Object.freeze([
|
|
26
|
+
// Package-manager scripts whose NAME says "does not exit".
|
|
27
|
+
/^(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:dev|start|serve|watch|preview)(?:\s|$)/i,
|
|
28
|
+
// Node/JS dev servers and bundlers in watch mode.
|
|
29
|
+
/^next\s+(?:dev|start)(?:\s|$)/i,
|
|
30
|
+
/^vite(?:\s|$)/i,
|
|
31
|
+
/^nuxt\s+dev(?:\s|$)/i,
|
|
32
|
+
/^astro\s+dev(?:\s|$)/i,
|
|
33
|
+
/^remix\s+dev(?:\s|$)/i,
|
|
34
|
+
/^ng\s+serve(?:\s|$)/i,
|
|
35
|
+
/^webpack\s+serve(?:\s|$)/i,
|
|
36
|
+
/^nodemon(?:\s|$)/i,
|
|
37
|
+
/^tsc\s+(?:--watch|-w)(?:\s|$)/i,
|
|
38
|
+
/^(?:serve|http-server)(?:\s|$)/i,
|
|
39
|
+
// Python.
|
|
40
|
+
/^uvicorn(?:\s|$)/i,
|
|
41
|
+
/^gunicorn(?:\s|$)/i,
|
|
42
|
+
/^hypercorn(?:\s|$)/i,
|
|
43
|
+
/^flask\s+run(?:\s|$)/i,
|
|
44
|
+
/^python[0-9.]*\s+(?:\S*[\\/])?manage\.py\s+runserver(?:\s|$)/i,
|
|
45
|
+
/^python[0-9.]*\s+-m\s+http\.server(?:\s|$)/i,
|
|
46
|
+
// Ruby / PHP / Rust.
|
|
47
|
+
/^rails\s+(?:server|s)(?:\s|$)/i,
|
|
48
|
+
/^php\s+-S(?:\s|$)/i,
|
|
49
|
+
/^cargo\s+watch(?:\s|$)/i,
|
|
50
|
+
// Containers. `up` WITHOUT `-d` only; the `-d` form is checked below.
|
|
51
|
+
/^docker(?:\s+compose|-compose)\s+up(?:\s|$)/i,
|
|
52
|
+
// Plain watchers.
|
|
53
|
+
/^tail\s+-f(?:\s|$)/i,
|
|
54
|
+
/^watch\s+/i,
|
|
55
|
+
]);
|
|
56
|
+
/** `docker compose up -d` returns on its own, so it is a foreground command. */
|
|
57
|
+
const DOCKER_DETACHED = /(?:^|\s)(?:-d|--detach)(?:\s|$)/i;
|
|
58
|
+
/**
|
|
59
|
+
* A leading `KEY=value ` run of assignments, which POSIX shells accept and which
|
|
60
|
+
* would otherwise hide the real leading token (`PORT=5173 vite`).
|
|
61
|
+
*/
|
|
62
|
+
const ENV_PREFIX = /^(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S*)\s+)+/;
|
|
63
|
+
/** Strip a leading env-assignment run, so the table can anchor on the command. */
|
|
64
|
+
function stripEnvPrefix(command) {
|
|
65
|
+
return command.replace(ENV_PREFIX, '');
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Does the command contain a TOP-LEVEL `&&`, `||`, `;` or `|`?
|
|
69
|
+
*
|
|
70
|
+
* "Top level" means outside quotes: `echo "a && b"` is one command. Anything
|
|
71
|
+
* quoted is skipped, which is enough for the judgement being made here — this
|
|
72
|
+
* decides whether to REFUSE to background, so an over-eager reading is the safe
|
|
73
|
+
* direction.
|
|
74
|
+
*/
|
|
75
|
+
export function hasTopLevelChain(command) {
|
|
76
|
+
let quote = null;
|
|
77
|
+
for (let i = 0; i < command.length; i += 1) {
|
|
78
|
+
const ch = command[i];
|
|
79
|
+
if (quote) {
|
|
80
|
+
if (ch === quote)
|
|
81
|
+
quote = null;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (ch === '"' || ch === "'") {
|
|
85
|
+
quote = ch;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (ch === ';')
|
|
89
|
+
return true;
|
|
90
|
+
// A lone `|` is a pipe and `||` is an or; both disqualify. So does `&`, in
|
|
91
|
+
// BOTH of its readings: `&&` is a chain, and a trailing single `&` is the
|
|
92
|
+
// manual-detach the prompt block bans - whatever it backgrounds, we cannot
|
|
93
|
+
// supervise. One branch each, because the two `&` cases have one answer.
|
|
94
|
+
if (ch === '|' || ch === '&')
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Would this command sit there rather than exiting?
|
|
101
|
+
*
|
|
102
|
+
* FALSE FOR ANYTHING CHAINED, unconditionally and before the table is consulted.
|
|
103
|
+
*/
|
|
104
|
+
export function looksLongRunning(command) {
|
|
105
|
+
const trimmed = command.trim();
|
|
106
|
+
if (trimmed.length === 0)
|
|
107
|
+
return false;
|
|
108
|
+
if (hasTopLevelChain(trimmed))
|
|
109
|
+
return false;
|
|
110
|
+
const bare = stripEnvPrefix(trimmed);
|
|
111
|
+
if (/^docker(?:\s+compose|-compose)\s+up(?:\s|$)/i.test(bare) && DOCKER_DETACHED.test(bare)) {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
return LONG_RUNNING_PATTERNS.some((re) => re.test(bare));
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The port the command asks for, when it names one.
|
|
118
|
+
*
|
|
119
|
+
* Used ONLY to enable the probe detector, so a miss is free (the URL detector
|
|
120
|
+
* still runs) and a wrong hit is bounded by the pre-flight probe: a port that
|
|
121
|
+
* already answers before we spawn disables the detector entirely rather than
|
|
122
|
+
* declaring every launch instantly ready.
|
|
123
|
+
*
|
|
124
|
+
* `--port N`, `--port=N`, `-p N`, `PORT=N`, and a `:N` suffix on a host in the
|
|
125
|
+
* command text (`php -S 127.0.0.1:8080`).
|
|
126
|
+
*/
|
|
127
|
+
export function extractPortHint(command) {
|
|
128
|
+
const candidates = [
|
|
129
|
+
/(?:^|\s)--port[=\s]+(\d{1,5})(?:\s|$)/i,
|
|
130
|
+
/(?:^|\s)-p[=\s]+(\d{1,5})(?:\s|$)/,
|
|
131
|
+
/(?:^|\s)PORT=(\d{1,5})(?:\s|$)/,
|
|
132
|
+
/(?:^|\s)\S*?:(\d{2,5})(?:\s|$)/,
|
|
133
|
+
];
|
|
134
|
+
for (const re of candidates) {
|
|
135
|
+
const m = re.exec(command);
|
|
136
|
+
if (!m || !m[1])
|
|
137
|
+
continue;
|
|
138
|
+
const port = Number.parseInt(m[1], 10);
|
|
139
|
+
if (Number.isFinite(port) && port > 0 && port <= 65535)
|
|
140
|
+
return port;
|
|
141
|
+
}
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|