@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.
Files changed (82) hide show
  1. package/CHANGELOG.md +131 -0
  2. package/README.md +227 -3
  3. package/dist/agent/agent-mode.d.ts +53 -0
  4. package/dist/agent/agent-mode.js +69 -0
  5. package/dist/agent/controller.d.ts +69 -0
  6. package/dist/agent/controller.js +154 -1
  7. package/dist/agent/plan-prompt.d.ts +18 -0
  8. package/dist/agent/plan-prompt.js +93 -0
  9. package/dist/agent/reducer.d.ts +14 -1
  10. package/dist/agent/reducer.js +9 -0
  11. package/dist/agent/system-prompt.d.ts +15 -0
  12. package/dist/agent/system-prompt.js +8 -0
  13. package/dist/cli.d.ts +1 -1
  14. package/dist/cli.js +183 -20
  15. package/dist/commands/builtins.js +60 -0
  16. package/dist/commands/registry.d.ts +17 -0
  17. package/dist/config/app-paths.d.ts +71 -4
  18. package/dist/config/app-paths.js +139 -8
  19. package/dist/config/cli-commands.d.ts +43 -0
  20. package/dist/config/cli-commands.js +196 -0
  21. package/dist/config/env.d.ts +7 -1
  22. package/dist/config/env.js +45 -1
  23. package/dist/config/load.d.ts +8 -0
  24. package/dist/config/load.js +59 -2
  25. package/dist/config/migrate-home.d.ts +63 -0
  26. package/dist/config/migrate-home.js +165 -0
  27. package/dist/config/migrate-legacy-state.d.ts +10 -0
  28. package/dist/config/migrate-legacy-state.js +15 -6
  29. package/dist/config/schema.d.ts +61 -0
  30. package/dist/config/schema.js +83 -0
  31. package/dist/config/store.d.ts +30 -12
  32. package/dist/config/store.js +47 -35
  33. package/dist/logging/cli-commands.d.ts +25 -0
  34. package/dist/logging/cli-commands.js +221 -0
  35. package/dist/logging/file-sink.d.ts +114 -0
  36. package/dist/logging/file-sink.js +326 -0
  37. package/dist/logging/install.d.ts +163 -0
  38. package/dist/logging/install.js +302 -0
  39. package/dist/logging/levels.d.ts +40 -0
  40. package/dist/logging/levels.js +59 -0
  41. package/dist/logging/logger.d.ts +110 -0
  42. package/dist/logging/logger.js +237 -0
  43. package/dist/logging/redact.d.ts +28 -0
  44. package/dist/logging/redact.js +90 -0
  45. package/dist/logging/secret-registry.d.ts +30 -0
  46. package/dist/logging/secret-registry.js +55 -0
  47. package/dist/skills/paths.d.ts +2 -4
  48. package/dist/skills/paths.js +7 -9
  49. package/dist/tools/human-input.d.ts +179 -0
  50. package/dist/tools/human-input.js +227 -0
  51. package/dist/tools/index.d.ts +35 -3
  52. package/dist/tools/index.js +96 -9
  53. package/dist/tools/plan-tools.d.ts +64 -0
  54. package/dist/tools/plan-tools.js +198 -0
  55. package/dist/ui/App.d.ts +4 -1
  56. package/dist/ui/App.js +182 -11
  57. package/dist/ui/Composer.d.ts +4 -1
  58. package/dist/ui/Composer.js +26 -7
  59. package/dist/ui/ModeChip.d.ts +29 -0
  60. package/dist/ui/ModeChip.js +15 -0
  61. package/dist/ui/PromptInput.d.ts +8 -1
  62. package/dist/ui/PromptInput.js +12 -2
  63. package/dist/ui/StatusBar.d.ts +15 -0
  64. package/dist/ui/StatusBar.js +10 -2
  65. package/dist/ui/entries/ToolPreview.d.ts +2 -0
  66. package/dist/ui/entries/ToolPreview.js +64 -0
  67. package/dist/ui/glyphs.d.ts +9 -0
  68. package/dist/ui/glyphs.js +8 -0
  69. package/dist/ui/layout/wrap-rows.d.ts +24 -0
  70. package/dist/ui/layout/wrap-rows.js +74 -0
  71. package/dist/ui/overlays/HelpOverlay.js +13 -0
  72. package/dist/ui/overlays/PlanReviewOverlay.d.ts +50 -0
  73. package/dist/ui/overlays/PlanReviewOverlay.js +117 -0
  74. package/dist/ui/overlays/QuestionOverlay.d.ts +30 -0
  75. package/dist/ui/overlays/QuestionOverlay.js +193 -0
  76. package/dist/ui/overlays/SettingsScreen.d.ts +7 -0
  77. package/dist/ui/overlays/SettingsScreen.js +8 -1
  78. package/dist/ui/use-startup-notices.d.ts +15 -0
  79. package/dist/ui/use-startup-notices.js +38 -0
  80. package/package.json +3 -2
  81. package/runtime/insecure-tls-warning.cjs +62 -0
  82. 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;