@leanandmean/coding-agent 0.74.1-scramjet.7 → 0.74.1-scramjet.70

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 (225) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/LICENSE +202 -0
  3. package/README.md +23 -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 +78 -15
  28. package/dist/core/agent-session.d.ts.map +1 -1
  29. package/dist/core/agent-session.js +1059 -203
  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/runner.d.ts +9 -5
  47. package/dist/core/extensions/runner.d.ts.map +1 -1
  48. package/dist/core/extensions/runner.js +160 -3
  49. package/dist/core/extensions/runner.js.map +1 -1
  50. package/dist/core/extensions/types.d.ts +85 -8
  51. package/dist/core/extensions/types.d.ts.map +1 -1
  52. package/dist/core/extensions/types.js.map +1 -1
  53. package/dist/core/keybindings.d.ts +8 -0
  54. package/dist/core/keybindings.d.ts.map +1 -1
  55. package/dist/core/model-registry.d.ts +4 -0
  56. package/dist/core/model-registry.d.ts.map +1 -1
  57. package/dist/core/model-registry.js +179 -28
  58. package/dist/core/model-registry.js.map +1 -1
  59. package/dist/core/model-resolver.d.ts.map +1 -1
  60. package/dist/core/model-resolver.js +24 -6
  61. package/dist/core/model-resolver.js.map +1 -1
  62. package/dist/core/output-throughput-history.d.ts +25 -0
  63. package/dist/core/output-throughput-history.d.ts.map +1 -0
  64. package/dist/core/output-throughput-history.js +240 -0
  65. package/dist/core/output-throughput-history.js.map +1 -0
  66. package/dist/core/output-throughput.d.ts +43 -0
  67. package/dist/core/output-throughput.d.ts.map +1 -0
  68. package/dist/core/output-throughput.js +161 -0
  69. package/dist/core/output-throughput.js.map +1 -0
  70. package/dist/core/resource-loader.d.ts +6 -0
  71. package/dist/core/resource-loader.d.ts.map +1 -1
  72. package/dist/core/resource-loader.js +76 -21
  73. package/dist/core/resource-loader.js.map +1 -1
  74. package/dist/core/scramjet-command-parser.d.ts +4 -0
  75. package/dist/core/scramjet-command-parser.d.ts.map +1 -1
  76. package/dist/core/scramjet-command-parser.js +72 -1
  77. package/dist/core/scramjet-command-parser.js.map +1 -1
  78. package/dist/core/sdk.d.ts +2 -0
  79. package/dist/core/sdk.d.ts.map +1 -1
  80. package/dist/core/sdk.js +61 -10
  81. package/dist/core/sdk.js.map +1 -1
  82. package/dist/core/session-manager.d.ts +11 -3
  83. package/dist/core/session-manager.d.ts.map +1 -1
  84. package/dist/core/session-manager.js +46 -11
  85. package/dist/core/session-manager.js.map +1 -1
  86. package/dist/core/settings-manager.d.ts +19 -6
  87. package/dist/core/settings-manager.d.ts.map +1 -1
  88. package/dist/core/settings-manager.js +131 -17
  89. package/dist/core/settings-manager.js.map +1 -1
  90. package/dist/core/skills.d.ts.map +1 -1
  91. package/dist/core/skills.js +6 -2
  92. package/dist/core/skills.js.map +1 -1
  93. package/dist/core/system-prompt.d.ts +2 -0
  94. package/dist/core/system-prompt.d.ts.map +1 -1
  95. package/dist/core/system-prompt.js +5 -2
  96. package/dist/core/system-prompt.js.map +1 -1
  97. package/dist/core/tools/bash.d.ts.map +1 -1
  98. package/dist/core/tools/bash.js +4 -3
  99. package/dist/core/tools/bash.js.map +1 -1
  100. package/dist/index.d.ts +4 -3
  101. package/dist/index.d.ts.map +1 -1
  102. package/dist/index.js +3 -2
  103. package/dist/index.js.map +1 -1
  104. package/dist/main.d.ts +16 -0
  105. package/dist/main.d.ts.map +1 -1
  106. package/dist/main.js +38 -10
  107. package/dist/main.js.map +1 -1
  108. package/dist/modes/interactive/components/armin.d.ts +4 -2
  109. package/dist/modes/interactive/components/armin.d.ts.map +1 -1
  110. package/dist/modes/interactive/components/armin.js +14 -8
  111. package/dist/modes/interactive/components/armin.js.map +1 -1
  112. package/dist/modes/interactive/components/assistant-message.d.ts +3 -1
  113. package/dist/modes/interactive/components/assistant-message.d.ts.map +1 -1
  114. package/dist/modes/interactive/components/assistant-message.js +19 -8
  115. package/dist/modes/interactive/components/assistant-message.js.map +1 -1
  116. package/dist/modes/interactive/components/daxnuts.d.ts +4 -1
  117. package/dist/modes/interactive/components/daxnuts.d.ts.map +1 -1
  118. package/dist/modes/interactive/components/daxnuts.js +14 -6
  119. package/dist/modes/interactive/components/daxnuts.js.map +1 -1
  120. package/dist/modes/interactive/components/extension-editor.d.ts.map +1 -1
  121. package/dist/modes/interactive/components/extension-editor.js +5 -3
  122. package/dist/modes/interactive/components/extension-editor.js.map +1 -1
  123. package/dist/modes/interactive/components/extension-selector.d.ts.map +1 -1
  124. package/dist/modes/interactive/components/extension-selector.js +4 -3
  125. package/dist/modes/interactive/components/extension-selector.js.map +1 -1
  126. package/dist/modes/interactive/components/footer.d.ts +4 -0
  127. package/dist/modes/interactive/components/footer.d.ts.map +1 -1
  128. package/dist/modes/interactive/components/footer.js +24 -20
  129. package/dist/modes/interactive/components/footer.js.map +1 -1
  130. package/dist/modes/interactive/components/model-selector.d.ts +3 -1
  131. package/dist/modes/interactive/components/model-selector.d.ts.map +1 -1
  132. package/dist/modes/interactive/components/model-selector.js +9 -3
  133. package/dist/modes/interactive/components/model-selector.js.map +1 -1
  134. package/dist/modes/interactive/components/scoped-models-selector.d.ts +3 -1
  135. package/dist/modes/interactive/components/scoped-models-selector.d.ts.map +1 -1
  136. package/dist/modes/interactive/components/scoped-models-selector.js +8 -2
  137. package/dist/modes/interactive/components/scoped-models-selector.js.map +1 -1
  138. package/dist/modes/interactive/components/settings-selector.d.ts +17 -3
  139. package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
  140. package/dist/modes/interactive/components/settings-selector.js +87 -16
  141. package/dist/modes/interactive/components/settings-selector.js.map +1 -1
  142. package/dist/modes/interactive/components/thinking-selector.d.ts.map +1 -1
  143. package/dist/modes/interactive/components/thinking-selector.js +2 -1
  144. package/dist/modes/interactive/components/thinking-selector.js.map +1 -1
  145. package/dist/modes/interactive/components/tool-execution.d.ts +19 -1
  146. package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  147. package/dist/modes/interactive/components/tool-execution.js +129 -36
  148. package/dist/modes/interactive/components/tool-execution.js.map +1 -1
  149. package/dist/modes/interactive/components/user-message.d.ts.map +1 -1
  150. package/dist/modes/interactive/components/user-message.js +3 -2
  151. package/dist/modes/interactive/components/user-message.js.map +1 -1
  152. package/dist/modes/interactive/components/visual-truncate.d.ts.map +1 -1
  153. package/dist/modes/interactive/components/visual-truncate.js +2 -2
  154. package/dist/modes/interactive/components/visual-truncate.js.map +1 -1
  155. package/dist/modes/interactive/interactive-mode.d.ts +37 -1
  156. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  157. package/dist/modes/interactive/interactive-mode.js +901 -176
  158. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  159. package/dist/modes/interactive/spellcheck.d.ts +26 -0
  160. package/dist/modes/interactive/spellcheck.d.ts.map +1 -0
  161. package/dist/modes/interactive/spellcheck.js +144 -0
  162. package/dist/modes/interactive/spellcheck.js.map +1 -0
  163. package/dist/modes/interactive/theme/{dark.json → pi-dark.json} +3 -2
  164. package/dist/modes/interactive/theme/{light.json → pi-light.json} +27 -26
  165. package/dist/modes/interactive/theme/theme-schema.json +6 -1
  166. package/dist/modes/interactive/theme/theme.d.ts +24 -4
  167. package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  168. package/dist/modes/interactive/theme/theme.js +123 -22
  169. package/dist/modes/interactive/theme/theme.js.map +1 -1
  170. package/dist/modes/print-mode.d.ts.map +1 -1
  171. package/dist/modes/print-mode.js +5 -0
  172. package/dist/modes/print-mode.js.map +1 -1
  173. package/dist/modes/rpc/rpc-client.d.ts +3 -1
  174. package/dist/modes/rpc/rpc-client.d.ts.map +1 -1
  175. package/dist/modes/rpc/rpc-client.js.map +1 -1
  176. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  177. package/dist/modes/rpc/rpc-mode.js +6 -1
  178. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  179. package/dist/package-manager-cli.d.ts.map +1 -1
  180. package/dist/package-manager-cli.js +33 -14
  181. package/dist/package-manager-cli.js.map +1 -1
  182. package/dist/utils/clipboard-native.d.ts +1 -0
  183. package/dist/utils/clipboard-native.d.ts.map +1 -1
  184. package/dist/utils/clipboard-native.js.map +1 -1
  185. package/dist/utils/clipboard.d.ts +6 -0
  186. package/dist/utils/clipboard.d.ts.map +1 -1
  187. package/dist/utils/clipboard.js +278 -10
  188. package/dist/utils/clipboard.js.map +1 -1
  189. package/dist/utils/tools-manager.d.ts +1 -1
  190. package/dist/utils/tools-manager.d.ts.map +1 -1
  191. package/dist/utils/tools-manager.js +7 -6
  192. package/dist/utils/tools-manager.js.map +1 -1
  193. package/dist/utils/version-check.d.ts +17 -5
  194. package/dist/utils/version-check.d.ts.map +1 -1
  195. package/dist/utils/version-check.js +88 -21
  196. package/dist/utils/version-check.js.map +1 -1
  197. package/docs/compaction.md +21 -3
  198. package/docs/custom-provider.md +33 -4
  199. package/docs/extensions.md +150 -24
  200. package/docs/json.md +2 -0
  201. package/docs/keybindings.md +25 -1
  202. package/docs/models.md +34 -2
  203. package/docs/packages.md +2 -0
  204. package/docs/prompt-templates.md +2 -0
  205. package/docs/quickstart.md +1 -4
  206. package/docs/rpc.md +8 -6
  207. package/docs/sdk.md +31 -3
  208. package/docs/session-format.md +5 -1
  209. package/docs/settings.md +19 -5
  210. package/docs/skills.md +7 -5
  211. package/docs/terminal-setup.md +79 -2
  212. package/docs/terminal-verification.md +71 -0
  213. package/docs/themes.md +63 -10
  214. package/docs/tui.md +120 -25
  215. package/docs/usage.md +6 -4
  216. package/examples/extensions/border-status-editor.ts +2 -3
  217. package/examples/extensions/doom-overlay/doom-component.ts +3 -3
  218. package/examples/extensions/mac-system-theme.ts +3 -2
  219. package/examples/extensions/modal-editor.ts +13 -8
  220. package/examples/extensions/overlay-qa-tests.ts +22 -9
  221. package/examples/extensions/overlay-test.ts +5 -3
  222. package/examples/extensions/snake.ts +3 -1
  223. package/examples/extensions/space-invaders.ts +6 -3
  224. package/examples/extensions/subagent/index.ts +20 -17
  225. package/package.json +69 -58
@@ -14,19 +14,25 @@
14
14
  */
15
15
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
16
16
  import { basename, dirname, resolve } from "node:path";
17
- import { clampThinkingLevel, cleanupSessionResources, flattenSystemPrompt, getSupportedThinkingLevels, isContextOverflow, modelsAreEqual, resetApiProviders, } from "@leanandmean/ai";
17
+ // SCRAMJET-DIVERGENCE: provider-safe harness tool-call id generation for the persisted-settlement
18
+ // acknowledgement key (#341).
19
+ import { generateHarnessToolCallId } from "@leanandmean/agent";
20
+ import { blocksFailureRecovery, clampThinkingLevel, cleanupSessionResources, flattenSystemPrompt, getSupportedThinkingLevels, inspectFailureEvidence, isContextOverflow, modelsAreEqual, resetApiProviders, } from "@leanandmean/ai";
18
21
  import { theme } from "../modes/interactive/theme/theme.js";
19
22
  import { stripFrontmatter } from "../utils/frontmatter.js";
20
23
  import { sleep } from "../utils/sleep.js";
21
24
  import { formatNoApiKeyFoundMessage, formatNoModelSelectedMessage } from "./auth-guidance.js";
22
25
  import { executeBashWithOperations } from "./bash-executor.js";
23
- import { calculateContextTokens, collectEntriesForBranchSummary, compact, estimateContextTokens, generateBranchSummary, prepareCompaction, shouldCompact, } from "./compaction/index.js";
26
+ import { calculateContextTokens, collectEntriesForBranchSummary, compact, estimateContextTokens, estimateTokens, generateBranchSummary, prepareCompaction, shouldCompact, } from "./compaction/index.js";
24
27
  import { DEFAULT_THINKING_LEVEL } from "./defaults.js";
25
28
  import { exportSessionToHtml } from "./export-html/index.js";
26
29
  import { createToolHtmlRenderer } from "./export-html/tool-renderer.js";
27
30
  import { ExtensionRunner, wrapRegisteredTools, } from "./extensions/index.js";
28
31
  import { emitSessionShutdownEvent, spliceContributedSections } from "./extensions/runner.js";
32
+ import { OutputThroughputTracker } from "./output-throughput.js";
33
+ import { OutputThroughputHistoryStore } from "./output-throughput-history.js";
29
34
  import { expandPromptTemplate } from "./prompt-templates.js";
35
+ import { indexScramjetCommandStarts, restoreScramjetCommandInvocation } from "./scramjet-command-parser.js";
30
36
  import { CURRENT_SESSION_VERSION, getLatestCompactionEntry } from "./session-manager.js";
31
37
  import { createSyntheticSourceInfo } from "./source-info.js";
32
38
  import { buildSystemPromptSections } from "./system-prompt.js";
@@ -53,35 +59,80 @@ export function parseSkillBlock(text) {
53
59
  // ============================================================================
54
60
  /** Standard thinking levels */
55
61
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high"];
56
- // ============================================================================
57
- // AgentSession Class
58
- // ============================================================================
62
+ function createDeferred() {
63
+ let resolve;
64
+ let reject;
65
+ const promise = new Promise((res, rej) => {
66
+ resolve = res;
67
+ reject = rej;
68
+ });
69
+ promise.catch(() => { });
70
+ return { promise, resolve, reject, settled: false };
71
+ }
72
+ function resolveDeferred(deferred, value) {
73
+ if (deferred.settled)
74
+ return false;
75
+ deferred.settled = true;
76
+ deferred.resolve(value);
77
+ return true;
78
+ }
79
+ function rejectDeferred(deferred, error) {
80
+ if (deferred.settled)
81
+ return false;
82
+ deferred.settled = true;
83
+ deferred.reject(error);
84
+ return true;
85
+ }
86
+ function settleDeferred(deferred, error) {
87
+ return error ? rejectDeferred(deferred, error) : resolveDeferred(deferred, undefined);
88
+ }
89
+ function validRecordedCost(value) {
90
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
91
+ }
59
92
  export class AgentSession {
60
93
  agent;
61
94
  sessionManager;
62
95
  settingsManager;
96
+ outputThroughputHistoryPath;
63
97
  _scopedModels;
64
98
  // Event subscription state
65
99
  _unsubscribeAgent;
66
100
  _eventListeners = [];
67
101
  _agentEventQueue = Promise.resolve();
102
+ // SCRAMJET-DIVERGENCE: execution ownership survives final-event queue latency until append succeeds (#598).
103
+ _toolCostExecutions = new Map();
104
+ _pendingToolCosts = new Set();
105
+ _historicalCostDefinitions = new Map();
106
+ _outputThroughputTracker = new OutputThroughputTracker();
107
+ _outputThroughputHistory;
108
+ _outputThroughputGeneration;
109
+ _latestOutputThroughputSample;
110
+ // SCRAMJET-DIVERGENCE: harness-tool persisted-settlement state (#341). `_disposed` gates post-
111
+ // teardown invocations; the map keys each in-flight invocation's tool-call id to its persistence
112
+ // acknowledgement so `_processAgentEvent` can settle it after the matching tool-result is persisted.
113
+ _disposed = false;
114
+ _harnessPersistenceAcks = new Map();
68
115
  /** Tracks pending steering messages for UI display. Removed when delivered. */
69
116
  _steeringMessages = [];
70
117
  /** Tracks pending follow-up messages for UI display. Removed when delivered. */
71
118
  _followUpMessages = [];
72
119
  /** Messages queued to be included with the next user prompt as context ("asides"). */
73
120
  _pendingNextTurnMessages = [];
121
+ _inputSessionEntries = new WeakMap();
74
122
  // Compaction state
75
123
  _compactionAbortController = undefined;
124
+ _manualCompactionDrain;
76
125
  _autoCompactionAbortController = undefined;
77
126
  _overflowRecoveryAttempted = false;
78
127
  // Branch summarization state
79
128
  _branchSummaryAbortController = undefined;
80
129
  // Retry state
81
- _retryAbortController = undefined;
82
- _retryAttempt = 0;
83
- _retryPromise = undefined;
84
- _retryResolve = undefined;
130
+ _retry = undefined;
131
+ _runRetryCount = 0;
132
+ _activeAgentRunSettlement = undefined;
133
+ _pendingAgentRunStart = undefined;
134
+ _unsettledAgentRuns = new Set();
135
+ _unsettledRetryChains = new Set();
85
136
  // Bash execution state
86
137
  _bashAbortController = undefined;
87
138
  _pendingBashMessages = [];
@@ -114,10 +165,22 @@ export class AgentSession {
114
165
  // can apply per-section cache control.
115
166
  _baseSystemPromptSections = [];
116
167
  _baseSystemPromptOptions;
168
+ // SCRAMJET-DIVERGENCE: active-tool prompt rebuilds preserve the current top-level run's
169
+ // before_agent_start composition while new prompts and reloads establish a fresh composition (#524).
170
+ _runPromptComposition = { kind: "generated-base" };
117
171
  constructor(config) {
118
172
  this.agent = config.agent;
119
173
  this.sessionManager = config.sessionManager;
120
174
  this.settingsManager = config.settingsManager;
175
+ this.outputThroughputHistoryPath = config.outputThroughputHistoryPath;
176
+ this._outputThroughputHistory = new OutputThroughputHistoryStore(this.outputThroughputHistoryPath, (diagnostic) => {
177
+ this._extensionRunner.emitError({
178
+ extensionPath: "output-throughput-history",
179
+ event: diagnostic.operation,
180
+ error: `${diagnostic.message} (${diagnostic.path})`,
181
+ stack: diagnostic.cause instanceof Error ? diagnostic.cause.stack : undefined,
182
+ });
183
+ });
121
184
  this._scopedModels = config.scopedModels ?? [];
122
185
  this._resourceLoader = config.resourceLoader;
123
186
  this._customTools = config.customTools ?? [];
@@ -161,8 +224,8 @@ export class AgentSession {
161
224
  throw new Error(formatNoApiKeyFoundMessage(model.provider));
162
225
  }
163
226
  // 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).
227
+ // processing has settled before proceeding. Used by beforeToolBatch (pre-extraction drain),
228
+ // beforeToolCall (per-tool drain), and the awaited turn_end listener barrier.
166
229
  async _drainAgentEventQueue() {
167
230
  await this._agentEventQueue;
168
231
  }
@@ -241,46 +304,200 @@ export class AgentSession {
241
304
  followUp: [...this._followUpMessages],
242
305
  });
243
306
  }
244
- // Track last assistant message for auto-compaction check
245
- _lastAssistantMessage = undefined;
246
307
  /** Internal handler for agent events - shared by subscribe and reconnect */
247
308
  _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));
309
+ if (this._disposed)
310
+ return;
311
+ this._captureOutputThroughput(event);
312
+ const toolCost = this._captureToolCost(event);
313
+ const runSettlement = this._captureAgentRunSettlement(event);
314
+ const harnessAck = this._harnessPersistenceAckForEvent(event);
315
+ const processing = this._agentEventQueue.then(() => this._processAgentEventTracked(event, runSettlement, harnessAck, toolCost), () => this._processAgentEventTracked(event, runSettlement, harnessAck, toolCost));
316
+ this._agentEventQueue = processing;
317
+ if (harnessAck)
318
+ harnessAck.persistenceTail = processing;
255
319
  // Keep queue alive if an event handler fails
256
- this._agentEventQueue.catch(() => { });
320
+ processing.catch(() => { });
321
+ // SCRAMJET-DIVERGENCE: turn_end tool/prompt changes must settle before Agent snapshots
322
+ // live state for the next provider request (#524).
323
+ if (event.type === "turn_end")
324
+ return this._agentEventQueue;
257
325
  };
258
- _createRetryPromiseForAgentEnd(event) {
259
- if (event.type !== "agent_end" || this._retryPromise) {
326
+ // SCRAMJET-DIVERGENCE: scalar reports are captured before mutable events enter async hooks (#598).
327
+ _captureToolCost(event) {
328
+ if (event.type === "tool_execution_start") {
329
+ const execution = {};
330
+ this._toolCostExecutions.set(event.toolCallId, execution);
331
+ this._pendingToolCosts.add(execution);
260
332
  return;
261
333
  }
262
- const settings = this.settingsManager.getRetrySettings();
263
- if (!settings.enabled) {
334
+ let toolCallId;
335
+ let reported;
336
+ const final = event.type === "message_end" && event.message.role === "toolResult";
337
+ if (event.type === "tool_execution_update" || event.type === "tool_execution_end") {
338
+ toolCallId = event.toolCallId;
339
+ reported = event.type === "tool_execution_update" ? event.partialResult?.cost : event.result?.cost;
340
+ }
341
+ else if (event.type === "message_end" && event.message.role === "toolResult") {
342
+ toolCallId = event.message.toolCallId;
343
+ reported = event.message.cost;
344
+ }
345
+ else {
264
346
  return;
265
347
  }
266
- const lastAssistant = this._findLastAssistantInMessages(event.messages);
267
- if (!lastAssistant || !this._isRetryableError(lastAssistant)) {
348
+ let execution = this._toolCostExecutions.get(toolCallId);
349
+ if (!execution && validRecordedCost(reported)) {
350
+ execution = {};
351
+ this._toolCostExecutions.set(toolCallId, execution);
352
+ this._pendingToolCosts.add(execution);
353
+ }
354
+ if (execution && validRecordedCost(reported))
355
+ execution.cost = reported;
356
+ if (final) {
357
+ this._toolCostExecutions.delete(toolCallId);
358
+ return execution;
359
+ }
360
+ return;
361
+ }
362
+ // SCRAMJET-DIVERGENCE: Capture provider events synchronously and reset stale throughput lifecycles (#476).
363
+ _captureOutputThroughput(event) {
364
+ if (event.type === "message_start" && event.message.role === "assistant" && event.message.origin === "provider") {
365
+ this._outputThroughputGeneration = this._outputThroughputTracker.start({
366
+ provider: event.message.provider,
367
+ model: event.message.model,
368
+ });
268
369
  return;
269
370
  }
270
- this._retryPromise = new Promise((resolve) => {
271
- this._retryResolve = resolve;
272
- });
371
+ const generation = this._outputThroughputGeneration;
372
+ if (generation === undefined)
373
+ return;
374
+ if (event.type === "message_update" && event.message.role === "assistant") {
375
+ this._outputThroughputTracker.observe(generation, event.assistantMessageEvent);
376
+ }
377
+ else if (event.type === "message_end" && event.message.role === "assistant") {
378
+ const sample = this._outputThroughputTracker.finalize(generation, event.message);
379
+ this._outputThroughputGeneration = undefined;
380
+ if (sample) {
381
+ this._latestOutputThroughputSample = sample;
382
+ this._outputThroughputHistory.submit(sample);
383
+ }
384
+ }
273
385
  }
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;
386
+ _resetOutputThroughput() {
387
+ this._outputThroughputTracker.reset();
388
+ this._outputThroughputGeneration = undefined;
389
+ }
390
+ // SCRAMJET-DIVERGENCE: every Agent run owns persistence state and a one-shot settlement token (#553).
391
+ _captureAgentRunSettlement(event) {
392
+ if (event.type === "agent_start") {
393
+ // A retry continuation's run adopts the chain its originating prompt awaits; the chain stays
394
+ // bound to the retry phase until this start consumes it, however late Agent emits it.
395
+ const chain = this._retry?.phase === "continuing" ? this._retry.chain : createDeferred();
396
+ this._unsettledRetryChains.add(chain);
397
+ const run = { ...createDeferred(), chain };
398
+ this._activeAgentRunSettlement = run;
399
+ this._unsettledAgentRuns.add(run);
400
+ const reservation = this._pendingAgentRunStart;
401
+ if (reservation) {
402
+ this._pendingAgentRunStart = undefined;
403
+ reservation.run = run;
404
+ resolveDeferred(reservation, run);
405
+ }
406
+ return run;
407
+ }
408
+ const run = this._activeAgentRunSettlement;
409
+ if (event.type === "agent_end" && this._activeAgentRunSettlement === run) {
410
+ this._activeAgentRunSettlement = undefined;
411
+ }
412
+ return run;
413
+ }
414
+ _settleAgentRun(run, error) {
415
+ if (settleDeferred(run, error))
416
+ this._unsettledAgentRuns.delete(run);
417
+ }
418
+ _settleRetryChain(chain, error) {
419
+ if (settleDeferred(chain, error))
420
+ this._unsettledRetryChains.delete(chain);
421
+ }
422
+ _completeAgentRun(run, error) {
423
+ this._settleAgentRun(run, error);
424
+ this._settleRetryChain(run.chain, error);
425
+ }
426
+ _reserveAgentRunStart() {
427
+ if (this._pendingAgentRunStart)
428
+ throw new Error("An Agent prompt is already waiting to start.");
429
+ const reservation = createDeferred();
430
+ this._pendingAgentRunStart = reservation;
431
+ return reservation;
432
+ }
433
+ _rejectAgentRunStart(reservation, error) {
434
+ if (this._pendingAgentRunStart === reservation)
435
+ this._pendingAgentRunStart = undefined;
436
+ rejectDeferred(reservation, error);
437
+ }
438
+ // SCRAMJET-DIVERGENCE: harness-tool persisted settlement (#341). Synchronous ownership keeps queued
439
+ // invocation events attributed after early failures; public settlement awaits execution and the final persistence tail.
440
+ async _processAgentEventTracked(event, run, harnessAck, toolCost) {
441
+ try {
442
+ await this._processAgentEvent(event, run, harnessAck !== undefined, toolCost);
443
+ }
444
+ catch (err) {
445
+ const error = err instanceof Error ? err : new Error(String(err));
446
+ if (this._manualCompactionDrain)
447
+ this._manualCompactionDrain.firstError ??= error;
448
+ if (harnessAck) {
449
+ harnessAck.firstPersistenceError ??= error;
450
+ }
451
+ else if (run && !this._manualCompactionDrain) {
452
+ if (event.type === "message_end" && event.message.role === "assistant") {
453
+ run.assistantPersistenceError = error;
454
+ }
455
+ this._releaseRetryForFailedRun(error);
456
+ this._completeAgentRun(run, error);
457
+ }
458
+ throw err;
459
+ }
460
+ }
461
+ _harnessPersistenceAckForEvent(event) {
462
+ let toolCallId;
463
+ switch (event.type) {
464
+ case "tool_execution_start":
465
+ case "tool_execution_update":
466
+ case "tool_execution_end":
467
+ toolCallId = event.toolCallId;
468
+ break;
469
+ case "message_start":
470
+ case "message_end": {
471
+ const message = event.message;
472
+ if (message.role === "toolResult") {
473
+ toolCallId = message.toolCallId;
474
+ }
475
+ else if (message.role === "assistant") {
476
+ for (const block of message.content) {
477
+ if (block.type === "toolCall" && this._harnessPersistenceAcks.has(block.id)) {
478
+ toolCallId = block.id;
479
+ break;
480
+ }
481
+ }
482
+ }
483
+ break;
279
484
  }
280
485
  }
281
- return undefined;
486
+ return toolCallId === undefined ? undefined : this._harnessPersistenceAcks.get(toolCallId);
282
487
  }
283
- async _processAgentEvent(event) {
488
+ async _processAgentEvent(event, run, fromHarnessInvocation = false, toolCost) {
489
+ if (this._disposed)
490
+ return;
491
+ // SCRAMJET-DIVERGENCE: queued lifecycle events must respect manual compaction ownership (#598).
492
+ if (this._manualCompactionDrain && event.type !== "message_end" && event.type !== "agent_end")
493
+ return;
494
+ if (this._manualCompactionDrain && event.type === "agent_end") {
495
+ if (this._retry)
496
+ this._finishCancelledRetry("cancelled_during_continuation");
497
+ if (run)
498
+ this._completeAgentRun(run, this._manualCompactionDrain.firstError);
499
+ return;
500
+ }
284
501
  // When a user message starts, check if it's from either queue and remove it BEFORE emitting
285
502
  // This ensures the UI sees the updated queue state
286
503
  if (event.type === "message_start" && event.message.role === "user") {
@@ -305,8 +522,22 @@ export class AgentSession {
305
522
  }
306
523
  // Emit to extensions first
307
524
  await this._emitExtensionEvent(event);
525
+ if (event.type === "message_end" && event.message.role === "toolResult" && toolCost?.cost !== undefined) {
526
+ event.message.cost = toolCost.cost;
527
+ }
528
+ // An awaited hook may have acquired manual ownership while this event was in flight.
529
+ if (this._manualCompactionDrain && event.type !== "message_end") {
530
+ if (event.type === "agent_end" && run) {
531
+ if (this._retry)
532
+ this._finishCancelledRetry("cancelled_during_continuation");
533
+ this._completeAgentRun(run, this._manualCompactionDrain.firstError);
534
+ }
535
+ return;
536
+ }
308
537
  // Notify all listeners
309
538
  this._emit(event);
539
+ if (this._disposed)
540
+ return;
310
541
  // Handle session persistence
311
542
  if (event.type === "message_end") {
312
543
  // Check if this is a custom message from extensions
@@ -317,51 +548,132 @@ export class AgentSession {
317
548
  else if (event.message.role === "user" ||
318
549
  event.message.role === "assistant" ||
319
550
  event.message.role === "toolResult") {
320
- // Regular LLM message - persist as SessionMessageEntry
321
- this.sessionManager.appendMessage(event.message);
551
+ // SCRAMJET-DIVERGENCE: retry settlement classifies the exact JSON-safe assistant snapshot
552
+ // that crossed the persistence boundary, never a later-mutated live Agent message (#553).
553
+ const persistedMessage = event.message.role === "assistant"
554
+ ? JSON.parse(JSON.stringify(event.message))
555
+ : event.message;
556
+ const messageEntryId = this.sessionManager.appendMessage(persistedMessage);
557
+ if (toolCost)
558
+ this._pendingToolCosts.delete(toolCost);
559
+ if (persistedMessage.role === "assistant" && !fromHarnessInvocation && run) {
560
+ run.persistedAssistantSnapshot = persistedMessage;
561
+ }
562
+ if (event.message.role === "user") {
563
+ const sessionEntries = this._inputSessionEntries.get(event.message);
564
+ this._inputSessionEntries.delete(event.message);
565
+ // This try/catch observes only failures thrown synchronously by
566
+ // appendCustomEntry. On the buffered first-turn path (before any
567
+ // assistant message) _persist stores the entry in memory with no I/O,
568
+ // so only metadata *serialization* failures surface here; the deferred
569
+ // disk write happens later in the unguarded appendMessage(assistant)
570
+ // flush, whose throw is swallowed by the _agentEventQueue.catch on the
571
+ // event-handler chain and degrades exact restoration silently.
572
+ for (const entry of sessionEntries ?? []) {
573
+ try {
574
+ this.sessionManager.appendCustomEntry(entry.customType, entry.data, messageEntryId);
575
+ }
576
+ catch (err) {
577
+ this._extensionRunner.emitError({
578
+ extensionPath: `session-entry:${entry.customType}`,
579
+ event: "session_entry_persistence",
580
+ error: `Failed to persist attached input metadata after its user message; exact input restoration may be unavailable: ${err instanceof Error ? err.message : String(err)}`,
581
+ stack: err instanceof Error ? err.stack : undefined,
582
+ });
583
+ }
584
+ }
585
+ }
322
586
  }
323
587
  // 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;
330
- }
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;
588
+ if (event.message.role === "assistant" &&
589
+ event.message.stopReason !== "error" &&
590
+ event.message.stopReason !== "aborted" &&
591
+ !fromHarnessInvocation &&
592
+ !this._manualCompactionDrain) {
593
+ this._overflowRecoveryAttempted = false;
594
+ if (this._retry)
595
+ this._retry = { ...this._retry, attempt: 0 };
596
+ }
597
+ }
598
+ if (event.type === "agent_end") {
599
+ if (this._disposed || !run)
600
+ return;
601
+ if (this._manualCompactionDrain) {
602
+ if (this._retry)
603
+ this._finishCancelledRetry("cancelled_during_continuation");
604
+ this._completeAgentRun(run, this._manualCompactionDrain.firstError);
605
+ return;
606
+ }
607
+ if (run.assistantPersistenceError) {
608
+ const error = run.assistantPersistenceError;
609
+ run.assistantPersistenceError = undefined;
610
+ run.persistedAssistantSnapshot = undefined;
611
+ this._completeAgentRun(run, error);
612
+ return;
613
+ }
614
+ const msg = run.persistedAssistantSnapshot;
615
+ run.persistedAssistantSnapshot = undefined;
616
+ if (!msg) {
617
+ this._releaseRetryForFailedRun(new Error("Retry continuation ended without an assistant message."));
618
+ this._completeAgentRun(run);
619
+ return;
620
+ }
621
+ if (msg.stopReason === "aborted" && this._retry) {
622
+ this._finishCancelledRetry("cancelled_during_continuation");
623
+ this._completeAgentRun(run);
624
+ return;
625
+ }
626
+ if (msg.stopReason !== "error") {
627
+ if (this._retry) {
628
+ const attempt = this._runRetryCount;
629
+ this._finishRetry({
630
+ schemaVersion: 1,
631
+ outcome: "succeeded",
632
+ attemptsCompleted: this._bounded(attempt),
633
+ cumulativeErrors: this._bounded(attempt),
634
+ }, { type: "auto_retry_end", success: true, attempt });
340
635
  }
636
+ this._completeAgentRun(run);
637
+ await this._checkCompaction(msg);
638
+ return;
341
639
  }
342
- }
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);
640
+ const classification = this._classifyRetry(msg);
641
+ if (classification.kind === "context_overflow") {
642
+ this._finishRetryWithoutSuccess({
643
+ schemaVersion: 1,
644
+ outcome: "not_attempted",
645
+ reason: "context_overflow_compaction",
646
+ evidence: "context_overflow",
647
+ }, msg);
648
+ this._completeAgentRun(run);
649
+ await this._checkCompaction(msg);
650
+ return;
651
+ }
652
+ const settings = this.settingsManager.getRetrySettings();
653
+ if (!settings.enabled) {
654
+ this._finishRetryWithoutSuccess({
655
+ schemaVersion: 1,
656
+ outcome: "not_attempted",
657
+ reason: "retry_disabled",
658
+ evidence: classification.evidence,
659
+ }, msg);
660
+ this._completeAgentRun(run);
661
+ await this._checkCompaction(msg);
662
+ return;
663
+ }
664
+ if (classification.kind === "retry") {
665
+ const didRetry = await this._handleRetryableError(msg, classification.evidence, run);
350
666
  if (didRetry)
351
- return; // Retry was initiated, don't proceed to compaction
667
+ return;
352
668
  }
353
- this._resolveRetry();
669
+ else {
670
+ const { kind: _kind, ...cause } = classification;
671
+ this._finishRetryWithoutSuccess({ schemaVersion: 1, outcome: "not_attempted", ...cause }, msg);
672
+ }
673
+ this._completeAgentRun(run);
354
674
  await this._checkCompaction(msg);
355
675
  }
356
676
  }
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
677
  /** Extract text content from a message */
366
678
  _getUserMessageText(message) {
367
679
  if (message.role !== "user")
@@ -510,8 +822,8 @@ export class AgentSession {
510
822
  * Preserves all existing listeners.
511
823
  */
512
824
  _reconnectToAgent() {
513
- if (this._unsubscribeAgent)
514
- return; // Already connected
825
+ if (this._disposed || this._unsubscribeAgent)
826
+ return; // Already connected or disposed
515
827
  this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);
516
828
  }
517
829
  /**
@@ -519,7 +831,69 @@ export class AgentSession {
519
831
  * Call this when completely done with the session.
520
832
  */
521
833
  dispose() {
834
+ // SCRAMJET-DIVERGENCE: idempotent disposal revokes harness invocations and closes retry settlement (#341, #553).
835
+ // A second call must not re-reject already-settled acknowledgements or re-run teardown.
836
+ if (this._disposed)
837
+ return;
838
+ this._disposed = true;
839
+ this._toolCostExecutions.clear();
840
+ this._pendingToolCosts.clear();
841
+ this._resetOutputThroughput();
842
+ const retryDisposeError = new Error("AgentSession disposed before retry settlement completed.");
843
+ const unsettledRuns = [...this._unsettledAgentRuns];
844
+ const retry = this._retry;
845
+ const unclassifiedError = unsettledRuns.find((run) => run.persistedAssistantSnapshot?.stopReason === "error")?.persistedAssistantSnapshot;
846
+ if (retry)
847
+ this.agent.abort();
848
+ try {
849
+ if (retry) {
850
+ this._appendAutoRetryRecord({
851
+ schemaVersion: 1,
852
+ outcome: "cancelled",
853
+ reason: "session_disposed",
854
+ attempt: this._bounded(retry.attempt),
855
+ cumulativeErrors: this._bounded(this._runRetryCount),
856
+ });
857
+ }
858
+ else if (unclassifiedError) {
859
+ const classification = this._classifyRetry(unclassifiedError);
860
+ this._appendAutoRetryRecord({
861
+ schemaVersion: 1,
862
+ outcome: "not_attempted",
863
+ reason: "session_disposed",
864
+ evidence: classification.kind === "context_overflow" ? "context_overflow" : classification.evidence,
865
+ });
866
+ }
867
+ }
868
+ catch (error) {
869
+ this._reportRetryFailure(error);
870
+ }
871
+ if (retry?.phase === "backoff")
872
+ retry.controller.abort();
873
+ this._retry = undefined;
874
+ this._activeAgentRunSettlement = undefined;
875
+ const pendingAgentRunStart = this._pendingAgentRunStart;
876
+ if (pendingAgentRunStart)
877
+ this._rejectAgentRunStart(pendingAgentRunStart, retryDisposeError);
878
+ const pendingRetryContinuation = retry?.phase === "continuing" ? retry.chain : undefined;
522
879
  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().");
880
+ // Reject pending persistence acknowledgements before revoking Agent-side invocations, so a caller
881
+ // awaiting invokeHarnessTool sees rejection rather than a promise that never settles. Already-
882
+ // running tool work may finish afterward; teardown revokes the old-session guarantee but cannot
883
+ // roll back arbitrary side effects.
884
+ const disposeError = new Error("AgentSession disposed before the harness tool invocation settled.");
885
+ for (const ack of this._harnessPersistenceAcks.values()) {
886
+ this._settleHarnessPersistenceAck(ack, disposeError);
887
+ }
888
+ this._harnessPersistenceAcks.clear();
889
+ this.agent.rejectUnsettledHarnessTools(disposeError);
890
+ const retryChains = new Set(this._unsettledRetryChains);
891
+ if (pendingRetryContinuation)
892
+ retryChains.add(pendingRetryContinuation);
893
+ for (const run of unsettledRuns)
894
+ this._settleAgentRun(run, retryDisposeError);
895
+ for (const chain of retryChains)
896
+ this._settleRetryChain(chain, retryDisposeError);
523
897
  this._disconnectFromAgent();
524
898
  this._eventListeners = [];
525
899
  cleanupSessionResources(this.sessionId);
@@ -547,9 +921,51 @@ export class AgentSession {
547
921
  get systemPrompt() {
548
922
  return flattenSystemPrompt(this.agent.state.systemPrompt);
549
923
  }
924
+ get liveOutputRate() {
925
+ const model = this.state.model;
926
+ const identity = this._outputThroughputTracker.activeIdentity;
927
+ if (!model || identity?.provider !== model.provider || identity.model !== model.id)
928
+ return undefined;
929
+ return this._outputThroughputTracker.liveRate();
930
+ }
931
+ get outputThroughputActive() {
932
+ return this._outputThroughputTracker.outputActive;
933
+ }
934
+ get outputThroughputGeneration() {
935
+ return this._outputThroughputTracker.generation;
936
+ }
937
+ get medianOutputRate() {
938
+ const model = this.state.model;
939
+ if (!model)
940
+ return undefined;
941
+ const history = this._outputThroughputHistory.snapshot();
942
+ const local = this._latestOutputThroughputSample;
943
+ if (local?.provider === model.provider &&
944
+ local.model === model.id &&
945
+ !history
946
+ .samples(model.provider, model.id)
947
+ .some((sample) => sample.provider === local.provider &&
948
+ sample.model === local.model &&
949
+ sample.responseModel === local.responseModel &&
950
+ sample.outputTokens === local.outputTokens &&
951
+ sample.durationMs === local.durationMs &&
952
+ sample.observedAt === local.observedAt)) {
953
+ history.add(local);
954
+ }
955
+ return history.median(model.provider, model.id);
956
+ }
957
+ get outputThroughputHistory() {
958
+ return this._outputThroughputHistory.snapshot();
959
+ }
960
+ refreshOutputThroughputHistory() {
961
+ return this._outputThroughputHistory.refresh();
962
+ }
963
+ flushOutputThroughputHistory() {
964
+ return this._outputThroughputHistory.flush();
965
+ }
550
966
  /** Current retry attempt (0 if not retrying) */
551
967
  get retryAttempt() {
552
- return this._retryAttempt;
968
+ return this._retry?.attempt ?? 0;
553
969
  }
554
970
  /**
555
971
  * Get the names of currently active tools.
@@ -600,7 +1016,7 @@ export class AgentSession {
600
1016
  // Rebuild base system prompt with new tool set
601
1017
  this._rebuildSystemPrompt(validToolNames);
602
1018
  }
603
- // SCRAMJET-DIVERGENCE: harness-tool invocation (#244).
1019
+ // SCRAMJET-DIVERGENCE: harness-tool invocation (#244), persisted-settlement boundary (#341).
604
1020
  /**
605
1021
  * Execute a registered tool as a harness-originated call (not requested by the model).
606
1022
  *
@@ -612,13 +1028,97 @@ export class AgentSession {
612
1028
  * run/turn framing. The idle-vs-mid-run branch lives in `runHarnessTool` (keyed on the agent's
613
1029
  * active run, which tracks `isStreaming`): idle calls execute immediately, mid-run calls queue
614
1030
  * and drain before the next intra-run LLM call.
1031
+ *
1032
+ * **Persisted settlement (#341).** This is a strictly stronger boundary than
1033
+ * {@link Agent.runHarnessTool}: the returned promise resolves only after (1) the Agent-core
1034
+ * pipeline fulfills *and* (2) `_processAgentEvent` has persisted the matching tool-result
1035
+ * `message_end` via `SessionManager.appendMessage`. Because the event queue preserves order,
1036
+ * that also proves the preceding synthetic assistant tool-call message was persisted first. It
1037
+ * rejects on Agent-core failure, on failure to process/persist a matching event, on disposal
1038
+ * before settlement, on a post-disposal call, on an unknown tool, or on a duplicate explicit
1039
+ * tool-call id that is already pending. **Rejection means the promised pipeline did not complete —
1040
+ * it does not prove artifact absence** (Agent state and persistence may have partially or fully
1041
+ * completed before a late failure), so callers must not blindly retry. A tool's own `execute()`
1042
+ * error is *not* a rejection: it becomes a normal `isError: true` result and the promise resolves
1043
+ * after that error-result pipeline is persisted.
1044
+ *
1045
+ * **Reentrancy.** As with `runHarnessTool`, do not `await` this from work whose return is required
1046
+ * for the Agent to reach the drain point or for the event queue to persist the matching result.
615
1047
  */
616
1048
  async invokeHarnessTool(name, args, options) {
1049
+ if (this._disposed) {
1050
+ throw new Error(`Cannot invoke harness tool "${name}": the session has been disposed.`);
1051
+ }
1052
+ // SCRAMJET-DIVERGENCE: reject new session work before allocating invocation ownership (#598).
1053
+ if (this._compactionAbortController) {
1054
+ throw new Error(`Cannot invoke harness tool "${name}": manual compaction is in progress. Retry after it settles.`);
1055
+ }
617
1056
  const tool = this._toolRegistry.get(name);
618
1057
  if (!tool) {
619
1058
  throw new Error(`Cannot invoke harness tool "${name}": no tool with that name is registered.`);
620
1059
  }
621
- await this.agent.runHarnessTool(tool, args, options);
1060
+ const toolCallId = this._allocateHarnessToolCallId(options?.toolCallId);
1061
+ // Register before Agent execution can emit events. The coordinator retains attribution until Agent
1062
+ // execution and the invocation's final queued persistence task both settle.
1063
+ const ack = this._registerHarnessPersistenceAck(toolCallId);
1064
+ void this._completeHarnessToolInvocation(this.agent.runHarnessTool(tool, args, { toolCallId }), ack);
1065
+ try {
1066
+ await ack.promise;
1067
+ }
1068
+ finally {
1069
+ // Identity-checked cleanup: only drop the entry if it is still this invocation's ack (disposal
1070
+ // may have cleared the map, and a later same-id invocation must not have its entry removed).
1071
+ if (this._harnessPersistenceAcks.get(toolCallId) === ack) {
1072
+ this._harnessPersistenceAcks.delete(toolCallId);
1073
+ }
1074
+ }
1075
+ }
1076
+ // SCRAMJET-DIVERGENCE: harness-tool persisted-settlement (#341). Allocate the tool-call id used as
1077
+ // the acknowledgement key: reject a duplicate explicit id that is still pending, retry a generated
1078
+ // id on the (astronomically unlikely) collision with a pending one.
1079
+ _allocateHarnessToolCallId(explicit) {
1080
+ if (explicit !== undefined) {
1081
+ // Validate at the public trust boundary (invokeHarnessTool is on ExtensionAPI): an explicit
1082
+ // id flows straight into the synthetic assistant message and on to the provider, so it must
1083
+ // meet the provider-safe shape generateHarnessToolCallId guarantees (Anthropic's
1084
+ // ^[a-zA-Z0-9_-]+$, bounded length).
1085
+ if (!/^[a-zA-Z0-9_-]{1,64}$/.test(explicit)) {
1086
+ throw new Error(`Cannot invoke harness tool with tool-call id "${explicit}": ids must be 1-64 characters of [a-zA-Z0-9_-].`);
1087
+ }
1088
+ if (this._harnessPersistenceAcks.has(explicit)) {
1089
+ throw new Error(`Cannot invoke harness tool with tool-call id "${explicit}": an invocation with that id is already pending.`);
1090
+ }
1091
+ return explicit;
1092
+ }
1093
+ let id = generateHarnessToolCallId();
1094
+ while (this._harnessPersistenceAcks.has(id)) {
1095
+ id = generateHarnessToolCallId();
1096
+ }
1097
+ return id;
1098
+ }
1099
+ async _completeHarnessToolInvocation(execution, ack) {
1100
+ let executionError;
1101
+ try {
1102
+ await execution;
1103
+ }
1104
+ catch (error) {
1105
+ executionError = error instanceof Error ? error : new Error(String(error));
1106
+ }
1107
+ try {
1108
+ await ack.persistenceTail;
1109
+ }
1110
+ catch { }
1111
+ this._settleHarnessPersistenceAck(ack, ack.firstPersistenceError ?? executionError);
1112
+ }
1113
+ _settleHarnessPersistenceAck(ack, error) {
1114
+ settleDeferred(ack, error);
1115
+ }
1116
+ // SCRAMJET-DIVERGENCE: harness-tool persisted-settlement (#341). Register a deferred acknowledgement
1117
+ // keyed by tool-call id before any invocation event can be emitted.
1118
+ _registerHarnessPersistenceAck(toolCallId) {
1119
+ const ack = { ...createDeferred(), persistenceTail: Promise.resolve() };
1120
+ this._harnessPersistenceAcks.set(toolCallId, ack);
1121
+ return ack;
622
1122
  }
623
1123
  /** Whether compaction or branch summarization is currently running */
624
1124
  get isCompacting() {
@@ -684,6 +1184,19 @@ export class AgentSession {
684
1184
  }
685
1185
  return Array.from(unique);
686
1186
  }
1187
+ _applyRunPromptComposition() {
1188
+ switch (this._runPromptComposition.kind) {
1189
+ case "generated-base":
1190
+ this.agent.state.systemPrompt = this._baseSystemPromptSections.slice();
1191
+ break;
1192
+ case "contributed-sections":
1193
+ this.agent.state.systemPrompt = spliceContributedSections(this._baseSystemPromptSections, this._runPromptComposition.sections);
1194
+ break;
1195
+ case "authoritative-string":
1196
+ this.agent.state.systemPrompt = this._runPromptComposition.systemPrompt;
1197
+ break;
1198
+ }
1199
+ }
687
1200
  /** Rebuild `_baseSystemPromptSections` from the current resources and tool set and apply it to agent state. */
688
1201
  _rebuildSystemPrompt(toolNames) {
689
1202
  const validToolNames = toolNames.filter((name) => this._toolRegistry.has(name));
@@ -704,6 +1217,7 @@ export class AgentSession {
704
1217
  const appendSystemPrompt = loaderAppendSystemPrompt.length > 0 ? loaderAppendSystemPrompt.join("\n\n") : undefined;
705
1218
  const loadedSkills = this._resourceLoader.getSkills().skills;
706
1219
  const loadedContextFiles = this._resourceLoader.getAgentsFiles().agentsFiles;
1220
+ // SCRAMJET-DIVERGENCE: pass session file path for volatile environment fact (issue 348)
707
1221
  this._baseSystemPromptOptions = {
708
1222
  cwd: this._cwd,
709
1223
  skills: loadedSkills,
@@ -713,9 +1227,10 @@ export class AgentSession {
713
1227
  selectedTools: validToolNames,
714
1228
  toolSnippets,
715
1229
  promptGuidelines,
1230
+ sessionFile: this.sessionManager.getSessionFile(),
716
1231
  };
717
1232
  this._baseSystemPromptSections = buildSystemPromptSections(this._baseSystemPromptOptions);
718
- this.agent.state.systemPrompt = this._baseSystemPromptSections.slice();
1233
+ this._applyRunPromptComposition();
719
1234
  }
720
1235
  // =========================================================================
721
1236
  // Prompting
@@ -733,6 +1248,8 @@ export class AgentSession {
733
1248
  const expandPromptTemplates = options?.expandPromptTemplates ?? true;
734
1249
  const preflightResult = options?.preflightResult;
735
1250
  let messages;
1251
+ let inputSessionEntries;
1252
+ let acceptInput;
736
1253
  try {
737
1254
  // Handle extension commands first (execute immediately, even during streaming)
738
1255
  // Extension commands manage their own LLM interaction via pi.sendMessage()
@@ -744,6 +1261,7 @@ export class AgentSession {
744
1261
  return;
745
1262
  }
746
1263
  }
1264
+ this._assertRetryNotOwningIdleAgent();
747
1265
  // Emit input event for extension interception (before skill/template expansion)
748
1266
  let currentText = text;
749
1267
  let currentImages = options?.images;
@@ -756,6 +1274,8 @@ export class AgentSession {
756
1274
  if (inputResult.action === "transform") {
757
1275
  currentText = inputResult.text;
758
1276
  currentImages = inputResult.images ?? currentImages;
1277
+ inputSessionEntries = inputResult.sessionEntries;
1278
+ acceptInput = inputResult.onAccepted;
759
1279
  }
760
1280
  }
761
1281
  // Expand skill commands (/skill:name args) and prompt templates (/template args)
@@ -771,11 +1291,12 @@ export class AgentSession {
771
1291
  "deliverAs when calling dispatchUserInput() or sendUserMessage() — to queue the message.");
772
1292
  }
773
1293
  if (options.streamingBehavior === "followUp") {
774
- await this._queueFollowUp(expandedText, currentImages);
1294
+ await this._queueFollowUp(expandedText, currentImages, inputSessionEntries);
775
1295
  }
776
1296
  else {
777
- await this._queueSteer(expandedText, currentImages);
1297
+ await this._queueSteer(expandedText, currentImages, inputSessionEntries);
778
1298
  }
1299
+ acceptInput?.();
779
1300
  preflightResult?.(true);
780
1301
  return;
781
1302
  }
@@ -797,7 +1318,7 @@ export class AgentSession {
797
1318
  // Check if we need to compact before sending (catches aborted responses)
798
1319
  const lastAssistant = this._findLastAssistantMessage();
799
1320
  if (lastAssistant) {
800
- await this._checkCompaction(lastAssistant, false);
1321
+ await this._checkCompaction(lastAssistant, "new_prompt");
801
1322
  }
802
1323
  // Build messages array (custom message if any, then user message)
803
1324
  messages = [];
@@ -806,16 +1327,21 @@ export class AgentSession {
806
1327
  if (currentImages) {
807
1328
  userContent.push(...currentImages);
808
1329
  }
809
- messages.push({
1330
+ const userMessage = {
810
1331
  role: "user",
811
1332
  content: userContent,
812
1333
  timestamp: Date.now(),
813
- });
1334
+ };
1335
+ messages.push(userMessage);
1336
+ if (inputSessionEntries?.length) {
1337
+ this._inputSessionEntries.set(userMessage, inputSessionEntries);
1338
+ }
814
1339
  // Inject any pending "nextTurn" messages as context alongside the user message
815
1340
  for (const msg of this._pendingNextTurnMessages) {
816
1341
  messages.push(msg);
817
1342
  }
818
1343
  this._pendingNextTurnMessages = [];
1344
+ this._runPromptComposition = { kind: "generated-base" };
819
1345
  // Emit before_agent_start extension event
820
1346
  const result = await this._extensionRunner.emitBeforeAgentStart(expandedText, currentImages, this._baseSystemPromptSections, this._baseSystemPromptOptions);
821
1347
  // Add all custom messages from extensions
@@ -835,15 +1361,18 @@ export class AgentSession {
835
1361
  // replacement is authoritative (even an empty string): it wins for the
836
1362
  // whole prompt and any contributed sections are dropped for this turn.
837
1363
  if (result?.systemPrompt !== undefined) {
838
- this.agent.state.systemPrompt = result.systemPrompt;
1364
+ this._runPromptComposition = {
1365
+ kind: "authoritative-string",
1366
+ systemPrompt: result.systemPrompt,
1367
+ };
839
1368
  }
840
1369
  else if (result?.systemPromptSections) {
841
- this.agent.state.systemPrompt = spliceContributedSections(this._baseSystemPromptSections, result.systemPromptSections);
842
- }
843
- else {
844
- // Ensure we're using the base prompt (in case previous turn had modifications)
845
- this.agent.state.systemPrompt = this._baseSystemPromptSections.slice();
1370
+ this._runPromptComposition = {
1371
+ kind: "contributed-sections",
1372
+ sections: result.systemPromptSections.map((section) => ({ ...section })),
1373
+ };
846
1374
  }
1375
+ this._applyRunPromptComposition();
847
1376
  }
848
1377
  catch (error) {
849
1378
  preflightResult?.(false);
@@ -852,9 +1381,9 @@ export class AgentSession {
852
1381
  if (!messages) {
853
1382
  return;
854
1383
  }
1384
+ acceptInput?.();
855
1385
  preflightResult?.(true);
856
- await this.agent.prompt(messages);
857
- await this.waitForRetry();
1386
+ await this._runAgentPrompt(messages);
858
1387
  }
859
1388
  /**
860
1389
  * Try to execute an extension command. Returns true if command was found and executed.
@@ -951,34 +1480,42 @@ export class AgentSession {
951
1480
  /**
952
1481
  * Internal: Queue a steering message (already expanded, no extension command check).
953
1482
  */
954
- async _queueSteer(text, images) {
1483
+ async _queueSteer(text, images, sessionEntries) {
955
1484
  this._steeringMessages.push(text);
956
1485
  this._emitQueueUpdate();
957
1486
  const content = [{ type: "text", text }];
958
1487
  if (images) {
959
1488
  content.push(...images);
960
1489
  }
961
- this.agent.steer({
1490
+ const message = {
962
1491
  role: "user",
963
1492
  content,
964
1493
  timestamp: Date.now(),
965
- });
1494
+ };
1495
+ if (sessionEntries?.length) {
1496
+ this._inputSessionEntries.set(message, sessionEntries);
1497
+ }
1498
+ this.agent.steer(message);
966
1499
  }
967
1500
  /**
968
1501
  * Internal: Queue a follow-up message (already expanded, no extension command check).
969
1502
  */
970
- async _queueFollowUp(text, images) {
1503
+ async _queueFollowUp(text, images, sessionEntries) {
971
1504
  this._followUpMessages.push(text);
972
1505
  this._emitQueueUpdate();
973
1506
  const content = [{ type: "text", text }];
974
1507
  if (images) {
975
1508
  content.push(...images);
976
1509
  }
977
- this.agent.followUp({
1510
+ const message = {
978
1511
  role: "user",
979
1512
  content,
980
1513
  timestamp: Date.now(),
981
- });
1514
+ };
1515
+ if (sessionEntries?.length) {
1516
+ this._inputSessionEntries.set(message, sessionEntries);
1517
+ }
1518
+ this.agent.followUp(message);
982
1519
  }
983
1520
  /**
984
1521
  * Throw an error if the text is an extension command.
@@ -1024,7 +1561,7 @@ export class AgentSession {
1024
1561
  }
1025
1562
  }
1026
1563
  else if (options?.triggerTurn) {
1027
- await this.agent.prompt(appMessage);
1564
+ await this._runAgentPrompt(appMessage);
1028
1565
  }
1029
1566
  else {
1030
1567
  this.agent.state.messages.push(appMessage);
@@ -1121,6 +1658,7 @@ export class AgentSession {
1121
1658
  */
1122
1659
  async abort() {
1123
1660
  this.abortRetry();
1661
+ this._resetOutputThroughput();
1124
1662
  this.agent.abort();
1125
1663
  await this.agent.waitForIdle();
1126
1664
  }
@@ -1308,11 +1846,32 @@ export class AgentSession {
1308
1846
  * @param customInstructions Optional instructions for the compaction summary
1309
1847
  */
1310
1848
  async compact(customInstructions) {
1311
- this._disconnectFromAgent();
1312
- await this.abort();
1313
- this._compactionAbortController = new AbortController();
1314
- this._emit({ type: "compaction_start", reason: "manual" });
1849
+ // SCRAMJET-DIVERGENCE: acquire one owner before awaits and drain terminal persistence (#598).
1850
+ if (this._disposed)
1851
+ throw new Error("Cannot compact a disposed session.");
1852
+ if (this.isCompacting)
1853
+ throw new Error("Compaction or branch summarization is already in progress. Retry after it settles.");
1854
+ if (this.agent.hasActiveTransientHarnessTools) {
1855
+ throw new Error("A harness tool is still executing outside an Agent run. Retry compaction after its persisted settlement.");
1856
+ }
1857
+ const controller = new AbortController();
1858
+ this._compactionAbortController = controller;
1859
+ const drain = {};
1860
+ this._manualCompactionDrain = drain;
1861
+ const runs = [...this._unsettledAgentRuns];
1315
1862
  try {
1863
+ this._resetOutputThroughput();
1864
+ this._disconnectFromAgent();
1865
+ this._unsubscribeAgent = this.agent.subscribe((event) => this._handleAgentEvent(event));
1866
+ this._emit({ type: "compaction_start", reason: "manual" });
1867
+ await this.abort();
1868
+ await this._agentEventQueue;
1869
+ if (drain.firstError)
1870
+ throw drain.firstError;
1871
+ if (this._disposed)
1872
+ throw new Error("Session disposed during compaction.");
1873
+ if (controller.signal.aborted)
1874
+ throw new Error("Compaction cancelled");
1316
1875
  if (!this.model) {
1317
1876
  throw new Error(formatNoModelSelectedMessage());
1318
1877
  }
@@ -1336,7 +1895,7 @@ export class AgentSession {
1336
1895
  preparation,
1337
1896
  branchEntries: pathEntries,
1338
1897
  customInstructions,
1339
- signal: this._compactionAbortController.signal,
1898
+ signal: controller.signal,
1340
1899
  }));
1341
1900
  if (result?.cancel) {
1342
1901
  throw new Error("Compaction cancelled");
@@ -1359,13 +1918,15 @@ export class AgentSession {
1359
1918
  }
1360
1919
  else {
1361
1920
  // Generate compaction result
1362
- const result = await compact(preparation, this.model, apiKey, headers, customInstructions, this._compactionAbortController.signal, this.thinkingLevel);
1921
+ const result = await compact(preparation, this.model, apiKey, headers, customInstructions, controller.signal, this.thinkingLevel);
1363
1922
  summary = result.summary;
1364
1923
  firstKeptEntryId = result.firstKeptEntryId;
1365
1924
  tokensBefore = result.tokensBefore;
1366
1925
  details = result.details;
1367
1926
  }
1368
- if (this._compactionAbortController.signal.aborted) {
1927
+ if (this._disposed)
1928
+ throw new Error("Session disposed during compaction.");
1929
+ if (controller.signal.aborted) {
1369
1930
  throw new Error("Compaction cancelled");
1370
1931
  }
1371
1932
  this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension);
@@ -1397,8 +1958,13 @@ export class AgentSession {
1397
1958
  return compactionResult;
1398
1959
  }
1399
1960
  catch (error) {
1400
- const message = error instanceof Error ? error.message : String(error);
1401
- const aborted = message === "Compaction cancelled" || (error instanceof Error && error.name === "AbortError");
1961
+ const failure = drain.firstError ?? error;
1962
+ const settlementError = failure instanceof Error ? failure : new Error(String(failure));
1963
+ this._releaseRetryForFailedRun(settlementError);
1964
+ for (const run of runs)
1965
+ this._completeAgentRun(run, settlementError);
1966
+ const message = settlementError.message;
1967
+ const aborted = message === "Compaction cancelled" || settlementError.name === "AbortError";
1402
1968
  this._emit({
1403
1969
  type: "compaction_end",
1404
1970
  reason: "manual",
@@ -1407,9 +1973,11 @@ export class AgentSession {
1407
1973
  willRetry: false,
1408
1974
  errorMessage: aborted ? undefined : `Compaction failed: ${message}`,
1409
1975
  });
1410
- throw error;
1976
+ throw failure;
1411
1977
  }
1412
1978
  finally {
1979
+ this._disconnectFromAgent();
1980
+ this._manualCompactionDrain = undefined;
1413
1981
  this._compactionAbortController = undefined;
1414
1982
  this._reconnectToAgent();
1415
1983
  }
@@ -1436,15 +2004,44 @@ export class AgentSession {
1436
2004
  * 2. Threshold: Context over threshold, compact, NO auto-retry (user continues manually)
1437
2005
  *
1438
2006
  * @param assistantMessage The assistant message to check
1439
- * @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true
1440
2007
  */
1441
- async _checkCompaction(assistantMessage, skipAbortedCheck = true) {
2008
+ async _checkCompaction(assistantMessage, trigger = "settlement") {
2009
+ if (this._manualCompactionDrain)
2010
+ return;
1442
2011
  const settings = this.settingsManager.getCompactionSettings();
1443
2012
  if (!settings.enabled)
1444
2013
  return;
1445
- // Skip if message was aborted (user cancelled) - unless skipAbortedCheck is false
1446
- if (skipAbortedCheck && assistantMessage.stopReason === "aborted")
2014
+ // SCRAMJET-DIVERGENCE: New prompts maintain context independently of failed-turn recovery.
2015
+ if (trigger === "settlement" && blocksFailureRecovery(assistantMessage))
2016
+ return;
2017
+ if (trigger === "new_prompt") {
2018
+ const boundary = getLatestCompactionEntry(this.sessionManager.getBranch());
2019
+ const messages = this.agent.state.messages;
2020
+ const reverseIndex = [...messages]
2021
+ .reverse()
2022
+ .findIndex((message) => message.role === "assistant" &&
2023
+ message.origin !== "harness" &&
2024
+ message.stopReason !== "error" &&
2025
+ message.stopReason !== "aborted" &&
2026
+ message.provider === this.model?.provider &&
2027
+ message.model === this.model?.id &&
2028
+ Number.isFinite(calculateContextTokens(message.usage)) &&
2029
+ calculateContextTokens(message.usage) >= 0 &&
2030
+ (!boundary || message.timestamp > new Date(boundary.timestamp).getTime()) &&
2031
+ [message.usage.input, message.usage.output, message.usage.cacheRead, message.usage.cacheWrite].every((n) => Number.isFinite(n) && n >= 0));
2032
+ if (reverseIndex < 0)
2033
+ return;
2034
+ const sourceIndex = messages.length - reverseIndex - 1;
2035
+ const source = messages[sourceIndex];
2036
+ const tokens = calculateContextTokens(source.usage) +
2037
+ messages.slice(sourceIndex + 1).reduce((total, message) => total + estimateTokens(message), 0);
2038
+ const window = this.model?.contextWindow ?? 0;
2039
+ if (shouldCompact(tokens, window, settings) ||
2040
+ (window > settings.reserveTokens && tokens > (this.model?.maxInputTokens ?? Infinity)))
2041
+ await this._runAutoCompaction("threshold", false);
1447
2042
  return;
2043
+ }
2044
+ // SCRAMJET-DIVERGENCE: Total context and independent input limits are separate constraints.
1448
2045
  const contextWindow = this.model?.contextWindow ?? 0;
1449
2046
  // Skip overflow check if the message came from a different model.
1450
2047
  // This handles the case where user switched from a smaller-context model (e.g. opus)
@@ -1460,7 +2057,8 @@ export class AgentSession {
1460
2057
  return;
1461
2058
  }
1462
2059
  // Case 1: Overflow - LLM returned context overflow error
1463
- if (sameModel && isContextOverflow(assistantMessage, contextWindow)) {
2060
+ if (sameModel &&
2061
+ isContextOverflow(assistantMessage, Math.min(contextWindow, this.model?.maxInputTokens ?? Infinity))) {
1464
2062
  if (this._overflowRecoveryAttempted) {
1465
2063
  this._emit({
1466
2064
  type: "compaction_end",
@@ -1505,7 +2103,8 @@ export class AgentSession {
1505
2103
  else {
1506
2104
  contextTokens = calculateContextTokens(assistantMessage.usage);
1507
2105
  }
1508
- if (shouldCompact(contextTokens, contextWindow, settings)) {
2106
+ if (shouldCompact(contextTokens, contextWindow, settings) ||
2107
+ (contextWindow > settings.reserveTokens && contextTokens > (this.model?.maxInputTokens ?? Infinity))) {
1509
2108
  await this._runAutoCompaction("threshold", false);
1510
2109
  }
1511
2110
  }
@@ -1514,9 +2113,10 @@ export class AgentSession {
1514
2113
  */
1515
2114
  async _runAutoCompaction(reason, willRetry) {
1516
2115
  const settings = this.settingsManager.getCompactionSettings();
1517
- this._emit({ type: "compaction_start", reason });
2116
+ // SCRAMJET-DIVERGENCE: reentrant subscribers must observe compaction ownership (#598).
1518
2117
  this._autoCompactionAbortController = new AbortController();
1519
2118
  try {
2119
+ this._emit({ type: "compaction_start", reason });
1520
2120
  if (!this.model) {
1521
2121
  this._emit({
1522
2122
  type: "compaction_end",
@@ -1815,6 +2415,7 @@ export class AgentSession {
1815
2415
  setThinkingLevel: (level) => this.setThinkingLevel(level),
1816
2416
  }, {
1817
2417
  getModel: () => this.model,
2418
+ getScopedModels: () => this.scopedModels,
1818
2419
  isIdle: () => !this.isStreaming,
1819
2420
  getSignal: () => this.agent.signal,
1820
2421
  abort: () => this.abort(),
@@ -1860,7 +2461,13 @@ export class AgentSession {
1860
2461
  definition,
1861
2462
  sourceInfo: createSyntheticSourceInfo(`<sdk:${definition.name}>`, { source: "sdk" }),
1862
2463
  })),
1863
- ].filter((tool) => isAllowedTool(tool.definition.name));
2464
+ ];
2465
+ // SCRAMJET-DIVERGENCE: historical interpretation follows definition collision winners, not execution allowlists (#598).
2466
+ this._historicalCostDefinitions = new Map(this._baseToolDefinitions);
2467
+ for (const tool of allCustomTools) {
2468
+ this._historicalCostDefinitions.set(tool.definition.name, tool.definition);
2469
+ }
2470
+ const allowedCustomTools = allCustomTools.filter((tool) => isAllowedTool(tool.definition.name));
1864
2471
  const definitionRegistry = new Map(Array.from(this._baseToolDefinitions.entries())
1865
2472
  .filter(([name]) => isAllowedTool(name))
1866
2473
  .map(([name, definition]) => [
@@ -1870,7 +2477,7 @@ export class AgentSession {
1870
2477
  sourceInfo: createSyntheticSourceInfo(`<builtin:${name}>`, { source: "builtin" }),
1871
2478
  },
1872
2479
  ]));
1873
- for (const tool of allCustomTools) {
2480
+ for (const tool of allowedCustomTools) {
1874
2481
  definitionRegistry.set(tool.definition.name, {
1875
2482
  definition: tool.definition,
1876
2483
  sourceInfo: tool.sourceInfo,
@@ -1890,7 +2497,7 @@ export class AgentSession {
1890
2497
  })
1891
2498
  .filter((entry) => entry !== undefined));
1892
2499
  const runner = this._extensionRunner;
1893
- const wrappedExtensionTools = wrapRegisteredTools(allCustomTools, runner);
2500
+ const wrappedExtensionTools = wrapRegisteredTools(allowedCustomTools, runner);
1894
2501
  const wrappedBuiltInTools = wrapRegisteredTools(Array.from(this._baseToolDefinitions.values())
1895
2502
  .filter((definition) => isAllowedTool(definition.name))
1896
2503
  .map((definition) => ({
@@ -1961,10 +2568,21 @@ export class AgentSession {
1961
2568
  }
1962
2569
  async reload() {
1963
2570
  const previousFlagValues = this._extensionRunner.getFlagValues();
1964
- await emitSessionShutdownEvent(this._extensionRunner, { type: "session_shutdown", reason: "reload" });
2571
+ // SCRAMJET-DIVERGENCE: the Scramjet builtin is a required product component, so reload must validate
2572
+ // the replacement loader before any irreversible runtime mutation. settingsManager.reload() runs first —
2573
+ // it commits fresh settings before the loader can throw, but the re-read is idempotent, so a failed reload
2574
+ // leaves the runtime itself untouched. It also runs first
2575
+ // because the loader's path resolution reads live settings (the loader defers its own settings/source-map
2576
+ // commit until after the builtin init succeeds, so on this path the shared manager is intentionally read
2577
+ // twice). resourceLoader.reload() then re-runs the required
2578
+ // builtin and throws RequiredBuiltinInitError on failure — before session_shutdown is emitted and before
2579
+ // resetApiProviders(). A throwing builtin therefore leaves the current runner, resources, and providers
2580
+ // intact and fires no session_shutdown; only a validated candidate proceeds to teardown and rebuild.
1965
2581
  await this.settingsManager.reload();
1966
- resetApiProviders();
1967
2582
  await this._resourceLoader.reload();
2583
+ await emitSessionShutdownEvent(this._extensionRunner, { type: "session_shutdown", reason: "reload" });
2584
+ resetApiProviders();
2585
+ this._runPromptComposition = { kind: "generated-base" };
1968
2586
  this._buildRuntime({
1969
2587
  activeToolNames: this.getActiveToolNames(),
1970
2588
  flagValues: previousFlagValues,
@@ -1982,114 +2600,325 @@ export class AgentSession {
1982
2600
  // =========================================================================
1983
2601
  // Auto-Retry
1984
2602
  // =========================================================================
1985
- /**
1986
- * Check if an error is retryable (overloaded, rate limit, server errors).
1987
- * Context overflow errors are NOT retryable (handled by compaction instead).
1988
- */
1989
- _isRetryableError(message) {
1990
- if (message.stopReason !== "error" || !message.errorMessage)
1991
- return false;
1992
- // Context overflow is handled by compaction, not retry
2603
+ // SCRAMJET-DIVERGENCE: persisted post-extension snapshots authoritatively classify and settle retries (#553).
2604
+ _classifyRetry(message) {
2605
+ if ((message.origin === "harness" || message.stopReason === "aborted") && blocksFailureRecovery(message))
2606
+ return { kind: "do_not_retry", reason: "non_provider_failure", evidence: "none" };
1993
2607
  const contextWindow = this.model?.contextWindow ?? 0;
1994
- if (isContextOverflow(message, contextWindow))
1995
- return false;
1996
- const err = message.errorMessage;
1997
- // 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
1998
- 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);
2608
+ if (isContextOverflow(message, Math.min(contextWindow, this.model?.maxInputTokens ?? Infinity))) {
2609
+ return { kind: "context_overflow" };
2610
+ }
2611
+ const structured = inspectFailureEvidence(message.diagnostics);
2612
+ if (structured.status === "valid") {
2613
+ if (structured.source !== "provider")
2614
+ return { kind: "do_not_retry", reason: "non_provider_failure", evidence: "none" };
2615
+ if (structured.suppression)
2616
+ return { kind: "do_not_retry", reason: structured.suppression, evidence: structured.family };
2617
+ if (structured.transient || ["missing_terminal_event", "missing_body"].includes(structured.category))
2618
+ return { kind: "retry", evidence: structured.family };
2619
+ return {
2620
+ kind: "do_not_retry",
2621
+ reason: ["unknown", "provider_error", "malformed_event"].includes(structured.category)
2622
+ ? "structured_unknown"
2623
+ : "structured_non_transient",
2624
+ evidence: structured.family,
2625
+ };
2626
+ }
2627
+ if (structured.status === "conflicting")
2628
+ return { kind: "do_not_retry", reason: "invalid_failure_evidence", evidence: "none" };
2629
+ if (structured.status === "malformed") {
2630
+ return { kind: "do_not_retry", reason: "malformed_provider_diagnostic", evidence: "none" };
2631
+ }
2632
+ if (structured.status === "duplicate") {
2633
+ return { kind: "do_not_retry", reason: "duplicate_provider_diagnostic", evidence: "none" };
2634
+ }
2635
+ if (!message.errorMessage) {
2636
+ return { kind: "do_not_retry", reason: "missing_error_evidence", evidence: "none" };
2637
+ }
2638
+ if (this._isLegacyRetryableError(message.errorMessage))
2639
+ return { kind: "retry", evidence: "legacy_text" };
2640
+ return { kind: "do_not_retry", reason: "legacy_non_retryable", evidence: "legacy_text" };
2641
+ }
2642
+ _isLegacyRetryableError(errorMessage) {
2643
+ 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);
2644
+ }
2645
+ _bounded(value) {
2646
+ return Math.min(65_535, Math.max(0, Number.isFinite(value) ? Math.trunc(value) : 65_535));
2647
+ }
2648
+ _appendAutoRetryRecord(record) {
2649
+ this.sessionManager.appendCustomEntry("coding-agent:auto-retry", record);
1999
2650
  }
2000
2651
  /**
2001
- * Handle retryable errors with exponential backoff.
2002
- * @returns true if retry was initiated, false if max retries exceeded or disabled
2652
+ * Persist a terminal record, emit `auto_retry_end` when a retry phase was active, and clear the phase.
2653
+ * The phase is released before persistence is attempted so a persistence failure can never strand it.
2003
2654
  */
2004
- async _handleRetryableError(message) {
2655
+ _finishRetry(record, end) {
2656
+ const retry = this._retry;
2657
+ this._retry = undefined;
2658
+ try {
2659
+ this._appendAutoRetryRecord(record);
2660
+ }
2661
+ catch (error) {
2662
+ if (retry && !this._manualCompactionDrain) {
2663
+ this._emit({
2664
+ type: "auto_retry_end",
2665
+ success: false,
2666
+ attempt: end?.attempt ?? retry.attempt,
2667
+ finalError: "Retry outcome persistence failed",
2668
+ });
2669
+ }
2670
+ throw this._reportRetryFailure(error);
2671
+ }
2672
+ if (retry && end && !this._manualCompactionDrain)
2673
+ this._emit(end);
2674
+ }
2675
+ _finishRetryWithoutSuccess(record, message) {
2676
+ const retry = this._retry;
2677
+ if (retry?.attempt === 0) {
2678
+ // The continuation already produced a successful message, so the retry recovered and this
2679
+ // later failure is a fresh, non-retried error; close the retry as succeeded first.
2680
+ const attempt = this._runRetryCount;
2681
+ this._finishRetry({
2682
+ schemaVersion: 1,
2683
+ outcome: "succeeded",
2684
+ attemptsCompleted: this._bounded(attempt),
2685
+ cumulativeErrors: this._bounded(attempt),
2686
+ }, { type: "auto_retry_end", success: true, attempt });
2687
+ this._finishRetry(record);
2688
+ return;
2689
+ }
2690
+ this._finishRetry(record, retry
2691
+ ? { type: "auto_retry_end", success: false, attempt: retry.attempt, finalError: message.errorMessage }
2692
+ : undefined);
2693
+ }
2694
+ /** A run that ends without a classifiable snapshot cannot continue the retry; close it so nothing stays wedged. */
2695
+ _releaseRetryForFailedRun(error) {
2696
+ const retry = this._retry;
2697
+ if (!retry)
2698
+ return;
2699
+ try {
2700
+ this._finishRetry({
2701
+ schemaVersion: 1,
2702
+ outcome: "failed",
2703
+ reason: "run_failed",
2704
+ attempt: this._bounded(retry.attempt),
2705
+ cumulativeErrors: this._bounded(this._runRetryCount),
2706
+ }, { type: "auto_retry_end", success: false, attempt: retry.attempt, finalError: error.message });
2707
+ }
2708
+ catch {
2709
+ // Already reported and the phase is released; the run settles with its own error.
2710
+ }
2711
+ }
2712
+ _reportRetryFailure(error) {
2713
+ const normalized = error instanceof Error ? error : new Error(String(error));
2714
+ this._extensionRunner.emitError({
2715
+ extensionPath: "agent-session:auto-retry",
2716
+ event: "retry_settlement",
2717
+ error: normalized.message,
2718
+ stack: normalized.stack,
2719
+ });
2720
+ return normalized;
2721
+ }
2722
+ async _handleRetryableError(message, evidence, run) {
2005
2723
  const settings = this.settingsManager.getRetrySettings();
2006
- if (!settings.enabled) {
2007
- this._resolveRetry();
2724
+ const attempt = (this._retry?.attempt ?? 0) + 1;
2725
+ const cumulativeCap = Math.min(Number.MAX_SAFE_INTEGER, settings.maxRetries * 2);
2726
+ if ((this._retry?.attempt ?? 0) >= settings.maxRetries) {
2727
+ const attemptsCompleted = attempt - 1;
2728
+ this._finishRetry({
2729
+ schemaVersion: 1,
2730
+ outcome: "exhausted",
2731
+ reason: "attempt_limit",
2732
+ attemptsCompleted: this._bounded(attemptsCompleted),
2733
+ maxAttempts: this._bounded(settings.maxRetries),
2734
+ cumulativeErrors: this._bounded(this._runRetryCount),
2735
+ }, { type: "auto_retry_end", success: false, attempt: attemptsCompleted, finalError: message.errorMessage });
2008
2736
  return false;
2009
2737
  }
2010
- // Retry promise is created synchronously in _handleAgentEvent for agent_end.
2011
- // Keep a defensive fallback here in case a future refactor bypasses that path.
2012
- if (!this._retryPromise) {
2013
- this._retryPromise = new Promise((resolve) => {
2014
- this._retryResolve = resolve;
2015
- });
2016
- }
2017
- this._retryAttempt++;
2018
- if (this._retryAttempt > settings.maxRetries) {
2019
- // Max retries exceeded, emit final failure and reset
2020
- this._emit({
2738
+ if (this._runRetryCount >= cumulativeCap) {
2739
+ const attemptsCompleted = this._runRetryCount;
2740
+ this._finishRetry({
2741
+ schemaVersion: 1,
2742
+ outcome: "exhausted",
2743
+ reason: "cumulative_limit",
2744
+ attemptsCompleted: this._bounded(attemptsCompleted),
2745
+ maxAttempts: this._bounded(settings.maxRetries),
2746
+ cumulativeErrors: this._bounded(this._runRetryCount + 1),
2747
+ }, {
2021
2748
  type: "auto_retry_end",
2022
2749
  success: false,
2023
- attempt: this._retryAttempt - 1,
2024
- finalError: message.errorMessage,
2750
+ attempt: attemptsCompleted,
2751
+ finalError: `Repeated retry failures (${attemptsCompleted} total attempts this prompt). ${message.errorMessage ?? ""}`.trim(),
2025
2752
  });
2026
- this._retryAttempt = 0;
2027
- this._resolveRetry(); // Resolve so waitForRetry() completes
2028
2753
  return false;
2029
2754
  }
2030
- const delayMs = settings.baseDelayMs * 2 ** (this._retryAttempt - 1);
2755
+ this._runRetryCount++;
2756
+ const delayMs = settings.baseDelayMs === 0
2757
+ ? 0
2758
+ : Math.min(2_147_483_647, settings.baseDelayMs * 2 ** Math.min(31, attempt - 1));
2759
+ const previous = this._retry;
2760
+ this._retry = undefined;
2761
+ try {
2762
+ this._appendAutoRetryRecord({
2763
+ schemaVersion: 1,
2764
+ outcome: "scheduled",
2765
+ evidence,
2766
+ attempt: this._bounded(attempt),
2767
+ maxAttempts: this._bounded(settings.maxRetries),
2768
+ cumulativeErrors: this._bounded(this._runRetryCount),
2769
+ delayMs: this._bounded(delayMs),
2770
+ });
2771
+ }
2772
+ catch (error) {
2773
+ if (previous) {
2774
+ this._emit({
2775
+ type: "auto_retry_end",
2776
+ success: false,
2777
+ attempt: previous.attempt,
2778
+ finalError: "Retry outcome persistence failed",
2779
+ });
2780
+ }
2781
+ throw this._reportRetryFailure(error);
2782
+ }
2783
+ const controller = new AbortController();
2784
+ const backoff = { phase: "backoff", attempt, controller, run };
2785
+ this._retry = backoff;
2031
2786
  this._emit({
2032
2787
  type: "auto_retry_start",
2033
- attempt: this._retryAttempt,
2788
+ attempt,
2034
2789
  maxAttempts: settings.maxRetries,
2035
2790
  delayMs,
2036
2791
  errorMessage: message.errorMessage || "Unknown error",
2792
+ cumulativeErrors: this._runRetryCount,
2037
2793
  });
2038
- // Remove error message from agent state (keep in session for history)
2794
+ if (controller.signal.aborted || this._disposed) {
2795
+ return this._finishCancelledRetry("cancelled_during_backoff", backoff);
2796
+ }
2039
2797
  const messages = this.agent.state.messages;
2040
2798
  if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
2041
2799
  this.agent.state.messages = messages.slice(0, -1);
2042
2800
  }
2043
- // Wait with exponential backoff (abortable)
2044
- this._retryAbortController = new AbortController();
2045
2801
  try {
2046
- await sleep(delayMs, this._retryAbortController.signal);
2802
+ await sleep(delayMs, controller.signal);
2803
+ await sleep(0, controller.signal);
2047
2804
  }
2048
2805
  catch {
2049
- // Aborted during sleep - emit end event so UI can clean up
2050
- const attempt = this._retryAttempt;
2051
- this._retryAttempt = 0;
2052
- this._retryAbortController = undefined;
2053
- this._emit({
2054
- type: "auto_retry_end",
2055
- success: false,
2056
- attempt,
2057
- finalError: "Retry cancelled",
2058
- });
2059
- this._resolveRetry();
2060
- return false;
2061
- }
2062
- this._retryAbortController = undefined;
2063
- // Retry via continue() - use setTimeout to break out of event handler chain
2064
- setTimeout(() => {
2065
- this.agent.continue().catch(() => {
2066
- // Retry failed - will be caught by next agent_end
2067
- });
2068
- }, 0);
2806
+ return this._finishCancelledRetry("cancelled_during_backoff", backoff);
2807
+ }
2808
+ if (controller.signal.aborted || this._disposed) {
2809
+ return this._finishCancelledRetry("cancelled_during_backoff", backoff);
2810
+ }
2811
+ if (this._manualCompactionDrain)
2812
+ return this._finishCancelledRetry("cancelled_during_backoff", backoff);
2813
+ // Hand the chain to the continuation before `continue()`; the run that eventually emits
2814
+ // `agent_start` adopts it (see `_captureAgentRunSettlement`), however long Agent defers that start.
2815
+ const continuing = { phase: "continuing", attempt, chain: run.chain };
2816
+ this._retry = continuing;
2817
+ const continuation = this.agent.continue();
2818
+ this._settleAgentRun(run);
2819
+ continuation.catch((error) => {
2820
+ if (this._disposed || this._retry !== continuing)
2821
+ return;
2822
+ const continuationError = this._reportRetryFailure(error);
2823
+ let settlementError = continuationError;
2824
+ try {
2825
+ this._finishRetry({
2826
+ schemaVersion: 1,
2827
+ outcome: "failed",
2828
+ reason: "continuation_rejected",
2829
+ attempt: this._bounded(attempt),
2830
+ cumulativeErrors: this._bounded(this._runRetryCount),
2831
+ }, { type: "auto_retry_end", success: false, attempt, finalError: continuationError.message });
2832
+ }
2833
+ catch (persistenceError) {
2834
+ settlementError = new AggregateError([continuationError, persistenceError], "Retry continuation and outcome persistence both failed.", { cause: continuationError });
2835
+ }
2836
+ this._settleRetryChain(run.chain, settlementError);
2837
+ });
2069
2838
  return true;
2070
2839
  }
2840
+ _finishCancelledRetry(reason, expected) {
2841
+ if (this._disposed) {
2842
+ this._retry = undefined;
2843
+ throw new Error("AgentSession disposed before retry settlement completed.");
2844
+ }
2845
+ const retry = this._retry;
2846
+ if (expected && retry !== expected)
2847
+ return false;
2848
+ const attempt = retry?.attempt ?? 0;
2849
+ this._finishRetry({
2850
+ schemaVersion: 1,
2851
+ outcome: "cancelled",
2852
+ reason,
2853
+ attempt: this._bounded(attempt),
2854
+ cumulativeErrors: this._bounded(this._runRetryCount),
2855
+ }, { type: "auto_retry_end", success: false, attempt, finalError: "Retry cancelled" });
2856
+ return false;
2857
+ }
2071
2858
  /**
2072
2859
  * Cancel in-progress retry.
2073
2860
  */
2074
2861
  abortRetry() {
2075
- this._retryAbortController?.abort();
2076
- // Note: _retryAttempt is reset in the catch block of _autoRetry
2077
- this._resolveRetry();
2078
- }
2079
- /**
2080
- * Wait for any in-progress retry to complete.
2081
- * Returns immediately if no retry is in progress.
2082
- */
2083
- async waitForRetry() {
2084
- if (!this._retryPromise) {
2862
+ const retry = this._retry;
2863
+ if (!retry)
2085
2864
  return;
2865
+ if (retry.phase === "backoff")
2866
+ retry.controller.abort();
2867
+ this.agent.abort();
2868
+ }
2869
+ /** Agent's own `activeRun` guard is false during backoff; the retry phase owns the idle Agent then. */
2870
+ _assertRetryNotOwningIdleAgent() {
2871
+ if (this._retry && !this.isStreaming) {
2872
+ throw new Error("Automatic retry is in progress. Cancel the retry before starting another prompt.");
2873
+ }
2874
+ }
2875
+ async _runAgentPrompt(messages) {
2876
+ this._assertRetryNotOwningIdleAgent();
2877
+ const start = this._reserveAgentRunStart();
2878
+ this._runRetryCount = 0;
2879
+ let promptFailure;
2880
+ try {
2881
+ await this.agent.prompt(messages);
2882
+ }
2883
+ catch (error) {
2884
+ promptFailure = { error };
2885
+ }
2886
+ if (!start.run && this._pendingAgentRunStart === start) {
2887
+ const startError = promptFailure
2888
+ ? promptFailure.error instanceof Error
2889
+ ? promptFailure.error
2890
+ : new Error(String(promptFailure.error))
2891
+ : new Error("Agent prompt completed before agent_start.");
2892
+ this._rejectAgentRunStart(start, startError);
2893
+ if (promptFailure)
2894
+ throw promptFailure.error;
2895
+ }
2896
+ let settlementFailure;
2897
+ try {
2898
+ const settlement = await start.promise;
2899
+ await this.waitForRetry(settlement);
2900
+ }
2901
+ catch (error) {
2902
+ settlementFailure = { error };
2903
+ }
2904
+ if (promptFailure && settlementFailure) {
2905
+ throw new AggregateError([promptFailure.error, settlementFailure.error], "Agent prompt and retry settlement both failed.", { cause: promptFailure.error });
2086
2906
  }
2087
- await this._retryPromise;
2907
+ if (promptFailure)
2908
+ throw promptFailure.error;
2909
+ if (settlementFailure)
2910
+ throw settlementFailure.error;
2911
+ }
2912
+ async waitForRetry(settlement) {
2913
+ if (!settlement)
2914
+ return;
2915
+ await settlement.promise;
2916
+ await settlement.chain.promise;
2088
2917
  await this.agent.waitForIdle();
2089
2918
  }
2090
2919
  /** Whether auto-retry is currently in progress */
2091
2920
  get isRetrying() {
2092
- return this._retryPromise !== undefined;
2921
+ return this._retry !== undefined;
2093
2922
  }
2094
2923
  /** Whether auto-retry is enabled */
2095
2924
  get autoRetryEnabled() {
@@ -2309,7 +3138,7 @@ export class AgentSession {
2309
3138
  if (targetEntry.type === "message" && targetEntry.message.role === "user") {
2310
3139
  // User message: leaf = parent (null if root), text goes to editor
2311
3140
  newLeafId = targetEntry.parentId;
2312
- editorText = this._extractUserMessageText(targetEntry.message.content);
3141
+ editorText = restoreScramjetCommandInvocation(targetEntry, this.sessionManager.getEntries());
2313
3142
  }
2314
3143
  else if (targetEntry.type === "custom_message") {
2315
3144
  // Custom message: leaf = parent (null if root), text goes to editor
@@ -2373,29 +3202,56 @@ export class AgentSession {
2373
3202
  */
2374
3203
  getUserMessagesForForking() {
2375
3204
  const entries = this.sessionManager.getEntries();
3205
+ const commandStarts = indexScramjetCommandStarts(entries);
2376
3206
  const result = [];
2377
3207
  for (const entry of entries) {
2378
3208
  if (entry.type !== "message")
2379
3209
  continue;
2380
3210
  if (entry.message.role !== "user")
2381
3211
  continue;
2382
- const text = this._extractUserMessageText(entry.message.content);
3212
+ const text = restoreScramjetCommandInvocation(entry, commandStarts);
2383
3213
  if (text) {
2384
3214
  result.push({ entryId: entry.id, text });
2385
3215
  }
2386
3216
  }
2387
3217
  return result;
2388
3218
  }
2389
- _extractUserMessageText(content) {
2390
- if (typeof content === "string")
2391
- return content;
2392
- if (Array.isArray(content)) {
2393
- return content
2394
- .filter((c) => c.type === "text")
2395
- .map((c) => c.text)
2396
- .join("");
3219
+ // SCRAMJET-DIVERGENCE: whole-journal parent/tool costs plus reports awaiting successful append (#598).
3220
+ getRecordedSessionCost() {
3221
+ let total = 0;
3222
+ for (const entry of this.sessionManager.getEntries()) {
3223
+ if (entry.type !== "message")
3224
+ continue;
3225
+ const message = entry.message;
3226
+ if (message.role === "assistant") {
3227
+ if (validRecordedCost(message.usage?.cost?.total))
3228
+ total += message.usage.cost.total;
3229
+ }
3230
+ else if (message.role === "toolResult") {
3231
+ if (validRecordedCost(message.cost)) {
3232
+ total += message.cost;
3233
+ continue;
3234
+ }
3235
+ const definition = this._historicalCostDefinitions.get(message.toolName);
3236
+ if (!definition?.getHistoricalCost)
3237
+ continue;
3238
+ try {
3239
+ const cost = definition.getHistoricalCost(message.details);
3240
+ if (validRecordedCost(cost))
3241
+ total += cost;
3242
+ }
3243
+ catch (error) {
3244
+ this._extensionRunner.emitError({
3245
+ extensionPath: `tool:${message.toolName}`,
3246
+ event: "historical_cost",
3247
+ error: `Failed to interpret historical tool cost: ${error instanceof Error ? error.message : String(error)}`,
3248
+ });
3249
+ }
3250
+ }
2397
3251
  }
2398
- return "";
3252
+ for (const execution of this._pendingToolCosts)
3253
+ total += execution.cost ?? 0;
3254
+ return total;
2399
3255
  }
2400
3256
  /**
2401
3257
  * Get session statistics.