@aragon-agent/cli 0.6.3 → 0.6.7

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 (138) hide show
  1. package/CHANGELOG.md +142 -0
  2. package/README.md +203 -13
  3. package/dist/agent/controller.d.ts +158 -2
  4. package/dist/agent/controller.js +400 -1
  5. package/dist/agent/reducer.d.ts +102 -19
  6. package/dist/agent/reducer.js +162 -71
  7. package/dist/agent/system-prompt.d.ts +14 -0
  8. package/dist/agent/system-prompt.js +2 -0
  9. package/dist/agent/usage.d.ts +34 -0
  10. package/dist/agent/usage.js +43 -0
  11. package/dist/cli.js +132 -23
  12. package/dist/commands/builtins.js +101 -1
  13. package/dist/compaction/child.js +5 -0
  14. package/dist/compaction/command.js +14 -1
  15. package/dist/compaction/compactor.d.ts +94 -24
  16. package/dist/compaction/compactor.js +247 -98
  17. package/dist/compaction/context-command.d.ts +33 -0
  18. package/dist/compaction/context-command.js +166 -0
  19. package/dist/compaction/meter.d.ts +251 -0
  20. package/dist/compaction/meter.js +428 -0
  21. package/dist/compaction/pressure.d.ts +6 -0
  22. package/dist/compaction/pressure.js +4 -0
  23. package/dist/compaction/types.d.ts +77 -0
  24. package/dist/compaction/wiring.d.ts +56 -0
  25. package/dist/compaction/wiring.js +107 -25
  26. package/dist/config/cli-commands.js +5 -0
  27. package/dist/config/env.d.ts +1 -1
  28. package/dist/config/env.js +63 -2
  29. package/dist/config/load.d.ts +28 -0
  30. package/dist/config/load.js +83 -1
  31. package/dist/config/schema.d.ts +129 -0
  32. package/dist/config/schema.js +69 -0
  33. package/dist/config/store.js +47 -1
  34. package/dist/exec/events.d.ts +138 -1
  35. package/dist/exec/events.js +42 -0
  36. package/dist/exec/index.js +43 -6
  37. package/dist/exec/progress-reporter.d.ts +23 -0
  38. package/dist/exec/progress-reporter.js +88 -0
  39. package/dist/exec/runner.d.ts +34 -0
  40. package/dist/exec/runner.js +137 -25
  41. package/dist/fast/reviewer.d.ts +19 -1
  42. package/dist/fast/reviewer.js +22 -0
  43. package/dist/fast/types.d.ts +15 -0
  44. package/dist/fast/wiring.d.ts +10 -1
  45. package/dist/fast/wiring.js +18 -1
  46. package/dist/input/limits.d.ts +72 -0
  47. package/dist/input/limits.js +63 -0
  48. package/dist/input/paste-parse.d.ts +71 -0
  49. package/dist/input/paste-parse.js +95 -0
  50. package/dist/input/stdin-filter.d.ts +81 -0
  51. package/dist/input/stdin-filter.js +379 -0
  52. package/dist/logging/install.d.ts +2 -0
  53. package/dist/logging/install.js +53 -1
  54. package/dist/proc/classify.d.ts +52 -0
  55. package/dist/proc/classify.js +143 -0
  56. package/dist/proc/kill-tree.d.ts +42 -0
  57. package/dist/proc/kill-tree.js +119 -0
  58. package/dist/proc/limits.d.ts +84 -0
  59. package/dist/proc/limits.js +84 -0
  60. package/dist/proc/log-ring.d.ts +68 -0
  61. package/dist/proc/log-ring.js +102 -0
  62. package/dist/proc/prompt.d.ts +24 -0
  63. package/dist/proc/prompt.js +49 -0
  64. package/dist/proc/readiness.d.ts +102 -0
  65. package/dist/proc/readiness.js +219 -0
  66. package/dist/proc/supervisor.d.ts +103 -0
  67. package/dist/proc/supervisor.js +465 -0
  68. package/dist/proc/types.d.ts +175 -0
  69. package/dist/proc/types.js +15 -0
  70. package/dist/session/persist.js +36 -1
  71. package/dist/todo/limits.d.ts +4 -17
  72. package/dist/todo/limits.js +4 -17
  73. package/dist/todo/panel-rows.d.ts +8 -6
  74. package/dist/todo/panel-rows.js +19 -6
  75. package/dist/tools/bash-tool.d.ts +51 -2
  76. package/dist/tools/bash-tool.js +321 -51
  77. package/dist/tools/fs-tools.d.ts +17 -0
  78. package/dist/tools/index.d.ts +55 -2
  79. package/dist/tools/index.js +39 -1
  80. package/dist/tools/proc-tools.d.ts +25 -0
  81. package/dist/tools/proc-tools.js +102 -0
  82. package/dist/ui/App.d.ts +15 -2
  83. package/dist/ui/App.js +330 -69
  84. package/dist/ui/AutocompletePopup.d.ts +3 -1
  85. package/dist/ui/AutocompletePopup.js +9 -11
  86. package/dist/ui/BottomStatusRow.d.ts +1 -1
  87. package/dist/ui/Composer.d.ts +56 -1
  88. package/dist/ui/Composer.js +50 -6
  89. package/dist/ui/PromptInput.d.ts +52 -3
  90. package/dist/ui/PromptInput.js +201 -55
  91. package/dist/ui/StatusBar.d.ts +40 -15
  92. package/dist/ui/StatusBar.js +51 -9
  93. package/dist/ui/TeamPanel.d.ts +3 -1
  94. package/dist/ui/TeamPanel.js +13 -9
  95. package/dist/ui/TodoPanel.d.ts +5 -5
  96. package/dist/ui/TodoPanel.js +56 -24
  97. package/dist/ui/Transcript.js +29 -1
  98. package/dist/ui/UpdateLine.d.ts +1 -1
  99. package/dist/ui/capabilities.js +5 -5
  100. package/dist/ui/composer-limits.d.ts +31 -0
  101. package/dist/ui/composer-limits.js +50 -0
  102. package/dist/ui/composer-rows.d.ts +73 -0
  103. package/dist/ui/composer-rows.js +201 -0
  104. package/dist/ui/editor-reducer.d.ts +37 -0
  105. package/dist/ui/editor-reducer.js +88 -12
  106. package/dist/ui/entries/ServiceCard.d.ts +66 -0
  107. package/dist/ui/entries/ServiceCard.js +116 -0
  108. package/dist/ui/entries/UserEntry.d.ts +17 -3
  109. package/dist/ui/entries/UserEntry.js +34 -5
  110. package/dist/ui/exit-snapshot.d.ts +9 -0
  111. package/dist/ui/layout/ScrollViewport.d.ts +1 -1
  112. package/dist/ui/layout/autocomplete.d.ts +18 -0
  113. package/dist/ui/layout/autocomplete.js +26 -0
  114. package/dist/ui/layout/budget.d.ts +34 -6
  115. package/dist/ui/layout/budget.js +32 -7
  116. package/dist/ui/layout/rail.d.ts +4 -53
  117. package/dist/ui/layout/rail.js +10 -62
  118. package/dist/ui/layout/team-panel.d.ts +18 -0
  119. package/dist/ui/layout/team-panel.js +22 -0
  120. package/dist/ui/layout/todo-layout.d.ts +24 -0
  121. package/dist/ui/layout/todo-layout.js +27 -0
  122. package/dist/ui/layout/virtual-window.js +51 -1
  123. package/dist/ui/overlays/HelpOverlay.js +3 -0
  124. package/dist/ui/overlays/PlanReviewOverlay.js +10 -2
  125. package/dist/ui/overlays/QuestionOverlay.js +5 -1
  126. package/dist/ui/overlays/SettingsScreen.js +14 -1
  127. package/dist/ui/paste-frames.d.ts +87 -0
  128. package/dist/ui/paste-frames.js +112 -0
  129. package/dist/ui/paste-tokens.d.ts +90 -0
  130. package/dist/ui/paste-tokens.js +147 -0
  131. package/dist/ui/screen.d.ts +16 -0
  132. package/dist/ui/screen.js +45 -2
  133. package/dist/ui/selection/selection-controller.d.ts +1 -1
  134. package/dist/ui/transcript-text.js +55 -2
  135. package/dist/ui/use-wheel-routing.d.ts +1 -1
  136. package/package.json +2 -2
  137. package/dist/input/stdin-mouse-filter.d.ts +0 -47
  138. package/dist/input/stdin-mouse-filter.js +0 -153
@@ -58,7 +58,18 @@ export async function runExec(flags, raw, promptArg, ctx) {
58
58
  const plan = planSession(options, cwd, err);
59
59
  if (!plan)
60
60
  return 2;
61
- const prompts = resolvePromptSource(options, promptArg, ctx, err);
61
+ /**
62
+ * Late-bound interrupt hook (see `resolvePromptSource` and `execute`).
63
+ *
64
+ * The stdin stream is built - and already reading - about eighty lines before
65
+ * the runner exists, so the frame handler cannot close over the runner
66
+ * directly. The box starts as a no-op and is filled in AFTER
67
+ * `runner.attach(controller)`; during that gap an interrupt does nothing at
68
+ * all, which is the honest outcome (the user simply presses it again). See
69
+ * `execute` for why "does nothing" beats "does half of it".
70
+ */
71
+ const interrupt = { fire: () => { } };
72
+ const prompts = resolvePromptSource(options, promptArg, ctx, err, interrupt);
62
73
  if (!prompts)
63
74
  return 2;
64
75
  const lock = plan.saving ? tryAcquireSessionLock(plan.id) : null;
@@ -68,14 +79,14 @@ export async function runExec(flags, raw, promptArg, ctx) {
68
79
  return 2;
69
80
  }
70
81
  try {
71
- return await execute({ flags, options, plan, prompts, ctx, out, err, now, appended });
82
+ return await execute({ flags, options, plan, prompts, ctx, out, err, now, appended, interrupt });
72
83
  }
73
84
  finally {
74
85
  lock?.release();
75
86
  }
76
87
  }
77
88
  async function execute(input) {
78
- const { options, plan, ctx, out, err, now } = input;
89
+ const { options, plan, ctx, out, err, now, interrupt } = input;
79
90
  const startedAt = now();
80
91
  const permission = resolvePermission({
81
92
  mode: options.permissionMode,
@@ -117,8 +128,29 @@ async function execute(input) {
117
128
  permissionMode: options.permissionMode,
118
129
  tools: controller.listTools().map((t) => t.name),
119
130
  resumed: plan.saved !== null,
131
+ // W2 - where this run's conversation lives, so a wrapper can check on disk
132
+ // whether it is still resumable rather than re-deriving the sessions dir.
133
+ //
134
+ // `plan.id`, NEVER `options.resume`. The flag accepts `<id|path>`, and
135
+ // `planSession` runs the caller's string through `resumedSessionId()` to
136
+ // decide what the file is actually named - so "the file the caller pointed
137
+ // at" and "the file this run writes" are routinely two different paths. The
138
+ // question a wrapper is asking ("what can I reattach to next time") is
139
+ // answered by the second one.
140
+ //
141
+ // `!plan.saving` is `--no-save-session`: nothing is written, so there will
142
+ // be nothing to reattach to, and `null` says that outright.
143
+ sessionFile: plan.saving ? sessionPathFor(plan.id) : null,
120
144
  });
121
145
  runner.attach(controller);
146
+ // AFTER `attach`, never before. The terminator closure ends on
147
+ // `this.controller?.abort()`, so before `attach` the controller is still
148
+ // `undefined` and that last statement is a silent no-op - while the SAME
149
+ // closure has already written `lastSignalAt` and `stopReason = 'interrupted'`.
150
+ // A real interrupt arriving within the next two seconds would then be judged a
151
+ // SECOND signal and take `signals.exit(128 + signo)`: a hard exit, no persist,
152
+ // no `result` - exactly the outcome the caller was trying to avoid.
153
+ interrupt.fire = () => runner.requestInterrupt();
122
154
  const pre = controller.preflight();
123
155
  if (!pre.ok) {
124
156
  runner.detach();
@@ -261,7 +293,7 @@ function failedResult(sessionId, config, message, code, durationMs) {
261
293
  * Returns `null` after writing the reason to stderr, so the caller can exit 2
262
294
  * without a second error vocabulary.
263
295
  */
264
- function resolvePromptSource(options, promptArg, ctx, err) {
296
+ function resolvePromptSource(options, promptArg, ctx, err, interrupt) {
265
297
  const stdin = ctx.stdin ?? process.stdin;
266
298
  const opening = [];
267
299
  const positional = promptArg?.trim();
@@ -282,8 +314,13 @@ function resolvePromptSource(options, promptArg, ctx, err) {
282
314
  // line is skipped either way, which is the property AC-20 pins.
283
315
  onBadLine: (message) => err.write(`${message}\n`),
284
316
  // The runner installs the signal terminator; a caller-requested
285
- // interrupt is the same intent arriving through a different channel.
286
- onInterrupt: () => process.kill(process.pid, 'SIGINT'),
317
+ // interrupt is the same intent arriving through a different channel -
318
+ // so it now calls the runner DIRECTLY rather than raising a signal at
319
+ // ourselves. `process.kill(process.pid, 'SIGINT')` does not reach this
320
+ // process's own handlers on Windows: it terminates with exit code 1,
321
+ // skipping the settle path, `persist()` and the `result` event. See
322
+ // `ExecRunner.requestInterrupt`.
323
+ onInterrupt: () => interrupt.fire(),
287
324
  }));
288
325
  }
289
326
  if (opening.length === 0 && !stdinIsTty(stdin)) {
@@ -0,0 +1,23 @@
1
+ import type { ExecProgressEvent } from './events.js';
2
+ /** Request-scoped, one-shot aggregation of real semantic events. */
3
+ export declare class ExecProgressReporter {
4
+ private readonly sessionId;
5
+ private readonly emit;
6
+ private readonly now;
7
+ private requestSeq;
8
+ private progressSeq;
9
+ private sentSeq;
10
+ private phase;
11
+ private retryDelayMs;
12
+ private lastSentAt;
13
+ private timer;
14
+ private enabled;
15
+ constructor(sessionId: string, emit: (event: ExecProgressEvent) => void, now?: () => number);
16
+ begin(requestSeq: number): void;
17
+ /** Called only after the started event has reached the emitter. */
18
+ started(requestSeq: number): void;
19
+ record(phase: ExecProgressEvent['phase'], retryDelayMs?: number): void;
20
+ flush(): void;
21
+ finish(requestSeq: number): void;
22
+ dispose(): void;
23
+ }
@@ -0,0 +1,88 @@
1
+ const PROGRESS_INTERVAL_MS = 1_000;
2
+ /** Request-scoped, one-shot aggregation of real semantic events. */
3
+ export class ExecProgressReporter {
4
+ sessionId;
5
+ emit;
6
+ now;
7
+ requestSeq = null;
8
+ progressSeq = 0;
9
+ sentSeq = 0;
10
+ phase = 'model';
11
+ retryDelayMs;
12
+ lastSentAt = -Infinity;
13
+ timer = null;
14
+ enabled = false;
15
+ constructor(sessionId, emit, now = () => performance.now()) {
16
+ this.sessionId = sessionId;
17
+ this.emit = emit;
18
+ this.now = now;
19
+ }
20
+ begin(requestSeq) {
21
+ this.dispose();
22
+ this.requestSeq = requestSeq;
23
+ this.progressSeq = 0;
24
+ this.sentSeq = 0;
25
+ this.phase = 'model';
26
+ this.retryDelayMs = undefined;
27
+ this.lastSentAt = -Infinity;
28
+ }
29
+ /** Called only after the started event has reached the emitter. */
30
+ started(requestSeq) {
31
+ if (this.requestSeq === requestSeq)
32
+ this.enabled = true;
33
+ }
34
+ record(phase, retryDelayMs) {
35
+ if (!this.enabled || this.requestSeq === null)
36
+ return;
37
+ const changed = phase !== this.phase;
38
+ if (changed)
39
+ this.flush();
40
+ this.phase = phase;
41
+ this.retryDelayMs = phase === 'retry' && Number.isFinite(retryDelayMs)
42
+ ? Math.min(120_000, Math.max(0, retryDelayMs)) : undefined;
43
+ this.progressSeq += 1;
44
+ if (changed || this.now() - this.lastSentAt >= PROGRESS_INTERVAL_MS) {
45
+ this.flush();
46
+ }
47
+ else if (!this.timer) {
48
+ const requestSeq = this.requestSeq;
49
+ this.timer = setTimeout(() => {
50
+ this.timer = null;
51
+ if (this.requestSeq === requestSeq && this.enabled)
52
+ this.flush();
53
+ }, Math.max(0, PROGRESS_INTERVAL_MS - (this.now() - this.lastSentAt)));
54
+ this.timer.unref?.();
55
+ }
56
+ }
57
+ flush() {
58
+ if (this.timer)
59
+ clearTimeout(this.timer);
60
+ this.timer = null;
61
+ if (!this.enabled || this.requestSeq === null || this.sentSeq === this.progressSeq)
62
+ return;
63
+ this.sentSeq = this.progressSeq;
64
+ this.lastSentAt = this.now();
65
+ this.emit({
66
+ type: 'execution_progress', sessionId: this.sessionId,
67
+ requestSeq: this.requestSeq, progressSeq: this.progressSeq, phase: this.phase,
68
+ ...(this.retryDelayMs !== undefined ? { retryDelayMs: this.retryDelayMs } : {}),
69
+ });
70
+ }
71
+ finish(requestSeq) {
72
+ if (this.requestSeq !== requestSeq)
73
+ return;
74
+ try {
75
+ this.flush();
76
+ }
77
+ finally {
78
+ this.dispose();
79
+ }
80
+ }
81
+ dispose() {
82
+ if (this.timer)
83
+ clearTimeout(this.timer);
84
+ this.timer = null;
85
+ this.requestSeq = null;
86
+ this.enabled = false;
87
+ }
88
+ }
@@ -18,6 +18,7 @@ import type { TokenUsage } from '@aragon-agent/core';
18
18
  import type { HeadlessController } from '../agent/headless.js';
19
19
  import { type FollowThroughMode } from '../todo/follow-through.js';
20
20
  import type { ExecEmitter } from './emitter.js';
21
+ import type { FastSnapshot } from '../fast/types.js';
21
22
  import type { ExecStopReason } from './events.js';
22
23
  /**
23
24
  * The controller surface the runner needs.
@@ -34,6 +35,16 @@ export interface ExecRunnerController extends HeadlessController {
34
35
  providerId: string;
35
36
  modelId: string;
36
37
  }): boolean;
38
+ /**
39
+ * The fast tier's current snapshot, or `null` when this session has no tier
40
+ * (web-use-tier-cooperation-and-control-closure §4.2.1).
41
+ *
42
+ * OPTIONAL, for the identical reason `subscribeFast` is: this interface is
43
+ * minimal so a test can satisfy it with an object literal, and a controller
44
+ * with no tier has nothing to report. Called with `?.()`, so a stub that omits
45
+ * it simply emits no `fast_tier` events.
46
+ */
47
+ fastSnapshot?(): FastSnapshot | null;
37
48
  }
38
49
  /** Where the caller's messages come from. One turn, or an NDJSON stdin stream. */
39
50
  export interface ExecPromptSource {
@@ -77,6 +88,7 @@ export declare class ExecRunner {
77
88
  private readonly opts;
78
89
  private readonly signals;
79
90
  private readonly now;
91
+ private readonly progress;
80
92
  private controller;
81
93
  private unsubscribers;
82
94
  private turns;
@@ -131,6 +143,9 @@ export declare class ExecRunner {
131
143
  * the two faces cannot drift about what "unfinished" means.
132
144
  */
133
145
  run(source: ExecPromptSource): Promise<void>;
146
+ private requestSeq;
147
+ private requestHadError;
148
+ private isRequestCancelled;
134
149
  /** One caller message plus every auto-continuation it produces. */
135
150
  private runFollowThroughChain;
136
151
  /**
@@ -192,6 +207,25 @@ export declare class ExecRunner {
192
207
  * this and add the getter.
193
208
  */
194
209
  private installTerminator;
210
+ /**
211
+ * Exactly synonymous with a SIGINT, only it does not travel through a signal.
212
+ *
213
+ * The stdin `{"type":"interrupt"}` frame routes here. It used to route through
214
+ * `process.kill(process.pid, 'SIGINT')`, which is fine on POSIX and WRONG on
215
+ * Windows: Node does not deliver a self-directed SIGINT to its own handlers
216
+ * there, so the process terminates with exit code 1 having run neither the
217
+ * terminator below, nor the settle path, nor `persist()`, nor the `result`
218
+ * event. A wrapper that interrupts a turn and respawns on the same
219
+ * `--session-id` would silently get a conversation missing everything since the
220
+ * last save - and `exec/index.ts` was already designed for exactly that caller
221
+ * ("PERSIST FIRST, ANNOUNCE SECOND").
222
+ *
223
+ * POSIX behaviour is byte-for-byte unchanged: the same closure runs, with
224
+ * `signalNumber = 2`, `exitCode() = 130`, and the two-second second-signal
225
+ * escape hatch intact.
226
+ */
227
+ requestInterrupt(): void;
228
+ private onSignal;
195
229
  private emit;
196
230
  /** Caller-facing prose. STDERR ALWAYS - stdout carries nothing but the schema. */
197
231
  private note;
@@ -17,6 +17,7 @@
17
17
  import { formatStreamError } from '../agent/reducer.js';
18
18
  import { setSignalTerminator } from '../logging/install.js';
19
19
  import { advanceBudget, decideFollowThrough, emptyBudget, } from '../todo/follow-through.js';
20
+ import { ExecProgressReporter } from './progress-reporter.js';
20
21
  export function singlePrompt(text) {
21
22
  let served = false;
22
23
  return {
@@ -40,6 +41,7 @@ export class ExecRunner {
40
41
  opts;
41
42
  signals;
42
43
  now;
44
+ progress;
43
45
  controller = null;
44
46
  unsubscribers = [];
45
47
  turns = 0;
@@ -62,6 +64,7 @@ export class ExecRunner {
62
64
  this.opts = options;
63
65
  this.signals = options.signals ?? DEFAULT_SIGNAL_PORT;
64
66
  this.now = options.now ?? Date.now;
67
+ this.progress = new ExecProgressReporter(this.sessionId, (event) => this.emit(event));
65
68
  }
66
69
  // -----------------------------------------------------------------------
67
70
  // Attach / detach
@@ -103,20 +106,65 @@ export class ExecRunner {
103
106
  });
104
107
  if (todos)
105
108
  this.unsubscribers.push(todos);
109
+ // FOUR BRANCHES, and the split is the whole of W2
110
+ // (web-use-tier-cooperation-and-control-closure §4.2.1):
111
+ //
112
+ // · `usage` — folded into the run's totals (unchanged).
113
+ // · `review_end` — the EXISTING `fast_review` still fires only when the
114
+ // critique has text, byte-for-byte as before; a
115
+ // `fast_tier` now fires UNCONDITIONALLY beside it, so a
116
+ // failed / empty / dropped review is finally visible.
117
+ // · `tier_changed` — a `fast_tier` with no `review` sub-object. This is the
118
+ // one that carries `selfDisabled: true`, because the
119
+ // reviewer emits it right after switching itself off.
120
+ // · `review_start` — DROPPED. `inFlight` on the snapshot already says it,
121
+ // and once every N turns it is pure noise.
106
122
  const fast = controller.subscribeFast?.((event) => {
107
123
  if (event.type === 'usage') {
108
124
  this.usage.inputTokens += event.usage.inputTokens;
109
125
  this.usage.outputTokens += event.usage.outputTokens;
110
126
  return;
111
127
  }
112
- if (event.type !== 'review_end' || !event.review.text)
128
+ if (event.type === 'review_start')
129
+ return;
130
+ if (event.type === 'review_end' && event.review.text) {
131
+ this.emit({
132
+ type: 'fast_review',
133
+ sessionId: this.sessionId,
134
+ turn: event.review.turn,
135
+ model: event.review.model,
136
+ text: event.review.text,
137
+ });
138
+ }
139
+ // `null` means this session never registered a tier, and a session with no
140
+ // tier has no business appearing on this channel at all.
141
+ const snapshot = controller.fastSnapshot?.();
142
+ if (!snapshot)
113
143
  return;
114
144
  this.emit({
115
- type: 'fast_review',
145
+ type: 'fast_tier',
116
146
  sessionId: this.sessionId,
117
- turn: event.review.turn,
118
- model: event.review.model,
119
- text: event.review.text,
147
+ turn: this.turns,
148
+ live: snapshot.live,
149
+ selfDisabled: snapshot.selfDisabled,
150
+ model: snapshot.model,
151
+ sameAsMain: snapshot.sameAsMain,
152
+ reviews: snapshot.reviews,
153
+ reviewBudget: snapshot.reviewBudget,
154
+ budgetReached: snapshot.budgetReached,
155
+ delegated: snapshot.delegated,
156
+ inFlight: snapshot.inFlight,
157
+ ...(event.type === 'review_end'
158
+ ? {
159
+ review: {
160
+ index: event.review.index,
161
+ kind: event.review.kind,
162
+ ...(event.review.detail !== undefined ? { detail: event.review.detail } : {}),
163
+ durationMs: event.review.durationMs,
164
+ injected: event.review.injected,
165
+ },
166
+ }
167
+ : {}),
120
168
  });
121
169
  });
122
170
  if (fast)
@@ -132,6 +180,7 @@ export class ExecRunner {
132
180
  return;
133
181
  }
134
182
  if (event.type === 'compaction_start') {
183
+ this.progress.record('compaction');
135
184
  this.emit({
136
185
  type: 'compaction',
137
186
  sessionId: this.sessionId,
@@ -143,6 +192,7 @@ export class ExecRunner {
143
192
  }
144
193
  if (event.type !== 'compaction_end')
145
194
  return;
195
+ this.progress.record('model');
146
196
  const r = event.record;
147
197
  this.emit({
148
198
  type: 'compaction',
@@ -177,6 +227,7 @@ export class ExecRunner {
177
227
  if (this.detached)
178
228
  return;
179
229
  this.detached = true;
230
+ this.progress.dispose();
180
231
  for (const off of this.unsubscribers.splice(0)) {
181
232
  try {
182
233
  off();
@@ -241,15 +292,42 @@ export class ExecRunner {
241
292
  // nothing more to say exits 0 with `end_turn`, not 3 with `max_turns`.
242
293
  if (next === null)
243
294
  break;
244
- if (this.turnBudgetExhausted()) {
245
- this.stopReason = 'max_turns';
246
- break;
295
+ const requestSeq = ++this.requestSeq;
296
+ this.requestHadError = false;
297
+ this.progress.begin(requestSeq);
298
+ this.emit({ type: 'turn_state', sessionId: this.sessionId, requestSeq, phase: 'started' });
299
+ this.progress.started(requestSeq);
300
+ try {
301
+ if (this.isRequestCancelled())
302
+ break;
303
+ if (this.turnBudgetExhausted()) {
304
+ this.stopReason = 'max_turns';
305
+ break;
306
+ }
307
+ const finished = await this.runFollowThroughChain(controller, next, followThrough);
308
+ if (finished)
309
+ break;
310
+ }
311
+ catch (err) {
312
+ this.errored = true;
313
+ this.recordError('agent_error', err instanceof Error ? err.message : String(err));
314
+ throw err;
315
+ }
316
+ finally {
317
+ this.progress.finish(requestSeq);
318
+ const phase = this.isRequestCancelled()
319
+ ? 'cancelled'
320
+ : this.requestHadError ? 'failed' : 'completed';
321
+ this.emit({ type: 'turn_state', sessionId: this.sessionId, requestSeq, phase });
247
322
  }
248
- const finished = await this.runFollowThroughChain(controller, next, followThrough);
249
- if (finished)
250
- break;
251
323
  }
252
324
  }
325
+ requestSeq = 0;
326
+ requestHadError = false;
327
+ isRequestCancelled() {
328
+ return this.stopReason === 'interrupted' || this.stopReason === 'timeout' ||
329
+ this.stopReason === 'max_turns';
330
+ }
253
331
  /** One caller message plus every auto-continuation it produces. */
254
332
  async runFollowThroughChain(controller, initial, followThrough) {
255
333
  let budget = emptyBudget();
@@ -281,7 +359,7 @@ export class ExecRunner {
281
359
  // `aborted` is false here for the same structural reason `-p` gives: an
282
360
  // interrupt sets `stopReason` and returns above, so this branch is only
283
361
  // reached by a run that ended on its own terms.
284
- runEnd: { aborted: false, errored: this.errored },
362
+ runEnd: { aborted: false, errored: this.requestHadError },
285
363
  budget,
286
364
  interactive: false,
287
365
  });
@@ -394,18 +472,39 @@ export class ExecRunner {
394
472
  * this and add the getter.
395
473
  */
396
474
  installTerminator() {
397
- this.signals.setTerminator((signo) => {
398
- const at = this.now();
399
- const secondSignal = at - this.lastSignalAt <= SECOND_SIGNAL_WINDOW_MS;
400
- if (this.detached || (this.lastSignalAt > 0 && secondSignal)) {
401
- this.signals.exit(128 + signo);
402
- return;
403
- }
404
- this.lastSignalAt = at;
405
- this.signalNumber = signo;
406
- this.stopReason = 'interrupted';
407
- this.controller?.abort();
408
- });
475
+ this.signals.setTerminator((signo) => this.onSignal(signo));
476
+ }
477
+ /**
478
+ * Exactly synonymous with a SIGINT, only it does not travel through a signal.
479
+ *
480
+ * The stdin `{"type":"interrupt"}` frame routes here. It used to route through
481
+ * `process.kill(process.pid, 'SIGINT')`, which is fine on POSIX and WRONG on
482
+ * Windows: Node does not deliver a self-directed SIGINT to its own handlers
483
+ * there, so the process terminates with exit code 1 having run neither the
484
+ * terminator below, nor the settle path, nor `persist()`, nor the `result`
485
+ * event. A wrapper that interrupts a turn and respawns on the same
486
+ * `--session-id` would silently get a conversation missing everything since the
487
+ * last save - and `exec/index.ts` was already designed for exactly that caller
488
+ * ("PERSIST FIRST, ANNOUNCE SECOND").
489
+ *
490
+ * POSIX behaviour is byte-for-byte unchanged: the same closure runs, with
491
+ * `signalNumber = 2`, `exitCode() = 130`, and the two-second second-signal
492
+ * escape hatch intact.
493
+ */
494
+ requestInterrupt() {
495
+ this.onSignal(2);
496
+ }
497
+ onSignal(signo) {
498
+ const at = this.now();
499
+ const secondSignal = at - this.lastSignalAt <= SECOND_SIGNAL_WINDOW_MS;
500
+ if (this.detached || (this.lastSignalAt > 0 && secondSignal)) {
501
+ this.signals.exit(128 + signo);
502
+ return;
503
+ }
504
+ this.lastSignalAt = at;
505
+ this.signalNumber = signo;
506
+ this.stopReason = 'interrupted';
507
+ this.controller?.abort();
409
508
  }
410
509
  // -----------------------------------------------------------------------
411
510
  // Event translation
@@ -431,6 +530,7 @@ export class ExecRunner {
431
530
  this.checkTurnBudget();
432
531
  break;
433
532
  case 'tool_execution_start':
533
+ this.progress.record('tool');
434
534
  this.emit({
435
535
  type: 'tool_call',
436
536
  sessionId: this.sessionId,
@@ -441,6 +541,7 @@ export class ExecRunner {
441
541
  });
442
542
  break;
443
543
  case 'tool_execution_end':
544
+ this.progress.record('model');
444
545
  this.emit({
445
546
  type: 'tool_result',
446
547
  sessionId: this.sessionId,
@@ -457,7 +558,7 @@ export class ExecRunner {
457
558
  break;
458
559
  case 'agent_end':
459
560
  // A swallowed throw (missing key / empty stream) produces no `turn_end`.
460
- if (!this.sawTurnEnd && !this.errored && this.stopReason === 'end_turn') {
561
+ if (!this.sawTurnEnd && !this.requestHadError && !this.isRequestCancelled()) {
461
562
  this.errored = true;
462
563
  this.recordError('agent_error', 'The run produced no assistant turn.');
463
564
  }
@@ -467,6 +568,16 @@ export class ExecRunner {
467
568
  }
468
569
  }
469
570
  onStreamEvent(se) {
571
+ if (se.type === 'text_delta' && se.delta)
572
+ this.progress.record('model');
573
+ if (se.type === 'thinking_delta' && se.delta)
574
+ this.progress.record('thinking');
575
+ if (se.type === 'tool_call_delta' && se.argsDelta)
576
+ this.progress.record('tool_arguments');
577
+ if (se.type === 'retry_scheduled')
578
+ this.progress.record('retry', se.delayMs);
579
+ if (se.type === 'retry_attempt')
580
+ this.progress.record('model');
470
581
  if (se.type === 'text_delta') {
471
582
  this.flushThinking();
472
583
  if (this.opts.partialMessages) {
@@ -527,6 +638,7 @@ export class ExecRunner {
527
638
  this.emit({ type: 'thinking', sessionId: this.sessionId, turn: this.turns + 1, text });
528
639
  }
529
640
  recordError(code, message) {
641
+ this.requestHadError = true;
530
642
  if (this.errorCode)
531
643
  return;
532
644
  this.errorCode = code;
@@ -59,7 +59,7 @@
59
59
  import type { AgentEvent, AssistantMessage, LLMRequest, TokenUsage } from '@aragon-agent/core';
60
60
  import type { FastConfig } from '../config/schema.js';
61
61
  import type { NoticeLevel } from '../agent/reducer.js';
62
- import type { FastEvent, FastTier } from './types.js';
62
+ import type { FastEvent, FastSnapshot, FastTier } from './types.js';
63
63
  /**
64
64
  * Everything the reviewer needs from the rest of the world.
65
65
  *
@@ -93,6 +93,14 @@ export interface FastReviewerDeps {
93
93
  available(): boolean;
94
94
  getApiKey(providerId: string): string | undefined;
95
95
  emit(event: FastEvent): void;
96
+ /**
97
+ * `FastWiring.snapshot()`, so the self-disable can announce itself.
98
+ *
99
+ * The reviewer cannot build a `FastSnapshot` on its own — `live`,
100
+ * `sameAsMain`, `delegated` and `pricingUnknown` all belong to the wiring —
101
+ * and the two existing `tier_changed` emitters read it the same way.
102
+ */
103
+ snapshot(): FastSnapshot;
96
104
  notify(level: NoticeLevel, text: string): void;
97
105
  /** Injected clock. A test that reads the wall clock fails on a slow machine
98
106
  * and nowhere else - the reason `TodoStore` and `TeamPanel` take one. */
@@ -155,6 +163,16 @@ export declare class FastReviewer {
155
163
  isBudgetReached(): boolean;
156
164
  sessionUsage(): TokenUsage;
157
165
  isReviewInFlight(): boolean;
166
+ /**
167
+ * Whether this reviewer switched itself off after
168
+ * `FAST_LIMITS.maxConsecutiveFailures` non-transient failures in a row.
169
+ *
170
+ * Carried onto `FastSnapshot` so a wrapper can say "the fast reviews stopped,
171
+ * and here is why" instead of showing a tier that reads as healthy while not
172
+ * one review ever arrives. Same shape as `reviewCount()` / `isBudgetReached()`
173
+ * — a read-only accessor, no side effects.
174
+ */
175
+ isSelfDisabled(): boolean;
158
176
  /**
159
177
  * `AgentController.abort()`.
160
178
  *
@@ -141,6 +141,18 @@ export class FastReviewer {
141
141
  isReviewInFlight() {
142
142
  return this.inFlight;
143
143
  }
144
+ /**
145
+ * Whether this reviewer switched itself off after
146
+ * `FAST_LIMITS.maxConsecutiveFailures` non-transient failures in a row.
147
+ *
148
+ * Carried onto `FastSnapshot` so a wrapper can say "the fast reviews stopped,
149
+ * and here is why" instead of showing a tier that reads as healthy while not
150
+ * one review ever arrives. Same shape as `reviewCount()` / `isBudgetReached()`
151
+ * — a read-only accessor, no side effects.
152
+ */
153
+ isSelfDisabled() {
154
+ return this.selfDisabled;
155
+ }
144
156
  /**
145
157
  * `AgentController.abort()`.
146
158
  *
@@ -561,6 +573,16 @@ export class FastReviewer {
561
573
  this.selfDisabled = true;
562
574
  this.deps.notify('warn', `Fast review disabled for this session after ${FAST_LIMITS.maxConsecutiveFailures} ` +
563
575
  `failures. Last error: ${errorText(err)}`);
576
+ // THE ONE LINE THAT MAKES THE FLAG OBSERVABLE OUTSIDE THIS PROCESS.
577
+ //
578
+ // `emitReview({kind:'failed'})` above already fired, carrying a snapshot
579
+ // in which `selfDisabled` was still `false`, and after this point the
580
+ // reviewer emits NOTHING EVER AGAIN (`shouldReview()` opens with
581
+ // `if (this.selfDisabled) return false;`). Without this extra
582
+ // `tier_changed`, the field exists, the accessor exists, the snapshot line
583
+ // exists — and no consumer can ever see it as `true`. The `App` chip also
584
+ // consumes `tier_changed`; one more refresh is free.
585
+ this.deps.emit({ type: 'tier_changed', snapshot: this.deps.snapshot() });
564
586
  }
565
587
  }
566
588
  // -----------------------------------------------------------------------
@@ -78,6 +78,21 @@ export interface FastReview {
78
78
  export interface FastSnapshot {
79
79
  /** The tier resolved AND the live switch is on. */
80
80
  live: boolean;
81
+ /**
82
+ * The reviewer switched ITSELF off after `maxConsecutiveFailures` in a row
83
+ * (`FastReviewer.onReviewFailure`).
84
+ *
85
+ * ORTHOGONAL TO `live`, AND NEITHER IMPLIES THE OTHER. `live` answers "did
86
+ * this tier resolve at all" and is `registered && enabled && tier.ok`; the
87
+ * self-disable touches none of those three, so `live` stays `true` after it.
88
+ * A consumer that wants to report "the fast reviews stopped happening" MUST
89
+ * read this field: deriving it as `live === false` is a predicate that can
90
+ * never be true, and a hand-written fixture makes its test green for ever.
91
+ *
92
+ * `available()` is deliberately NOT widened to include it — see the note on
93
+ * `FastWiring.available`.
94
+ */
95
+ selfDisabled: boolean;
81
96
  model: string;
82
97
  sameAsMain: boolean;
83
98
  reviews: number;
@@ -115,7 +115,16 @@ export declare class FastWiring {
115
115
  private getFastRegistry;
116
116
  isRegistered(): boolean;
117
117
  isEnabled(): boolean;
118
- /** §3.3's live predicate. THE THIRD TERM IS THE POINT - see the header. */
118
+ /**
119
+ * §3.3's live predicate. THE THIRD TERM IS THE POINT - see the header.
120
+ *
121
+ * `FastReviewer.isSelfDisabled()` is DELIBERATELY NOT a fourth term. Folding
122
+ * it in would look tidier and would break something else: `delegationAvailable()`
123
+ * is `available() && config.fast.delegate === true`, so three consecutive
124
+ * REVIEW failures would silently switch DELEGATION off as well - turning a
125
+ * diagnostic signal into a behaviour change for every user. The two facts are
126
+ * reported separately on `FastSnapshot` instead (`live` / `selfDisabled`).
127
+ */
119
128
  available(): boolean;
120
129
  /**
121
130
  * Whether `model: "fast"` on `task` is honoured right now.