@leanandmean/coding-agent 0.74.1-scramjet.6 → 0.74.1-scramjet.60

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 (227) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/LICENSE +202 -0
  3. package/README.md +21 -9
  4. package/dist/cli/args.d.ts.map +1 -1
  5. package/dist/cli/args.js +1 -1
  6. package/dist/cli/args.js.map +1 -1
  7. package/dist/cli/initial-message.d.ts +1 -0
  8. package/dist/cli/initial-message.d.ts.map +1 -1
  9. package/dist/cli/initial-message.js +3 -0
  10. package/dist/cli/initial-message.js.map +1 -1
  11. package/dist/cli/list-models.d.ts +2 -0
  12. package/dist/cli/list-models.d.ts.map +1 -1
  13. package/dist/cli/list-models.js +4 -1
  14. package/dist/cli/list-models.js.map +1 -1
  15. package/dist/config.d.ts +9 -2
  16. package/dist/config.d.ts.map +1 -1
  17. package/dist/config.js +33 -22
  18. package/dist/config.js.map +1 -1
  19. package/dist/core/agent-session-runtime.d.ts +12 -3
  20. package/dist/core/agent-session-runtime.d.ts.map +1 -1
  21. package/dist/core/agent-session-runtime.js +64 -38
  22. package/dist/core/agent-session-runtime.js.map +1 -1
  23. package/dist/core/agent-session-services.d.ts +1 -0
  24. package/dist/core/agent-session-services.d.ts.map +1 -1
  25. package/dist/core/agent-session-services.js +2 -0
  26. package/dist/core/agent-session-services.js.map +1 -1
  27. package/dist/core/agent-session.d.ts +86 -16
  28. package/dist/core/agent-session.d.ts.map +1 -1
  29. package/dist/core/agent-session.js +865 -181
  30. package/dist/core/agent-session.js.map +1 -1
  31. package/dist/core/compaction/branch-summarization.d.ts.map +1 -1
  32. package/dist/core/compaction/branch-summarization.js +21 -17
  33. package/dist/core/compaction/branch-summarization.js.map +1 -1
  34. package/dist/core/compaction/compaction.d.ts +3 -5
  35. package/dist/core/compaction/compaction.d.ts.map +1 -1
  36. package/dist/core/compaction/compaction.js +43 -10
  37. package/dist/core/compaction/compaction.js.map +1 -1
  38. package/dist/core/exec.d.ts +12 -0
  39. package/dist/core/exec.d.ts.map +1 -1
  40. package/dist/core/exec.js +74 -28
  41. package/dist/core/exec.js.map +1 -1
  42. package/dist/core/export-html/template.js +4 -0
  43. package/dist/core/extensions/index.d.ts +1 -1
  44. package/dist/core/extensions/index.d.ts.map +1 -1
  45. package/dist/core/extensions/index.js.map +1 -1
  46. package/dist/core/extensions/loader.d.ts.map +1 -1
  47. package/dist/core/extensions/loader.js +7 -0
  48. package/dist/core/extensions/loader.js.map +1 -1
  49. package/dist/core/extensions/runner.d.ts +9 -5
  50. package/dist/core/extensions/runner.d.ts.map +1 -1
  51. package/dist/core/extensions/runner.js +162 -3
  52. package/dist/core/extensions/runner.js.map +1 -1
  53. package/dist/core/extensions/types.d.ts +110 -6
  54. package/dist/core/extensions/types.d.ts.map +1 -1
  55. package/dist/core/extensions/types.js.map +1 -1
  56. package/dist/core/keybindings.d.ts +8 -0
  57. package/dist/core/keybindings.d.ts.map +1 -1
  58. package/dist/core/model-registry.d.ts +4 -0
  59. package/dist/core/model-registry.d.ts.map +1 -1
  60. package/dist/core/model-registry.js +179 -28
  61. package/dist/core/model-registry.js.map +1 -1
  62. package/dist/core/model-resolver.d.ts.map +1 -1
  63. package/dist/core/model-resolver.js +24 -6
  64. package/dist/core/model-resolver.js.map +1 -1
  65. package/dist/core/output-throughput-history.d.ts +25 -0
  66. package/dist/core/output-throughput-history.d.ts.map +1 -0
  67. package/dist/core/output-throughput-history.js +240 -0
  68. package/dist/core/output-throughput-history.js.map +1 -0
  69. package/dist/core/output-throughput.d.ts +43 -0
  70. package/dist/core/output-throughput.d.ts.map +1 -0
  71. package/dist/core/output-throughput.js +161 -0
  72. package/dist/core/output-throughput.js.map +1 -0
  73. package/dist/core/resource-loader.d.ts +6 -0
  74. package/dist/core/resource-loader.d.ts.map +1 -1
  75. package/dist/core/resource-loader.js +76 -21
  76. package/dist/core/resource-loader.js.map +1 -1
  77. package/dist/core/scramjet-command-parser.d.ts +4 -0
  78. package/dist/core/scramjet-command-parser.d.ts.map +1 -1
  79. package/dist/core/scramjet-command-parser.js +72 -1
  80. package/dist/core/scramjet-command-parser.js.map +1 -1
  81. package/dist/core/sdk.d.ts +2 -0
  82. package/dist/core/sdk.d.ts.map +1 -1
  83. package/dist/core/sdk.js +61 -10
  84. package/dist/core/sdk.js.map +1 -1
  85. package/dist/core/session-manager.d.ts +11 -3
  86. package/dist/core/session-manager.d.ts.map +1 -1
  87. package/dist/core/session-manager.js +46 -11
  88. package/dist/core/session-manager.js.map +1 -1
  89. package/dist/core/settings-manager.d.ts +18 -6
  90. package/dist/core/settings-manager.d.ts.map +1 -1
  91. package/dist/core/settings-manager.js +84 -16
  92. package/dist/core/settings-manager.js.map +1 -1
  93. package/dist/core/skills.d.ts.map +1 -1
  94. package/dist/core/skills.js +6 -2
  95. package/dist/core/skills.js.map +1 -1
  96. package/dist/core/system-prompt.d.ts +2 -0
  97. package/dist/core/system-prompt.d.ts.map +1 -1
  98. package/dist/core/system-prompt.js +5 -2
  99. package/dist/core/system-prompt.js.map +1 -1
  100. package/dist/core/tools/bash.d.ts.map +1 -1
  101. package/dist/core/tools/bash.js +4 -3
  102. package/dist/core/tools/bash.js.map +1 -1
  103. package/dist/index.d.ts +4 -3
  104. package/dist/index.d.ts.map +1 -1
  105. package/dist/index.js +3 -2
  106. package/dist/index.js.map +1 -1
  107. package/dist/main.d.ts +16 -0
  108. package/dist/main.d.ts.map +1 -1
  109. package/dist/main.js +38 -10
  110. package/dist/main.js.map +1 -1
  111. package/dist/modes/interactive/components/armin.d.ts +4 -2
  112. package/dist/modes/interactive/components/armin.d.ts.map +1 -1
  113. package/dist/modes/interactive/components/armin.js +14 -8
  114. package/dist/modes/interactive/components/armin.js.map +1 -1
  115. package/dist/modes/interactive/components/assistant-message.d.ts +3 -1
  116. package/dist/modes/interactive/components/assistant-message.d.ts.map +1 -1
  117. package/dist/modes/interactive/components/assistant-message.js +19 -8
  118. package/dist/modes/interactive/components/assistant-message.js.map +1 -1
  119. package/dist/modes/interactive/components/daxnuts.d.ts +4 -1
  120. package/dist/modes/interactive/components/daxnuts.d.ts.map +1 -1
  121. package/dist/modes/interactive/components/daxnuts.js +14 -6
  122. package/dist/modes/interactive/components/daxnuts.js.map +1 -1
  123. package/dist/modes/interactive/components/extension-editor.d.ts.map +1 -1
  124. package/dist/modes/interactive/components/extension-editor.js +5 -3
  125. package/dist/modes/interactive/components/extension-editor.js.map +1 -1
  126. package/dist/modes/interactive/components/extension-selector.d.ts.map +1 -1
  127. package/dist/modes/interactive/components/extension-selector.js +4 -3
  128. package/dist/modes/interactive/components/extension-selector.js.map +1 -1
  129. package/dist/modes/interactive/components/footer.d.ts +4 -0
  130. package/dist/modes/interactive/components/footer.d.ts.map +1 -1
  131. package/dist/modes/interactive/components/footer.js +22 -18
  132. package/dist/modes/interactive/components/footer.js.map +1 -1
  133. package/dist/modes/interactive/components/model-selector.d.ts +3 -1
  134. package/dist/modes/interactive/components/model-selector.d.ts.map +1 -1
  135. package/dist/modes/interactive/components/model-selector.js +9 -3
  136. package/dist/modes/interactive/components/model-selector.js.map +1 -1
  137. package/dist/modes/interactive/components/scoped-models-selector.d.ts +3 -1
  138. package/dist/modes/interactive/components/scoped-models-selector.d.ts.map +1 -1
  139. package/dist/modes/interactive/components/scoped-models-selector.js +8 -2
  140. package/dist/modes/interactive/components/scoped-models-selector.js.map +1 -1
  141. package/dist/modes/interactive/components/settings-selector.d.ts +17 -3
  142. package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
  143. package/dist/modes/interactive/components/settings-selector.js +87 -16
  144. package/dist/modes/interactive/components/settings-selector.js.map +1 -1
  145. package/dist/modes/interactive/components/thinking-selector.d.ts.map +1 -1
  146. package/dist/modes/interactive/components/thinking-selector.js +2 -1
  147. package/dist/modes/interactive/components/thinking-selector.js.map +1 -1
  148. package/dist/modes/interactive/components/tool-execution.d.ts +19 -1
  149. package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  150. package/dist/modes/interactive/components/tool-execution.js +129 -36
  151. package/dist/modes/interactive/components/tool-execution.js.map +1 -1
  152. package/dist/modes/interactive/components/user-message.d.ts.map +1 -1
  153. package/dist/modes/interactive/components/user-message.js +3 -2
  154. package/dist/modes/interactive/components/user-message.js.map +1 -1
  155. package/dist/modes/interactive/components/visual-truncate.d.ts.map +1 -1
  156. package/dist/modes/interactive/components/visual-truncate.js +2 -2
  157. package/dist/modes/interactive/components/visual-truncate.js.map +1 -1
  158. package/dist/modes/interactive/interactive-mode.d.ts +37 -1
  159. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  160. package/dist/modes/interactive/interactive-mode.js +876 -175
  161. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  162. package/dist/modes/interactive/spellcheck.d.ts +26 -0
  163. package/dist/modes/interactive/spellcheck.d.ts.map +1 -0
  164. package/dist/modes/interactive/spellcheck.js +144 -0
  165. package/dist/modes/interactive/spellcheck.js.map +1 -0
  166. package/dist/modes/interactive/theme/{dark.json → pi-dark.json} +3 -2
  167. package/dist/modes/interactive/theme/{light.json → pi-light.json} +27 -26
  168. package/dist/modes/interactive/theme/theme-schema.json +6 -1
  169. package/dist/modes/interactive/theme/theme.d.ts +24 -4
  170. package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  171. package/dist/modes/interactive/theme/theme.js +123 -22
  172. package/dist/modes/interactive/theme/theme.js.map +1 -1
  173. package/dist/modes/print-mode.d.ts.map +1 -1
  174. package/dist/modes/print-mode.js +5 -0
  175. package/dist/modes/print-mode.js.map +1 -1
  176. package/dist/modes/rpc/rpc-client.d.ts +3 -1
  177. package/dist/modes/rpc/rpc-client.d.ts.map +1 -1
  178. package/dist/modes/rpc/rpc-client.js.map +1 -1
  179. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  180. package/dist/modes/rpc/rpc-mode.js +6 -1
  181. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  182. package/dist/package-manager-cli.d.ts.map +1 -1
  183. package/dist/package-manager-cli.js +33 -14
  184. package/dist/package-manager-cli.js.map +1 -1
  185. package/dist/utils/clipboard-native.d.ts +1 -0
  186. package/dist/utils/clipboard-native.d.ts.map +1 -1
  187. package/dist/utils/clipboard-native.js.map +1 -1
  188. package/dist/utils/clipboard.d.ts +6 -0
  189. package/dist/utils/clipboard.d.ts.map +1 -1
  190. package/dist/utils/clipboard.js +278 -10
  191. package/dist/utils/clipboard.js.map +1 -1
  192. package/dist/utils/tools-manager.d.ts +1 -1
  193. package/dist/utils/tools-manager.d.ts.map +1 -1
  194. package/dist/utils/tools-manager.js +7 -6
  195. package/dist/utils/tools-manager.js.map +1 -1
  196. package/dist/utils/version-check.d.ts +17 -5
  197. package/dist/utils/version-check.d.ts.map +1 -1
  198. package/dist/utils/version-check.js +88 -21
  199. package/dist/utils/version-check.js.map +1 -1
  200. package/docs/compaction.md +9 -3
  201. package/docs/custom-provider.md +13 -4
  202. package/docs/extensions.md +139 -21
  203. package/docs/keybindings.md +25 -1
  204. package/docs/models.md +34 -2
  205. package/docs/packages.md +2 -0
  206. package/docs/prompt-templates.md +2 -0
  207. package/docs/quickstart.md +1 -4
  208. package/docs/rpc.md +8 -6
  209. package/docs/sdk.md +22 -3
  210. package/docs/session-format.md +2 -1
  211. package/docs/settings.md +13 -3
  212. package/docs/skills.md +7 -5
  213. package/docs/terminal-setup.md +79 -2
  214. package/docs/terminal-verification.md +71 -0
  215. package/docs/themes.md +63 -10
  216. package/docs/tui.md +120 -25
  217. package/docs/usage.md +6 -4
  218. package/examples/extensions/border-status-editor.ts +2 -3
  219. package/examples/extensions/doom-overlay/doom-component.ts +3 -3
  220. package/examples/extensions/mac-system-theme.ts +3 -2
  221. package/examples/extensions/modal-editor.ts +13 -8
  222. package/examples/extensions/overlay-qa-tests.ts +22 -9
  223. package/examples/extensions/overlay-test.ts +5 -3
  224. package/examples/extensions/snake.ts +3 -1
  225. package/examples/extensions/space-invaders.ts +6 -3
  226. package/examples/extensions/subagent/index.ts +20 -17
  227. package/package.json +69 -58
@@ -14,7 +14,11 @@
14
14
  */
15
15
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
16
16
  import { basename, dirname, resolve } from "node:path";
17
+ // SCRAMJET-DIVERGENCE: provider-safe harness tool-call id generation for the persisted-settlement
18
+ // acknowledgement key (#341).
19
+ import { generateHarnessToolCallId } from "@leanandmean/agent";
17
20
  import { clampThinkingLevel, cleanupSessionResources, flattenSystemPrompt, getSupportedThinkingLevels, isContextOverflow, modelsAreEqual, resetApiProviders, } from "@leanandmean/ai";
21
+ import { validateResponsesProviderFailure } from "@leanandmean/ai/openai-responses";
18
22
  import { theme } from "../modes/interactive/theme/theme.js";
19
23
  import { stripFrontmatter } from "../utils/frontmatter.js";
20
24
  import { sleep } from "../utils/sleep.js";
@@ -26,7 +30,10 @@ import { exportSessionToHtml } from "./export-html/index.js";
26
30
  import { createToolHtmlRenderer } from "./export-html/tool-renderer.js";
27
31
  import { ExtensionRunner, wrapRegisteredTools, } from "./extensions/index.js";
28
32
  import { emitSessionShutdownEvent, spliceContributedSections } from "./extensions/runner.js";
33
+ import { OutputThroughputTracker } from "./output-throughput.js";
34
+ import { OutputThroughputHistoryStore } from "./output-throughput-history.js";
29
35
  import { expandPromptTemplate } from "./prompt-templates.js";
36
+ import { indexScramjetCommandStarts, restoreScramjetCommandInvocation } from "./scramjet-command-parser.js";
30
37
  import { CURRENT_SESSION_VERSION, getLatestCompactionEntry } from "./session-manager.js";
31
38
  import { createSyntheticSourceInfo } from "./source-info.js";
32
39
  import { buildSystemPromptSections } from "./system-prompt.js";
@@ -53,24 +60,59 @@ export function parseSkillBlock(text) {
53
60
  // ============================================================================
54
61
  /** Standard thinking levels */
55
62
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high"];
56
- // ============================================================================
57
- // AgentSession Class
58
- // ============================================================================
63
+ function createDeferred() {
64
+ let resolve;
65
+ let reject;
66
+ const promise = new Promise((res, rej) => {
67
+ resolve = res;
68
+ reject = rej;
69
+ });
70
+ promise.catch(() => { });
71
+ return { promise, resolve, reject, settled: false };
72
+ }
73
+ function resolveDeferred(deferred, value) {
74
+ if (deferred.settled)
75
+ return false;
76
+ deferred.settled = true;
77
+ deferred.resolve(value);
78
+ return true;
79
+ }
80
+ function rejectDeferred(deferred, error) {
81
+ if (deferred.settled)
82
+ return false;
83
+ deferred.settled = true;
84
+ deferred.reject(error);
85
+ return true;
86
+ }
87
+ function settleDeferred(deferred, error) {
88
+ return error ? rejectDeferred(deferred, error) : resolveDeferred(deferred, undefined);
89
+ }
59
90
  export class AgentSession {
60
91
  agent;
61
92
  sessionManager;
62
93
  settingsManager;
94
+ outputThroughputHistoryPath;
63
95
  _scopedModels;
64
96
  // Event subscription state
65
97
  _unsubscribeAgent;
66
98
  _eventListeners = [];
67
99
  _agentEventQueue = Promise.resolve();
100
+ _outputThroughputTracker = new OutputThroughputTracker();
101
+ _outputThroughputHistory;
102
+ _outputThroughputGeneration;
103
+ _latestOutputThroughputSample;
104
+ // SCRAMJET-DIVERGENCE: harness-tool persisted-settlement state (#341). `_disposed` gates post-
105
+ // teardown invocations; the map keys each in-flight invocation's tool-call id to its persistence
106
+ // acknowledgement so `_processAgentEvent` can settle it after the matching tool-result is persisted.
107
+ _disposed = false;
108
+ _harnessPersistenceAcks = new Map();
68
109
  /** Tracks pending steering messages for UI display. Removed when delivered. */
69
110
  _steeringMessages = [];
70
111
  /** Tracks pending follow-up messages for UI display. Removed when delivered. */
71
112
  _followUpMessages = [];
72
113
  /** Messages queued to be included with the next user prompt as context ("asides"). */
73
114
  _pendingNextTurnMessages = [];
115
+ _inputSessionEntries = new WeakMap();
74
116
  // Compaction state
75
117
  _compactionAbortController = undefined;
76
118
  _autoCompactionAbortController = undefined;
@@ -78,10 +120,12 @@ export class AgentSession {
78
120
  // Branch summarization state
79
121
  _branchSummaryAbortController = undefined;
80
122
  // Retry state
81
- _retryAbortController = undefined;
82
- _retryAttempt = 0;
83
- _retryPromise = undefined;
84
- _retryResolve = undefined;
123
+ _retry = undefined;
124
+ _runRetryCount = 0;
125
+ _activeAgentRunSettlement = undefined;
126
+ _pendingAgentRunStart = undefined;
127
+ _unsettledAgentRuns = new Set();
128
+ _unsettledRetryChains = new Set();
85
129
  // Bash execution state
86
130
  _bashAbortController = undefined;
87
131
  _pendingBashMessages = [];
@@ -114,10 +158,22 @@ export class AgentSession {
114
158
  // can apply per-section cache control.
115
159
  _baseSystemPromptSections = [];
116
160
  _baseSystemPromptOptions;
161
+ // SCRAMJET-DIVERGENCE: active-tool prompt rebuilds preserve the current top-level run's
162
+ // before_agent_start composition while new prompts and reloads establish a fresh composition (#524).
163
+ _runPromptComposition = { kind: "generated-base" };
117
164
  constructor(config) {
118
165
  this.agent = config.agent;
119
166
  this.sessionManager = config.sessionManager;
120
167
  this.settingsManager = config.settingsManager;
168
+ this.outputThroughputHistoryPath = config.outputThroughputHistoryPath;
169
+ this._outputThroughputHistory = new OutputThroughputHistoryStore(this.outputThroughputHistoryPath, (diagnostic) => {
170
+ this._extensionRunner.emitError({
171
+ extensionPath: "output-throughput-history",
172
+ event: diagnostic.operation,
173
+ error: `${diagnostic.message} (${diagnostic.path})`,
174
+ stack: diagnostic.cause instanceof Error ? diagnostic.cause.stack : undefined,
175
+ });
176
+ });
121
177
  this._scopedModels = config.scopedModels ?? [];
122
178
  this._resourceLoader = config.resourceLoader;
123
179
  this._customTools = config.customTools ?? [];
@@ -161,8 +217,8 @@ export class AgentSession {
161
217
  throw new Error(formatNoApiKeyFoundMessage(model.provider));
162
218
  }
163
219
  // SCRAMJET-DIVERGENCE: _drainAgentEventQueue ensures all queued async extension event
164
- // processing has settled before proceeding. Used by beforeToolBatch (pre-extraction drain)
165
- // and beforeToolCall (per-tool drain).
220
+ // processing has settled before proceeding. Used by beforeToolBatch (pre-extraction drain),
221
+ // beforeToolCall (per-tool drain), and the awaited turn_end listener barrier.
166
222
  async _drainAgentEventQueue() {
167
223
  await this._agentEventQueue;
168
224
  }
@@ -241,46 +297,147 @@ export class AgentSession {
241
297
  followUp: [...this._followUpMessages],
242
298
  });
243
299
  }
244
- // Track last assistant message for auto-compaction check
245
- _lastAssistantMessage = undefined;
246
300
  /** Internal handler for agent events - shared by subscribe and reconnect */
247
301
  _handleAgentEvent = (event) => {
248
- // Create retry promise synchronously before queueing async processing.
249
- // Agent.emit() calls this handler synchronously, and prompt() calls waitForRetry()
250
- // as soon as agent.prompt() resolves. If _retryPromise is created only inside
251
- // _processAgentEvent, slow earlier queued events can delay agent_end processing
252
- // and waitForRetry() can miss the in-flight retry.
253
- this._createRetryPromiseForAgentEnd(event);
254
- this._agentEventQueue = this._agentEventQueue.then(() => this._processAgentEvent(event), () => this._processAgentEvent(event));
302
+ this._captureOutputThroughput(event);
303
+ const runSettlement = this._captureAgentRunSettlement(event);
304
+ const harnessAck = this._harnessPersistenceAckForEvent(event);
305
+ const processing = this._agentEventQueue.then(() => this._processAgentEventTracked(event, runSettlement, harnessAck), () => this._processAgentEventTracked(event, runSettlement, harnessAck));
306
+ this._agentEventQueue = processing;
307
+ if (harnessAck)
308
+ harnessAck.persistenceTail = processing;
255
309
  // Keep queue alive if an event handler fails
256
- this._agentEventQueue.catch(() => { });
310
+ processing.catch(() => { });
311
+ // SCRAMJET-DIVERGENCE: turn_end tool/prompt changes must settle before Agent snapshots
312
+ // live state for the next provider request (#524).
313
+ if (event.type === "turn_end")
314
+ return this._agentEventQueue;
257
315
  };
258
- _createRetryPromiseForAgentEnd(event) {
259
- if (event.type !== "agent_end" || this._retryPromise) {
316
+ // SCRAMJET-DIVERGENCE: Capture provider events synchronously and reset stale throughput lifecycles (#476).
317
+ _captureOutputThroughput(event) {
318
+ if (event.type === "message_start" && event.message.role === "assistant" && event.message.origin === "provider") {
319
+ this._outputThroughputGeneration = this._outputThroughputTracker.start({
320
+ provider: event.message.provider,
321
+ model: event.message.model,
322
+ });
260
323
  return;
261
324
  }
262
- const settings = this.settingsManager.getRetrySettings();
263
- if (!settings.enabled) {
325
+ const generation = this._outputThroughputGeneration;
326
+ if (generation === undefined)
264
327
  return;
328
+ if (event.type === "message_update" && event.message.role === "assistant") {
329
+ this._outputThroughputTracker.observe(generation, event.assistantMessageEvent);
265
330
  }
266
- const lastAssistant = this._findLastAssistantInMessages(event.messages);
267
- if (!lastAssistant || !this._isRetryableError(lastAssistant)) {
268
- return;
331
+ else if (event.type === "message_end" && event.message.role === "assistant") {
332
+ const sample = this._outputThroughputTracker.finalize(generation, event.message);
333
+ this._outputThroughputGeneration = undefined;
334
+ if (sample) {
335
+ this._latestOutputThroughputSample = sample;
336
+ this._outputThroughputHistory.submit(sample);
337
+ }
269
338
  }
270
- this._retryPromise = new Promise((resolve) => {
271
- this._retryResolve = resolve;
272
- });
273
339
  }
274
- _findLastAssistantInMessages(messages) {
275
- for (let i = messages.length - 1; i >= 0; i--) {
276
- const message = messages[i];
277
- if (message.role === "assistant") {
278
- return message;
340
+ _resetOutputThroughput() {
341
+ this._outputThroughputTracker.reset();
342
+ this._outputThroughputGeneration = undefined;
343
+ }
344
+ // SCRAMJET-DIVERGENCE: every Agent run owns persistence state and a one-shot settlement token (#553).
345
+ _captureAgentRunSettlement(event) {
346
+ if (event.type === "agent_start") {
347
+ // A retry continuation's run adopts the chain its originating prompt awaits; the chain stays
348
+ // bound to the retry phase until this start consumes it, however late Agent emits it.
349
+ const chain = this._retry?.phase === "continuing" ? this._retry.chain : createDeferred();
350
+ this._unsettledRetryChains.add(chain);
351
+ const run = { ...createDeferred(), chain };
352
+ this._activeAgentRunSettlement = run;
353
+ this._unsettledAgentRuns.add(run);
354
+ const reservation = this._pendingAgentRunStart;
355
+ if (reservation) {
356
+ this._pendingAgentRunStart = undefined;
357
+ reservation.run = run;
358
+ resolveDeferred(reservation, run);
359
+ }
360
+ return run;
361
+ }
362
+ const run = this._activeAgentRunSettlement;
363
+ if (event.type === "agent_end" && this._activeAgentRunSettlement === run) {
364
+ this._activeAgentRunSettlement = undefined;
365
+ }
366
+ return run;
367
+ }
368
+ _settleAgentRun(run, error) {
369
+ if (settleDeferred(run, error))
370
+ this._unsettledAgentRuns.delete(run);
371
+ }
372
+ _settleRetryChain(chain, error) {
373
+ if (settleDeferred(chain, error))
374
+ this._unsettledRetryChains.delete(chain);
375
+ }
376
+ _completeAgentRun(run, error) {
377
+ this._settleAgentRun(run, error);
378
+ this._settleRetryChain(run.chain, error);
379
+ }
380
+ _reserveAgentRunStart() {
381
+ if (this._pendingAgentRunStart)
382
+ throw new Error("An Agent prompt is already waiting to start.");
383
+ const reservation = createDeferred();
384
+ this._pendingAgentRunStart = reservation;
385
+ return reservation;
386
+ }
387
+ _rejectAgentRunStart(reservation, error) {
388
+ if (this._pendingAgentRunStart === reservation)
389
+ this._pendingAgentRunStart = undefined;
390
+ rejectDeferred(reservation, error);
391
+ }
392
+ // SCRAMJET-DIVERGENCE: harness-tool persisted settlement (#341). Synchronous ownership keeps queued
393
+ // invocation events attributed after early failures; public settlement awaits execution and the final persistence tail.
394
+ async _processAgentEventTracked(event, run, harnessAck) {
395
+ try {
396
+ await this._processAgentEvent(event, run, harnessAck !== undefined);
397
+ }
398
+ catch (err) {
399
+ const error = err instanceof Error ? err : new Error(String(err));
400
+ if (harnessAck) {
401
+ harnessAck.firstPersistenceError ??= error;
402
+ }
403
+ else if (run) {
404
+ if (event.type === "message_end" && event.message.role === "assistant") {
405
+ run.assistantPersistenceError = error;
406
+ }
407
+ this._releaseRetryForFailedRun(error);
408
+ this._completeAgentRun(run, error);
409
+ }
410
+ throw err;
411
+ }
412
+ }
413
+ _harnessPersistenceAckForEvent(event) {
414
+ let toolCallId;
415
+ switch (event.type) {
416
+ case "tool_execution_start":
417
+ case "tool_execution_update":
418
+ case "tool_execution_end":
419
+ toolCallId = event.toolCallId;
420
+ break;
421
+ case "message_start":
422
+ case "message_end": {
423
+ const message = event.message;
424
+ if (message.role === "toolResult") {
425
+ toolCallId = message.toolCallId;
426
+ }
427
+ else if (message.role === "assistant") {
428
+ for (const block of message.content) {
429
+ if (block.type === "toolCall" && this._harnessPersistenceAcks.has(block.id)) {
430
+ toolCallId = block.id;
431
+ break;
432
+ }
433
+ }
434
+ }
435
+ break;
279
436
  }
280
437
  }
281
- return undefined;
438
+ return toolCallId === undefined ? undefined : this._harnessPersistenceAcks.get(toolCallId);
282
439
  }
283
- async _processAgentEvent(event) {
440
+ async _processAgentEvent(event, run, fromHarnessInvocation = false) {
284
441
  // When a user message starts, check if it's from either queue and remove it BEFORE emitting
285
442
  // This ensures the UI sees the updated queue state
286
443
  if (event.type === "message_start" && event.message.role === "user") {
@@ -307,6 +464,8 @@ export class AgentSession {
307
464
  await this._emitExtensionEvent(event);
308
465
  // Notify all listeners
309
466
  this._emit(event);
467
+ if (this._disposed)
468
+ return;
310
469
  // Handle session persistence
311
470
  if (event.type === "message_end") {
312
471
  // Check if this is a custom message from extensions
@@ -317,51 +476,123 @@ export class AgentSession {
317
476
  else if (event.message.role === "user" ||
318
477
  event.message.role === "assistant" ||
319
478
  event.message.role === "toolResult") {
320
- // Regular LLM message - persist as SessionMessageEntry
321
- this.sessionManager.appendMessage(event.message);
322
- }
323
- // Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere
324
- // Track assistant message for auto-compaction (checked on agent_end)
325
- if (event.message.role === "assistant") {
326
- this._lastAssistantMessage = event.message;
327
- const assistantMsg = event.message;
328
- if (assistantMsg.stopReason !== "error") {
329
- this._overflowRecoveryAttempted = false;
479
+ // SCRAMJET-DIVERGENCE: retry settlement classifies the exact JSON-safe assistant snapshot
480
+ // that crossed the persistence boundary, never a later-mutated live Agent message (#553).
481
+ const persistedMessage = event.message.role === "assistant"
482
+ ? JSON.parse(JSON.stringify(event.message))
483
+ : event.message;
484
+ const messageEntryId = this.sessionManager.appendMessage(persistedMessage);
485
+ if (persistedMessage.role === "assistant" && !fromHarnessInvocation && run) {
486
+ run.persistedAssistantSnapshot = persistedMessage;
330
487
  }
331
- // Reset retry counter immediately on successful assistant response
332
- // This prevents accumulation across multiple LLM calls within a turn
333
- if (assistantMsg.stopReason !== "error" && this._retryAttempt > 0) {
334
- this._emit({
335
- type: "auto_retry_end",
336
- success: true,
337
- attempt: this._retryAttempt,
338
- });
339
- this._retryAttempt = 0;
488
+ if (event.message.role === "user") {
489
+ const sessionEntries = this._inputSessionEntries.get(event.message);
490
+ this._inputSessionEntries.delete(event.message);
491
+ // This try/catch observes only failures thrown synchronously by
492
+ // appendCustomEntry. On the buffered first-turn path (before any
493
+ // assistant message) _persist stores the entry in memory with no I/O,
494
+ // so only metadata *serialization* failures surface here; the deferred
495
+ // disk write happens later in the unguarded appendMessage(assistant)
496
+ // flush, whose throw is swallowed by the _agentEventQueue.catch on the
497
+ // event-handler chain and degrades exact restoration silently.
498
+ for (const entry of sessionEntries ?? []) {
499
+ try {
500
+ this.sessionManager.appendCustomEntry(entry.customType, entry.data, messageEntryId);
501
+ }
502
+ catch (err) {
503
+ this._extensionRunner.emitError({
504
+ extensionPath: `session-entry:${entry.customType}`,
505
+ event: "session_entry_persistence",
506
+ error: `Failed to persist attached input metadata after its user message; exact input restoration may be unavailable: ${err instanceof Error ? err.message : String(err)}`,
507
+ stack: err instanceof Error ? err.stack : undefined,
508
+ });
509
+ }
510
+ }
340
511
  }
341
512
  }
513
+ // Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere
514
+ if (event.message.role === "assistant" &&
515
+ event.message.stopReason !== "error" &&
516
+ event.message.stopReason !== "aborted" &&
517
+ !fromHarnessInvocation) {
518
+ this._overflowRecoveryAttempted = false;
519
+ if (this._retry)
520
+ this._retry = { ...this._retry, attempt: 0 };
521
+ }
342
522
  }
343
- // Check auto-retry and auto-compaction after agent completes
344
- if (event.type === "agent_end" && this._lastAssistantMessage) {
345
- const msg = this._lastAssistantMessage;
346
- this._lastAssistantMessage = undefined;
347
- // Check for retryable errors first (overloaded, rate limit, server errors)
348
- if (this._isRetryableError(msg)) {
349
- const didRetry = await this._handleRetryableError(msg);
523
+ if (event.type === "agent_end") {
524
+ if (this._disposed || !run)
525
+ return;
526
+ if (run.assistantPersistenceError) {
527
+ const error = run.assistantPersistenceError;
528
+ run.assistantPersistenceError = undefined;
529
+ run.persistedAssistantSnapshot = undefined;
530
+ this._completeAgentRun(run, error);
531
+ return;
532
+ }
533
+ const msg = run.persistedAssistantSnapshot;
534
+ run.persistedAssistantSnapshot = undefined;
535
+ if (!msg) {
536
+ this._releaseRetryForFailedRun(new Error("Retry continuation ended without an assistant message."));
537
+ this._completeAgentRun(run);
538
+ return;
539
+ }
540
+ if (msg.stopReason === "aborted" && this._retry) {
541
+ this._finishCancelledRetry("cancelled_during_continuation");
542
+ this._completeAgentRun(run);
543
+ return;
544
+ }
545
+ if (msg.stopReason !== "error") {
546
+ if (this._retry) {
547
+ const attempt = this._runRetryCount;
548
+ this._finishRetry({
549
+ schemaVersion: 1,
550
+ outcome: "succeeded",
551
+ attemptsCompleted: this._bounded(attempt),
552
+ cumulativeErrors: this._bounded(attempt),
553
+ }, { type: "auto_retry_end", success: true, attempt });
554
+ }
555
+ this._completeAgentRun(run);
556
+ await this._checkCompaction(msg);
557
+ return;
558
+ }
559
+ const classification = this._classifyRetry(msg);
560
+ if (classification.kind === "context_overflow") {
561
+ this._finishRetryWithoutSuccess({
562
+ schemaVersion: 1,
563
+ outcome: "not_attempted",
564
+ reason: "context_overflow_compaction",
565
+ evidence: "context_overflow",
566
+ }, msg);
567
+ this._completeAgentRun(run);
568
+ await this._checkCompaction(msg);
569
+ return;
570
+ }
571
+ const settings = this.settingsManager.getRetrySettings();
572
+ if (!settings.enabled) {
573
+ this._finishRetryWithoutSuccess({
574
+ schemaVersion: 1,
575
+ outcome: "not_attempted",
576
+ reason: "retry_disabled",
577
+ evidence: classification.evidence,
578
+ }, msg);
579
+ this._completeAgentRun(run);
580
+ await this._checkCompaction(msg);
581
+ return;
582
+ }
583
+ if (classification.kind === "retry") {
584
+ const didRetry = await this._handleRetryableError(msg, classification.evidence, run);
350
585
  if (didRetry)
351
- return; // Retry was initiated, don't proceed to compaction
586
+ return;
352
587
  }
353
- this._resolveRetry();
588
+ else {
589
+ const { kind: _kind, ...cause } = classification;
590
+ this._finishRetryWithoutSuccess({ schemaVersion: 1, outcome: "not_attempted", ...cause }, msg);
591
+ }
592
+ this._completeAgentRun(run);
354
593
  await this._checkCompaction(msg);
355
594
  }
356
595
  }
357
- /** Resolve the pending retry promise */
358
- _resolveRetry() {
359
- if (this._retryResolve) {
360
- this._retryResolve();
361
- this._retryResolve = undefined;
362
- this._retryPromise = undefined;
363
- }
364
- }
365
596
  /** Extract text content from a message */
366
597
  _getUserMessageText(message) {
367
598
  if (message.role !== "user")
@@ -519,7 +750,67 @@ export class AgentSession {
519
750
  * Call this when completely done with the session.
520
751
  */
521
752
  dispose() {
753
+ // SCRAMJET-DIVERGENCE: idempotent disposal revokes harness invocations and closes retry settlement (#341, #553).
754
+ // A second call must not re-reject already-settled acknowledgements or re-run teardown.
755
+ if (this._disposed)
756
+ return;
757
+ this._disposed = true;
758
+ this._resetOutputThroughput();
759
+ const retryDisposeError = new Error("AgentSession disposed before retry settlement completed.");
760
+ const unsettledRuns = [...this._unsettledAgentRuns];
761
+ const retry = this._retry;
762
+ const unclassifiedError = unsettledRuns.find((run) => run.persistedAssistantSnapshot?.stopReason === "error")?.persistedAssistantSnapshot;
763
+ if (retry)
764
+ this.agent.abort();
765
+ try {
766
+ if (retry) {
767
+ this._appendAutoRetryRecord({
768
+ schemaVersion: 1,
769
+ outcome: "cancelled",
770
+ reason: "session_disposed",
771
+ attempt: this._bounded(retry.attempt),
772
+ cumulativeErrors: this._bounded(this._runRetryCount),
773
+ });
774
+ }
775
+ else if (unclassifiedError) {
776
+ const classification = this._classifyRetry(unclassifiedError);
777
+ this._appendAutoRetryRecord({
778
+ schemaVersion: 1,
779
+ outcome: "not_attempted",
780
+ reason: "session_disposed",
781
+ evidence: classification.kind === "context_overflow" ? "context_overflow" : classification.evidence,
782
+ });
783
+ }
784
+ }
785
+ catch (error) {
786
+ this._reportRetryFailure(error);
787
+ }
788
+ if (retry?.phase === "backoff")
789
+ retry.controller.abort();
790
+ this._retry = undefined;
791
+ this._activeAgentRunSettlement = undefined;
792
+ const pendingAgentRunStart = this._pendingAgentRunStart;
793
+ if (pendingAgentRunStart)
794
+ this._rejectAgentRunStart(pendingAgentRunStart, retryDisposeError);
795
+ const pendingRetryContinuation = retry?.phase === "continuing" ? retry.chain : undefined;
522
796
  this._extensionRunner.invalidate("This extension ctx is stale after session replacement or reload. Do not use a captured pi or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload(). For newSession, fork, and switchSession, move post-replacement work into withSession and use the ctx passed to withSession. For reload, do not use the old ctx after await ctx.reload().");
797
+ // Reject pending persistence acknowledgements before revoking Agent-side invocations, so a caller
798
+ // awaiting invokeHarnessTool sees rejection rather than a promise that never settles. Already-
799
+ // running tool work may finish afterward; teardown revokes the old-session guarantee but cannot
800
+ // roll back arbitrary side effects.
801
+ const disposeError = new Error("AgentSession disposed before the harness tool invocation settled.");
802
+ for (const ack of this._harnessPersistenceAcks.values()) {
803
+ this._settleHarnessPersistenceAck(ack, disposeError);
804
+ }
805
+ this._harnessPersistenceAcks.clear();
806
+ this.agent.rejectUnsettledHarnessTools(disposeError);
807
+ const retryChains = new Set(this._unsettledRetryChains);
808
+ if (pendingRetryContinuation)
809
+ retryChains.add(pendingRetryContinuation);
810
+ for (const run of unsettledRuns)
811
+ this._settleAgentRun(run, retryDisposeError);
812
+ for (const chain of retryChains)
813
+ this._settleRetryChain(chain, retryDisposeError);
523
814
  this._disconnectFromAgent();
524
815
  this._eventListeners = [];
525
816
  cleanupSessionResources(this.sessionId);
@@ -547,9 +838,51 @@ export class AgentSession {
547
838
  get systemPrompt() {
548
839
  return flattenSystemPrompt(this.agent.state.systemPrompt);
549
840
  }
841
+ get liveOutputRate() {
842
+ const model = this.state.model;
843
+ const identity = this._outputThroughputTracker.activeIdentity;
844
+ if (!model || identity?.provider !== model.provider || identity.model !== model.id)
845
+ return undefined;
846
+ return this._outputThroughputTracker.liveRate();
847
+ }
848
+ get outputThroughputActive() {
849
+ return this._outputThroughputTracker.outputActive;
850
+ }
851
+ get outputThroughputGeneration() {
852
+ return this._outputThroughputTracker.generation;
853
+ }
854
+ get medianOutputRate() {
855
+ const model = this.state.model;
856
+ if (!model)
857
+ return undefined;
858
+ const history = this._outputThroughputHistory.snapshot();
859
+ const local = this._latestOutputThroughputSample;
860
+ if (local?.provider === model.provider &&
861
+ local.model === model.id &&
862
+ !history
863
+ .samples(model.provider, model.id)
864
+ .some((sample) => sample.provider === local.provider &&
865
+ sample.model === local.model &&
866
+ sample.responseModel === local.responseModel &&
867
+ sample.outputTokens === local.outputTokens &&
868
+ sample.durationMs === local.durationMs &&
869
+ sample.observedAt === local.observedAt)) {
870
+ history.add(local);
871
+ }
872
+ return history.median(model.provider, model.id);
873
+ }
874
+ get outputThroughputHistory() {
875
+ return this._outputThroughputHistory.snapshot();
876
+ }
877
+ refreshOutputThroughputHistory() {
878
+ return this._outputThroughputHistory.refresh();
879
+ }
880
+ flushOutputThroughputHistory() {
881
+ return this._outputThroughputHistory.flush();
882
+ }
550
883
  /** Current retry attempt (0 if not retrying) */
551
884
  get retryAttempt() {
552
- return this._retryAttempt;
885
+ return this._retry?.attempt ?? 0;
553
886
  }
554
887
  /**
555
888
  * Get the names of currently active tools.
@@ -582,6 +915,14 @@ export class AgentSession {
582
915
  const tools = [];
583
916
  const validToolNames = [];
584
917
  for (const name of toolNames) {
918
+ // SCRAMJET-DIVERGENCE: harness-only tools are never placed in the provider-visible tool
919
+ // set, even when explicitly requested (#244). This is the single choke point for
920
+ // `agent.state.tools`, so filtering here structurally guarantees a harness-only tool can
921
+ // never masquerade as a model-callable one — while it stays resolvable in `_toolRegistry`
922
+ // for `invokeHarnessTool`.
923
+ if (this._toolDefinitions.get(name)?.definition.activation === "harness-only") {
924
+ continue;
925
+ }
585
926
  const tool = this._toolRegistry.get(name);
586
927
  if (tool) {
587
928
  tools.push(tool);
@@ -592,6 +933,106 @@ export class AgentSession {
592
933
  // Rebuild base system prompt with new tool set
593
934
  this._rebuildSystemPrompt(validToolNames);
594
935
  }
936
+ // SCRAMJET-DIVERGENCE: harness-tool invocation (#244), persisted-settlement boundary (#341).
937
+ /**
938
+ * Execute a registered tool as a harness-originated call (not requested by the model).
939
+ *
940
+ * Resolves the wrapped tool from the full registry — including `"harness-only"` tools that are
941
+ * never provider-visible — so its `execute` still receives the extension context and its
942
+ * `tool_call`/`tool_result` hooks still fire. Delegates to {@link Agent.runHarnessTool}, which
943
+ * runs the call through the real prepare/execute/finalize pipeline: identical live
944
+ * `tool_execution_*`/message events, persisted session entries, and extension hooks, but no
945
+ * run/turn framing. The idle-vs-mid-run branch lives in `runHarnessTool` (keyed on the agent's
946
+ * active run, which tracks `isStreaming`): idle calls execute immediately, mid-run calls queue
947
+ * and drain before the next intra-run LLM call.
948
+ *
949
+ * **Persisted settlement (#341).** This is a strictly stronger boundary than
950
+ * {@link Agent.runHarnessTool}: the returned promise resolves only after (1) the Agent-core
951
+ * pipeline fulfills *and* (2) `_processAgentEvent` has persisted the matching tool-result
952
+ * `message_end` via `SessionManager.appendMessage`. Because the event queue preserves order,
953
+ * that also proves the preceding synthetic assistant tool-call message was persisted first. It
954
+ * rejects on Agent-core failure, on failure to process/persist a matching event, on disposal
955
+ * before settlement, on a post-disposal call, on an unknown tool, or on a duplicate explicit
956
+ * tool-call id that is already pending. **Rejection means the promised pipeline did not complete —
957
+ * it does not prove artifact absence** (Agent state and persistence may have partially or fully
958
+ * completed before a late failure), so callers must not blindly retry. A tool's own `execute()`
959
+ * error is *not* a rejection: it becomes a normal `isError: true` result and the promise resolves
960
+ * after that error-result pipeline is persisted.
961
+ *
962
+ * **Reentrancy.** As with `runHarnessTool`, do not `await` this from work whose return is required
963
+ * for the Agent to reach the drain point or for the event queue to persist the matching result.
964
+ */
965
+ async invokeHarnessTool(name, args, options) {
966
+ if (this._disposed) {
967
+ throw new Error(`Cannot invoke harness tool "${name}": the session has been disposed.`);
968
+ }
969
+ const tool = this._toolRegistry.get(name);
970
+ if (!tool) {
971
+ throw new Error(`Cannot invoke harness tool "${name}": no tool with that name is registered.`);
972
+ }
973
+ const toolCallId = this._allocateHarnessToolCallId(options?.toolCallId);
974
+ // Register before Agent execution can emit events. The coordinator retains attribution until Agent
975
+ // execution and the invocation's final queued persistence task both settle.
976
+ const ack = this._registerHarnessPersistenceAck(toolCallId);
977
+ void this._completeHarnessToolInvocation(this.agent.runHarnessTool(tool, args, { toolCallId }), ack);
978
+ try {
979
+ await ack.promise;
980
+ }
981
+ finally {
982
+ // Identity-checked cleanup: only drop the entry if it is still this invocation's ack (disposal
983
+ // may have cleared the map, and a later same-id invocation must not have its entry removed).
984
+ if (this._harnessPersistenceAcks.get(toolCallId) === ack) {
985
+ this._harnessPersistenceAcks.delete(toolCallId);
986
+ }
987
+ }
988
+ }
989
+ // SCRAMJET-DIVERGENCE: harness-tool persisted-settlement (#341). Allocate the tool-call id used as
990
+ // the acknowledgement key: reject a duplicate explicit id that is still pending, retry a generated
991
+ // id on the (astronomically unlikely) collision with a pending one.
992
+ _allocateHarnessToolCallId(explicit) {
993
+ if (explicit !== undefined) {
994
+ // Validate at the public trust boundary (invokeHarnessTool is on ExtensionAPI): an explicit
995
+ // id flows straight into the synthetic assistant message and on to the provider, so it must
996
+ // meet the provider-safe shape generateHarnessToolCallId guarantees (Anthropic's
997
+ // ^[a-zA-Z0-9_-]+$, bounded length).
998
+ if (!/^[a-zA-Z0-9_-]{1,64}$/.test(explicit)) {
999
+ throw new Error(`Cannot invoke harness tool with tool-call id "${explicit}": ids must be 1-64 characters of [a-zA-Z0-9_-].`);
1000
+ }
1001
+ if (this._harnessPersistenceAcks.has(explicit)) {
1002
+ throw new Error(`Cannot invoke harness tool with tool-call id "${explicit}": an invocation with that id is already pending.`);
1003
+ }
1004
+ return explicit;
1005
+ }
1006
+ let id = generateHarnessToolCallId();
1007
+ while (this._harnessPersistenceAcks.has(id)) {
1008
+ id = generateHarnessToolCallId();
1009
+ }
1010
+ return id;
1011
+ }
1012
+ async _completeHarnessToolInvocation(execution, ack) {
1013
+ let executionError;
1014
+ try {
1015
+ await execution;
1016
+ }
1017
+ catch (error) {
1018
+ executionError = error instanceof Error ? error : new Error(String(error));
1019
+ }
1020
+ try {
1021
+ await ack.persistenceTail;
1022
+ }
1023
+ catch { }
1024
+ this._settleHarnessPersistenceAck(ack, ack.firstPersistenceError ?? executionError);
1025
+ }
1026
+ _settleHarnessPersistenceAck(ack, error) {
1027
+ settleDeferred(ack, error);
1028
+ }
1029
+ // SCRAMJET-DIVERGENCE: harness-tool persisted-settlement (#341). Register a deferred acknowledgement
1030
+ // keyed by tool-call id before any invocation event can be emitted.
1031
+ _registerHarnessPersistenceAck(toolCallId) {
1032
+ const ack = { ...createDeferred(), persistenceTail: Promise.resolve() };
1033
+ this._harnessPersistenceAcks.set(toolCallId, ack);
1034
+ return ack;
1035
+ }
595
1036
  /** Whether compaction or branch summarization is currently running */
596
1037
  get isCompacting() {
597
1038
  return (this._autoCompactionAbortController !== undefined ||
@@ -656,6 +1097,19 @@ export class AgentSession {
656
1097
  }
657
1098
  return Array.from(unique);
658
1099
  }
1100
+ _applyRunPromptComposition() {
1101
+ switch (this._runPromptComposition.kind) {
1102
+ case "generated-base":
1103
+ this.agent.state.systemPrompt = this._baseSystemPromptSections.slice();
1104
+ break;
1105
+ case "contributed-sections":
1106
+ this.agent.state.systemPrompt = spliceContributedSections(this._baseSystemPromptSections, this._runPromptComposition.sections);
1107
+ break;
1108
+ case "authoritative-string":
1109
+ this.agent.state.systemPrompt = this._runPromptComposition.systemPrompt;
1110
+ break;
1111
+ }
1112
+ }
659
1113
  /** Rebuild `_baseSystemPromptSections` from the current resources and tool set and apply it to agent state. */
660
1114
  _rebuildSystemPrompt(toolNames) {
661
1115
  const validToolNames = toolNames.filter((name) => this._toolRegistry.has(name));
@@ -676,6 +1130,7 @@ export class AgentSession {
676
1130
  const appendSystemPrompt = loaderAppendSystemPrompt.length > 0 ? loaderAppendSystemPrompt.join("\n\n") : undefined;
677
1131
  const loadedSkills = this._resourceLoader.getSkills().skills;
678
1132
  const loadedContextFiles = this._resourceLoader.getAgentsFiles().agentsFiles;
1133
+ // SCRAMJET-DIVERGENCE: pass session file path for volatile environment fact (issue 348)
679
1134
  this._baseSystemPromptOptions = {
680
1135
  cwd: this._cwd,
681
1136
  skills: loadedSkills,
@@ -685,9 +1140,10 @@ export class AgentSession {
685
1140
  selectedTools: validToolNames,
686
1141
  toolSnippets,
687
1142
  promptGuidelines,
1143
+ sessionFile: this.sessionManager.getSessionFile(),
688
1144
  };
689
1145
  this._baseSystemPromptSections = buildSystemPromptSections(this._baseSystemPromptOptions);
690
- this.agent.state.systemPrompt = this._baseSystemPromptSections.slice();
1146
+ this._applyRunPromptComposition();
691
1147
  }
692
1148
  // =========================================================================
693
1149
  // Prompting
@@ -705,6 +1161,8 @@ export class AgentSession {
705
1161
  const expandPromptTemplates = options?.expandPromptTemplates ?? true;
706
1162
  const preflightResult = options?.preflightResult;
707
1163
  let messages;
1164
+ let inputSessionEntries;
1165
+ let acceptInput;
708
1166
  try {
709
1167
  // Handle extension commands first (execute immediately, even during streaming)
710
1168
  // Extension commands manage their own LLM interaction via pi.sendMessage()
@@ -716,6 +1174,7 @@ export class AgentSession {
716
1174
  return;
717
1175
  }
718
1176
  }
1177
+ this._assertRetryNotOwningIdleAgent();
719
1178
  // Emit input event for extension interception (before skill/template expansion)
720
1179
  let currentText = text;
721
1180
  let currentImages = options?.images;
@@ -728,6 +1187,8 @@ export class AgentSession {
728
1187
  if (inputResult.action === "transform") {
729
1188
  currentText = inputResult.text;
730
1189
  currentImages = inputResult.images ?? currentImages;
1190
+ inputSessionEntries = inputResult.sessionEntries;
1191
+ acceptInput = inputResult.onAccepted;
731
1192
  }
732
1193
  }
733
1194
  // Expand skill commands (/skill:name args) and prompt templates (/template args)
@@ -743,11 +1204,12 @@ export class AgentSession {
743
1204
  "deliverAs when calling dispatchUserInput() or sendUserMessage() — to queue the message.");
744
1205
  }
745
1206
  if (options.streamingBehavior === "followUp") {
746
- await this._queueFollowUp(expandedText, currentImages);
1207
+ await this._queueFollowUp(expandedText, currentImages, inputSessionEntries);
747
1208
  }
748
1209
  else {
749
- await this._queueSteer(expandedText, currentImages);
1210
+ await this._queueSteer(expandedText, currentImages, inputSessionEntries);
750
1211
  }
1212
+ acceptInput?.();
751
1213
  preflightResult?.(true);
752
1214
  return;
753
1215
  }
@@ -778,16 +1240,21 @@ export class AgentSession {
778
1240
  if (currentImages) {
779
1241
  userContent.push(...currentImages);
780
1242
  }
781
- messages.push({
1243
+ const userMessage = {
782
1244
  role: "user",
783
1245
  content: userContent,
784
1246
  timestamp: Date.now(),
785
- });
1247
+ };
1248
+ messages.push(userMessage);
1249
+ if (inputSessionEntries?.length) {
1250
+ this._inputSessionEntries.set(userMessage, inputSessionEntries);
1251
+ }
786
1252
  // Inject any pending "nextTurn" messages as context alongside the user message
787
1253
  for (const msg of this._pendingNextTurnMessages) {
788
1254
  messages.push(msg);
789
1255
  }
790
1256
  this._pendingNextTurnMessages = [];
1257
+ this._runPromptComposition = { kind: "generated-base" };
791
1258
  // Emit before_agent_start extension event
792
1259
  const result = await this._extensionRunner.emitBeforeAgentStart(expandedText, currentImages, this._baseSystemPromptSections, this._baseSystemPromptOptions);
793
1260
  // Add all custom messages from extensions
@@ -807,15 +1274,18 @@ export class AgentSession {
807
1274
  // replacement is authoritative (even an empty string): it wins for the
808
1275
  // whole prompt and any contributed sections are dropped for this turn.
809
1276
  if (result?.systemPrompt !== undefined) {
810
- this.agent.state.systemPrompt = result.systemPrompt;
1277
+ this._runPromptComposition = {
1278
+ kind: "authoritative-string",
1279
+ systemPrompt: result.systemPrompt,
1280
+ };
811
1281
  }
812
1282
  else if (result?.systemPromptSections) {
813
- this.agent.state.systemPrompt = spliceContributedSections(this._baseSystemPromptSections, result.systemPromptSections);
814
- }
815
- else {
816
- // Ensure we're using the base prompt (in case previous turn had modifications)
817
- this.agent.state.systemPrompt = this._baseSystemPromptSections.slice();
1283
+ this._runPromptComposition = {
1284
+ kind: "contributed-sections",
1285
+ sections: result.systemPromptSections.map((section) => ({ ...section })),
1286
+ };
818
1287
  }
1288
+ this._applyRunPromptComposition();
819
1289
  }
820
1290
  catch (error) {
821
1291
  preflightResult?.(false);
@@ -824,9 +1294,9 @@ export class AgentSession {
824
1294
  if (!messages) {
825
1295
  return;
826
1296
  }
1297
+ acceptInput?.();
827
1298
  preflightResult?.(true);
828
- await this.agent.prompt(messages);
829
- await this.waitForRetry();
1299
+ await this._runAgentPrompt(messages);
830
1300
  }
831
1301
  /**
832
1302
  * Try to execute an extension command. Returns true if command was found and executed.
@@ -923,34 +1393,42 @@ export class AgentSession {
923
1393
  /**
924
1394
  * Internal: Queue a steering message (already expanded, no extension command check).
925
1395
  */
926
- async _queueSteer(text, images) {
1396
+ async _queueSteer(text, images, sessionEntries) {
927
1397
  this._steeringMessages.push(text);
928
1398
  this._emitQueueUpdate();
929
1399
  const content = [{ type: "text", text }];
930
1400
  if (images) {
931
1401
  content.push(...images);
932
1402
  }
933
- this.agent.steer({
1403
+ const message = {
934
1404
  role: "user",
935
1405
  content,
936
1406
  timestamp: Date.now(),
937
- });
1407
+ };
1408
+ if (sessionEntries?.length) {
1409
+ this._inputSessionEntries.set(message, sessionEntries);
1410
+ }
1411
+ this.agent.steer(message);
938
1412
  }
939
1413
  /**
940
1414
  * Internal: Queue a follow-up message (already expanded, no extension command check).
941
1415
  */
942
- async _queueFollowUp(text, images) {
1416
+ async _queueFollowUp(text, images, sessionEntries) {
943
1417
  this._followUpMessages.push(text);
944
1418
  this._emitQueueUpdate();
945
1419
  const content = [{ type: "text", text }];
946
1420
  if (images) {
947
1421
  content.push(...images);
948
1422
  }
949
- this.agent.followUp({
1423
+ const message = {
950
1424
  role: "user",
951
1425
  content,
952
1426
  timestamp: Date.now(),
953
- });
1427
+ };
1428
+ if (sessionEntries?.length) {
1429
+ this._inputSessionEntries.set(message, sessionEntries);
1430
+ }
1431
+ this.agent.followUp(message);
954
1432
  }
955
1433
  /**
956
1434
  * Throw an error if the text is an extension command.
@@ -996,7 +1474,7 @@ export class AgentSession {
996
1474
  }
997
1475
  }
998
1476
  else if (options?.triggerTurn) {
999
- await this.agent.prompt(appMessage);
1477
+ await this._runAgentPrompt(appMessage);
1000
1478
  }
1001
1479
  else {
1002
1480
  this.agent.state.messages.push(appMessage);
@@ -1093,6 +1571,7 @@ export class AgentSession {
1093
1571
  */
1094
1572
  async abort() {
1095
1573
  this.abortRetry();
1574
+ this._resetOutputThroughput();
1096
1575
  this.agent.abort();
1097
1576
  await this.agent.waitForIdle();
1098
1577
  }
@@ -1280,6 +1759,7 @@ export class AgentSession {
1280
1759
  * @param customInstructions Optional instructions for the compaction summary
1281
1760
  */
1282
1761
  async compact(customInstructions) {
1762
+ this._resetOutputThroughput();
1283
1763
  this._disconnectFromAgent();
1284
1764
  await this.abort();
1285
1765
  this._compactionAbortController = new AbortController();
@@ -1417,6 +1897,7 @@ export class AgentSession {
1417
1897
  // Skip if message was aborted (user cancelled) - unless skipAbortedCheck is false
1418
1898
  if (skipAbortedCheck && assistantMessage.stopReason === "aborted")
1419
1899
  return;
1900
+ // SCRAMJET-DIVERGENCE: Total context and independent input limits are separate constraints.
1420
1901
  const contextWindow = this.model?.contextWindow ?? 0;
1421
1902
  // Skip overflow check if the message came from a different model.
1422
1903
  // This handles the case where user switched from a smaller-context model (e.g. opus)
@@ -1432,7 +1913,8 @@ export class AgentSession {
1432
1913
  return;
1433
1914
  }
1434
1915
  // Case 1: Overflow - LLM returned context overflow error
1435
- if (sameModel && isContextOverflow(assistantMessage, contextWindow)) {
1916
+ if (sameModel &&
1917
+ isContextOverflow(assistantMessage, Math.min(contextWindow, this.model?.maxInputTokens ?? Infinity))) {
1436
1918
  if (this._overflowRecoveryAttempted) {
1437
1919
  this._emit({
1438
1920
  type: "compaction_end",
@@ -1477,7 +1959,8 @@ export class AgentSession {
1477
1959
  else {
1478
1960
  contextTokens = calculateContextTokens(assistantMessage.usage);
1479
1961
  }
1480
- if (shouldCompact(contextTokens, contextWindow, settings)) {
1962
+ if (shouldCompact(contextTokens, contextWindow, settings) ||
1963
+ (contextWindow > settings.reserveTokens && contextTokens > (this.model?.maxInputTokens ?? Infinity))) {
1481
1964
  await this._runAutoCompaction("threshold", false);
1482
1965
  }
1483
1966
  }
@@ -1773,6 +2256,8 @@ export class AgentSession {
1773
2256
  getActiveTools: () => this.getActiveToolNames(),
1774
2257
  getAllTools: () => this.getAllTools(),
1775
2258
  setActiveTools: (toolNames) => this.setActiveToolsByName(toolNames),
2259
+ // SCRAMJET-DIVERGENCE: harness-tool invocation (#244).
2260
+ invokeHarnessTool: (name, args, options) => this.invokeHarnessTool(name, args, options),
1776
2261
  refreshTools: () => this._refreshToolRegistry(),
1777
2262
  getCommands,
1778
2263
  setModel: async (model) => {
@@ -1785,6 +2270,7 @@ export class AgentSession {
1785
2270
  setThinkingLevel: (level) => this.setThinkingLevel(level),
1786
2271
  }, {
1787
2272
  getModel: () => this.model,
2273
+ getScopedModels: () => this.scopedModels,
1788
2274
  isIdle: () => !this.isStreaming,
1789
2275
  getSignal: () => this.agent.signal,
1790
2276
  abort: () => this.abort(),
@@ -1931,10 +2417,21 @@ export class AgentSession {
1931
2417
  }
1932
2418
  async reload() {
1933
2419
  const previousFlagValues = this._extensionRunner.getFlagValues();
1934
- await emitSessionShutdownEvent(this._extensionRunner, { type: "session_shutdown", reason: "reload" });
2420
+ // SCRAMJET-DIVERGENCE: the Scramjet builtin is a required product component, so reload must validate
2421
+ // the replacement loader before any irreversible runtime mutation. settingsManager.reload() runs first —
2422
+ // it commits fresh settings before the loader can throw, but the re-read is idempotent, so a failed reload
2423
+ // leaves the runtime itself untouched. It also runs first
2424
+ // because the loader's path resolution reads live settings (the loader defers its own settings/source-map
2425
+ // commit until after the builtin init succeeds, so on this path the shared manager is intentionally read
2426
+ // twice). resourceLoader.reload() then re-runs the required
2427
+ // builtin and throws RequiredBuiltinInitError on failure — before session_shutdown is emitted and before
2428
+ // resetApiProviders(). A throwing builtin therefore leaves the current runner, resources, and providers
2429
+ // intact and fires no session_shutdown; only a validated candidate proceeds to teardown and rebuild.
1935
2430
  await this.settingsManager.reload();
1936
- resetApiProviders();
1937
2431
  await this._resourceLoader.reload();
2432
+ await emitSessionShutdownEvent(this._extensionRunner, { type: "session_shutdown", reason: "reload" });
2433
+ resetApiProviders();
2434
+ this._runPromptComposition = { kind: "generated-base" };
1938
2435
  this._buildRuntime({
1939
2436
  activeToolNames: this.getActiveToolNames(),
1940
2437
  flagValues: previousFlagValues,
@@ -1952,114 +2449,311 @@ export class AgentSession {
1952
2449
  // =========================================================================
1953
2450
  // Auto-Retry
1954
2451
  // =========================================================================
1955
- /**
1956
- * Check if an error is retryable (overloaded, rate limit, server errors).
1957
- * Context overflow errors are NOT retryable (handled by compaction instead).
1958
- */
1959
- _isRetryableError(message) {
1960
- if (message.stopReason !== "error" || !message.errorMessage)
1961
- return false;
1962
- // Context overflow is handled by compaction, not retry
2452
+ // SCRAMJET-DIVERGENCE: persisted post-extension snapshots authoritatively classify and settle retries (#553).
2453
+ _classifyRetry(message) {
1963
2454
  const contextWindow = this.model?.contextWindow ?? 0;
1964
- if (isContextOverflow(message, contextWindow))
1965
- return false;
1966
- const err = message.errorMessage;
1967
- // Match: overloaded_error, provider returned error, rate limit, 429, 500, 502, 503, 504, service unavailable, network/connection errors (including connection lost), WebSocket transport closes/errors, fetch failed, premature stream endings, HTTP/2 closed before response, terminated, retry delay exceeded
1968
- return /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|stream ended before message_stop|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i.test(err);
2455
+ if (isContextOverflow(message, Math.min(contextWindow, this.model?.maxInputTokens ?? Infinity))) {
2456
+ return { kind: "context_overflow" };
2457
+ }
2458
+ const structured = validateResponsesProviderFailure(message.diagnostics);
2459
+ if (structured.status === "valid") {
2460
+ if (structured.retryDisposition === "transient")
2461
+ return { kind: "retry", evidence: "provider_failure" };
2462
+ return {
2463
+ kind: "do_not_retry",
2464
+ reason: structured.retryDisposition === "non_transient" ? "structured_non_transient" : "structured_unknown",
2465
+ evidence: "provider_failure",
2466
+ };
2467
+ }
2468
+ if (structured.status === "malformed") {
2469
+ return { kind: "do_not_retry", reason: "malformed_provider_diagnostic", evidence: "none" };
2470
+ }
2471
+ if (structured.status === "duplicate") {
2472
+ return { kind: "do_not_retry", reason: "duplicate_provider_diagnostic", evidence: "none" };
2473
+ }
2474
+ if (!message.errorMessage) {
2475
+ return { kind: "do_not_retry", reason: "missing_error_evidence", evidence: "none" };
2476
+ }
2477
+ if (this._isLegacyRetryableError(message.errorMessage))
2478
+ return { kind: "retry", evidence: "legacy_text" };
2479
+ return { kind: "do_not_retry", reason: "legacy_non_retryable", evidence: "legacy_text" };
2480
+ }
2481
+ _isLegacyRetryableError(errorMessage) {
2482
+ return /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|stream ended before message_stop|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i.test(errorMessage);
2483
+ }
2484
+ _bounded(value) {
2485
+ return Math.min(65_535, Math.max(0, Number.isFinite(value) ? Math.trunc(value) : 65_535));
2486
+ }
2487
+ _appendAutoRetryRecord(record) {
2488
+ this.sessionManager.appendCustomEntry("coding-agent:auto-retry", record);
1969
2489
  }
1970
2490
  /**
1971
- * Handle retryable errors with exponential backoff.
1972
- * @returns true if retry was initiated, false if max retries exceeded or disabled
2491
+ * Persist a terminal record, emit `auto_retry_end` when a retry phase was active, and clear the phase.
2492
+ * The phase is released before persistence is attempted so a persistence failure can never strand it.
1973
2493
  */
1974
- async _handleRetryableError(message) {
2494
+ _finishRetry(record, end) {
2495
+ const retry = this._retry;
2496
+ this._retry = undefined;
2497
+ try {
2498
+ this._appendAutoRetryRecord(record);
2499
+ }
2500
+ catch (error) {
2501
+ if (retry) {
2502
+ this._emit({
2503
+ type: "auto_retry_end",
2504
+ success: false,
2505
+ attempt: end?.attempt ?? retry.attempt,
2506
+ finalError: "Retry outcome persistence failed",
2507
+ });
2508
+ }
2509
+ throw this._reportRetryFailure(error);
2510
+ }
2511
+ if (retry && end)
2512
+ this._emit(end);
2513
+ }
2514
+ _finishRetryWithoutSuccess(record, message) {
2515
+ const retry = this._retry;
2516
+ if (retry?.attempt === 0) {
2517
+ // The continuation already produced a successful message, so the retry recovered and this
2518
+ // later failure is a fresh, non-retried error; close the retry as succeeded first.
2519
+ const attempt = this._runRetryCount;
2520
+ this._finishRetry({
2521
+ schemaVersion: 1,
2522
+ outcome: "succeeded",
2523
+ attemptsCompleted: this._bounded(attempt),
2524
+ cumulativeErrors: this._bounded(attempt),
2525
+ }, { type: "auto_retry_end", success: true, attempt });
2526
+ this._finishRetry(record);
2527
+ return;
2528
+ }
2529
+ this._finishRetry(record, retry
2530
+ ? { type: "auto_retry_end", success: false, attempt: retry.attempt, finalError: message.errorMessage }
2531
+ : undefined);
2532
+ }
2533
+ /** A run that ends without a classifiable snapshot cannot continue the retry; close it so nothing stays wedged. */
2534
+ _releaseRetryForFailedRun(error) {
2535
+ const retry = this._retry;
2536
+ if (!retry)
2537
+ return;
2538
+ try {
2539
+ this._finishRetry({
2540
+ schemaVersion: 1,
2541
+ outcome: "failed",
2542
+ reason: "run_failed",
2543
+ attempt: this._bounded(retry.attempt),
2544
+ cumulativeErrors: this._bounded(this._runRetryCount),
2545
+ }, { type: "auto_retry_end", success: false, attempt: retry.attempt, finalError: error.message });
2546
+ }
2547
+ catch {
2548
+ // Already reported and the phase is released; the run settles with its own error.
2549
+ }
2550
+ }
2551
+ _reportRetryFailure(error) {
2552
+ const normalized = error instanceof Error ? error : new Error(String(error));
2553
+ this._extensionRunner.emitError({
2554
+ extensionPath: "agent-session:auto-retry",
2555
+ event: "retry_settlement",
2556
+ error: normalized.message,
2557
+ stack: normalized.stack,
2558
+ });
2559
+ return normalized;
2560
+ }
2561
+ async _handleRetryableError(message, evidence, run) {
1975
2562
  const settings = this.settingsManager.getRetrySettings();
1976
- if (!settings.enabled) {
1977
- this._resolveRetry();
2563
+ const attempt = (this._retry?.attempt ?? 0) + 1;
2564
+ this._runRetryCount++;
2565
+ const cumulativeCap = settings.maxRetries * 2;
2566
+ if (attempt > settings.maxRetries) {
2567
+ const attemptsCompleted = attempt - 1;
2568
+ this._finishRetry({
2569
+ schemaVersion: 1,
2570
+ outcome: "exhausted",
2571
+ reason: "attempt_limit",
2572
+ attemptsCompleted: this._bounded(attemptsCompleted),
2573
+ maxAttempts: this._bounded(settings.maxRetries),
2574
+ cumulativeErrors: this._bounded(this._runRetryCount),
2575
+ }, { type: "auto_retry_end", success: false, attempt: attemptsCompleted, finalError: message.errorMessage });
1978
2576
  return false;
1979
2577
  }
1980
- // Retry promise is created synchronously in _handleAgentEvent for agent_end.
1981
- // Keep a defensive fallback here in case a future refactor bypasses that path.
1982
- if (!this._retryPromise) {
1983
- this._retryPromise = new Promise((resolve) => {
1984
- this._retryResolve = resolve;
1985
- });
1986
- }
1987
- this._retryAttempt++;
1988
- if (this._retryAttempt > settings.maxRetries) {
1989
- // Max retries exceeded, emit final failure and reset
1990
- this._emit({
2578
+ if (this._runRetryCount > cumulativeCap) {
2579
+ const attemptsCompleted = this._runRetryCount - 1;
2580
+ this._finishRetry({
2581
+ schemaVersion: 1,
2582
+ outcome: "exhausted",
2583
+ reason: "cumulative_limit",
2584
+ attemptsCompleted: this._bounded(attemptsCompleted),
2585
+ maxAttempts: this._bounded(settings.maxRetries),
2586
+ cumulativeErrors: this._bounded(this._runRetryCount),
2587
+ }, {
1991
2588
  type: "auto_retry_end",
1992
2589
  success: false,
1993
- attempt: this._retryAttempt - 1,
1994
- finalError: message.errorMessage,
2590
+ attempt: attemptsCompleted,
2591
+ finalError: `Repeated retry failures (${attemptsCompleted} total attempts this prompt). ${message.errorMessage ?? ""}`.trim(),
1995
2592
  });
1996
- this._retryAttempt = 0;
1997
- this._resolveRetry(); // Resolve so waitForRetry() completes
1998
2593
  return false;
1999
2594
  }
2000
- const delayMs = settings.baseDelayMs * 2 ** (this._retryAttempt - 1);
2595
+ const delayMs = settings.baseDelayMs * 2 ** (attempt - 1);
2596
+ const previous = this._retry;
2597
+ this._retry = undefined;
2598
+ try {
2599
+ this._appendAutoRetryRecord({
2600
+ schemaVersion: 1,
2601
+ outcome: "scheduled",
2602
+ evidence,
2603
+ attempt: this._bounded(attempt),
2604
+ maxAttempts: this._bounded(settings.maxRetries),
2605
+ cumulativeErrors: this._bounded(this._runRetryCount),
2606
+ delayMs: this._bounded(delayMs),
2607
+ });
2608
+ }
2609
+ catch (error) {
2610
+ if (previous) {
2611
+ this._emit({
2612
+ type: "auto_retry_end",
2613
+ success: false,
2614
+ attempt: previous.attempt,
2615
+ finalError: "Retry outcome persistence failed",
2616
+ });
2617
+ }
2618
+ throw this._reportRetryFailure(error);
2619
+ }
2620
+ const controller = new AbortController();
2621
+ const backoff = { phase: "backoff", attempt, controller, run };
2622
+ this._retry = backoff;
2001
2623
  this._emit({
2002
2624
  type: "auto_retry_start",
2003
- attempt: this._retryAttempt,
2625
+ attempt,
2004
2626
  maxAttempts: settings.maxRetries,
2005
2627
  delayMs,
2006
2628
  errorMessage: message.errorMessage || "Unknown error",
2629
+ cumulativeErrors: this._runRetryCount,
2007
2630
  });
2008
- // Remove error message from agent state (keep in session for history)
2631
+ if (controller.signal.aborted || this._disposed) {
2632
+ return this._finishCancelledRetry("cancelled_during_backoff", backoff);
2633
+ }
2009
2634
  const messages = this.agent.state.messages;
2010
2635
  if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
2011
2636
  this.agent.state.messages = messages.slice(0, -1);
2012
2637
  }
2013
- // Wait with exponential backoff (abortable)
2014
- this._retryAbortController = new AbortController();
2015
2638
  try {
2016
- await sleep(delayMs, this._retryAbortController.signal);
2639
+ await sleep(delayMs, controller.signal);
2640
+ await sleep(0, controller.signal);
2017
2641
  }
2018
2642
  catch {
2019
- // Aborted during sleep - emit end event so UI can clean up
2020
- const attempt = this._retryAttempt;
2021
- this._retryAttempt = 0;
2022
- this._retryAbortController = undefined;
2023
- this._emit({
2024
- type: "auto_retry_end",
2025
- success: false,
2026
- attempt,
2027
- finalError: "Retry cancelled",
2028
- });
2029
- this._resolveRetry();
2030
- return false;
2031
- }
2032
- this._retryAbortController = undefined;
2033
- // Retry via continue() - use setTimeout to break out of event handler chain
2034
- setTimeout(() => {
2035
- this.agent.continue().catch(() => {
2036
- // Retry failed - will be caught by next agent_end
2037
- });
2038
- }, 0);
2643
+ return this._finishCancelledRetry("cancelled_during_backoff", backoff);
2644
+ }
2645
+ if (controller.signal.aborted || this._disposed) {
2646
+ return this._finishCancelledRetry("cancelled_during_backoff", backoff);
2647
+ }
2648
+ // Hand the chain to the continuation before `continue()`; the run that eventually emits
2649
+ // `agent_start` adopts it (see `_captureAgentRunSettlement`), however long Agent defers that start.
2650
+ const continuing = { phase: "continuing", attempt, chain: run.chain };
2651
+ this._retry = continuing;
2652
+ const continuation = this.agent.continue();
2653
+ this._settleAgentRun(run);
2654
+ continuation.catch((error) => {
2655
+ if (this._disposed || this._retry !== continuing)
2656
+ return;
2657
+ const continuationError = this._reportRetryFailure(error);
2658
+ let settlementError = continuationError;
2659
+ try {
2660
+ this._finishRetry({
2661
+ schemaVersion: 1,
2662
+ outcome: "failed",
2663
+ reason: "continuation_rejected",
2664
+ attempt: this._bounded(attempt),
2665
+ cumulativeErrors: this._bounded(this._runRetryCount),
2666
+ }, { type: "auto_retry_end", success: false, attempt, finalError: continuationError.message });
2667
+ }
2668
+ catch (persistenceError) {
2669
+ settlementError = new AggregateError([continuationError, persistenceError], "Retry continuation and outcome persistence both failed.", { cause: continuationError });
2670
+ }
2671
+ this._settleRetryChain(run.chain, settlementError);
2672
+ });
2039
2673
  return true;
2040
2674
  }
2675
+ _finishCancelledRetry(reason, expected) {
2676
+ if (this._disposed) {
2677
+ this._retry = undefined;
2678
+ throw new Error("AgentSession disposed before retry settlement completed.");
2679
+ }
2680
+ const retry = this._retry;
2681
+ if (expected && retry !== expected)
2682
+ return false;
2683
+ const attempt = retry?.attempt ?? 0;
2684
+ this._finishRetry({
2685
+ schemaVersion: 1,
2686
+ outcome: "cancelled",
2687
+ reason,
2688
+ attempt: this._bounded(attempt),
2689
+ cumulativeErrors: this._bounded(this._runRetryCount),
2690
+ }, { type: "auto_retry_end", success: false, attempt, finalError: "Retry cancelled" });
2691
+ return false;
2692
+ }
2041
2693
  /**
2042
2694
  * Cancel in-progress retry.
2043
2695
  */
2044
2696
  abortRetry() {
2045
- this._retryAbortController?.abort();
2046
- // Note: _retryAttempt is reset in the catch block of _autoRetry
2047
- this._resolveRetry();
2048
- }
2049
- /**
2050
- * Wait for any in-progress retry to complete.
2051
- * Returns immediately if no retry is in progress.
2052
- */
2053
- async waitForRetry() {
2054
- if (!this._retryPromise) {
2697
+ const retry = this._retry;
2698
+ if (!retry)
2055
2699
  return;
2700
+ if (retry.phase === "backoff")
2701
+ retry.controller.abort();
2702
+ this.agent.abort();
2703
+ }
2704
+ /** Agent's own `activeRun` guard is false during backoff; the retry phase owns the idle Agent then. */
2705
+ _assertRetryNotOwningIdleAgent() {
2706
+ if (this._retry && !this.isStreaming) {
2707
+ throw new Error("Automatic retry is in progress. Cancel the retry before starting another prompt.");
2708
+ }
2709
+ }
2710
+ async _runAgentPrompt(messages) {
2711
+ this._assertRetryNotOwningIdleAgent();
2712
+ const start = this._reserveAgentRunStart();
2713
+ this._runRetryCount = 0;
2714
+ let promptFailure;
2715
+ try {
2716
+ await this.agent.prompt(messages);
2056
2717
  }
2057
- await this._retryPromise;
2718
+ catch (error) {
2719
+ promptFailure = { error };
2720
+ }
2721
+ if (!start.run && this._pendingAgentRunStart === start) {
2722
+ const startError = promptFailure
2723
+ ? promptFailure.error instanceof Error
2724
+ ? promptFailure.error
2725
+ : new Error(String(promptFailure.error))
2726
+ : new Error("Agent prompt completed before agent_start.");
2727
+ this._rejectAgentRunStart(start, startError);
2728
+ if (promptFailure)
2729
+ throw promptFailure.error;
2730
+ }
2731
+ let settlementFailure;
2732
+ try {
2733
+ const settlement = await start.promise;
2734
+ await this.waitForRetry(settlement);
2735
+ }
2736
+ catch (error) {
2737
+ settlementFailure = { error };
2738
+ }
2739
+ if (promptFailure && settlementFailure) {
2740
+ throw new AggregateError([promptFailure.error, settlementFailure.error], "Agent prompt and retry settlement both failed.", { cause: promptFailure.error });
2741
+ }
2742
+ if (promptFailure)
2743
+ throw promptFailure.error;
2744
+ if (settlementFailure)
2745
+ throw settlementFailure.error;
2746
+ }
2747
+ async waitForRetry(settlement) {
2748
+ if (!settlement)
2749
+ return;
2750
+ await settlement.promise;
2751
+ await settlement.chain.promise;
2058
2752
  await this.agent.waitForIdle();
2059
2753
  }
2060
2754
  /** Whether auto-retry is currently in progress */
2061
2755
  get isRetrying() {
2062
- return this._retryPromise !== undefined;
2756
+ return this._retry !== undefined;
2063
2757
  }
2064
2758
  /** Whether auto-retry is enabled */
2065
2759
  get autoRetryEnabled() {
@@ -2279,7 +2973,7 @@ export class AgentSession {
2279
2973
  if (targetEntry.type === "message" && targetEntry.message.role === "user") {
2280
2974
  // User message: leaf = parent (null if root), text goes to editor
2281
2975
  newLeafId = targetEntry.parentId;
2282
- editorText = this._extractUserMessageText(targetEntry.message.content);
2976
+ editorText = restoreScramjetCommandInvocation(targetEntry, this.sessionManager.getEntries());
2283
2977
  }
2284
2978
  else if (targetEntry.type === "custom_message") {
2285
2979
  // Custom message: leaf = parent (null if root), text goes to editor
@@ -2343,30 +3037,20 @@ export class AgentSession {
2343
3037
  */
2344
3038
  getUserMessagesForForking() {
2345
3039
  const entries = this.sessionManager.getEntries();
3040
+ const commandStarts = indexScramjetCommandStarts(entries);
2346
3041
  const result = [];
2347
3042
  for (const entry of entries) {
2348
3043
  if (entry.type !== "message")
2349
3044
  continue;
2350
3045
  if (entry.message.role !== "user")
2351
3046
  continue;
2352
- const text = this._extractUserMessageText(entry.message.content);
3047
+ const text = restoreScramjetCommandInvocation(entry, commandStarts);
2353
3048
  if (text) {
2354
3049
  result.push({ entryId: entry.id, text });
2355
3050
  }
2356
3051
  }
2357
3052
  return result;
2358
3053
  }
2359
- _extractUserMessageText(content) {
2360
- if (typeof content === "string")
2361
- return content;
2362
- if (Array.isArray(content)) {
2363
- return content
2364
- .filter((c) => c.type === "text")
2365
- .map((c) => c.text)
2366
- .join("");
2367
- }
2368
- return "";
2369
- }
2370
3054
  /**
2371
3055
  * Get session statistics.
2372
3056
  */