@aragon-agent/cli 0.6.38 → 0.6.40

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 (209) hide show
  1. package/CHANGELOG.md +263 -0
  2. package/README.md +544 -11
  3. package/dist/agent/controller.d.ts +245 -10
  4. package/dist/agent/controller.js +367 -22
  5. package/dist/agent/model-profile-settings.js +12 -1
  6. package/dist/agent/plan-prompt.d.ts +1 -3
  7. package/dist/agent/plan-prompt.js +13 -7
  8. package/dist/agent/reducer.d.ts +70 -2
  9. package/dist/agent/reducer.js +59 -6
  10. package/dist/agent/skill-triage-prompt.d.ts +42 -0
  11. package/dist/agent/skill-triage-prompt.js +74 -0
  12. package/dist/agent/system-prompt.d.ts +28 -2
  13. package/dist/agent/system-prompt.js +12 -4
  14. package/dist/cli.js +106 -32
  15. package/dist/commands/builtins.js +24 -3
  16. package/dist/commands/keys-command.d.ts +23 -0
  17. package/dist/commands/keys-command.js +38 -0
  18. package/dist/commands/registry.d.ts +39 -0
  19. package/dist/commands/skills.js +86 -1
  20. package/dist/commands/subagent-command.d.ts +25 -0
  21. package/dist/commands/subagent-command.js +242 -0
  22. package/dist/commands/task-command.d.ts +39 -0
  23. package/dist/commands/task-command.js +102 -0
  24. package/dist/compaction/command.js +16 -3
  25. package/dist/compaction/compactor.d.ts +25 -0
  26. package/dist/compaction/compactor.js +111 -10
  27. package/dist/compaction/digest.d.ts +24 -1
  28. package/dist/compaction/digest.js +139 -17
  29. package/dist/compaction/limits.d.ts +21 -0
  30. package/dist/compaction/limits.js +21 -0
  31. package/dist/compaction/reason-text.d.ts +36 -0
  32. package/dist/compaction/reason-text.js +78 -0
  33. package/dist/compaction/types.d.ts +82 -14
  34. package/dist/compaction/wiring.d.ts +13 -8
  35. package/dist/compaction/wiring.js +1 -0
  36. package/dist/config/cli-commands.d.ts +23 -0
  37. package/dist/config/cli-commands.js +36 -0
  38. package/dist/config/env.js +17 -4
  39. package/dist/config/load.js +55 -3
  40. package/dist/config/schema.d.ts +125 -10
  41. package/dist/config/schema.js +138 -14
  42. package/dist/config/store.js +23 -3
  43. package/dist/diagnostics/doctor.d.ts +9 -0
  44. package/dist/diagnostics/doctor.js +34 -0
  45. package/dist/diagnostics/info.d.ts +71 -0
  46. package/dist/diagnostics/info.js +91 -5
  47. package/dist/exec/bridge/caller-tools.d.ts +96 -0
  48. package/dist/exec/bridge/caller-tools.js +213 -0
  49. package/dist/exec/bridge/decisions.d.ts +78 -0
  50. package/dist/exec/bridge/decisions.js +116 -0
  51. package/dist/exec/bridge/human-answers.d.ts +38 -0
  52. package/dist/exec/bridge/human-answers.js +164 -0
  53. package/dist/exec/bridge/limits.d.ts +68 -0
  54. package/dist/exec/bridge/limits.js +68 -0
  55. package/dist/exec/bridge/ndjson.d.ts +17 -0
  56. package/dist/exec/bridge/ndjson.js +83 -0
  57. package/dist/exec/bridge/pending.d.ts +70 -0
  58. package/dist/exec/bridge/pending.js +132 -0
  59. package/dist/exec/bridge/protocol.d.ts +346 -0
  60. package/dist/exec/bridge/protocol.js +256 -0
  61. package/dist/exec/bridge/server.d.ts +142 -0
  62. package/dist/exec/bridge/server.js +502 -0
  63. package/dist/exec/bridge/source.d.ts +58 -0
  64. package/dist/exec/bridge/source.js +145 -0
  65. package/dist/exec/bridge/writer.d.ts +26 -0
  66. package/dist/exec/bridge/writer.js +156 -0
  67. package/dist/exec/cli-commands.js +30 -2
  68. package/dist/exec/emitter.d.ts +16 -2
  69. package/dist/exec/emitter.js +26 -6
  70. package/dist/exec/events.d.ts +465 -10
  71. package/dist/exec/events.js +47 -1
  72. package/dist/exec/hooks/dispatch.d.ts +76 -0
  73. package/dist/exec/hooks/dispatch.js +169 -0
  74. package/dist/exec/hooks/index.d.ts +83 -0
  75. package/dist/exec/hooks/index.js +205 -0
  76. package/dist/exec/hooks/limits.d.ts +56 -0
  77. package/dist/exec/hooks/limits.js +49 -0
  78. package/dist/exec/hooks/registry.d.ts +49 -0
  79. package/dist/exec/hooks/registry.js +91 -0
  80. package/dist/exec/hooks/tool-wrapper.d.ts +94 -0
  81. package/dist/exec/hooks/tool-wrapper.js +154 -0
  82. package/dist/exec/hooks/turn.d.ts +61 -0
  83. package/dist/exec/hooks/turn.js +59 -0
  84. package/dist/exec/hooks/validate.d.ts +85 -0
  85. package/dist/exec/hooks/validate.js +310 -0
  86. package/dist/exec/index.d.ts +130 -4
  87. package/dist/exec/index.js +501 -10
  88. package/dist/exec/options.d.ts +124 -2
  89. package/dist/exec/options.js +413 -6
  90. package/dist/exec/permission.d.ts +9 -1
  91. package/dist/exec/permission.js +7 -1
  92. package/dist/exec/request/limits.d.ts +51 -0
  93. package/dist/exec/request/limits.js +49 -0
  94. package/dist/exec/request/scope.d.ts +106 -0
  95. package/dist/exec/request/scope.js +149 -0
  96. package/dist/exec/result/limits.d.ts +74 -0
  97. package/dist/exec/result/limits.js +72 -0
  98. package/dist/exec/result/prompt.d.ts +28 -0
  99. package/dist/exec/result/prompt.js +74 -0
  100. package/dist/exec/result/schema.d.ts +99 -0
  101. package/dist/exec/result/schema.js +464 -0
  102. package/dist/exec/result/sink.d.ts +56 -0
  103. package/dist/exec/result/sink.js +50 -0
  104. package/dist/exec/result/tool.d.ts +37 -0
  105. package/dist/exec/result/tool.js +118 -0
  106. package/dist/exec/runner.d.ts +395 -1
  107. package/dist/exec/runner.js +860 -8
  108. package/dist/exec/threads/aggregate.d.ts +34 -0
  109. package/dist/exec/threads/aggregate.js +30 -0
  110. package/dist/exec/threads/capacity.d.ts +23 -0
  111. package/dist/exec/threads/capacity.js +38 -0
  112. package/dist/exec/threads/dispatch-source.d.ts +60 -0
  113. package/dist/exec/threads/dispatch-source.js +106 -0
  114. package/dist/exec/threads/execute-threads.d.ts +53 -0
  115. package/dist/exec/threads/execute-threads.js +234 -0
  116. package/dist/exec/threads/gate.d.ts +42 -0
  117. package/dist/exec/threads/gate.js +81 -0
  118. package/dist/exec/threads/identity.d.ts +36 -0
  119. package/dist/exec/threads/identity.js +67 -0
  120. package/dist/exec/threads/interrupt.d.ts +29 -0
  121. package/dist/exec/threads/interrupt.js +79 -0
  122. package/dist/exec/threads/limits.d.ts +58 -0
  123. package/dist/exec/threads/limits.js +35 -0
  124. package/dist/exec/threads/outcome.d.ts +47 -0
  125. package/dist/exec/threads/outcome.js +26 -0
  126. package/dist/exec/threads/registry.d.ts +81 -0
  127. package/dist/exec/threads/registry.js +427 -0
  128. package/dist/exec/threads/runtime.d.ts +107 -0
  129. package/dist/exec/threads/runtime.js +523 -0
  130. package/dist/exec/threads/thread-source.d.ts +20 -0
  131. package/dist/exec/threads/thread-source.js +134 -0
  132. package/dist/fast/limits.d.ts +54 -0
  133. package/dist/fast/limits.js +54 -0
  134. package/dist/fast/summary-call.d.ts +72 -0
  135. package/dist/fast/summary-call.js +133 -0
  136. package/dist/fast/summary-prompt.d.ts +106 -0
  137. package/dist/fast/summary-prompt.js +320 -0
  138. package/dist/fast/task-summary-types.d.ts +66 -0
  139. package/dist/fast/task-summary-types.js +17 -0
  140. package/dist/logging/logger.d.ts +1 -1
  141. package/dist/proc/limits.d.ts +7 -0
  142. package/dist/proc/limits.js +7 -0
  143. package/dist/proc/log-mirror.d.ts +21 -4
  144. package/dist/proc/log-mirror.js +29 -7
  145. package/dist/proc/output-streams.d.ts +50 -0
  146. package/dist/proc/output-streams.js +54 -0
  147. package/dist/skills/service.d.ts +41 -0
  148. package/dist/skills/service.js +62 -0
  149. package/dist/skills/triage.d.ts +194 -0
  150. package/dist/skills/triage.js +393 -0
  151. package/dist/team/forced-dispatch.d.ts +95 -0
  152. package/dist/team/forced-dispatch.js +208 -0
  153. package/dist/team/limits.d.ts +21 -1
  154. package/dist/team/limits.js +21 -1
  155. package/dist/team/normalize.d.ts +15 -0
  156. package/dist/team/normalize.js +21 -4
  157. package/dist/team/prompt.d.ts +6 -3
  158. package/dist/team/prompt.js +9 -5
  159. package/dist/team/runtime.d.ts +9 -0
  160. package/dist/team/runtime.js +8 -0
  161. package/dist/team/subagent.d.ts +15 -0
  162. package/dist/team/subagent.js +45 -1
  163. package/dist/team/task-tool.js +11 -7
  164. package/dist/tools/bash-tool.d.ts +10 -0
  165. package/dist/tools/bash-tool.js +71 -6
  166. package/dist/tools/human-input.d.ts +35 -2
  167. package/dist/tools/human-input.js +38 -0
  168. package/dist/tools/index.d.ts +93 -1
  169. package/dist/tools/index.js +112 -3
  170. package/dist/tools/plan-tools.d.ts +20 -12
  171. package/dist/tools/plan-tools.js +24 -17
  172. package/dist/tools/tool-gate.d.ts +100 -0
  173. package/dist/tools/tool-gate.js +87 -0
  174. package/dist/ui/App.js +238 -12
  175. package/dist/ui/Header.d.ts +28 -16
  176. package/dist/ui/Header.js +21 -19
  177. package/dist/ui/StatusBar.d.ts +1 -0
  178. package/dist/ui/StatusBar.js +1 -0
  179. package/dist/ui/Transcript.js +1 -1
  180. package/dist/ui/entries/CompactionCard.d.ts +11 -1
  181. package/dist/ui/entries/CompactionCard.js +65 -4
  182. package/dist/ui/interaction-hints.js +6 -0
  183. package/dist/ui/layout/OverlayFrame.d.ts +18 -0
  184. package/dist/ui/layout/OverlayFrame.js +19 -4
  185. package/dist/ui/layout/header-hints.d.ts +55 -62
  186. package/dist/ui/layout/header-hints.js +59 -166
  187. package/dist/ui/layout/header-left.d.ts +115 -0
  188. package/dist/ui/layout/header-left.js +206 -0
  189. package/dist/ui/layout/shortcut-panel-layout.d.ts +191 -0
  190. package/dist/ui/layout/shortcut-panel-layout.js +321 -0
  191. package/dist/ui/layout/status-detail-layout.js +5 -1
  192. package/dist/ui/layout/status-layout.d.ts +10 -0
  193. package/dist/ui/layout/status-layout.js +18 -1
  194. package/dist/ui/layout/task-summary-cell.d.ts +61 -0
  195. package/dist/ui/layout/task-summary-cell.js +65 -0
  196. package/dist/ui/overlays/HelpOverlay.d.ts +12 -0
  197. package/dist/ui/overlays/HelpOverlay.js +29 -2
  198. package/dist/ui/overlays/SettingsScreen.js +29 -5
  199. package/dist/ui/overlays/ShortcutPanel.d.ts +39 -0
  200. package/dist/ui/overlays/ShortcutPanel.js +106 -0
  201. package/dist/ui/overlays/TeamFanoutPage.d.ts +35 -0
  202. package/dist/ui/overlays/TeamFanoutPage.js +78 -0
  203. package/dist/ui/overlays/model-profile-state.d.ts +19 -1
  204. package/dist/ui/overlays/model-profile-state.js +12 -0
  205. package/dist/ui/overlays/team-fanout-state.d.ts +73 -0
  206. package/dist/ui/overlays/team-fanout-state.js +97 -0
  207. package/dist/ui/shortcuts.d.ts +81 -0
  208. package/dist/ui/shortcuts.js +221 -0
  209. package/package.json +2 -2
@@ -17,17 +17,24 @@
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 { EXEC_SCHEMA_VERSION } from './events.js';
20
+ import { EXEC_SCHEMA_VERSION, buildRequestEndEvent } from './events.js';
21
21
  import { ExecProgressReporter } from './progress-reporter.js';
22
+ import { REQUEST_LIMITS } from './request/limits.js';
23
+ import { createRequestScope } from './request/scope.js';
24
+ import { RESULT_LIMITS } from './result/limits.js';
25
+ import { buildResultRepairMessage } from './result/prompt.js';
22
26
  export function singlePrompt(text) {
23
27
  let served = false;
28
+ let last;
24
29
  return {
25
30
  next: async () => {
26
31
  if (served)
27
32
  return null;
28
33
  served = true;
34
+ last = { text };
29
35
  return text;
30
36
  },
37
+ lastPrompt: () => last,
31
38
  };
32
39
  }
33
40
  const DEFAULT_SIGNAL_PORT = {
@@ -44,6 +51,11 @@ export class ExecRunner {
44
51
  now;
45
52
  progress;
46
53
  controller = null;
54
+ /** Set by `attachBridge`; absent in every non-bridge run (I-1). */
55
+ bridge = null;
56
+ bridgeSource = null;
57
+ /** The caller's id for the turn currently running, if there is one. */
58
+ requestId;
47
59
  unsubscribers = [];
48
60
  /** Per-job progress throttle state: the last text emitted, and when. */
49
61
  jobProgressAt = new Map();
@@ -60,10 +72,45 @@ export class ExecRunner {
60
72
  errorMessage = null;
61
73
  signalNumber = null;
62
74
  thinkingBuffer = '';
75
+ /**
76
+ * An injection that has been queued but whose acceptance has not been observed.
77
+ *
78
+ * ITS ONLY JOB IS TO MAKE I-H12 TRUE: a `turn_start` injection queued for a request that
79
+ * then fails to start must be REPORTED, not silently left in the steering queue where the
80
+ * next request's loop top would push it into history.
81
+ */
82
+ pendingInjection = null;
83
+ /**
84
+ * How many `steering_accepted` receipts have arrived.
85
+ *
86
+ * IT EXISTS TO CLOSE A ONE-TURN RACE: a stub (or a very fast engine) can confirm the
87
+ * injection INSIDE `queueUserMessage`, in which case setting the pending marker afterwards
88
+ * would leave a stale one and report a healthy injection as stranded.
89
+ */
90
+ steeringReceipts = 0;
63
91
  budgetTimer = null;
64
92
  timerStarted = false;
65
93
  detached = false;
66
94
  lastSignalAt = 0;
95
+ /**
96
+ * The one owner of every request-level fact (section 2.4.3).
97
+ *
98
+ * CREATED UNCONDITIONALLY, even outside bridge mode, and that is a deliberate
99
+ * departure from the design's "only in bridge mode" (implementation note): the
100
+ * acceptance latch and the per-request reset have to exist for a single-request run
101
+ * too, or `checkResultComplete` would need a second latch of its own - and two
102
+ * latches for one fact is how "the second request never stops" happens. It emits
103
+ * nothing and starts no timer unless a request budget was configured, so a
104
+ * non-bridge run is byte-identical.
105
+ */
106
+ requestScope;
107
+ /** True between the request's `ack` and its `request_end`. */
108
+ requestInFlight = false;
109
+ /** Repair continuations used by the request in flight. */
110
+ repairsUsed = 0;
111
+ resultUnsatisfied = false;
112
+ /** The last value any request accepted, for the process-level `result` event. */
113
+ lastStructured;
67
114
  constructor(options) {
68
115
  this.emitter = options.emitter;
69
116
  this.sessionId = options.sessionId;
@@ -71,6 +118,30 @@ export class ExecRunner {
71
118
  this.signals = options.signals ?? DEFAULT_SIGNAL_PORT;
72
119
  this.now = options.now ?? Date.now;
73
120
  this.progress = new ExecProgressReporter(this.sessionId, (event) => this.emit(event));
121
+ this.requestScope = createRequestScope({
122
+ ...(options.requestMaxTurns !== undefined ? { maxTurns: options.requestMaxTurns } : {}),
123
+ ...(options.requestMaxDurationMs !== undefined
124
+ ? { maxDurationMs: options.requestMaxDurationMs }
125
+ : {}),
126
+ now: this.now,
127
+ // THE TIMER'S ONLY ACTION. It arrives with the generation it was armed under, so
128
+ // a callback that lost the race with `end()` cannot reach this line at all.
129
+ onDurationExpired: () => {
130
+ this.requestScope.cancel('timeout');
131
+ this.bridge?.abortInFlight();
132
+ this.controller?.abort();
133
+ },
134
+ totals: () => ({
135
+ usage: { inputTokens: this.usage.inputTokens, outputTokens: this.usage.outputTokens },
136
+ cost: this.costOf(this.usage, this.compactionBilling),
137
+ }),
138
+ });
139
+ }
140
+ /** `buildCost` when the composition root supplied it; zero-and-unknown otherwise. */
141
+ costOf(usage, compaction) {
142
+ if (this.opts.cost)
143
+ return this.opts.cost(usage, compaction);
144
+ return { amount: 0, currency: 'USD', known: false };
74
145
  }
75
146
  // -----------------------------------------------------------------------
76
147
  // Attach / detach
@@ -245,7 +316,186 @@ export class ExecRunner {
245
316
  const proc = controller.subscribeProc?.((event) => this.onProcEvent(event));
246
317
  if (proc)
247
318
  this.unsubscribers.push(proc);
248
- this.installTerminator();
319
+ // THREAD MODE INSTALLS ITS OWN (section 2.3.8a). One terminator per PROCESS, owned by
320
+ // `exec/threads/interrupt.ts`; a runner that installed a second one would silently
321
+ // replace it and Ctrl-C would stop exactly one tenant.
322
+ if (this.opts.installSignalTerminator !== false)
323
+ this.installTerminator();
324
+ }
325
+ /**
326
+ * Wire the protocol channel. Called AFTER `attach(controller)`, never before:
327
+ * the turn ports below reach the controller.
328
+ *
329
+ * THREE LINES OF STATE AND ONE INSTALLATION, so that everything bridge-shaped in
330
+ * this file is reachable from one place. `steer` and `interrupt` belong here
331
+ * rather than in the server because they need the RUNNER's idea of what is in
332
+ * flight - and routing them through the server keeps the source's frame router
333
+ * a pure demultiplexer.
334
+ */
335
+ attachBridge(server, source) {
336
+ this.bridge = server;
337
+ this.bridgeSource = source ?? null;
338
+ server.attachTurnPorts({
339
+ steer: (text, id) => this.onSteerFrame(text, id),
340
+ interrupt: (requestId, id) => this.onInterruptFrame(requestId, id),
341
+ // EXACTLY THE SHAPE OF `interrupt`, AND DELIBERATELY NOT THE SAME MEANING:
342
+ // `interrupt` stops the RUN, `cancelRequest` stops ONE REQUEST and leaves the
343
+ // process serving the next one (section 2.4.7).
344
+ cancelRequest: (requestId, id) => this.cancelRequest(requestId, id),
345
+ });
346
+ }
347
+ /**
348
+ * `steer` over the wire.
349
+ *
350
+ * THE CHECK AND THE CALL ARE SEPARATE, AND BOTH ARE NEEDED: `queueUserMessage()`
351
+ * throws when the agent is not running, and "it was running when I looked" is
352
+ * not the same claim as "it accepted the message". Both failures map to the same
353
+ * receipt, because a caller cannot act on the difference and a lie ("accepted")
354
+ * would make it wait for text that never enters the conversation.
355
+ */
356
+ onSteerFrame(text, id) {
357
+ const receipt = (extra) => {
358
+ this.writeToBridge({ type: 'ack', ...(id ? { id } : {}), ...extra });
359
+ };
360
+ if (this.controller?.isRunning?.() !== true) {
361
+ receipt({ accepted: false, code: 'not_running' });
362
+ return;
363
+ }
364
+ try {
365
+ const queueUserMessage = this.controller.queueUserMessage;
366
+ if (!queueUserMessage) {
367
+ receipt({ accepted: false, code: 'not_running' });
368
+ return;
369
+ }
370
+ const steeringId = queueUserMessage.call(this.controller, text);
371
+ receipt({ accepted: true, ...(steeringId ? { steeringId } : {}) });
372
+ }
373
+ catch {
374
+ receipt({ accepted: false, code: 'not_running' });
375
+ }
376
+ }
377
+ /**
378
+ * `interrupt` over the wire, and the three things it can mean.
379
+ *
380
+ * A NAMED QUEUED TURN IS DELETED, NOT ABORTED: the user removed a message that
381
+ * had not started, and aborting the run in flight would cancel somebody else's
382
+ * work. An unnamed (or in-flight) request is the ordinary signal path, which
383
+ * already settles, persists and reports - the runner's own `requestInterrupt`.
384
+ */
385
+ onInterruptFrame(requestId, id) {
386
+ const receipt = (extra) => {
387
+ this.writeToBridge({ type: 'ack', ...(id ? { id } : {}), ...extra });
388
+ };
389
+ if (requestId && this.bridgeSource) {
390
+ const verdict = this.bridgeSource.cancelQueued(requestId);
391
+ if (verdict === 'cancelled') {
392
+ receipt({ accepted: true, cancelled: 'queued' });
393
+ return;
394
+ }
395
+ if (verdict === 'unknown') {
396
+ receipt({ accepted: false, code: 'unknown_turn' });
397
+ return;
398
+ }
399
+ }
400
+ this.requestInterrupt();
401
+ }
402
+ /**
403
+ * `cancel_request` over the wire: stop ONE request, never the process.
404
+ *
405
+ * THREE OUTCOMES, and the third one is the important one (section 2.4.7):
406
+ *
407
+ * QUEUED the source deletes it and THIS method writes its `request_end`,
408
+ * because `run()`'s loop never saw that prompt and never will.
409
+ * The `ack` echoes the requestId, since a queued prompt has no
410
+ * `ack` of its own before it is served.
411
+ * IN FLIGHT cancel the scope and abort. The request's own `finally` writes the
412
+ * `request_end`, so there is still exactly one writer per fact.
413
+ * UNKNOWN `ack{accepted:false}` and a non-fatal error. A FINISHED REQUEST IS
414
+ * `unknown`, NEVER "in flight" - aborting a healthy run because a
415
+ * caller cancelled something it already got an answer for is the
416
+ * exact defect the source's `cancelQueued` comment records.
417
+ */
418
+ cancelRequest(requestId, frameId) {
419
+ const receipt = (extra) => {
420
+ this.writeToBridge({ type: 'ack', ...(frameId ? { id: frameId } : {}), ...extra });
421
+ };
422
+ // THE SOURCE IS ASKED FIRST, because it is the only object that can tell a QUEUED
423
+ // prompt from a SERVED one. Its three-way verdict is exactly the three outcomes
424
+ // this frame has, which is why it was written that way in the first round.
425
+ if (requestId && this.bridgeSource) {
426
+ const verdict = this.bridgeSource.cancelQueued(requestId);
427
+ if (verdict === 'cancelled') {
428
+ receipt({ accepted: true, requestId, code: 'queued_cancelled' });
429
+ this.writeCancelledRequestEnd(requestId);
430
+ return;
431
+ }
432
+ if (verdict === 'unknown') {
433
+ // A FINISHED REQUEST IS `unknown`, NEVER "in flight": answering anything else
434
+ // would make a late cancel abort a healthy run.
435
+ this.reportUnknownRequest(requestId, frameId);
436
+ return;
437
+ }
438
+ // `in_flight` falls through: the named request is the one running.
439
+ }
440
+ const target = requestId ?? this.requestScope.requestId();
441
+ if (this.requestInFlight && target !== undefined && target === this.requestScope.requestId()) {
442
+ this.requestScope.cancel('cancelled');
443
+ receipt({ accepted: true, requestId: target });
444
+ this.bridge?.abortInFlight();
445
+ this.controller?.abort();
446
+ return;
447
+ }
448
+ this.reportUnknownRequest(requestId, frameId);
449
+ }
450
+ /**
451
+ * The refusal, written where the caller can act on it.
452
+ *
453
+ * TWO FRAMES, DELIBERATELY: the `ack` says "not accepted" and the non-fatal `error`
454
+ * says why. A caller that only watches `ack` is not left guessing, and one that only
455
+ * watches `error` is not left without a receipt.
456
+ */
457
+ reportUnknownRequest(requestId, frameId) {
458
+ this.writeToBridge({
459
+ type: 'ack',
460
+ ...(frameId ? { id: frameId } : {}),
461
+ accepted: false,
462
+ code: 'unknown_turn',
463
+ ...(requestId ? { requestId } : {}),
464
+ });
465
+ this.writeToBridge({
466
+ type: 'error',
467
+ sessionId: this.sessionId,
468
+ fatal: false,
469
+ code: 'bridge_unknown_request',
470
+ message: `cancel_request named "${requestId ?? 'the in-flight request'}", which is not running.`,
471
+ });
472
+ }
473
+ /**
474
+ * The `request_end` for a request that was cancelled BEFORE it was served.
475
+ *
476
+ * `requestSeq` IS ABSENT, NOT ZERO: the number is minted when a prompt is served,
477
+ * this one never was, and inventing one would collide with a later real request.
478
+ * Everything else is honestly zero, including `cost` - which is `known: true` with
479
+ * `amount: 0` because nothing was spent and that is a fact, not a missing price.
480
+ */
481
+ writeCancelledRequestEnd(requestId) {
482
+ if (!this.bridge)
483
+ return;
484
+ if (!this.requestScope.remember(requestId))
485
+ return;
486
+ this.writeToBridge(buildRequestEndEvent({
487
+ sessionId: this.sessionId,
488
+ requestId,
489
+ stopReason: 'cancelled',
490
+ isError: false,
491
+ turns: 0,
492
+ durationMs: 0,
493
+ usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
494
+ cost: { amount: 0, currency: 'USD', known: true },
495
+ result: '',
496
+ ...(this.opts.resultSink ? { structured: null } : {}),
497
+ error: null,
498
+ }));
249
499
  }
250
500
  /**
251
501
  * Map a supervisor event onto the `job` channel.
@@ -335,6 +585,10 @@ export class ExecRunner {
335
585
  clearTimeout(this.budgetTimer);
336
586
  this.budgetTimer = null;
337
587
  }
588
+ // THE REQUEST TIMER TOO (R-13). `detach()` runs after the last request, so a
589
+ // surviving request timer could only ever fire against a disposed controller - the
590
+ // same class of late callback the scope's own generation latch exists for.
591
+ this.requestScope.detach();
338
592
  }
339
593
  stats() {
340
594
  return {
@@ -344,6 +598,8 @@ export class ExecRunner {
344
598
  ...this.compactionBilling, usage: { ...this.compactionBilling.usage },
345
599
  } } : {}),
346
600
  lastAssistantText: this.lastAssistantText,
601
+ ...(this.lastStructured ? { lastStructured: { ...this.lastStructured } } : {}),
602
+ resultUnsatisfied: this.resultUnsatisfied,
347
603
  errored: this.errored,
348
604
  stopReason: this.stopReason,
349
605
  errorCode: this.errorCode,
@@ -389,10 +645,75 @@ export class ExecRunner {
389
645
  // nothing more to say exits 0 with `end_turn`, not 3 with `max_turns`.
390
646
  if (next === null)
391
647
  break;
648
+ const prompt = source.lastPrompt?.();
649
+ this.requestId = prompt?.requestId;
392
650
  const requestSeq = ++this.requestSeq;
393
651
  this.requestHadError = false;
652
+ this.resultUnsatisfied = false;
653
+ this.repairsUsed = 0;
654
+ // THE REQUEST BEGINS HERE, BEFORE THE ACK, because `requestSeq` is part of the
655
+ // request's identity and the ack promises it.
656
+ this.requestScope.begin({
657
+ requestSeq,
658
+ ...(prompt?.requestId !== undefined ? { requestId: prompt.requestId } : {}),
659
+ });
660
+ this.requestInFlight = true;
661
+ // THE ONE RESET POINT (P1-11). AFTER `begin()`, BEFORE `prompt()`: the previous
662
+ // request's tool calls have all settled by now (its `finally` is on the await
663
+ // chain), and this request's first turn has not been sent. Resetting at the END
664
+ // of a request instead would let a late tool result be judged as the next
665
+ // request's answer.
666
+ this.opts.resultSink?.reset();
394
667
  this.progress.begin(requestSeq);
395
- this.emit({ type: 'turn_state', sessionId: this.sessionId, requestSeq, phase: 'started' });
668
+ // `prompt_pre`, BETWEEN `begin()` AND THE ACK (section 2.4.7, review P1-11).
669
+ //
670
+ // THE POSITION IS THE CONTRACT. Before `begin()` there is no `requestId` to put on
671
+ // the `hook_request`; after the ack a denial would arrive as `ack{accepted:true}`
672
+ // followed by `request_end{cancelled}` - one frame saying "I took it" and one saying
673
+ // "I did not". Here both facts are honest, and the denial reuses the shape
674
+ // `writeCancelledRequestEnd` already established.
675
+ // CONDITIONAL, NOT AN UNCONDITIONAL AWAIT (I-C1). A run with no hook channel must
676
+ // reach `prompt()` after EXACTLY as many microtasks as it did before this feature
677
+ // existed: an extra `await` here reorders observable timing for callers that drive
678
+ // `run()` a tick at a time (this package's own progress tests found it).
679
+ const hookVerdict = this.opts.hookPort
680
+ ? await this.applyPromptPre(next, prompt, requestSeq)
681
+ : next;
682
+ if (hookVerdict === null)
683
+ continue;
684
+ // THE ACK IS EMITTED HERE, NOT BY THE SOURCE, because `requestSeq` is the
685
+ // runner's own counter and a receipt that guessed at it would be worse than
686
+ // one that omitted it. `queued` was captured at arrival, where it means
687
+ // something; the order of these acks is the order the source served, which
688
+ // is the order the caller wrote.
689
+ if (this.bridge && prompt?.requestId) {
690
+ this.writeToBridge({
691
+ type: 'ack',
692
+ id: prompt.requestId,
693
+ accepted: true,
694
+ queued: prompt.queued === true,
695
+ requestSeq,
696
+ });
697
+ }
698
+ // THE TURN BOUNDARY, WHICH IS THE ONLY PLACE A DEFERRED `tool_register` MAY
699
+ // BE APPLIED (I-8): the engine is idle here and `prompt()` is about to be
700
+ // called, so the new definition reaches the very next LLM request.
701
+ this.bridge?.beginTurn(this.turns + 1);
702
+ // CONDITIONAL AWAIT, NOT `await this.drainBridge()`. With no bridge there is
703
+ // no promise to await, and an unconditional `await` would still cost a
704
+ // microtask per turn - enough to change the observable ordering for callers
705
+ // that drive `run()` a tick at a time (and for this package's own runner
706
+ // tests, which is how this was found).
707
+ const drain = this.bridge?.drainIfNeeded();
708
+ if (drain)
709
+ await drain;
710
+ this.emit({
711
+ type: 'turn_state',
712
+ sessionId: this.sessionId,
713
+ requestSeq,
714
+ phase: 'started',
715
+ ...this.correlation(),
716
+ });
396
717
  this.progress.started(requestSeq);
397
718
  try {
398
719
  if (this.isRequestCancelled())
@@ -401,7 +722,7 @@ export class ExecRunner {
401
722
  this.stopReason = 'max_turns';
402
723
  break;
403
724
  }
404
- const finished = await this.runFollowThroughChain(controller, next, followThrough);
725
+ const finished = await this.runFollowThroughChain(controller, hookVerdict, followThrough);
405
726
  if (finished)
406
727
  break;
407
728
  }
@@ -412,13 +733,242 @@ export class ExecRunner {
412
733
  }
413
734
  finally {
414
735
  this.progress.finish(requestSeq);
415
- const phase = this.isRequestCancelled()
736
+ const phase = this.isRequestCancelled() || this.requestScope.cancelledReason() !== null
416
737
  ? 'cancelled'
417
738
  : this.requestHadError ? 'failed' : 'completed';
418
- this.emit({ type: 'turn_state', sessionId: this.sessionId, requestSeq, phase });
739
+ // THE `turn_state` GOES FIRST, AND THE `request_end` RIGHT AFTER IT: same
740
+ // `finally`, same writer, so "the request's last state precedes its account"
741
+ // is structural rather than remembered (I-R3).
742
+ this.emit({
743
+ type: 'turn_state',
744
+ sessionId: this.sessionId,
745
+ requestSeq,
746
+ phase,
747
+ ...this.correlation(),
748
+ });
749
+ this.requestInFlight = false;
750
+ // BEFORE `settleRequest`, so the account it writes can carry `injection_stranded`
751
+ // in `error.code` (I-H12). Reporting it after would mean the caller reads a clean
752
+ // `request_end` and never learns the injected context was dropped.
753
+ this.reportStrandedInjection();
754
+ this.settleRequest(requestSeq);
755
+ // THE GATE'S RELEASE POINT: after the account is written, in the request's own
756
+ // `finally`, so a slot is never freed before the request it was holding is over.
757
+ this.opts.onRequestSettled?.(requestSeq);
419
758
  }
420
759
  }
421
760
  }
761
+ /**
762
+ * Run `prompt_pre` for the request that just began, and settle it if a hook refuses.
763
+ *
764
+ * RETURNS THE TEXT TO SEND, or `null` when the request has already been settled and the
765
+ * caller's loop must continue with the next one. A caller hook's denial is a FAILED
766
+ * request - the caller did not get what it asked for - which is why `isError` is true
767
+ * here and false for a caller's own `cancel_request`.
768
+ */
769
+ async applyPromptPre(text, prompt, requestSeq) {
770
+ const port = this.opts.hookPort;
771
+ if (!port)
772
+ return text;
773
+ // A REQUEST ID ALWAYS EXISTS BY THIS POINT, and if the caller did not supply one the
774
+ // runner's own `request_<n>` stands in: `scope.begin()` has already run, so a hook request
775
+ // without an id would be the one case the caller could not correlate.
776
+ const requestId = prompt?.requestId ?? `request_${requestSeq}`;
777
+ const decision = await port.promptPre({
778
+ text,
779
+ queued: prompt?.queued === true,
780
+ requestId,
781
+ });
782
+ if (decision.action === 'deny') {
783
+ this.writeDeniedRequestEnd(requestId, requestSeq, decision);
784
+ return null;
785
+ }
786
+ return decision.action === 'rewrite' && decision.text !== undefined ? decision.text : text;
787
+ }
788
+ /**
789
+ * The `request_end` for a request a hook refused.
790
+ *
791
+ * ONE DELIBERATE DIFFERENCE FROM `writeCancelledRequestEnd`: `requestSeq` IS PRESENT.
792
+ * That method serves a prompt that was withdrawn while queued, before `begin()` ever ran,
793
+ * so it has no number to give; this one HAS begun, and inventing a denial without the
794
+ * number would make "which request was refused" a guessing game.
795
+ */
796
+ writeDeniedRequestEnd(requestId, requestSeq, decision) {
797
+ const code = decision.code ?? 'hook_denied';
798
+ const message = decision.message ?? 'A caller hook refused this prompt.';
799
+ this.writeToBridge({
800
+ type: 'ack',
801
+ id: requestId,
802
+ accepted: false,
803
+ code,
804
+ requestSeq,
805
+ });
806
+ this.writeToBridge({
807
+ type: 'error',
808
+ sessionId: this.sessionId,
809
+ fatal: false,
810
+ code,
811
+ message,
812
+ });
813
+ if (!this.requestScope.remember(requestId))
814
+ return;
815
+ this.requestInFlight = false;
816
+ this.requestScope.cancel('cancelled');
817
+ this.requestScope.end({ now: this.now(), error: { code, message } });
818
+ this.writeToBridge(buildRequestEndEvent({
819
+ sessionId: this.sessionId,
820
+ requestId,
821
+ requestSeq,
822
+ stopReason: 'cancelled',
823
+ isError: decision.isError !== false,
824
+ turns: 0,
825
+ durationMs: 0,
826
+ usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
827
+ cost: { amount: 0, currency: 'USD', known: true },
828
+ result: '',
829
+ ...(this.opts.resultSink ? { structured: null } : {}),
830
+ error: { code, message },
831
+ }));
832
+ }
833
+ /**
834
+ * Record a hook-caused request failure (section 2.4.8's `error` policy).
835
+ *
836
+ * ALL THREE FIELDS, because three different readers need them: `errorCode` and
837
+ * `errorMessage` become `request_end.error`, and `requestHadError` is what makes
838
+ * `requestError()` return anything at all - setting the first two alone leaves the frame
839
+ * looking clean, which is exactly the failure `--on-hook-timeout error` exists to prevent.
840
+ */
841
+ markHookError(code) {
842
+ if (this.errorCode === null) {
843
+ this.errorCode = code ?? 'hook_timeout';
844
+ this.errorMessage = 'A caller lifecycle hook did not answer; --on-hook-timeout error.';
845
+ }
846
+ this.requestHadError = true;
847
+ }
848
+ /**
849
+ * Report an injection that was queued for a request that never accepted it (I-H12).
850
+ *
851
+ * TWO FRAMES, because there are two facts: the hook round trip ended as `aborted` with
852
+ * `injection_stranded` (visible in the hook channel), and this request's `error.code`
853
+ * carries the same string so a caller reading only `request_end` learns it too.
854
+ */
855
+ reportStrandedInjection(hookId) {
856
+ const stranded = this.pendingInjection;
857
+ if (!stranded && hookId === undefined)
858
+ return;
859
+ this.pendingInjection = null;
860
+ this.writeToBridge({
861
+ type: 'hook_resolved',
862
+ sessionId: this.sessionId,
863
+ requestId: this.requestScope.requestId() ?? 'unknown',
864
+ hookId: stranded?.hookId ?? hookId ?? 'h?',
865
+ point: 'turn_start',
866
+ outcome: 'aborted',
867
+ code: 'injection_stranded',
868
+ });
869
+ if (this.errorCode === null) {
870
+ this.errorCode = 'injection_stranded';
871
+ this.errorMessage =
872
+ 'A caller hook injected context for a turn that never started, so it was not added.';
873
+ }
874
+ // `requestHadError` TOO, not just `errorCode`: `requestError()` is what `request_end.error`
875
+ // reads, and it returns null unless the request is marked as failed - so setting only the
876
+ // code would leave the frame clean and the fact invisible.
877
+ this.requestHadError = true;
878
+ }
879
+ /**
880
+ * Write this request's `request_end` - the FIRST of the two writers (I-R9).
881
+ *
882
+ * The second is `cancelRequest`, for a prompt that was cancelled while queued and
883
+ * therefore never reached this method at all. Both go through
884
+ * `RequestScope.remember()`, which is what makes "exactly one per request" true
885
+ * instead of merely intended.
886
+ */
887
+ settleRequest(requestSeq) {
888
+ const requestId = this.requestScope.requestId();
889
+ if (!this.requestScope.remember(requestId ?? `request_${requestSeq}`))
890
+ return;
891
+ const sink = this.opts.resultSink;
892
+ const accepted = sink?.accepted() ?? null;
893
+ if (accepted) {
894
+ // PROCESS-LEVEL MEMORY FOR A PROCESS-LEVEL EVENT. `result` is one event for the
895
+ // whole run; a later request that submits nothing must not erase the fact that
896
+ // an answer WAS produced.
897
+ this.lastStructured = { value: accepted.value, attempts: accepted.attempts };
898
+ }
899
+ if (!this.bridge) {
900
+ this.requestScope.end(this.settlementInput());
901
+ return;
902
+ }
903
+ const settlement = this.requestScope.end(this.settlementInput());
904
+ this.writeToBridge(buildRequestEndEvent({
905
+ sessionId: this.sessionId,
906
+ requestId: settlement.requestId,
907
+ ...(settlement.requestSeq !== undefined ? { requestSeq: settlement.requestSeq } : {}),
908
+ stopReason: settlement.stopReason,
909
+ isError: settlement.isError,
910
+ turns: settlement.turns,
911
+ durationMs: settlement.durationMs,
912
+ usage: settlement.usage,
913
+ cost: settlement.cost,
914
+ result: clipText(this.lastAssistantText, REQUEST_LIMITS.maxResultChars),
915
+ ...(sink
916
+ ? { structured: accepted
917
+ ? { value: accepted.value, schemaName: this.opts.resultSchemaName ?? 'result', attempts: accepted.attempts }
918
+ : null }
919
+ : {}),
920
+ error: settlement.error,
921
+ }));
922
+ }
923
+ /**
924
+ * What `RequestScope.end()` needs: the clock, the failure, and - when the PROCESS
925
+ * stopped underneath this request - the process reason (section 2.4.2).
926
+ *
927
+ * SEPARATE FROM `requestError()` BECAUSE THE TWO ANSWER DIFFERENT QUESTIONS: the
928
+ * error says "you did not get what you asked for", the reason says "why this request
929
+ * ended". A process `interrupt` sets the second and not the first, which is exactly
930
+ * the case a caller reading only `stopReason` has to see as `cancelled` rather than
931
+ * as a finished answer.
932
+ */
933
+ settlementInput() {
934
+ const processReason = this.processStopForRequest();
935
+ return {
936
+ now: this.now(),
937
+ error: this.requestError(),
938
+ ...(processReason ? { stopReason: processReason } : {}),
939
+ };
940
+ }
941
+ /**
942
+ * The process-level stop, translated into the REQUEST vocabulary (section 2.4.2).
943
+ *
944
+ * `undefined` for `end_turn` and for everything a request already speaks for itself.
945
+ * Only the three cases where the RUN ended underneath a live request are mapped, and
946
+ * the mapping is one-way on purpose: `ExecStopReason` and `ExecRequestStopReason` are
947
+ * different unions over different questions and must stay different.
948
+ */
949
+ processStopForRequest() {
950
+ switch (this.stopReason) {
951
+ case 'max_turns':
952
+ return 'max_turns';
953
+ case 'timeout':
954
+ return 'timeout';
955
+ case 'interrupted':
956
+ return 'cancelled';
957
+ case 'error':
958
+ return 'error';
959
+ default:
960
+ return undefined;
961
+ }
962
+ }
963
+ /** The request-level error, or `null`. One place so `result` and `request_end` agree. */
964
+ requestError() {
965
+ if (!this.requestHadError && !this.resultUnsatisfied)
966
+ return null;
967
+ return {
968
+ code: this.errorCode ?? 'agent_error',
969
+ message: this.errorMessage ?? 'The run failed.',
970
+ };
971
+ }
422
972
  onCompactionUsage(event) {
423
973
  const usage = event.usage;
424
974
  this.usage.inputTokens += usage.inputTokens;
@@ -461,11 +1011,114 @@ export class ExecRunner {
461
1011
  turn: this.turns + 1,
462
1012
  text: message,
463
1013
  source,
1014
+ // AN AUTO-CONTINUATION HAS NO `requestId`: it is the CLI's own message,
1015
+ // not the caller's request, and `source: 'todo_continue'` already says so.
1016
+ ...(source === 'caller' ? this.correlation() : {}),
464
1017
  });
1018
+ // THE FOLLOW-THROUGH CHAIN'S LATER ITERATIONS ARE NEW TURNS, so the registry
1019
+ // boundary is re-announced: a `tool_register` that arrived during iteration N
1020
+ // must be live for iteration N+1, and `prompt()` below is where that matters.
1021
+ this.bridge?.beginTurn(this.turns + 1);
465
1022
  this.startBudgetTimer();
1023
+ // THE SAME JUDGEMENT THE POST-TURN CHECK MAKES, ASKED BEFORE THE TURN STARTS:
1024
+ // `stopRequestOnBudget()` is the ONE place a request budget is decided, and it is
1025
+ // asked here as well as after each turn (a `--request-max-duration` that expires
1026
+ // between two continuations must not be given another turn). Prompting an already
1027
+ // aborted controller would also emit an `agent_end` with no assistant turn, which
1028
+ // the silent-failure detector below would report as an `agent_error` - a request
1029
+ // budget must never make the process look broken (I-R1).
1030
+ // `turn_start`, AND ITS POSITION IS NOT NEGOTIABLE (section 2.4.6, review P1-9):
1031
+ // the hook is ASKED here, but the injection is QUEUED only after the budget check
1032
+ // below. `Agent.steer()` does not throw when the engine is idle - it queues the text
1033
+ // for the next `acceptSteering`, which is the NEXT request's loop top - so an
1034
+ // injection made before a budget stop would surface in somebody else's conversation.
1035
+ // CONDITIONAL, FOR THE SAME REASON `prompt_pre` IS (I-C1): `await undefined` still costs
1036
+ // a microtask, and a runner with no hook channel must reach `prompt()` on exactly the
1037
+ // turn count it did before this feature existed.
1038
+ const injection = this.opts.beforeTurn
1039
+ ? ((await this.opts.beforeTurn({
1040
+ turn: this.turns + 1,
1041
+ source,
1042
+ // `requestScope.requestId()` IS the id by now: `scope.begin()` runs before the
1043
+ // first turn, and a request without a caller-supplied id gets `request_<n>` from
1044
+ // the scope itself, so the fallback here never fires (it satisfies the type).
1045
+ requestId: this.requestScope.requestId() ?? 'unknown',
1046
+ })) ?? undefined)
1047
+ : undefined;
1048
+ if (this.haltRequestOnBudget()) {
1049
+ // NOTHING WAS QUEUED, SO NOTHING IS STRANDED (section 2.4.6). `beforeTurn` produced
1050
+ // text that never reached the steering queue, and the hook round trip already wrote
1051
+ // its own `hook_resolved` - so there is no missing frame to close. Reporting
1052
+ // `injection_stranded` here would invent a frame for an injection that never
1053
+ // existed AND mark a request budget stop as a hook failure, which is the one thing
1054
+ // I-R1 forbids (`--request-max-turns` must never make the process look broken).
1055
+ return false;
1056
+ }
1057
+ if (injection?.injected) {
1058
+ const receiptsBefore = this.steeringReceipts;
1059
+ const queued = controller.queueUserMessage?.(injection.injected);
1060
+ // RECORDED BEFORE `prompt()` SO A FAILED START CAN STILL REPORT IT; the
1061
+ // `steering_accepted` receipt is the only thing that clears it - including one that
1062
+ // arrives synchronously, which the counter detects.
1063
+ const confirmed = this.steeringReceipts > receiptsBefore;
1064
+ this.pendingInjection =
1065
+ queued === undefined || confirmed ? null : { hookId: injection.hookId ?? 'h?' };
1066
+ }
466
1067
  await controller.prompt(message);
1068
+ if (injection?.isError === true)
1069
+ this.markHookError(injection.code);
467
1070
  if (this.stopReason !== 'end_turn')
468
1071
  return true;
1072
+ // A REQUEST-LEVEL STOP ENDS *THIS REQUEST*, NEVER THE PROCESS (I-R1).
1073
+ const cancelled = this.requestScope.cancelledReason();
1074
+ if (cancelled !== null) {
1075
+ // A CALLER'S OWN CANCELLATION IS NOT A CONTRACT FAILURE. A BUDGET STOP IS, and
1076
+ // the caller has to learn both facts (AC-51): `stopReason` answers "why did
1077
+ // this stop", `error.code` answers "did I get what I asked for".
1078
+ if (cancelled !== 'cancelled' && this.opts.resultSink && !this.opts.resultSink.accepted()) {
1079
+ this.markResultUnsatisfied();
1080
+ }
1081
+ return false;
1082
+ }
1083
+ // BUDGET BEFORE REWORK (P1-9). A repair attempt that the budget cannot pay for
1084
+ // would report `max_turns` while the real problem is an unsubmitted result.
1085
+ if (this.haltRequestOnBudget())
1086
+ return false;
1087
+ const contract = this.resultContractOutcome();
1088
+ // "ACCEPT THEN STOP" (I-S4): the abort in `checkResultComplete` already ended the
1089
+ // model's loop; this is what refuses a follow-through continuation on the far
1090
+ // side of it.
1091
+ if (contract === 'accepted')
1092
+ return false;
1093
+ // THE PROCESS BUDGET IS JUDGED BEFORE THE CONTRACT'S REMAINING BRANCHES, AND ONLY
1094
+ // WHEN A SCHEMA FAILED (section 2.3.5). A repair is an ordinary turn, so a repair
1095
+ // the caller's `--max-turns` cannot pay for must report `max_turns` rather than be
1096
+ // started and immediately aborted. The `contract === 'none'` case never reaches
1097
+ // here, which is what keeps a successful non-schema run ending on `end_turn` even
1098
+ // when it used exactly its whole budget (IF-3).
1099
+ // `contract === 'none'` IS EXCLUDED ON PURPOSE (implementation note I-6): a run with
1100
+ // no schema that uses exactly its whole budget must still report `end_turn`, which is
1101
+ // the IF-3 defect the block budget's own comment records. Only a run whose contract
1102
+ // already failed can be said to have "run out of budget for the repair".
1103
+ if (contract !== 'none' && this.turnBudgetExhausted()) {
1104
+ this.stopReason = 'max_turns';
1105
+ return true;
1106
+ }
1107
+ if (contract === 'repair') {
1108
+ const sink = this.opts.resultSink;
1109
+ this.repairsUsed += 1;
1110
+ // THE CLI SPEAKS FOR THE CALLER HERE, exactly as `todo_continue` does, and says
1111
+ // so through `source` so a replayed transcript can tell them apart.
1112
+ source = 'result_repair';
1113
+ message = buildResultRepairMessage(sink?.lastViolations() ?? [], sink?.attempts() ?? 0);
1114
+ continue;
1115
+ }
1116
+ if (contract === 'unsatisfied') {
1117
+ this.markResultUnsatisfied();
1118
+ // `error` ends the run (the single-call default); `continue` leaves the process
1119
+ // serving the next request with `result.isError` still true.
1120
+ return this.opts.onResultUnsatisfied !== 'continue';
1121
+ }
469
1122
  const snapshot = controller.getTodoSnapshot?.() ?? null;
470
1123
  budget = advanceBudget(budget, snapshot, wasAutoContinuation);
471
1124
  wasAutoContinuation = false;
@@ -491,6 +1144,8 @@ export class ExecRunner {
491
1144
  this.stopReason = 'max_turns';
492
1145
  return true;
493
1146
  }
1147
+ if (this.haltRequestOnBudget())
1148
+ return false;
494
1149
  // CHARGED BEFORE THE RE-PROMPT. Headless has no grace timer, so without
495
1150
  // this line `maxAutoContinuesPerList` would never bind in the one
496
1151
  // environment with no human to press Esc.
@@ -501,6 +1156,115 @@ export class ExecRunner {
501
1156
  source = 'todo_continue';
502
1157
  }
503
1158
  }
1159
+ /**
1160
+ * What the structured-result contract says about the request that just ended.
1161
+ *
1162
+ * FOUR ANSWERS, and `none` is the important one: with no `--result-schema` this
1163
+ * method reads nothing and the caller's behaviour is exactly what it was before the
1164
+ * feature existed (I-S2).
1165
+ */
1166
+ resultContractOutcome() {
1167
+ const sink = this.opts.resultSink;
1168
+ if (!sink)
1169
+ return 'none';
1170
+ if (sink.accepted())
1171
+ return 'accepted';
1172
+ const repairTurns = this.opts.resultRepairTurns ?? RESULT_LIMITS.defaultRepairTurns;
1173
+ return this.repairsUsed < repairTurns ? 'repair' : 'unsatisfied';
1174
+ }
1175
+ /**
1176
+ * Record that this request ended without the answer the caller asked for.
1177
+ *
1178
+ * TWO FACTS, TWO FIELDS, NEVER ONE NAME FOR BOTH (P1-9): `error.code` says the
1179
+ * contract failed, and the process-level `stopReason` moves to `error` ONLY when
1180
+ * `--on-result-unsatisfied error` (the default) and nothing else already decided why
1181
+ * the request stopped.
1182
+ */
1183
+ markResultUnsatisfied() {
1184
+ this.resultUnsatisfied = true;
1185
+ this.requestScope.cancel('error');
1186
+ if (this.errorCode === null) {
1187
+ this.errorCode = 'result_schema_unsatisfied';
1188
+ this.errorMessage = `No result matching ${this.opts.resultSchemaName ?? 'the schema'} was submitted.`;
1189
+ }
1190
+ const contractIsTheCause = this.requestScope.cancelledReason() === 'error';
1191
+ if (contractIsTheCause && this.stopReason === 'end_turn'
1192
+ && this.opts.onResultUnsatisfied !== 'continue') {
1193
+ // THE PROCESS STOP REASON *AND* THE FLAG THE EXIT CODE READS. `exitCode()` is
1194
+ // `errored ? 1 : 0` for everything that is not a signal or a budget, so setting
1195
+ // only `stopReason` would report the failure in `result` while exiting 0 - the
1196
+ // one combination a shell script cannot defend against.
1197
+ this.stopReason = 'error';
1198
+ this.errored = true;
1199
+ }
1200
+ }
1201
+ /**
1202
+ * Stop the request for a budget reason, and record the contract failure it implies.
1203
+ *
1204
+ * THE CANCELLATION GUARD IS THE POINT. A caller that withdrew this request has not
1205
+ * failed to deliver a result, so `cancel_request` must not leave a
1206
+ * `result_schema_unsatisfied` behind - "the work was called off" and "the answer
1207
+ * never arrived" are different facts and only the second is a contract failure
1208
+ * (section 2.3.7). One helper rather than three copies, because the copy that
1209
+ * forgets this line is a defect nobody notices for a release.
1210
+ */
1211
+ haltRequestOnBudget() {
1212
+ if (!this.stopRequestOnBudget())
1213
+ return false;
1214
+ if (this.opts.resultSink &&
1215
+ !this.opts.resultSink.accepted() &&
1216
+ this.requestScope.cancelledReason() !== 'cancelled') {
1217
+ this.markResultUnsatisfied();
1218
+ }
1219
+ return true;
1220
+ }
1221
+ /**
1222
+ * The request-level budgets, at a boundary the engine can survive.
1223
+ *
1224
+ * RETURNS TRUE WHEN IT STOPPED THE REQUEST. The process is never touched: calling
1225
+ * `controller.abort()` ends the model's current loop, and the next `prompt()` starts
1226
+ * a fresh one - which is the whole of I-R1.
1227
+ */
1228
+ stopRequestOnBudget() {
1229
+ if (this.requestScope.cancelledReason() !== null)
1230
+ return true;
1231
+ if (this.requestScope.turnsExhausted()) {
1232
+ this.requestScope.cancel('max_turns');
1233
+ }
1234
+ else if (this.requestScope.isOverDurationBudget(this.now())) {
1235
+ // THE LAZY HALF OF THE TIMER. A `setTimeout` can be late by an arbitrary amount
1236
+ // when the loop is busy, and a budget that only exists in a timer is a budget
1237
+ // that sometimes does not exist.
1238
+ this.requestScope.cancel('timeout');
1239
+ }
1240
+ else {
1241
+ return false;
1242
+ }
1243
+ this.bridge?.abortInFlight();
1244
+ this.controller?.abort();
1245
+ return true;
1246
+ }
1247
+ /**
1248
+ * "ACCEPTED MEANS FINISHED", enforced at the one event that follows a tool result
1249
+ * (section 2.3.6).
1250
+ *
1251
+ * `turn_end` fires BEFORE `tool_execution_start`, so by the time this runs the turn
1252
+ * has already been counted and `agent_end` cannot mistake the abort for a run that
1253
+ * produced nothing.
1254
+ */
1255
+ checkResultComplete() {
1256
+ if (!this.opts.resultSink?.accepted())
1257
+ return;
1258
+ // PER REQUEST, NOT PER PROCESS (P1-5): the latch lives in the scope, so the second
1259
+ // request of a long-lived process aborts itself exactly as the first one did - and
1260
+ // a request that never submitted anything is never aborted by a stale value.
1261
+ if (this.requestScope.acceptanceAborted())
1262
+ return;
1263
+ this.requestScope.markAcceptanceAborted();
1264
+ // THE SAME ABORT PATH AS THE BUDGET AND Esc, so subagents are torn down and the
1265
+ // engine settles normally instead of being abandoned mid-turn.
1266
+ this.controller?.abort();
1267
+ }
504
1268
  // -----------------------------------------------------------------------
505
1269
  // Budgets (section 3.5)
506
1270
  // -----------------------------------------------------------------------
@@ -588,6 +1352,12 @@ export class ExecRunner {
588
1352
  * this and add the getter.
589
1353
  */
590
1354
  installTerminator() {
1355
+ // THE OPTION IS CHECKED HERE AS WELL AS AT THE CALL SITE (section 2.3.8a, R-13).
1356
+ // Two guards for one rule is not redundancy when the rule is "a second install
1357
+ // silently disables the first": a future `attach()` that forgets the call-site guard
1358
+ // still cannot take the terminator away from the thread owner.
1359
+ if (this.opts.installSignalTerminator === false)
1360
+ return;
591
1361
  this.signals.setTerminator((signo) => this.onSignal(signo));
592
1362
  }
593
1363
  /**
@@ -620,13 +1390,50 @@ export class ExecRunner {
620
1390
  this.lastSignalAt = at;
621
1391
  this.signalNumber = signo;
622
1392
  this.stopReason = 'interrupted';
1393
+ // BEFORE THE ABORT, AND SYNCHRONOUSLY. A decision wait is inside a tool
1394
+ // execution, and the executor abandons a tool 5 seconds after the abort; a
1395
+ // bridge that only learned about the interrupt from a later event would hand
1396
+ // the model "did not stop within 5000ms of abort" instead of "the turn was
1397
+ // interrupted" (AC-49). Settling first also means the tool's own promise has
1398
+ // already resolved when `abort()` reaches the executor.
1399
+ this.bridge?.abortInFlight();
623
1400
  this.controller?.abort();
624
1401
  }
625
1402
  // -----------------------------------------------------------------------
626
1403
  // Event translation
627
1404
  // -----------------------------------------------------------------------
1405
+ /**
1406
+ * THE ONE STAMPING POINT (section 2.3.10, I-T10; review P1-1).
1407
+ *
1408
+ * NOT `correlation()`, which returns only `{requestId}` and is spread into a handful of
1409
+ * events: the thread id has to be on EVERY line this runner produces, including the ones
1410
+ * that carry no request (a `todo` snapshot, a `job` update). Stamping here and in
1411
+ * `writeToBridge` covers the whole file - and AC-54 turns the claim into a single
1412
+ * universal assertion ("every line with a sessionId has a threadId") rather than a
1413
+ * per-event checklist that a new event can quietly escape.
1414
+ */
628
1415
  emit(event) {
629
- this.emitter.emit(event);
1416
+ this.emitter.emit(this.opts.threadId !== undefined
1417
+ ? { ...event, threadId: this.opts.threadId }
1418
+ : event);
1419
+ }
1420
+ /**
1421
+ * The same stamp for the frames that bypass `emit()`.
1422
+ *
1423
+ * SIX SITES USE THIS AND THEY ARE ALL ACKS AND CONTROL FRAMES: steer ack, interrupt ack,
1424
+ * the cancelled `request_end`, the request ack, the settled `request_end` and the
1425
+ * `steering` receipt. A seventh added later that writes to `bridge` directly would lose
1426
+ * the stamp - which is why the method exists rather than six copies of a conditional
1427
+ * spread.
1428
+ */
1429
+ writeToBridge(event) {
1430
+ if (!this.bridge)
1431
+ return;
1432
+ this.bridge.write(this.opts.threadId !== undefined ? { ...event, threadId: this.opts.threadId } : event);
1433
+ }
1434
+ /** The correlation fields every attributable event carries in bridge mode. */
1435
+ correlation() {
1436
+ return this.bridge && this.requestId ? { requestId: this.requestId } : {};
630
1437
  }
631
1438
  /** Caller-facing prose. STDERR ALWAYS - stdout carries nothing but the schema. */
632
1439
  note(text) {
@@ -654,6 +1461,7 @@ export class ExecRunner {
654
1461
  id: event.toolCallId,
655
1462
  name: event.toolName,
656
1463
  input: (event.args ?? {}),
1464
+ ...this.correlation(),
657
1465
  });
658
1466
  break;
659
1467
  case 'tool_execution_end':
@@ -667,6 +1475,29 @@ export class ExecRunner {
667
1475
  isError: event.isError,
668
1476
  durationMs: event.duration,
669
1477
  output: toolResultText(event.result),
1478
+ ...this.correlation(),
1479
+ });
1480
+ // AFTER THE `tool_result`, SO THE CALLER SEES THE ACCEPTANCE BEFORE THE ABORT
1481
+ // THAT FOLLOWS IT. `turn_end` has already fired (it precedes the tool call), so
1482
+ // the abort cannot be mistaken for a turn that produced nothing.
1483
+ this.checkResultComplete();
1484
+ break;
1485
+ case 'steering_accepted':
1486
+ // ONE EVENT, THE WHOLE BATCH (review P1-7). Core's receipt carries every
1487
+ // id a single drain accepted; reporting only the first would leave the rest
1488
+ // permanently unconfirmed on the caller's side, which is the exact failure
1489
+ // the receipt exists to prevent.
1490
+ //
1491
+ // IT ALSO CONFIRMS THE `turn_start` INJECTION (section 2.4.6): this is the only
1492
+ // signal that the text actually entered history, and clearing the pending marker
1493
+ // here is what keeps that fact from being misreported as stranded.
1494
+ this.pendingInjection = null;
1495
+ this.steeringReceipts += 1;
1496
+ this.writeToBridge({
1497
+ type: 'steering',
1498
+ sessionId: this.sessionId,
1499
+ steeringIds: [...event.ids],
1500
+ ...this.correlation(),
670
1501
  });
671
1502
  break;
672
1503
  case 'turn_end':
@@ -737,13 +1568,23 @@ export class ExecRunner {
737
1568
  onTurnEnd(message, usage) {
738
1569
  this.sawTurnEnd = true;
739
1570
  this.turns += 1;
1571
+ // THE SAME MOMENT AS THE PROCESS COUNTER, DELIBERATELY: two counters over one
1572
+ // event is one fact seen twice; two counters over two events is two facts that can
1573
+ // disagree, and the budget would be enforced against the wrong one.
1574
+ this.requestScope.countTurn();
740
1575
  this.usage.inputTokens += usage.inputTokens;
741
1576
  this.usage.outputTokens += usage.outputTokens;
742
1577
  this.flushThinking();
743
1578
  const text = assistantText(message);
744
1579
  if (text.length > 0) {
745
1580
  this.lastAssistantText = text;
746
- this.emit({ type: 'assistant', sessionId: this.sessionId, turn: this.turns, text });
1581
+ this.emit({
1582
+ type: 'assistant',
1583
+ sessionId: this.sessionId,
1584
+ turn: this.turns,
1585
+ text,
1586
+ ...this.correlation(),
1587
+ });
747
1588
  }
748
1589
  }
749
1590
  flushThinking() {
@@ -823,6 +1664,17 @@ function assistantText(message) {
823
1664
  }
824
1665
  return parts.join('').trim();
825
1666
  }
1667
+ /**
1668
+ * Clip one model-visible string to a bound the frame can carry.
1669
+ *
1670
+ * `request_end.result` is the ONLY place in this file where the full answer is
1671
+ * truncated, and the bound comes from `REQUEST_LIMITS` rather than from a literal: the
1672
+ * complete text is still on the `assistant` events, so this is a frame decision, not a
1673
+ * data-loss decision.
1674
+ */
1675
+ function clipText(text, max) {
1676
+ return text.length <= max ? text : `${text.slice(0, Math.max(0, max - 3))}...`;
1677
+ }
826
1678
  /**
827
1679
  * Flatten a `ToolResult` to the text the MODEL saw.
828
1680
  *