@aragon-agent/cli 0.5.0 → 0.5.1
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 +131 -0
- package/README.md +227 -3
- package/dist/agent/agent-mode.d.ts +53 -0
- package/dist/agent/agent-mode.js +69 -0
- package/dist/agent/controller.d.ts +69 -0
- package/dist/agent/controller.js +154 -1
- package/dist/agent/plan-prompt.d.ts +18 -0
- package/dist/agent/plan-prompt.js +93 -0
- package/dist/agent/reducer.d.ts +14 -1
- package/dist/agent/reducer.js +9 -0
- package/dist/agent/system-prompt.d.ts +15 -0
- package/dist/agent/system-prompt.js +8 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +183 -20
- package/dist/commands/builtins.js +60 -0
- package/dist/commands/registry.d.ts +17 -0
- package/dist/config/app-paths.d.ts +71 -4
- package/dist/config/app-paths.js +139 -8
- package/dist/config/cli-commands.d.ts +43 -0
- package/dist/config/cli-commands.js +196 -0
- package/dist/config/env.d.ts +7 -1
- package/dist/config/env.js +45 -1
- package/dist/config/load.d.ts +8 -0
- package/dist/config/load.js +59 -2
- package/dist/config/migrate-home.d.ts +63 -0
- package/dist/config/migrate-home.js +165 -0
- package/dist/config/migrate-legacy-state.d.ts +10 -0
- package/dist/config/migrate-legacy-state.js +15 -6
- package/dist/config/schema.d.ts +61 -0
- package/dist/config/schema.js +83 -0
- package/dist/config/store.d.ts +30 -12
- package/dist/config/store.js +47 -35
- package/dist/logging/cli-commands.d.ts +25 -0
- package/dist/logging/cli-commands.js +221 -0
- package/dist/logging/file-sink.d.ts +114 -0
- package/dist/logging/file-sink.js +326 -0
- package/dist/logging/install.d.ts +163 -0
- package/dist/logging/install.js +302 -0
- package/dist/logging/levels.d.ts +40 -0
- package/dist/logging/levels.js +59 -0
- package/dist/logging/logger.d.ts +110 -0
- package/dist/logging/logger.js +237 -0
- package/dist/logging/redact.d.ts +28 -0
- package/dist/logging/redact.js +90 -0
- package/dist/logging/secret-registry.d.ts +30 -0
- package/dist/logging/secret-registry.js +55 -0
- package/dist/skills/paths.d.ts +2 -4
- package/dist/skills/paths.js +7 -9
- package/dist/tools/human-input.d.ts +179 -0
- package/dist/tools/human-input.js +227 -0
- package/dist/tools/index.d.ts +35 -3
- package/dist/tools/index.js +96 -9
- package/dist/tools/plan-tools.d.ts +64 -0
- package/dist/tools/plan-tools.js +198 -0
- package/dist/ui/App.d.ts +4 -1
- package/dist/ui/App.js +182 -11
- package/dist/ui/Composer.d.ts +4 -1
- package/dist/ui/Composer.js +26 -7
- package/dist/ui/ModeChip.d.ts +29 -0
- package/dist/ui/ModeChip.js +15 -0
- package/dist/ui/PromptInput.d.ts +8 -1
- package/dist/ui/PromptInput.js +12 -2
- package/dist/ui/StatusBar.d.ts +15 -0
- package/dist/ui/StatusBar.js +10 -2
- package/dist/ui/entries/ToolPreview.d.ts +2 -0
- package/dist/ui/entries/ToolPreview.js +64 -0
- package/dist/ui/glyphs.d.ts +9 -0
- package/dist/ui/glyphs.js +8 -0
- package/dist/ui/layout/wrap-rows.d.ts +24 -0
- package/dist/ui/layout/wrap-rows.js +74 -0
- package/dist/ui/overlays/HelpOverlay.js +13 -0
- package/dist/ui/overlays/PlanReviewOverlay.d.ts +50 -0
- package/dist/ui/overlays/PlanReviewOverlay.js +117 -0
- package/dist/ui/overlays/QuestionOverlay.d.ts +30 -0
- package/dist/ui/overlays/QuestionOverlay.js +193 -0
- package/dist/ui/overlays/SettingsScreen.d.ts +7 -0
- package/dist/ui/overlays/SettingsScreen.js +8 -1
- package/dist/ui/use-startup-notices.d.ts +15 -0
- package/dist/ui/use-startup-notices.js +38 -0
- package/package.json +3 -2
- package/runtime/insecure-tls-warning.cjs +62 -0
- package/runtime/insecure-tls-warning.d.cts +5 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The file sink — a memory queue in front of ONE synchronous append fd.
|
|
3
|
+
*
|
|
4
|
+
* WHY NOT `createWriteStream` (design P1-4). Three reasons, each fatal on its
|
|
5
|
+
* own:
|
|
6
|
+
* - a stream's internal buffer is DISCARDED by `process.exit()`, and the
|
|
7
|
+
* records still sitting in it during a crash are the ones worth having;
|
|
8
|
+
* - an exit-path `appendFileSync` plus a steady-state stream are two writers
|
|
9
|
+
* on one file with no defined ordering between them;
|
|
10
|
+
* - a stream reports write failures through an async `'error'` event, which
|
|
11
|
+
* THROWS when nobody is listening — so "make the log directory read-only"
|
|
12
|
+
* would take the whole CLI down instead of degrading.
|
|
13
|
+
*
|
|
14
|
+
* With `openSync` + `writeSync` every failure is a synchronous throw one
|
|
15
|
+
* `try/catch` away, and `flushSync()` and the steady-state flush are literally
|
|
16
|
+
* the same code path. `O_APPEND` keeps concurrent `aragon` processes from
|
|
17
|
+
* truncating each other, and one batched `writeSync` stays far below any size
|
|
18
|
+
* that could tear a line.
|
|
19
|
+
*/
|
|
20
|
+
import { closeSync, existsSync, mkdirSync, openSync, readdirSync, renameSync, rmSync, statSync, writeSync, } from 'node:fs';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
/** Flush triggers: whichever of the two comes first. */
|
|
23
|
+
const FLUSH_AT_RECORDS = 64;
|
|
24
|
+
const FLUSH_INTERVAL_MS = 200;
|
|
25
|
+
/**
|
|
26
|
+
* Hard queue ceiling. On overflow the NEWEST records are dropped and the head
|
|
27
|
+
* is kept: in a flood, the oldest lines are the ones that explain how it
|
|
28
|
+
* started, so discarding them first would throw away the log's whole value at
|
|
29
|
+
* the moment it finally has some.
|
|
30
|
+
*
|
|
31
|
+
* A backstop rather than a working limit — `write()` flushes at
|
|
32
|
+
* `FLUSH_AT_RECORDS` and the flush is synchronous, so the queue does not
|
|
33
|
+
* normally grow past that. It stays because the batching threshold is a tuning
|
|
34
|
+
* knob and an unbounded array behind one is not something to rediscover later.
|
|
35
|
+
* The drop COUNTER, by contrast, is reached routinely: see `flushSync`, where a
|
|
36
|
+
* failed write would otherwise discard its batch without a word.
|
|
37
|
+
*/
|
|
38
|
+
const MAX_QUEUED_RECORDS = 5000;
|
|
39
|
+
/** Consecutive write failures after which the sink stops trying (§4.4.6). */
|
|
40
|
+
const MAX_CONSECUTIVE_FAILURES = 3;
|
|
41
|
+
const FILE_PREFIX = 'aragon-';
|
|
42
|
+
const FILE_SUFFIX = '.log';
|
|
43
|
+
/**
|
|
44
|
+
* `YYYY-MM-DD` in LOCAL time.
|
|
45
|
+
*
|
|
46
|
+
* Local, not UTC, and the record timestamps use the same clock deliberately: a
|
|
47
|
+
* user west of UTC would otherwise find an evening's records filed under the
|
|
48
|
+
* previous local day and stamped with the next one, which makes "open today's
|
|
49
|
+
* log" a lie twice over.
|
|
50
|
+
*/
|
|
51
|
+
export function localDateStamp(date = new Date()) {
|
|
52
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
53
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Delete the oldest files until at most `maxFiles` remain.
|
|
57
|
+
*
|
|
58
|
+
* A STANDALONE STEP, called on three occasions — first write of the process,
|
|
59
|
+
* midnight roll-over, and size rotation — because retention that only ran
|
|
60
|
+
* during size rotation would never fire for the many users who never reach
|
|
61
|
+
* `maxFileBytes`: one file per day, forever, under a setting that says 10.
|
|
62
|
+
*
|
|
63
|
+
* Never throws. On Windows another `aragon` process may hold a handle on a file
|
|
64
|
+
* we would like to remove; that is a reason to skip it, not to fail a write.
|
|
65
|
+
*/
|
|
66
|
+
export function enforceRetention(dir, maxFiles) {
|
|
67
|
+
try {
|
|
68
|
+
const files = readdirSync(dir)
|
|
69
|
+
.filter((name) => name.startsWith(FILE_PREFIX) && name.endsWith(FILE_SUFFIX))
|
|
70
|
+
.map((name) => {
|
|
71
|
+
const full = join(dir, name);
|
|
72
|
+
try {
|
|
73
|
+
return { full, mtime: statSync(full).mtimeMs };
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
.filter((entry) => entry !== null)
|
|
80
|
+
.sort((a, b) => a.mtime - b.mtime);
|
|
81
|
+
for (const entry of files.slice(0, Math.max(0, files.length - maxFiles))) {
|
|
82
|
+
try {
|
|
83
|
+
rmSync(entry.full, { force: true });
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// Locked by another instance. Retention is best-effort by design.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// Directory does not exist yet, or is unreadable. Nothing to retain.
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
export class FileSink {
|
|
95
|
+
options;
|
|
96
|
+
queue = [];
|
|
97
|
+
dropped = 0;
|
|
98
|
+
fd = null;
|
|
99
|
+
currentFile = null;
|
|
100
|
+
currentDay = '';
|
|
101
|
+
currentSize = 0;
|
|
102
|
+
timer = null;
|
|
103
|
+
failures = 0;
|
|
104
|
+
failed = false;
|
|
105
|
+
failureReason;
|
|
106
|
+
retentionRun = false;
|
|
107
|
+
constructor(options) {
|
|
108
|
+
this.options = { ...options };
|
|
109
|
+
}
|
|
110
|
+
get disabled() {
|
|
111
|
+
return this.failed;
|
|
112
|
+
}
|
|
113
|
+
get lastError() {
|
|
114
|
+
return this.failureReason;
|
|
115
|
+
}
|
|
116
|
+
/** The file records are going to right now (may not exist until first write). */
|
|
117
|
+
get currentPath() {
|
|
118
|
+
if (this.currentFile)
|
|
119
|
+
return this.currentFile;
|
|
120
|
+
return join(this.options.dir, `${FILE_PREFIX}${localDateStamp()}${FILE_SUFFIX}`);
|
|
121
|
+
}
|
|
122
|
+
/** Enqueue one already-serialised line (including its trailing newline). */
|
|
123
|
+
write(line) {
|
|
124
|
+
if (this.failed)
|
|
125
|
+
return;
|
|
126
|
+
if (this.queue.length >= MAX_QUEUED_RECORDS) {
|
|
127
|
+
this.dropped += 1;
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
this.queue.push(line);
|
|
131
|
+
if (this.queue.length >= FLUSH_AT_RECORDS) {
|
|
132
|
+
this.flushSync();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
this.startTimer();
|
|
136
|
+
}
|
|
137
|
+
/** Threshold/timer flush. Identical to `flushSync` — see the module header. */
|
|
138
|
+
flush() {
|
|
139
|
+
this.flushSync();
|
|
140
|
+
}
|
|
141
|
+
/** Idempotent: safe to call from `exit`, a signal handler and a crash handler. */
|
|
142
|
+
flushSync() {
|
|
143
|
+
if (this.queue.length === 0 && this.dropped === 0)
|
|
144
|
+
return;
|
|
145
|
+
if (this.failed) {
|
|
146
|
+
this.queue = [];
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const pending = this.queue.length;
|
|
150
|
+
const droppedBefore = this.dropped;
|
|
151
|
+
const batch = this.takeBatch();
|
|
152
|
+
try {
|
|
153
|
+
this.prepareTarget(Buffer.byteLength(batch));
|
|
154
|
+
writeSync(this.fd, batch);
|
|
155
|
+
this.currentSize += Buffer.byteLength(batch);
|
|
156
|
+
this.failures = 0;
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
// The batch is gone — retrying it would need the queue to grow unbounded
|
|
160
|
+
// behind a directory that may never become writable. But it is ACCOUNTED
|
|
161
|
+
// FOR: the count (including the notice this batch was carrying) rides
|
|
162
|
+
// along to the next flush that succeeds. Silently losing records is the
|
|
163
|
+
// one thing a logger must not do.
|
|
164
|
+
this.dropped = droppedBefore + pending;
|
|
165
|
+
this.recordFailure(err);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
close() {
|
|
169
|
+
this.flushSync();
|
|
170
|
+
this.stopTimer();
|
|
171
|
+
this.closeFd();
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Apply new settings. Changing the directory closes the current fd so the next
|
|
175
|
+
* write lazily opens the new one; the sink never eagerly creates a directory
|
|
176
|
+
* the user may never write a record into.
|
|
177
|
+
*/
|
|
178
|
+
reconfigure(options) {
|
|
179
|
+
const dirChanged = options.dir !== this.options.dir;
|
|
180
|
+
if (dirChanged) {
|
|
181
|
+
this.flushSync();
|
|
182
|
+
this.closeFd();
|
|
183
|
+
this.retentionRun = false;
|
|
184
|
+
}
|
|
185
|
+
this.options = { ...this.options, ...options };
|
|
186
|
+
}
|
|
187
|
+
// -------------------------------------------------------------------------
|
|
188
|
+
// Internals
|
|
189
|
+
// -------------------------------------------------------------------------
|
|
190
|
+
takeBatch() {
|
|
191
|
+
const lines = this.queue;
|
|
192
|
+
this.queue = [];
|
|
193
|
+
if (this.dropped > 0) {
|
|
194
|
+
const notice = this.options.formatDropNotice?.(this.dropped);
|
|
195
|
+
this.dropped = 0;
|
|
196
|
+
// Appended, not prepended: it describes what happened to the records that
|
|
197
|
+
// would have followed these, and silence about a drop is unacceptable.
|
|
198
|
+
if (notice)
|
|
199
|
+
lines.push(notice);
|
|
200
|
+
}
|
|
201
|
+
return lines.join('');
|
|
202
|
+
}
|
|
203
|
+
/** Open (or roll) the target file so `incomingBytes` can be appended to it. */
|
|
204
|
+
prepareTarget(incomingBytes) {
|
|
205
|
+
const day = localDateStamp();
|
|
206
|
+
if (this.fd !== null && day !== this.currentDay) {
|
|
207
|
+
// Midnight: yesterday's file keeps its own name, so there is nothing to
|
|
208
|
+
// rename — just close it and open today's. Retention runs here because
|
|
209
|
+
// this is the path a user who never hits `maxFileBytes` takes every day.
|
|
210
|
+
this.closeFd();
|
|
211
|
+
this.openTarget(day);
|
|
212
|
+
enforceRetention(this.options.dir, this.options.maxFiles);
|
|
213
|
+
}
|
|
214
|
+
if (this.fd === null) {
|
|
215
|
+
this.openTarget(day);
|
|
216
|
+
if (!this.retentionRun) {
|
|
217
|
+
this.retentionRun = true;
|
|
218
|
+
enforceRetention(this.options.dir, this.options.maxFiles);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (this.currentSize + incomingBytes > this.options.maxFileBytes) {
|
|
222
|
+
this.rotateBySize(day);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
openTarget(day) {
|
|
226
|
+
mkdirSync(this.options.dir, { recursive: true, mode: 0o700 });
|
|
227
|
+
const file = join(this.options.dir, `${FILE_PREFIX}${day}${FILE_SUFFIX}`);
|
|
228
|
+
this.fd = openSync(file, 'a', 0o600);
|
|
229
|
+
this.currentFile = file;
|
|
230
|
+
this.currentDay = day;
|
|
231
|
+
this.currentSize = existsSync(file) ? statSync(file).size : 0;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* flush → close → rename → open → retention, in that order.
|
|
235
|
+
*
|
|
236
|
+
* `closeSync` MUST precede `renameSync`: on Windows renaming a file the
|
|
237
|
+
* process still holds a handle on fails with EPERM. A failed rename is
|
|
238
|
+
* swallowed and writing continues into the original file — two instances
|
|
239
|
+
* rotating at once should cost an extra small file, not the log itself.
|
|
240
|
+
*/
|
|
241
|
+
rotateBySize(day) {
|
|
242
|
+
const previous = this.currentFile;
|
|
243
|
+
const index = this.nextRotationIndex(day);
|
|
244
|
+
const target = join(this.options.dir, `${FILE_PREFIX}${day}.${index}${FILE_SUFFIX}`);
|
|
245
|
+
this.closeFd();
|
|
246
|
+
if (previous) {
|
|
247
|
+
try {
|
|
248
|
+
renameSync(previous, target);
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
// Another instance won the race, or holds a handle. Keep writing.
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
this.openTarget(day);
|
|
255
|
+
enforceRetention(this.options.dir, this.options.maxFiles);
|
|
256
|
+
}
|
|
257
|
+
/** Highest existing `aragon-<day>.<n>.log` index, plus one. */
|
|
258
|
+
nextRotationIndex(day) {
|
|
259
|
+
const prefix = `${FILE_PREFIX}${day}.`;
|
|
260
|
+
let max = 0;
|
|
261
|
+
try {
|
|
262
|
+
for (const name of readdirSync(this.options.dir)) {
|
|
263
|
+
if (!name.startsWith(prefix) || !name.endsWith(FILE_SUFFIX))
|
|
264
|
+
continue;
|
|
265
|
+
const n = Number.parseInt(name.slice(prefix.length, name.length - FILE_SUFFIX.length), 10);
|
|
266
|
+
if (Number.isFinite(n) && n > max)
|
|
267
|
+
max = n;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
// Unreadable directory: index 1 is as good a guess as any.
|
|
272
|
+
}
|
|
273
|
+
return max + 1;
|
|
274
|
+
}
|
|
275
|
+
closeFd() {
|
|
276
|
+
if (this.fd === null)
|
|
277
|
+
return;
|
|
278
|
+
try {
|
|
279
|
+
closeSync(this.fd);
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
// Already closed by a racing handler; nothing to do.
|
|
283
|
+
}
|
|
284
|
+
this.fd = null;
|
|
285
|
+
this.currentFile = null;
|
|
286
|
+
this.currentSize = 0;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Logging failure must never become CLI failure, and must never write to the
|
|
290
|
+
* terminal itself (invariant I-4 — a stray line permanently misaligns the
|
|
291
|
+
* full-screen frame). The owner is told once through `onFailure`, which routes
|
|
292
|
+
* to a toast when the TUI is mounted and to stderr when it is not.
|
|
293
|
+
*/
|
|
294
|
+
recordFailure(err) {
|
|
295
|
+
this.failures += 1;
|
|
296
|
+
this.failureReason = err instanceof Error ? err.message : String(err);
|
|
297
|
+
this.closeFd();
|
|
298
|
+
if (this.failures < MAX_CONSECUTIVE_FAILURES)
|
|
299
|
+
return;
|
|
300
|
+
this.failed = true;
|
|
301
|
+
this.stopTimer();
|
|
302
|
+
this.options.onFailure?.(this.failureReason);
|
|
303
|
+
}
|
|
304
|
+
startTimer() {
|
|
305
|
+
if (this.timer)
|
|
306
|
+
return;
|
|
307
|
+
this.timer = setInterval(() => this.flushSync(), FLUSH_INTERVAL_MS);
|
|
308
|
+
// Without `unref`, an `aragon --version` that logs one line would be held
|
|
309
|
+
// open by this timer for 200ms after it had nothing left to do.
|
|
310
|
+
this.timer.unref?.();
|
|
311
|
+
}
|
|
312
|
+
stopTimer() {
|
|
313
|
+
if (!this.timer)
|
|
314
|
+
return;
|
|
315
|
+
clearInterval(this.timer);
|
|
316
|
+
this.timer = null;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
/** Exposed for the CLI's `logs` commands, which list the same file family. */
|
|
320
|
+
export function isLogFileName(name) {
|
|
321
|
+
return name.startsWith(FILE_PREFIX) && name.endsWith(FILE_SUFFIX);
|
|
322
|
+
}
|
|
323
|
+
/** The log file the CURRENT day would use inside a log directory. */
|
|
324
|
+
export function currentLogFileName(date = new Date()) {
|
|
325
|
+
return `${FILE_PREFIX}${localDateStamp(date)}${FILE_SUFFIX}`;
|
|
326
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Assembly: bootstrap level resolution, replay of what happened before the
|
|
3
|
+
* logger existed, ownership of the four process hooks, and the agent-event
|
|
4
|
+
* mapping.
|
|
5
|
+
*
|
|
6
|
+
* ══════════════════════════════════════════════════════════════════════════
|
|
7
|
+
* SINGLE-OWNER MODEL — the part that is easiest to get wrong and hardest to
|
|
8
|
+
* notice, because getting it wrong still compiles and still passes a smoke test.
|
|
9
|
+
*
|
|
10
|
+
* The logger owns FLUSHING and never owns EXITING.
|
|
11
|
+
* The screen owns RESTORING and never owns FLUSHING.
|
|
12
|
+
*
|
|
13
|
+
* All four hooks below change Node's default behaviour merely by existing:
|
|
14
|
+
*
|
|
15
|
+
* `uncaughtException` — registering a listener REPLACES Node's "print the
|
|
16
|
+
* stack and exit". `main().catch()` in `cli.tsx` is not that hook; it is a
|
|
17
|
+
* promise chain's catch and only ever sees rejections of that one chain.
|
|
18
|
+
* So there is no prior behaviour to inherit: without an explicit
|
|
19
|
+
* `process.exit(1)` here, an exception thrown inside an Ink render
|
|
20
|
+
* callback or a timer stops terminating the process. In full-screen that
|
|
21
|
+
* means the alternate screen is never restored and Ink keeps drawing onto
|
|
22
|
+
* a broken controller — the user's only way out is `reset`.
|
|
23
|
+
*
|
|
24
|
+
* `unhandledRejection` — Node 18 defaults to `--unhandled-rejections=throw`.
|
|
25
|
+
* Adding a listener suppresses it, turning a crash into a silent hang.
|
|
26
|
+
*
|
|
27
|
+
* `SIGINT` / `SIGTERM` / `SIGHUP` — the handlers in `runInteractive()` exist
|
|
28
|
+
* ONLY in its full-screen branch, so inline mode, `-p`, and every
|
|
29
|
+
* subcommand had no handler at all and took Node's default termination,
|
|
30
|
+
* which does not run `'exit'` listeners: the whole queue was lost. But a
|
|
31
|
+
* signal listener registered here runs BEFORE the one `runInteractive`
|
|
32
|
+
* would add (EventEmitter order), and merely having one stops Node from
|
|
33
|
+
* terminating — so it must exit itself, and would then beat the screen
|
|
34
|
+
* restore. Hence: this module flushes and then calls a REPLACEABLE
|
|
35
|
+
* terminator. The default is a bare exit, which covers headless and every
|
|
36
|
+
* subcommand; the full-screen branch swaps in one that restores first.
|
|
37
|
+
* ══════════════════════════════════════════════════════════════════════════
|
|
38
|
+
*/
|
|
39
|
+
import { type LogConfig } from '../config/schema.js';
|
|
40
|
+
import { Logger, type LogScope } from './logger.js';
|
|
41
|
+
/**
|
|
42
|
+
* Hand the screen-restore duty to whoever actually owns the screen.
|
|
43
|
+
* Called by `runInteractive()`'s full-screen branch and by nobody else.
|
|
44
|
+
*/
|
|
45
|
+
export declare function setScreenRestore(fn: () => void): void;
|
|
46
|
+
/**
|
|
47
|
+
* Replace what a signal does after the flush. The default (a bare
|
|
48
|
+
* `exit(128 + signo)`) is correct for headless, inline and every subcommand;
|
|
49
|
+
* full-screen substitutes one that restores the terminal first.
|
|
50
|
+
*/
|
|
51
|
+
export declare function setSignalTerminator(fn: (signo: number) => void): void;
|
|
52
|
+
/** Tests only: undo the hook swaps so cases cannot leak into each other. */
|
|
53
|
+
export declare function resetProcessHooksForTest(): void;
|
|
54
|
+
export interface PendingNote {
|
|
55
|
+
level: 'info' | 'warn' | 'error';
|
|
56
|
+
scope: LogScope;
|
|
57
|
+
msg: string;
|
|
58
|
+
data?: Record<string, unknown>;
|
|
59
|
+
}
|
|
60
|
+
/** The process surface the hooks need, so tests can pass a stub. */
|
|
61
|
+
export interface ProcessHookPort {
|
|
62
|
+
on(event: string, listener: (...args: unknown[]) => void): unknown;
|
|
63
|
+
exit(code: number): never;
|
|
64
|
+
stderr: {
|
|
65
|
+
write(chunk: string): unknown;
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
export interface InstallLoggingOptions {
|
|
69
|
+
/** Notes accumulated before a logger existed (migrations, path warnings). */
|
|
70
|
+
pending?: PendingNote[];
|
|
71
|
+
/** Injectable for tests; defaults to `process.argv`. */
|
|
72
|
+
argv?: string[];
|
|
73
|
+
/** Injectable for tests; defaults to the real `process`. */
|
|
74
|
+
processPort?: ProcessHookPort;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Resolve the BOOTSTRAP level.
|
|
78
|
+
*
|
|
79
|
+
* Inputs are only `process.env`, the config file, and a deliberately tiny argv
|
|
80
|
+
* scan. Not commander (it has not parsed yet) and not `.env` (`loadDotenv()`
|
|
81
|
+
* runs inside `loadConfig()`, much later). This level covers exactly the window
|
|
82
|
+
* before `loadConfig()` — migrations, path warnings, an early crash — after
|
|
83
|
+
* which `Logger.reconfigure()` installs the authoritative one.
|
|
84
|
+
*
|
|
85
|
+
* The duplication with `loadConfig()` is intentional and cheap. The alternative
|
|
86
|
+
* is to install the logger after config resolution, which would leave precisely
|
|
87
|
+
* the events that are hardest to debug with nowhere to be recorded.
|
|
88
|
+
*/
|
|
89
|
+
export declare function resolveBootstrapLogConfig(argv?: string[]): LogConfig;
|
|
90
|
+
/**
|
|
91
|
+
* Build the logger, replay what came before it, and take ownership of the four
|
|
92
|
+
* process hooks. Returns the logger, which is also installed as the singleton.
|
|
93
|
+
*/
|
|
94
|
+
export declare function installLogging(opts?: InstallLoggingOptions): Logger;
|
|
95
|
+
/**
|
|
96
|
+
* What `attachAgentEvents` needs from a controller.
|
|
97
|
+
*
|
|
98
|
+
* Structural rather than a direct `AgentController` import: it keeps this module
|
|
99
|
+
* off the controller's dependency graph, and it lets the tests feed the nine
|
|
100
|
+
* event shapes through a few lines of fake instead of a live agent.
|
|
101
|
+
*/
|
|
102
|
+
export interface AgentEventSource {
|
|
103
|
+
subscribe(listener: (event: AgentLogEvent) => void): () => void;
|
|
104
|
+
getConfig(): {
|
|
105
|
+
provider: string;
|
|
106
|
+
model: string;
|
|
107
|
+
thinkingLevel: string;
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/** The nine members of the core `AgentEvent` union, as this module reads them. */
|
|
111
|
+
export type AgentLogEvent = {
|
|
112
|
+
type: 'agent_start';
|
|
113
|
+
} | {
|
|
114
|
+
type: 'agent_end';
|
|
115
|
+
messages: unknown[];
|
|
116
|
+
} | {
|
|
117
|
+
type: 'turn_start';
|
|
118
|
+
} | {
|
|
119
|
+
type: 'turn_end';
|
|
120
|
+
usage?: {
|
|
121
|
+
inputTokens?: number;
|
|
122
|
+
outputTokens?: number;
|
|
123
|
+
};
|
|
124
|
+
} | {
|
|
125
|
+
type: 'message_update';
|
|
126
|
+
streamEvent?: {
|
|
127
|
+
type?: string;
|
|
128
|
+
delta?: unknown;
|
|
129
|
+
};
|
|
130
|
+
} | {
|
|
131
|
+
type: 'tool_execution_start';
|
|
132
|
+
toolCallId?: string;
|
|
133
|
+
toolName?: string;
|
|
134
|
+
args?: unknown;
|
|
135
|
+
} | {
|
|
136
|
+
type: 'tool_execution_end';
|
|
137
|
+
toolName?: string;
|
|
138
|
+
duration?: number;
|
|
139
|
+
isError?: boolean;
|
|
140
|
+
result?: unknown;
|
|
141
|
+
} | {
|
|
142
|
+
type: 'code_execution_start';
|
|
143
|
+
language?: string;
|
|
144
|
+
code?: string;
|
|
145
|
+
} | {
|
|
146
|
+
type: 'code_execution_end';
|
|
147
|
+
duration?: number;
|
|
148
|
+
error?: string;
|
|
149
|
+
output?: string;
|
|
150
|
+
};
|
|
151
|
+
/**
|
|
152
|
+
* Subscribe to the agent's events and record them.
|
|
153
|
+
*
|
|
154
|
+
* Returns the unsubscribe function `controller.subscribe()` gives back.
|
|
155
|
+
*
|
|
156
|
+
* `message_update` fires thousands of times per answer, so it is `trace`-only
|
|
157
|
+
* AND records no content — a `debug` run would otherwise write a log larger
|
|
158
|
+
* than the conversation. The turn timer is a closure variable rather than
|
|
159
|
+
* module state because `turn_end` carries `{message, usage}` and NO duration,
|
|
160
|
+
* so the elapsed time has to be measured here; module state would let one test
|
|
161
|
+
* (or a future second controller) read another's clock.
|
|
162
|
+
*/
|
|
163
|
+
export declare function attachAgentEvents(logger: Logger, controller: AgentEventSource): () => void;
|