@aragon-agent/cli 0.6.4 → 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 (72) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +108 -11
  3. package/dist/agent/controller.d.ts +39 -2
  4. package/dist/agent/controller.js +125 -0
  5. package/dist/agent/reducer.d.ts +33 -19
  6. package/dist/agent/reducer.js +62 -71
  7. package/dist/agent/usage.d.ts +34 -0
  8. package/dist/agent/usage.js +43 -0
  9. package/dist/cli.js +48 -1
  10. package/dist/commands/builtins.js +9 -0
  11. package/dist/compaction/compactor.d.ts +60 -24
  12. package/dist/compaction/compactor.js +89 -61
  13. package/dist/compaction/context-command.d.ts +33 -0
  14. package/dist/compaction/context-command.js +166 -0
  15. package/dist/compaction/meter.d.ts +251 -0
  16. package/dist/compaction/meter.js +428 -0
  17. package/dist/compaction/pressure.d.ts +6 -0
  18. package/dist/compaction/pressure.js +4 -0
  19. package/dist/compaction/types.d.ts +41 -0
  20. package/dist/compaction/wiring.d.ts +47 -0
  21. package/dist/compaction/wiring.js +88 -27
  22. package/dist/config/cli-commands.js +5 -0
  23. package/dist/config/env.js +15 -1
  24. package/dist/config/load.d.ts +17 -0
  25. package/dist/config/load.js +45 -1
  26. package/dist/config/schema.d.ts +44 -0
  27. package/dist/config/schema.js +36 -0
  28. package/dist/config/store.js +27 -1
  29. package/dist/exec/events.d.ts +138 -1
  30. package/dist/exec/events.js +42 -0
  31. package/dist/exec/index.js +43 -6
  32. package/dist/exec/progress-reporter.d.ts +23 -0
  33. package/dist/exec/progress-reporter.js +88 -0
  34. package/dist/exec/runner.d.ts +34 -0
  35. package/dist/exec/runner.js +137 -25
  36. package/dist/fast/reviewer.d.ts +19 -1
  37. package/dist/fast/reviewer.js +22 -0
  38. package/dist/fast/types.d.ts +15 -0
  39. package/dist/fast/wiring.d.ts +10 -1
  40. package/dist/fast/wiring.js +18 -1
  41. package/dist/todo/limits.d.ts +4 -17
  42. package/dist/todo/limits.js +4 -17
  43. package/dist/todo/panel-rows.d.ts +8 -6
  44. package/dist/todo/panel-rows.js +19 -6
  45. package/dist/ui/App.js +75 -63
  46. package/dist/ui/AutocompletePopup.d.ts +3 -1
  47. package/dist/ui/AutocompletePopup.js +9 -11
  48. package/dist/ui/BottomStatusRow.d.ts +1 -1
  49. package/dist/ui/Composer.d.ts +3 -1
  50. package/dist/ui/Composer.js +2 -2
  51. package/dist/ui/PromptInput.d.ts +4 -1
  52. package/dist/ui/PromptInput.js +19 -5
  53. package/dist/ui/StatusBar.d.ts +21 -15
  54. package/dist/ui/StatusBar.js +48 -9
  55. package/dist/ui/TeamPanel.d.ts +3 -1
  56. package/dist/ui/TeamPanel.js +13 -9
  57. package/dist/ui/TodoPanel.d.ts +5 -5
  58. package/dist/ui/TodoPanel.js +56 -24
  59. package/dist/ui/UpdateLine.d.ts +1 -1
  60. package/dist/ui/capabilities.js +5 -5
  61. package/dist/ui/exit-snapshot.d.ts +9 -0
  62. package/dist/ui/layout/ScrollViewport.d.ts +1 -1
  63. package/dist/ui/layout/autocomplete.d.ts +18 -0
  64. package/dist/ui/layout/autocomplete.js +26 -0
  65. package/dist/ui/layout/rail.d.ts +4 -53
  66. package/dist/ui/layout/rail.js +10 -62
  67. package/dist/ui/layout/team-panel.d.ts +18 -0
  68. package/dist/ui/layout/team-panel.js +22 -0
  69. package/dist/ui/layout/todo-layout.d.ts +24 -0
  70. package/dist/ui/layout/todo-layout.js +27 -0
  71. package/dist/ui/transcript-text.js +6 -2
  72. package/package.json +2 -2
@@ -14,7 +14,7 @@ import { dirname } from 'node:path';
14
14
  import process from 'node:process';
15
15
  import { getConfigPath, getSessionsDir } from './app-paths.js';
16
16
  import { registerSecretsFrom } from '../logging/secret-registry.js';
17
- import { clampBashConfig, CONFIG_VERSION, DEFAULT_CONFIG, clampCompactionConfig, clampFastConfig, clampLogConfig, clampMaxTokens, clampRetryConfig, clampSkillsConfig, clampTeamConfig, clampTodoConfig, clampUpdateConfig, isAutoToken, stripLegacyStateKeys, } from './schema.js';
17
+ import { clampBashConfig, CONFIG_VERSION, DEFAULT_CONFIG, clampCompactionConfig, clampFastConfig, clampLogConfig, clampMaxTokens, clampRetryConfig, clampSkillsConfig, clampTeamConfig, clampTodoConfig, clampContextWindow, clampUpdateConfig, isAutoToken, stripLegacyStateKeys, } from './schema.js';
18
18
  /**
19
19
  * Normalize `maxTokens` for both directions of the config file.
20
20
  *
@@ -31,6 +31,22 @@ function normalizeMaxTokens(v) {
31
31
  return null;
32
32
  return clampMaxTokens(v) ?? null;
33
33
  }
34
+ /**
35
+ * Normalize `contextWindow` for both directions of the config file.
36
+ *
37
+ * THE SAME TRI-STATE DISCIPLINE AS `maxTokens` ABOVE, and for the same reason:
38
+ * hardening only the read path leaves a bad value on disk that reverts on every
39
+ * launch and presents as "my setting won't stick". `null` is AUTO and survives
40
+ * untouched; `auto` / `0` / an empty string mean AUTO too; a number is clamped;
41
+ * anything else on a hand-edited file becomes AUTO rather than a silent default.
42
+ */
43
+ function normalizeContextWindow(v) {
44
+ if (v === undefined)
45
+ return DEFAULT_CONFIG.contextWindow;
46
+ if (v === null || isAutoToken(v))
47
+ return null;
48
+ return clampContextWindow(v, null);
49
+ }
34
50
  // Re-exported so the many existing importers of `store.js` keep working; the
35
51
  // definitions live in `app-paths.ts` because they are pure path arithmetic.
36
52
  export { getConfigPath, getSessionsDir };
@@ -114,6 +130,11 @@ export function loadPersistedConfig() {
114
130
  maxTokens: Object.prototype.hasOwnProperty.call(partial, 'maxTokens')
115
131
  ? normalizeMaxTokens(partial.maxTokens)
116
132
  : DEFAULT_CONFIG.maxTokens,
133
+ // Own-property aware for the reason its neighbour records: `null` is AUTO
134
+ // and a spread cannot tell it from an absent key.
135
+ contextWindow: Object.prototype.hasOwnProperty.call(partial, 'contextWindow')
136
+ ? normalizeContextWindow(partial.contextWindow)
137
+ : DEFAULT_CONFIG.contextWindow,
117
138
  apiKeys: { ...DEFAULT_CONFIG.apiKeys, ...(partial.apiKeys ?? {}) },
118
139
  // `skills` is the second nested object in this file and needs the same
119
140
  // treatment `apiKeys` gets — see the note on updatePersistedConfig below.
@@ -198,6 +219,11 @@ export function updatePersistedConfig(patch) {
198
219
  maxTokens: Object.prototype.hasOwnProperty.call(patch, 'maxTokens')
199
220
  ? normalizeMaxTokens(patch.maxTokens)
200
221
  : current.maxTokens,
222
+ // The write half of the same gate: without it `config set contextWindow
223
+ // 99999999` puts a number on disk that the read path silently rewrites.
224
+ contextWindow: Object.prototype.hasOwnProperty.call(patch, 'contextWindow')
225
+ ? normalizeContextWindow(patch.contextWindow)
226
+ : current.contextWindow,
201
227
  apiKeys: { ...current.apiKeys, ...(patch.apiKeys ?? {}) },
202
228
  skills: clampSkillsConfig({ ...current.skills, ...(patch.skills ?? {}) }),
203
229
  // Without this line, changing the log level from the settings screen would
@@ -71,6 +71,43 @@ export interface ExecSystemInitEvent {
71
71
  permissionMode: ExecPermissionMode;
72
72
  tools: string[];
73
73
  resumed: boolean;
74
+ /**
75
+ * Capabilities of THIS build that a caller has to know about before using
76
+ * them. Optional so older consumers are unaffected, and additive so
77
+ * `EXEC_SCHEMA_VERSION` does not move (see the file header).
78
+ *
79
+ * `'interrupt'` means the stdin `{"type":"interrupt"}` frame really does run
80
+ * the full settle -> persist -> `result` sequence ON EVERY PLATFORM. Builds
81
+ * before that fix routed it through a self-directed SIGINT, which on Windows
82
+ * terminates the process outright with exit code 1 - no persistence, no
83
+ * `result`. A wrapper cannot tell the two apart by observation, and guessing
84
+ * wrong costs the user their conversation, so this build states it.
85
+ */
86
+ capabilities?: string[];
87
+ /**
88
+ * Absolute path of the session file THIS run reads and writes; `null` under
89
+ * `--no-save-session` (web-use-durable-control-and-tier-accountability W2).
90
+ *
91
+ * Optional and additive, so `EXEC_SCHEMA_VERSION` does not move (file header).
92
+ * Stamped by `buildInitEvent`, never set by a caller - the same discipline
93
+ * `capabilities` follows.
94
+ *
95
+ * ## Why a wrapper cannot work this out for itself
96
+ *
97
+ * Deriving it host-side means re-implementing `getSessionsDir()`
98
+ * (`envPaths('aragon-agent')` plus its environment override) in a second
99
+ * codebase that has no way to notice when this one changes. The failure mode
100
+ * of a drifted copy is a path that does not exist, which a wrapper reads as
101
+ * "that conversation is gone" - identical, on screen, to the session really
102
+ * being gone. The run that is writing the file is the only party that can say
103
+ * where it is without guessing.
104
+ *
105
+ * It is the id THIS RUN writes back under, not the string the caller typed:
106
+ * `--resume` accepts `<id|path>`, and `resumedSessionId()` decides what the
107
+ * file is actually called. A wrapper asking "can I reattach later" needs the
108
+ * latter.
109
+ */
110
+ sessionFile?: string | null;
74
111
  }
75
112
  export interface ExecUserEvent {
76
113
  type: 'user';
@@ -84,6 +121,22 @@ export interface ExecUserEvent {
84
121
  */
85
122
  source: 'caller' | 'todo_continue';
86
123
  }
124
+ /** One caller input and its complete automatic follow-through chain. */
125
+ export interface ExecTurnStateEvent {
126
+ type: 'turn_state';
127
+ sessionId: string;
128
+ requestSeq: number;
129
+ phase: 'started' | 'completed' | 'failed' | 'cancelled';
130
+ }
131
+ /** Semantic source progress; never includes model or tool content. */
132
+ export interface ExecProgressEvent {
133
+ type: 'execution_progress';
134
+ sessionId: string;
135
+ requestSeq: number;
136
+ progressSeq: number;
137
+ phase: 'model' | 'thinking' | 'tool_arguments' | 'tool' | 'compaction' | 'retry';
138
+ retryDelayMs?: number;
139
+ }
87
140
  export interface ExecTextDeltaEvent {
88
141
  type: 'text_delta';
89
142
  sessionId: string;
@@ -157,6 +210,57 @@ export interface ExecFastReviewEvent {
157
210
  model: string;
158
211
  text: string;
159
212
  }
213
+ /**
214
+ * What the fast tier is ACTUALLY doing, as opposed to what it was configured to
215
+ * do (web-use-tier-cooperation-and-control-closure W2).
216
+ *
217
+ * ONE ADDITIVE EVENT TYPE, AND `EXEC_SCHEMA_VERSION` DOES NOT MOVE — the same
218
+ * rule `ExecCompactionEvent` states. A consumer that ignores event types it does
219
+ * not know is unaffected.
220
+ *
221
+ * THE PROBLEM IT SOLVES. `fast_review` only fires when a review produced TEXT,
222
+ * so every other outcome — a review that failed, a tier that switched itself off
223
+ * after three failures, a budget that ran out, a delegation that did or did not
224
+ * happen — was invisible to a wrapper. The single most likely and least
225
+ * self-diagnosable failure ("it runs but not one review card ever arrives") had
226
+ * no signal at all on this stream.
227
+ *
228
+ * IT IS A STATE EVENT, NOT A LOG LINE. It fires on `tier_changed` and on every
229
+ * `review_end`, which in a session with reviews is a handful per hundred turns.
230
+ */
231
+ export interface ExecFastTierEvent {
232
+ type: 'fast_tier';
233
+ sessionId: string;
234
+ /** Taken from the runner's own turn counter — `tier_changed` carries none. */
235
+ turn: number;
236
+ /** The tier resolved AND the live switch is on. */
237
+ live: boolean;
238
+ /**
239
+ * The CLI switched the fast REVIEWS off by itself after three consecutive
240
+ * non-transient failures.
241
+ *
242
+ * ORTHOGONAL TO `live` AND NOT DERIVABLE FROM IT: the self-disable leaves
243
+ * `live` at `true`. A consumer reporting "the reviews stopped" MUST read this
244
+ * field; `live === false && reviews > 0` is a predicate that can never be true.
245
+ */
246
+ selfDisabled: boolean;
247
+ model: string;
248
+ sameAsMain: boolean;
249
+ reviews: number;
250
+ reviewBudget: number;
251
+ budgetReached: boolean;
252
+ /** Fast-tier children dispatched — the only evidence delegation is working. */
253
+ delegated: number;
254
+ inFlight: boolean;
255
+ /** Present only on the `review_end` branch. */
256
+ review?: {
257
+ index: number;
258
+ kind: 'ok' | 'advice' | 'empty' | 'failed' | 'dropped';
259
+ detail?: string;
260
+ durationMs: number;
261
+ injected: boolean;
262
+ };
263
+ }
160
264
  /**
161
265
  * Context compaction (context-auto-compaction §4.6).
162
266
  *
@@ -251,7 +355,7 @@ export interface ExecResultEvent {
251
355
  todos: ExecTodoCounts | null;
252
356
  error: ExecErrorInfo | null;
253
357
  }
254
- export type ExecEvent = ExecSystemInitEvent | ExecUserEvent | ExecTextDeltaEvent | ExecThinkingEvent | ExecAssistantEvent | ExecToolCallEvent | ExecToolResultEvent | ExecTodoEvent | ExecTeamEvent | ExecFastReviewEvent | ExecCompactionEvent | ExecRetryEvent | ExecErrorEvent | ExecResultEvent;
358
+ export type ExecEvent = ExecSystemInitEvent | ExecUserEvent | ExecTurnStateEvent | ExecProgressEvent | ExecTextDeltaEvent | ExecThinkingEvent | ExecAssistantEvent | ExecToolCallEvent | ExecToolResultEvent | ExecTodoEvent | ExecTeamEvent | ExecFastReviewEvent | ExecFastTierEvent | ExecCompactionEvent | ExecRetryEvent | ExecErrorEvent | ExecResultEvent;
255
359
  /** Everything `system/init` needs that the runner cannot derive for itself. */
256
360
  export interface ExecInitParams {
257
361
  sessionId: string;
@@ -266,7 +370,40 @@ export interface ExecInitParams {
266
370
  permissionMode: ExecPermissionMode;
267
371
  tools: string[];
268
372
  resumed: boolean;
373
+ /**
374
+ * Absolute path of the session file this run reads and writes; `null` under
375
+ * `--no-save-session`. See `ExecSystemInitEvent.sessionFile`.
376
+ */
377
+ sessionFile?: string | null;
269
378
  }
379
+ /**
380
+ * What this build announces in `system/init`.
381
+ *
382
+ * Stamped by `buildInitEvent`, never by a caller - the same discipline as
383
+ * `schemaVersion`. A caller that could set it could claim a capability the
384
+ * binary does not have, which is precisely the mistake the field exists to
385
+ * prevent.
386
+ */
387
+ /**
388
+ * APPEND ONLY, NEVER REORDER. Consumers use `includes()`, but the next one may
389
+ * not; inserting ahead of `'interrupt'` costs nothing and makes a diff look
390
+ * bigger than it is.
391
+ *
392
+ * · `'interrupt'` — `{"type":"interrupt"}` on stdin settles the turn
393
+ * without killing the process.
394
+ * · `'fast-policy'` — this build understands `--fast-delegate` and
395
+ * `--fast-review`. FOR AFTER-THE-FACT DISPLAY ONLY:
396
+ * argv is fixed before `system/init` arrives, so it
397
+ * cannot gate whether the flags are passed.
398
+ * · `'fast-tier-events'` — this build emits `fast_tier`. Its ABSENCE is what
399
+ * lets a wrapper say "this CLI does not report" and
400
+ * not "the fast tier did nothing" — two very
401
+ * different sentences to a user.
402
+ * · `'session-file'` — `system/init` carries `sessionFile`, so a wrapper
403
+ * can check on disk whether a stopped conversation
404
+ * is still resumable instead of guessing the path.
405
+ */
406
+ export declare const EXEC_CAPABILITIES: readonly string[];
270
407
  export declare function buildInitEvent(params: ExecInitParams): ExecSystemInitEvent;
271
408
  /** Everything `result` needs. `schemaVersion` is stamped here, never by a caller. */
272
409
  export type ExecResultParams = Omit<ExecResultEvent, 'type' | 'schemaVersion'>;
@@ -19,6 +19,41 @@
19
19
  * additive change and every wrapper would pin an exact number forever.
20
20
  */
21
21
  export const EXEC_SCHEMA_VERSION = 1;
22
+ /**
23
+ * What this build announces in `system/init`.
24
+ *
25
+ * Stamped by `buildInitEvent`, never by a caller - the same discipline as
26
+ * `schemaVersion`. A caller that could set it could claim a capability the
27
+ * binary does not have, which is precisely the mistake the field exists to
28
+ * prevent.
29
+ */
30
+ /**
31
+ * APPEND ONLY, NEVER REORDER. Consumers use `includes()`, but the next one may
32
+ * not; inserting ahead of `'interrupt'` costs nothing and makes a diff look
33
+ * bigger than it is.
34
+ *
35
+ * · `'interrupt'` — `{"type":"interrupt"}` on stdin settles the turn
36
+ * without killing the process.
37
+ * · `'fast-policy'` — this build understands `--fast-delegate` and
38
+ * `--fast-review`. FOR AFTER-THE-FACT DISPLAY ONLY:
39
+ * argv is fixed before `system/init` arrives, so it
40
+ * cannot gate whether the flags are passed.
41
+ * · `'fast-tier-events'` — this build emits `fast_tier`. Its ABSENCE is what
42
+ * lets a wrapper say "this CLI does not report" and
43
+ * not "the fast tier did nothing" — two very
44
+ * different sentences to a user.
45
+ * · `'session-file'` — `system/init` carries `sessionFile`, so a wrapper
46
+ * can check on disk whether a stopped conversation
47
+ * is still resumable instead of guessing the path.
48
+ */
49
+ export const EXEC_CAPABILITIES = [
50
+ 'interrupt',
51
+ 'fast-policy',
52
+ 'fast-tier-events',
53
+ 'session-file',
54
+ 'turn-lifecycle',
55
+ 'execution-progress',
56
+ ];
22
57
  export function buildInitEvent(params) {
23
58
  return {
24
59
  type: 'system',
@@ -32,6 +67,13 @@ export function buildInitEvent(params) {
32
67
  permissionMode: params.permissionMode,
33
68
  tools: params.tools,
34
69
  resumed: params.resumed,
70
+ capabilities: [...EXEC_CAPABILITIES],
71
+ // `?? null` rather than a conditional spread: the field's contract is
72
+ // three-valued for a CONSUMER (`undefined` = an old build that cannot say,
73
+ // `null` = this run saves nothing, a string = here it is), and omitting it
74
+ // when a caller passed nothing would make this build indistinguishable from
75
+ // one that predates the field.
76
+ sessionFile: params.sessionFile ?? null,
35
77
  };
36
78
  }
37
79
  export function buildResultEvent(params) {
@@ -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;