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

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