@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.
Files changed (94) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +95 -2
  3. package/dist/agent/controller.d.ts +119 -0
  4. package/dist/agent/controller.js +275 -1
  5. package/dist/agent/reducer.d.ts +69 -0
  6. package/dist/agent/reducer.js +100 -0
  7. package/dist/agent/system-prompt.d.ts +14 -0
  8. package/dist/agent/system-prompt.js +2 -0
  9. package/dist/cli.js +84 -22
  10. package/dist/commands/builtins.js +92 -1
  11. package/dist/compaction/child.js +5 -0
  12. package/dist/compaction/command.js +14 -1
  13. package/dist/compaction/compactor.d.ts +34 -0
  14. package/dist/compaction/compactor.js +158 -37
  15. package/dist/compaction/types.d.ts +36 -0
  16. package/dist/compaction/wiring.d.ts +9 -0
  17. package/dist/compaction/wiring.js +22 -1
  18. package/dist/config/env.d.ts +1 -1
  19. package/dist/config/env.js +48 -1
  20. package/dist/config/load.d.ts +11 -0
  21. package/dist/config/load.js +39 -1
  22. package/dist/config/schema.d.ts +85 -0
  23. package/dist/config/schema.js +33 -0
  24. package/dist/config/store.js +21 -1
  25. package/dist/input/limits.d.ts +72 -0
  26. package/dist/input/limits.js +63 -0
  27. package/dist/input/paste-parse.d.ts +71 -0
  28. package/dist/input/paste-parse.js +95 -0
  29. package/dist/input/stdin-filter.d.ts +81 -0
  30. package/dist/input/stdin-filter.js +379 -0
  31. package/dist/logging/install.d.ts +2 -0
  32. package/dist/logging/install.js +53 -1
  33. package/dist/proc/classify.d.ts +52 -0
  34. package/dist/proc/classify.js +143 -0
  35. package/dist/proc/kill-tree.d.ts +42 -0
  36. package/dist/proc/kill-tree.js +119 -0
  37. package/dist/proc/limits.d.ts +84 -0
  38. package/dist/proc/limits.js +84 -0
  39. package/dist/proc/log-ring.d.ts +68 -0
  40. package/dist/proc/log-ring.js +102 -0
  41. package/dist/proc/prompt.d.ts +24 -0
  42. package/dist/proc/prompt.js +49 -0
  43. package/dist/proc/readiness.d.ts +102 -0
  44. package/dist/proc/readiness.js +219 -0
  45. package/dist/proc/supervisor.d.ts +103 -0
  46. package/dist/proc/supervisor.js +465 -0
  47. package/dist/proc/types.d.ts +175 -0
  48. package/dist/proc/types.js +15 -0
  49. package/dist/session/persist.js +36 -1
  50. package/dist/tools/bash-tool.d.ts +51 -2
  51. package/dist/tools/bash-tool.js +321 -51
  52. package/dist/tools/fs-tools.d.ts +17 -0
  53. package/dist/tools/index.d.ts +55 -2
  54. package/dist/tools/index.js +39 -1
  55. package/dist/tools/proc-tools.d.ts +25 -0
  56. package/dist/tools/proc-tools.js +102 -0
  57. package/dist/ui/App.d.ts +15 -2
  58. package/dist/ui/App.js +256 -7
  59. package/dist/ui/Composer.d.ts +54 -1
  60. package/dist/ui/Composer.js +50 -6
  61. package/dist/ui/PromptInput.d.ts +49 -3
  62. package/dist/ui/PromptInput.js +184 -52
  63. package/dist/ui/StatusBar.d.ts +19 -0
  64. package/dist/ui/StatusBar.js +5 -2
  65. package/dist/ui/Transcript.js +29 -1
  66. package/dist/ui/composer-limits.d.ts +31 -0
  67. package/dist/ui/composer-limits.js +50 -0
  68. package/dist/ui/composer-rows.d.ts +73 -0
  69. package/dist/ui/composer-rows.js +201 -0
  70. package/dist/ui/editor-reducer.d.ts +37 -0
  71. package/dist/ui/editor-reducer.js +88 -12
  72. package/dist/ui/entries/ServiceCard.d.ts +66 -0
  73. package/dist/ui/entries/ServiceCard.js +116 -0
  74. package/dist/ui/entries/UserEntry.d.ts +17 -3
  75. package/dist/ui/entries/UserEntry.js +34 -5
  76. package/dist/ui/layout/budget.d.ts +34 -6
  77. package/dist/ui/layout/budget.js +32 -7
  78. package/dist/ui/layout/virtual-window.js +51 -1
  79. package/dist/ui/overlays/HelpOverlay.js +3 -0
  80. package/dist/ui/overlays/PlanReviewOverlay.js +10 -2
  81. package/dist/ui/overlays/QuestionOverlay.js +5 -1
  82. package/dist/ui/overlays/SettingsScreen.js +14 -1
  83. package/dist/ui/paste-frames.d.ts +87 -0
  84. package/dist/ui/paste-frames.js +112 -0
  85. package/dist/ui/paste-tokens.d.ts +90 -0
  86. package/dist/ui/paste-tokens.js +147 -0
  87. package/dist/ui/screen.d.ts +16 -0
  88. package/dist/ui/screen.js +45 -2
  89. package/dist/ui/selection/selection-controller.d.ts +1 -1
  90. package/dist/ui/transcript-text.js +49 -0
  91. package/dist/ui/use-wheel-routing.d.ts +1 -1
  92. package/package.json +2 -2
  93. package/dist/input/stdin-mouse-filter.d.ts +0 -47
  94. 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 {
@@ -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
+ }