@aragon-agent/cli 0.6.2 → 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 (167) hide show
  1. package/CHANGELOG.md +321 -0
  2. package/README.md +297 -2
  3. package/dist/agent/controller.d.ts +193 -1
  4. package/dist/agent/controller.js +475 -2
  5. package/dist/agent/headless.d.ts +8 -0
  6. package/dist/agent/headless.js +47 -0
  7. package/dist/agent/reducer.d.ts +189 -0
  8. package/dist/agent/reducer.js +301 -2
  9. package/dist/agent/system-prompt.d.ts +14 -0
  10. package/dist/agent/system-prompt.js +2 -0
  11. package/dist/cli.js +297 -16
  12. package/dist/commands/builtins.js +165 -24
  13. package/dist/commands/registry.d.ts +27 -0
  14. package/dist/compaction/archive.d.ts +175 -0
  15. package/dist/compaction/archive.js +267 -0
  16. package/dist/compaction/child.d.ts +97 -0
  17. package/dist/compaction/child.js +118 -0
  18. package/dist/compaction/command.d.ts +41 -0
  19. package/dist/compaction/command.js +355 -0
  20. package/dist/compaction/compactor.d.ts +302 -0
  21. package/dist/compaction/compactor.js +810 -0
  22. package/dist/compaction/digest.d.ts +62 -0
  23. package/dist/compaction/digest.js +164 -0
  24. package/dist/compaction/limits.d.ts +165 -0
  25. package/dist/compaction/limits.js +172 -0
  26. package/dist/compaction/pressure.d.ts +135 -0
  27. package/dist/compaction/pressure.js +167 -0
  28. package/dist/compaction/summarize-call.d.ts +70 -0
  29. package/dist/compaction/summarize-call.js +86 -0
  30. package/dist/compaction/summary-prompt.d.ts +85 -0
  31. package/dist/compaction/summary-prompt.js +197 -0
  32. package/dist/compaction/tail-budget.d.ts +52 -0
  33. package/dist/compaction/tail-budget.js +67 -0
  34. package/dist/compaction/types.d.ts +215 -0
  35. package/dist/compaction/types.js +22 -0
  36. package/dist/compaction/wiring.d.ts +264 -0
  37. package/dist/compaction/wiring.js +577 -0
  38. package/dist/config/app-paths.d.ts +14 -0
  39. package/dist/config/app-paths.js +16 -0
  40. package/dist/config/cli-commands.d.ts +41 -0
  41. package/dist/config/cli-commands.js +77 -1
  42. package/dist/config/env.d.ts +5 -2
  43. package/dist/config/env.js +140 -3
  44. package/dist/config/load.d.ts +42 -0
  45. package/dist/config/load.js +101 -1
  46. package/dist/config/schema.d.ts +244 -0
  47. package/dist/config/schema.js +170 -0
  48. package/dist/config/store.js +37 -1
  49. package/dist/config/ui-state.d.ts +28 -1
  50. package/dist/config/ui-state.js +9 -0
  51. package/dist/exec/events.d.ts +50 -1
  52. package/dist/exec/runner.js +42 -0
  53. package/dist/input/limits.d.ts +72 -0
  54. package/dist/input/limits.js +63 -0
  55. package/dist/input/mouse-events.d.ts +31 -5
  56. package/dist/input/mouse-events.js +86 -27
  57. package/dist/input/paste-parse.d.ts +71 -0
  58. package/dist/input/paste-parse.js +95 -0
  59. package/dist/input/stdin-filter.d.ts +81 -0
  60. package/dist/input/stdin-filter.js +379 -0
  61. package/dist/logging/install.d.ts +53 -1
  62. package/dist/logging/install.js +81 -1
  63. package/dist/logging/logger.d.ts +1 -1
  64. package/dist/proc/classify.d.ts +52 -0
  65. package/dist/proc/classify.js +143 -0
  66. package/dist/proc/kill-tree.d.ts +42 -0
  67. package/dist/proc/kill-tree.js +119 -0
  68. package/dist/proc/limits.d.ts +84 -0
  69. package/dist/proc/limits.js +84 -0
  70. package/dist/proc/log-ring.d.ts +68 -0
  71. package/dist/proc/log-ring.js +102 -0
  72. package/dist/proc/prompt.d.ts +24 -0
  73. package/dist/proc/prompt.js +49 -0
  74. package/dist/proc/readiness.d.ts +102 -0
  75. package/dist/proc/readiness.js +219 -0
  76. package/dist/proc/supervisor.d.ts +103 -0
  77. package/dist/proc/supervisor.js +465 -0
  78. package/dist/proc/types.d.ts +175 -0
  79. package/dist/proc/types.js +15 -0
  80. package/dist/session/persist.js +55 -1
  81. package/dist/team/report.js +7 -1
  82. package/dist/team/runtime.d.ts +10 -0
  83. package/dist/team/runtime.js +7 -0
  84. package/dist/team/subagent.d.ts +31 -1
  85. package/dist/team/subagent.js +42 -15
  86. package/dist/team/types.d.ts +8 -0
  87. package/dist/tools/bash-tool.d.ts +51 -2
  88. package/dist/tools/bash-tool.js +321 -51
  89. package/dist/tools/fs-tools.d.ts +17 -0
  90. package/dist/tools/index.d.ts +55 -2
  91. package/dist/tools/index.js +39 -1
  92. package/dist/tools/proc-tools.d.ts +25 -0
  93. package/dist/tools/proc-tools.js +102 -0
  94. package/dist/ui/ActivityLine.d.ts +19 -1
  95. package/dist/ui/ActivityLine.js +10 -2
  96. package/dist/ui/App.d.ts +62 -2
  97. package/dist/ui/App.js +546 -16
  98. package/dist/ui/Composer.d.ts +66 -1
  99. package/dist/ui/Composer.js +50 -6
  100. package/dist/ui/PromptInput.d.ts +82 -3
  101. package/dist/ui/PromptInput.js +232 -49
  102. package/dist/ui/StatusBar.d.ts +59 -0
  103. package/dist/ui/StatusBar.js +16 -4
  104. package/dist/ui/TeamPanel.js +7 -1
  105. package/dist/ui/Transcript.d.ts +21 -1
  106. package/dist/ui/Transcript.js +61 -3
  107. package/dist/ui/clipboard.d.ts +52 -0
  108. package/dist/ui/clipboard.js +91 -0
  109. package/dist/ui/composer-limits.d.ts +31 -0
  110. package/dist/ui/composer-limits.js +50 -0
  111. package/dist/ui/composer-rows.d.ts +73 -0
  112. package/dist/ui/composer-rows.js +201 -0
  113. package/dist/ui/editor-reducer.d.ts +37 -0
  114. package/dist/ui/editor-reducer.js +88 -12
  115. package/dist/ui/entries/CompactionCard.d.ts +75 -0
  116. package/dist/ui/entries/CompactionCard.js +158 -0
  117. package/dist/ui/entries/ServiceCard.d.ts +66 -0
  118. package/dist/ui/entries/ServiceCard.js +116 -0
  119. package/dist/ui/entries/UserEntry.d.ts +17 -3
  120. package/dist/ui/entries/UserEntry.js +34 -5
  121. package/dist/ui/frame-differ.d.ts +46 -0
  122. package/dist/ui/frame-differ.js +94 -10
  123. package/dist/ui/gauge.d.ts +16 -1
  124. package/dist/ui/gauge.js +25 -6
  125. package/dist/ui/glyphs.d.ts +11 -0
  126. package/dist/ui/glyphs.js +2 -0
  127. package/dist/ui/layout/ScrollViewport.d.ts +43 -6
  128. package/dist/ui/layout/ScrollViewport.js +128 -21
  129. package/dist/ui/layout/budget.d.ts +44 -9
  130. package/dist/ui/layout/budget.js +42 -10
  131. package/dist/ui/layout/follow-state.d.ts +137 -0
  132. package/dist/ui/layout/follow-state.js +150 -0
  133. package/dist/ui/layout/virtual-window.d.ts +11 -0
  134. package/dist/ui/layout/virtual-window.js +85 -1
  135. package/dist/ui/overlays/HelpOverlay.js +9 -1
  136. package/dist/ui/overlays/PlanReviewOverlay.js +10 -2
  137. package/dist/ui/overlays/QuestionOverlay.js +5 -1
  138. package/dist/ui/overlays/SettingsScreen.d.ts +47 -1
  139. package/dist/ui/overlays/SettingsScreen.js +74 -2
  140. package/dist/ui/palettes.d.ts +16 -0
  141. package/dist/ui/palettes.js +6 -0
  142. package/dist/ui/paste-frames.d.ts +87 -0
  143. package/dist/ui/paste-frames.js +112 -0
  144. package/dist/ui/paste-tokens.d.ts +90 -0
  145. package/dist/ui/paste-tokens.js +147 -0
  146. package/dist/ui/screen.d.ts +38 -0
  147. package/dist/ui/screen.js +113 -8
  148. package/dist/ui/selection/highlight.d.ts +39 -0
  149. package/dist/ui/selection/highlight.js +64 -0
  150. package/dist/ui/selection/screen-mirror.d.ts +24 -0
  151. package/dist/ui/selection/screen-mirror.js +35 -0
  152. package/dist/ui/selection/selection-controller.d.ts +88 -0
  153. package/dist/ui/selection/selection-controller.js +282 -0
  154. package/dist/ui/selection/selection.d.ts +90 -0
  155. package/dist/ui/selection/selection.js +223 -0
  156. package/dist/ui/stdout-frame-writer.d.ts +33 -0
  157. package/dist/ui/stdout-frame-writer.js +15 -0
  158. package/dist/ui/theme.d.ts +11 -0
  159. package/dist/ui/theme.js +2 -0
  160. package/dist/ui/transcript-text.js +72 -0
  161. package/dist/ui/use-startup-notices.d.ts +27 -1
  162. package/dist/ui/use-startup-notices.js +43 -11
  163. package/dist/ui/use-wheel-routing.d.ts +1 -1
  164. package/dist/ui/use-wheel-routing.js +8 -0
  165. package/package.json +4 -2
  166. package/dist/input/stdin-mouse-filter.d.ts +0 -42
  167. package/dist/input/stdin-mouse-filter.js +0 -153
@@ -0,0 +1,81 @@
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 { type MouseEvent } from './mouse-events.js';
35
+ import { type PasteBridge } from './limits.js';
36
+ export interface MouseSource {
37
+ /**
38
+ * Returns an unsubscribe function. Multiple subscribers are allowed -- the
39
+ * wheel router and (since tui-selection-and-scroll-follow) the selection
40
+ * controller both take this one channel, and each ignores the kinds it does
41
+ * not own.
42
+ *
43
+ * With `features.mouse` false the channel exists and stays SILENT forever, so
44
+ * a subscriber written against it does not have to know which features are on.
45
+ */
46
+ subscribe(listener: (event: MouseEvent) => void): () => void;
47
+ }
48
+ /**
49
+ * Which stream-level features this filter is running.
50
+ *
51
+ * TOLD, NEVER DERIVED (D-17 / P0-1). With `{ mouse: false, paste: true }` no
52
+ * `MouseEvent` is ever emitted and `splitMouseEvents` is not called at all: a
53
+ * `--no-mouse` session never wrote `?1000h`, so no real report can arrive, and
54
+ * running the parser anyway could only ever EAT pasted bytes that happen to look
55
+ * like one. With `{ mouse: true, paste: false }` this is byte-for-byte the
56
+ * v0.6.3 mouse filter.
57
+ */
58
+ export interface StdinFilterFeatures {
59
+ /** Parse and strip SGR / X10 mouse reports, and emit `MouseEvent`s. */
60
+ readonly mouse: boolean;
61
+ /** Recognise pastes and deliver them framed. */
62
+ readonly paste: boolean;
63
+ }
64
+ export interface StdinFilter {
65
+ /** Hand this to Ink: `render(<App/>, { stdin: filter.stdin })`. */
66
+ readonly stdin: NodeJS.ReadStream;
67
+ readonly source: MouseSource;
68
+ /** Idempotent. Detaches from the real stdin and clears every timer (I-7). */
69
+ dispose(): void;
70
+ }
71
+ export declare function createStdinFilter(real: NodeJS.ReadStream, features: StdinFilterFeatures, bridge?: PasteBridge, onWarn?: (reason: string) => void): StdinFilter;
72
+ /**
73
+ * Build a filter, or `null` if construction throws.
74
+ *
75
+ * The `null` return is the whole point (P0-1): a construction failure must
76
+ * degrade to "the wheel is inert and pastes are keystrokes" -- a supported,
77
+ * tested rung of the fail-safe ladder -- rather than leaving `?1000h` or `?2004h`
78
+ * on with no filter in front of Ink. `cli.tsx` derives BOTH `mouseOn` and
79
+ * `pasteOn` from the returned handle for exactly that reason.
80
+ */
81
+ export declare function tryCreateStdinFilter(real: NodeJS.ReadStream, features: StdinFilterFeatures, onError?: (reason: string) => void, bridge?: PasteBridge): StdinFilter | null;
@@ -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 {
@@ -129,7 +131,17 @@ export interface AgentEventSource {
129
131
  thinkingLevel: string;
130
132
  };
131
133
  }
132
- /** The nine members of the core `AgentEvent` union, as this module reads them. */
134
+ /**
135
+ * The ELEVEN members of the core `AgentEvent` union, as this module reads them.
136
+ *
137
+ * THIS UNION MUST STAY A SUPERSET OF CORE'S, and the failure when it does not is
138
+ * a COMPILE error rather than a silent one (implementation finding IF-3): the
139
+ * `AgentEventSource` interface above takes a listener of THIS type, so a core
140
+ * union member missing here makes `AgentController` structurally incompatible
141
+ * with `attachAgentEvents` and every call site fails to type-check. That is the
142
+ * right failure mode, but it means adding an event to core is also a change
143
+ * here — the two `compaction_*` members below arrived that way.
144
+ */
133
145
  export type AgentLogEvent = {
134
146
  type: 'agent_start';
135
147
  } | {
@@ -183,6 +195,46 @@ export type AgentLogEvent = {
183
195
  duration?: number;
184
196
  error?: string;
185
197
  output?: string;
198
+ }
199
+ /**
200
+ * Context compaction (context-auto-compaction §5.1).
201
+ *
202
+ * RECORDED UNDER THE `agent` SCOPE HERE, not `compaction`. This module logs the
203
+ * LEAD'S LIFECYCLE, and "the run paused to compact" is part of that story — a
204
+ * support reader following a run's `agent` records must not find an unexplained
205
+ * gap where a 40-second summarization was. The `compaction` scope carries the
206
+ * subsystem's own detail (`compaction/compactor.ts`), which is a different
207
+ * question.
208
+ *
209
+ * NO `summary` FIELD IS READ, ever: it can be tens of thousands of characters,
210
+ * and this file's `message_update` case already records why a log larger than
211
+ * the conversation is not a log.
212
+ */
213
+ | {
214
+ type: 'compaction_start';
215
+ trigger?: string;
216
+ messageCount?: number;
217
+ } | {
218
+ type: 'compaction_end';
219
+ applied?: boolean;
220
+ mode?: string;
221
+ reason?: string;
222
+ messagesBefore?: number;
223
+ messagesAfter?: number;
224
+ droppedMessages?: number;
225
+ /**
226
+ * The tail-relief report (context-auto-compaction-hardening W2).
227
+ *
228
+ * THIS UNION MUST STAY A SUPERSET OF CORE'S (IF-3). A field added to
229
+ * `CompactionEndEvent` and not mirrored here does not fail the build — the
230
+ * structural type simply ignores it — so the only trace of a degradation
231
+ * the user is entitled to know about would be missing from the log.
232
+ */
233
+ tailRelief?: {
234
+ messages: number;
235
+ charsRemoved: number;
236
+ };
237
+ durationMs?: number;
186
238
  };
187
239
  /**
188
240
  * Subscribe to the agent's events and record them.
@@ -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();
@@ -273,6 +325,34 @@ export function attachAgentEvents(logger, controller) {
273
325
  ms: turnStartedAt > 0 ? Date.now() - turnStartedAt : 0,
274
326
  });
275
327
  break;
328
+ // Context compaction (context-auto-compaction §5.1). At `info`, like
329
+ // `turn_end`, so a run's `agent` records have no unexplained 40-second gap
330
+ // where a summarization was.
331
+ case 'compaction_start':
332
+ logger.info('agent', 'compaction_start', {
333
+ trigger: event.trigger,
334
+ messages: event.messageCount,
335
+ });
336
+ break;
337
+ case 'compaction_end':
338
+ // `applied: false` is a WARN, not an info: it means the run is still at
339
+ // the occupancy that triggered this and is about to send anyway.
340
+ logger[event.applied ? 'info' : 'warn']('agent', 'compaction_end', {
341
+ applied: event.applied,
342
+ mode: event.mode,
343
+ reason: event.reason,
344
+ before: event.messagesBefore,
345
+ after: event.messagesAfter,
346
+ dropped: event.droppedMessages,
347
+ ...(event.tailRelief
348
+ ? {
349
+ clipped: event.tailRelief.messages,
350
+ clippedChars: event.tailRelief.charsRemoved,
351
+ }
352
+ : {}),
353
+ ms: event.durationMs,
354
+ });
355
+ break;
276
356
  case 'message_update': {
277
357
  // API retry (llm-api-retry-backoff §6.8), ABOVE the `trace` line so a
278
358
  // three-minute pause is in the log at the DEFAULT level. Redaction is
@@ -23,7 +23,7 @@
23
23
  import type { LogConfig } from '../config/schema.js';
24
24
  import { type LogLevelName } from './levels.js';
25
25
  /** Closed set, not free text: a typo'd scope is a record nobody can filter on. */
26
- export type LogScope = 'cli' | 'config' | 'agent' | 'tool' | 'skills' | 'llm' | 'migrate' | 'log' | 'fast' | 'history' | 'update';
26
+ export type LogScope = 'cli' | 'config' | 'agent' | 'tool' | 'skills' | 'llm' | 'migrate' | 'log' | 'fast' | 'history' | 'update' | 'compaction';
27
27
  export interface LogRecord {
28
28
  ts: string;
29
29
  lv: LogLevelName;