@oh-my-pi/pi-coding-agent 17.0.0 → 17.0.2

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 (202) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/dist/cli.js +4006 -3966
  3. package/dist/types/advisor/advise-tool.d.ts +5 -2
  4. package/dist/types/advisor/config.d.ts +14 -6
  5. package/dist/types/advisor/runtime.d.ts +20 -11
  6. package/dist/types/autolearn/controller.d.ts +1 -0
  7. package/dist/types/cli-commands.d.ts +14 -1
  8. package/dist/types/config/model-discovery.d.ts +17 -0
  9. package/dist/types/config/model-registry.d.ts +4 -0
  10. package/dist/types/config/model-resolver.d.ts +6 -2
  11. package/dist/types/config/models-config-schema.d.ts +10 -0
  12. package/dist/types/config/models-config.d.ts +6 -0
  13. package/dist/types/config/settings-schema.d.ts +32 -7
  14. package/dist/types/config/settings.d.ts +50 -0
  15. package/dist/types/cursor.d.ts +11 -0
  16. package/dist/types/discovery/helpers.d.ts +5 -2
  17. package/dist/types/discovery/substitute-plugin-root.d.ts +20 -7
  18. package/dist/types/exec/bash-executor.d.ts +2 -0
  19. package/dist/types/extensibility/extensions/managed-timers.d.ts +15 -0
  20. package/dist/types/extensibility/extensions/runner.d.ts +7 -0
  21. package/dist/types/extensibility/extensions/types.d.ts +18 -0
  22. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +40 -0
  23. package/dist/types/extensibility/shared-events.d.ts +6 -0
  24. package/dist/types/extensibility/utils.d.ts +2 -2
  25. package/dist/types/internal-urls/agent-protocol.d.ts +1 -0
  26. package/dist/types/lib/xai-http.d.ts +11 -0
  27. package/dist/types/mcp/transports/stdio.d.ts +13 -1
  28. package/dist/types/modes/acp/acp-agent.d.ts +3 -0
  29. package/dist/types/modes/acp/acp-mode.d.ts +6 -2
  30. package/dist/types/modes/components/advisor-config.d.ts +8 -1
  31. package/dist/types/modes/components/assistant-message.d.ts +2 -0
  32. package/dist/types/modes/components/custom-editor-plugin-ctor.test.d.ts +1 -0
  33. package/dist/types/modes/components/custom-editor.d.ts +23 -1
  34. package/dist/types/modes/components/hook-editor.d.ts +16 -2
  35. package/dist/types/modes/components/login-dialog.test.d.ts +1 -0
  36. package/dist/types/modes/components/model-hub.d.ts +5 -2
  37. package/dist/types/modes/components/session-selector.d.ts +4 -0
  38. package/dist/types/modes/components/tool-execution.d.ts +8 -1
  39. package/dist/types/modes/components/tool-execution.test.d.ts +1 -0
  40. package/dist/types/modes/controllers/command-controller.d.ts +8 -0
  41. package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
  42. package/dist/types/modes/interactive-mode.d.ts +6 -0
  43. package/dist/types/modes/noninteractive-dispose.test.d.ts +1 -0
  44. package/dist/types/modes/print-mode.d.ts +1 -1
  45. package/dist/types/modes/types.d.ts +10 -0
  46. package/dist/types/modes/utils/transcript-render-helpers.d.ts +2 -2
  47. package/dist/types/modes/warp-events.d.ts +24 -0
  48. package/dist/types/modes/warp-events.test.d.ts +1 -0
  49. package/dist/types/plan-mode/model-transition.d.ts +47 -0
  50. package/dist/types/plan-mode/model-transition.test.d.ts +1 -0
  51. package/dist/types/registry/agent-lifecycle.d.ts +26 -1
  52. package/dist/types/sdk.d.ts +13 -2
  53. package/dist/types/session/agent-session.d.ts +43 -11
  54. package/dist/types/session/session-history-format.d.ts +10 -0
  55. package/dist/types/session/session-manager.d.ts +14 -0
  56. package/dist/types/session/streaming-output.d.ts +2 -0
  57. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +9 -0
  58. package/dist/types/slash-commands/types.d.ts +4 -4
  59. package/dist/types/system-prompt.d.ts +1 -1
  60. package/dist/types/task/label.d.ts +1 -1
  61. package/dist/types/telemetry-export.d.ts +34 -9
  62. package/dist/types/tools/approval.d.ts +8 -0
  63. package/dist/types/tools/bash.d.ts +2 -0
  64. package/dist/types/tools/browser/registry.d.ts +7 -3
  65. package/dist/types/tools/browser/tab-supervisor.d.ts +2 -0
  66. package/dist/types/tools/essential-tools.d.ts +29 -0
  67. package/dist/types/tools/image-gen.d.ts +2 -1
  68. package/dist/types/tools/index.d.ts +4 -1
  69. package/dist/types/tools/manage-skill.d.ts +2 -0
  70. package/dist/types/tools/xdev.d.ts +11 -2
  71. package/dist/types/utils/title-generator.d.ts +2 -1
  72. package/dist/types/web/search/index.d.ts +2 -0
  73. package/dist/types/web/search/providers/base.d.ts +3 -0
  74. package/dist/types/web/search/providers/kimi.d.ts +4 -1
  75. package/dist/types/web/search/types.d.ts +1 -1
  76. package/package.json +21 -16
  77. package/src/advisor/__tests__/advisor.test.ts +1358 -86
  78. package/src/advisor/__tests__/config.test.ts +58 -2
  79. package/src/advisor/advise-tool.ts +7 -3
  80. package/src/advisor/config.ts +76 -24
  81. package/src/advisor/runtime.ts +445 -92
  82. package/src/autolearn/controller.ts +23 -28
  83. package/src/cli/bench-cli.ts +4 -1
  84. package/src/cli/grep-cli.ts +2 -1
  85. package/src/cli-commands.ts +57 -23
  86. package/src/cli.ts +5 -1
  87. package/src/config/model-discovery.ts +81 -21
  88. package/src/config/model-registry.ts +34 -6
  89. package/src/config/model-resolver.ts +57 -12
  90. package/src/config/models-config-schema.ts +1 -0
  91. package/src/config/settings-schema.ts +43 -6
  92. package/src/config/settings.ts +405 -25
  93. package/src/cursor.ts +20 -3
  94. package/src/debug/report-bundle.ts +40 -4
  95. package/src/discovery/codex.ts +14 -4
  96. package/src/discovery/helpers.ts +28 -5
  97. package/src/discovery/substitute-plugin-root.ts +23 -7
  98. package/src/exec/bash-executor.ts +14 -5
  99. package/src/export/html/template.js +2 -0
  100. package/src/extensibility/custom-tools/loader.ts +3 -3
  101. package/src/extensibility/custom-tools/wrapper.ts +2 -1
  102. package/src/extensibility/extensions/loader.ts +3 -3
  103. package/src/extensibility/extensions/managed-timers.ts +83 -0
  104. package/src/extensibility/extensions/runner.ts +26 -0
  105. package/src/extensibility/extensions/types.ts +18 -0
  106. package/src/extensibility/extensions/wrapper.ts +2 -1
  107. package/src/extensibility/hooks/loader.ts +3 -3
  108. package/src/extensibility/legacy-pi-coding-agent-shim.ts +96 -1
  109. package/src/extensibility/plugins/legacy-pi-compat.ts +225 -22
  110. package/src/extensibility/plugins/manager.ts +2 -2
  111. package/src/extensibility/shared-events.ts +6 -0
  112. package/src/extensibility/utils.ts +91 -25
  113. package/src/internal-urls/__tests__/agent-protocol-nested.test.ts +73 -0
  114. package/src/internal-urls/agent-protocol.ts +73 -39
  115. package/src/irc/bus.ts +22 -3
  116. package/src/launch/broker.ts +3 -2
  117. package/src/launch/client.ts +2 -2
  118. package/src/launch/presence.ts +2 -2
  119. package/src/lib/xai-http.ts +28 -2
  120. package/src/lsp/client.ts +1 -1
  121. package/src/main.ts +11 -8
  122. package/src/mcp/manager.ts +9 -3
  123. package/src/mcp/transports/stdio.ts +103 -23
  124. package/src/modes/acp/acp-agent.ts +28 -9
  125. package/src/modes/acp/acp-event-mapper.ts +8 -0
  126. package/src/modes/acp/acp-mode.ts +18 -4
  127. package/src/modes/components/advisor-config.ts +65 -3
  128. package/src/modes/components/ask-dialog.ts +1 -1
  129. package/src/modes/components/assistant-message.ts +35 -14
  130. package/src/modes/components/chat-transcript-builder.ts +2 -0
  131. package/src/modes/components/custom-editor-plugin-ctor.test.ts +36 -0
  132. package/src/modes/components/custom-editor.ts +62 -1
  133. package/src/modes/components/hook-editor.ts +35 -5
  134. package/src/modes/components/login-dialog.test.ts +56 -0
  135. package/src/modes/components/login-dialog.ts +7 -3
  136. package/src/modes/components/model-hub.ts +138 -42
  137. package/src/modes/components/plan-review-overlay.ts +51 -10
  138. package/src/modes/components/session-selector.ts +10 -0
  139. package/src/modes/components/status-line/component.test.ts +1 -0
  140. package/src/modes/components/status-line/component.ts +2 -4
  141. package/src/modes/components/status-line/segments.ts +21 -6
  142. package/src/modes/components/tool-execution.test.ts +101 -0
  143. package/src/modes/components/tool-execution.ts +117 -9
  144. package/src/modes/components/transcript-container.ts +30 -2
  145. package/src/modes/controllers/command-controller-shared.ts +1 -1
  146. package/src/modes/controllers/command-controller.ts +168 -48
  147. package/src/modes/controllers/event-controller.ts +6 -0
  148. package/src/modes/controllers/extension-ui-controller.ts +4 -22
  149. package/src/modes/controllers/input-controller.ts +12 -12
  150. package/src/modes/controllers/selector-controller.ts +208 -46
  151. package/src/modes/interactive-mode.ts +205 -65
  152. package/src/modes/noninteractive-dispose.test.ts +60 -0
  153. package/src/modes/print-mode.ts +10 -5
  154. package/src/modes/rpc/host-tools.ts +2 -1
  155. package/src/modes/rpc/rpc-mode.ts +31 -7
  156. package/src/modes/theme/theme.ts +2 -2
  157. package/src/modes/types.ts +10 -0
  158. package/src/modes/utils/interactive-context-helpers.ts +3 -1
  159. package/src/modes/utils/transcript-render-helpers.ts +3 -2
  160. package/src/modes/warp-events.test.ts +794 -0
  161. package/src/modes/warp-events.ts +232 -0
  162. package/src/plan-mode/model-transition.test.ts +60 -0
  163. package/src/plan-mode/model-transition.ts +51 -0
  164. package/src/prompts/system/system-prompt.md +1 -1
  165. package/src/registry/agent-lifecycle.ts +133 -18
  166. package/src/sdk.ts +227 -44
  167. package/src/session/agent-session.ts +1364 -382
  168. package/src/session/session-history-format.ts +20 -5
  169. package/src/session/session-listing.ts +6 -5
  170. package/src/session/session-loader.ts +9 -0
  171. package/src/session/session-manager.ts +48 -0
  172. package/src/session/session-persistence.ts +11 -0
  173. package/src/session/streaming-output.ts +3 -1
  174. package/src/slash-commands/builtin-registry.ts +9 -0
  175. package/src/slash-commands/helpers/active-oauth-account.ts +16 -0
  176. package/src/slash-commands/types.ts +4 -4
  177. package/src/system-prompt.ts +2 -2
  178. package/src/task/executor.ts +1 -1
  179. package/src/task/label.ts +2 -0
  180. package/src/telemetry-export.ts +453 -97
  181. package/src/tools/approval.ts +11 -0
  182. package/src/tools/bash.ts +71 -38
  183. package/src/tools/browser/registry.ts +9 -2
  184. package/src/tools/browser/tab-supervisor.ts +65 -10
  185. package/src/tools/browser.ts +4 -3
  186. package/src/tools/essential-tools.ts +45 -0
  187. package/src/tools/eval.ts +3 -0
  188. package/src/tools/gh.ts +169 -2
  189. package/src/tools/image-gen.ts +624 -513
  190. package/src/tools/index.ts +10 -6
  191. package/src/tools/manage-skill.ts +5 -3
  192. package/src/tools/output-meta.ts +7 -0
  193. package/src/tools/path-utils.ts +8 -5
  194. package/src/tools/read.ts +48 -3
  195. package/src/tools/write.ts +22 -4
  196. package/src/tools/xdev.ts +14 -3
  197. package/src/utils/title-generator.ts +15 -4
  198. package/src/web/search/index.ts +21 -6
  199. package/src/web/search/providers/base.ts +3 -0
  200. package/src/web/search/providers/kimi.ts +18 -12
  201. package/src/web/search/providers/xai.ts +40 -9
  202. package/src/web/search/types.ts +6 -1
@@ -1,6 +1,7 @@
1
1
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
2
2
  import { estimateTokens } from "@oh-my-pi/pi-agent-core/compaction";
3
3
  import type { AssistantMessage, ImageContent, TextContent } from "@oh-my-pi/pi-ai";
4
+ import * as AIError from "@oh-my-pi/pi-ai/error";
4
5
  import { logger } from "@oh-my-pi/pi-utils";
5
6
  import { obfuscateToolArguments, type SecretObfuscator } from "../secrets/obfuscator";
6
7
  import { formatSessionHistoryMarkdown, PRIMARY_CONTEXT_CUSTOM_TYPES } from "../session/session-history-format";
@@ -35,10 +36,10 @@ export interface AdvisorRuntimeHost {
35
36
  * Pre-prompt context maintenance for the advisor's own append-only context.
36
37
  * Promotes the advisor model to a larger sibling when its context nears the
37
38
  * window (mirroring the primary's promote-first policy) and resolves `true`
38
- * when the advisor should re-prime reset and replay the current
39
- * primary-bounded transcript because promotion did not free enough room.
40
- * Optional: hosts that omit it get no maintenance (context only shrinks when
41
- * the primary's next compaction triggers {@link AdvisorRuntime.reset}).
39
+ * when the advisor must clear its own context before sending the current
40
+ * incremental update. The cursor stays at the current primary position: this
41
+ * recovery path must never replay the full primary transcript.
42
+ * Optional: hosts that omit it get no proactive maintenance.
42
43
  */
43
44
  maintainContext?(incomingTokens: number): Promise<boolean>;
44
45
  /**
@@ -51,16 +52,36 @@ export interface AdvisorRuntimeHost {
51
52
  /**
52
53
  * Called with the error of every failed advisor turn, before the retry sleep
53
54
  * or the dropped-after-3 path. Lets the host apply credential-level remedies
54
- * the advisor loop lacks: the in-stream a/b/c auth retry rotates through
55
- * sibling credentials within one request but never blocks the LAST failing
56
- * one the primary agent's retry pipeline does that via
57
- * `markUsageLimitReached`, so without this hook the advisor re-picks the
58
- * same usage-limited account on every retry. Errors thrown here are logged
59
- * and swallowed.
55
+ * and configured model fallback that the advisor loop cannot perform itself.
56
+ * Return `true` after switching models so the same clean batch is retried
57
+ * immediately with a fresh failure budget. `failedMessages` contains the
58
+ * failed prompt's appended turns before rollback. Errors thrown here are
59
+ * logged and swallowed.
60
60
  */
61
- onTurnError?(error: unknown): Promise<void> | void;
61
+ onTurnError?(
62
+ error: unknown,
63
+ failedMessages: readonly AgentMessage[],
64
+ ): Promise<boolean | undefined> | boolean | undefined;
65
+ /** Called after a successful advisor turn so the host can finish fallback lifecycle reporting. */
66
+ onTurnSuccess?(): Promise<void> | void;
62
67
  /** Surface a non-recovering advisor failure to the host UI without adding model-visible context. */
63
68
  notifyFailure?(error: unknown): void;
69
+ /** Signal that the advisor paused on a quota/rate-limit after host-level
70
+ * recovery (credential switch, fallback chain) declined. Cleared only by
71
+ * an explicit reset (`/new`, config rebuild, session restart). */
72
+ notifyQuotaExhausted?(): void;
73
+ }
74
+
75
+ /**
76
+ * A request rejection that no amount of retrying can fix for this advisor
77
+ * configuration: the provider refuses the model/request shape outright (e.g.
78
+ * "The 'gpt-5.3-codex-spark' model is not supported when using Codex with a
79
+ * ChatGPT account", code=invalid_request_error). Distinct from quota errors,
80
+ * which pause via the dedicated quota path and auto-resume on reset.
81
+ */
82
+ function isPermanentAdvisorError(error: unknown): boolean {
83
+ const message = error instanceof Error ? error.message : String(error);
84
+ return /invalid_request_error|model[_ ]not[_ ]found|is not supported when|does not exist/i.test(message);
64
85
  }
65
86
 
66
87
  const ADVISOR_QUARANTINE_PREFIX = "Advisor response quarantined";
@@ -178,11 +199,22 @@ export function buildAdvisorQuarantineSourceText(currentInput: string, messages:
178
199
  */
179
200
  const MAX_COALESCE_ROUNDS = 3;
180
201
 
202
+ const ADVISOR_RENDER_OPTIONS = {
203
+ includeThinking: true,
204
+ includeToolIntent: true,
205
+ watchedRoles: true,
206
+ expandPrimaryContext: true,
207
+ expandEditDiffs: true,
208
+ } as const;
209
+
181
210
  interface PendingDelta {
182
211
  text: string;
212
+ rawMessages: AgentMessage[];
213
+ renderRevision: number;
183
214
  turns: number;
184
215
  /** Whether the primary was mid-turn (willContinue:true) when this delta was rendered. */
185
216
  wip: boolean;
217
+ overflowRecovery?: boolean;
186
218
  }
187
219
 
188
220
  interface CatchupWaiter {
@@ -192,19 +224,55 @@ interface CatchupWaiter {
192
224
  timer?: NodeJS.Timeout;
193
225
  }
194
226
 
227
+ interface DeliveredMessage {
228
+ message: AgentMessage;
229
+ fingerprint: bigint | undefined;
230
+ }
231
+
232
+ function fingerprintMessage(message: AgentMessage): bigint | undefined {
233
+ try {
234
+ const serialized = JSON.stringify(message);
235
+ if (serialized === undefined) return undefined;
236
+ return Bun.hash.wyhash(serialized);
237
+ } catch {
238
+ return undefined;
239
+ }
240
+ }
241
+
195
242
  export class AdvisorRuntime {
196
243
  #lastCount = 0;
244
+ /**
245
+ * Delivered prefix identities. References make the normal append-only path
246
+ * allocation-free; fingerprints preserve identity across equivalent clones.
247
+ */
248
+ #deliveredPrefix: DeliveredMessage[] = [];
197
249
  /** Last-shown body, keyed by primary-context customType (plan/goal mode rules,
198
250
  * approved plan). These prompts are re-injected verbatim every primary turn;
199
251
  * this lets {@link #renderDelta} collapse an unchanged copy to a one-line
200
252
  * marker so the advisor isn't re-fed the full ~1k-token rules each turn.
201
253
  * Cleared on every re-prime/seed and when a failed batch is dropped. */
202
254
  #seenContext = new Map<string, string>();
255
+ /** Incremented whenever the advisor loses context so queued raw deltas are re-rendered against fresh dedupe state. */
256
+ #renderRevision = 0;
203
257
  #pending: PendingDelta[] = [];
204
258
  #busy = false;
205
259
  #backlog = 0;
206
260
  #consecutiveFailures = 0;
207
261
  #failureNotified = false;
262
+ /** Completed 3-failure backlog-drop cycles since the last success/reset. */
263
+ #droppedBacklogs = 0;
264
+ /**
265
+ * Hard stop after repeated drop cycles or a permanent request rejection
266
+ * (e.g. "model not supported"): without it the advisor re-attempts on every
267
+ * new delta forever, and in a shared daemon that unbounded churn burns CPU
268
+ * and starves every hosted session's event loop. Cleared only by an
269
+ * explicit {@link reset} (config rebuild, /new, session restart).
270
+ */
271
+ #halted = false;
272
+ /** True from the moment an advisor turn fails until one succeeds (or an
273
+ * explicit reset/seed). While set, {@link waitForCatchup} resolves
274
+ * immediately: the primary agent NEVER parks on a failing advisor. */
275
+ #failing = false;
208
276
  #latestMessages?: AgentMessage[];
209
277
  #waiters: CatchupWaiter[] = [];
210
278
  /** Bumped by every external {@link reset}/{@link dispose}. A drain iteration
@@ -213,6 +281,13 @@ export class AdvisorRuntime {
213
281
  * being retried/requeued into the post-reset conversation. */
214
282
  #epoch = 0;
215
283
  disposed = false;
284
+ /** Quota/rate-limit pause state. When `true`, the advisor stops processing
285
+ * turns and drops new deltas until an explicit {@link reset} clears it
286
+ * (triggered by `/new`, config rebuild, or session restart). There is no
287
+ * timer-based auto-resume: provider quota windows (5h/7d) are far longer
288
+ * than any reasonable timer, and premature retries waste calls and
289
+ * re-trigger the same error. */
290
+ #quotaExhausted = false;
216
291
 
217
292
  constructor(
218
293
  private readonly agent: AdvisorAgent,
@@ -223,6 +298,16 @@ export class AdvisorRuntime {
223
298
  get backlog(): number {
224
299
  return this.#backlog;
225
300
  }
301
+ get quotaExhausted(): boolean {
302
+ return this.#quotaExhausted;
303
+ }
304
+ get failureNotified(): boolean {
305
+ return this.#failureNotified;
306
+ }
307
+ /** True after the runtime hard-stopped on repeated or permanent failures. */
308
+ get halted(): boolean {
309
+ return this.#halted;
310
+ }
226
311
 
227
312
  /**
228
313
  * True when `#pending` is non-empty while the drain loop is busy — i.e., newer
@@ -247,13 +332,34 @@ export class AdvisorRuntime {
247
332
  * the delta and forwarded to the reprime path so it is never silently dropped.
248
333
  */
249
334
  onTurnEnd(messages?: AgentMessage[], opts?: { willContinue?: boolean }): void {
250
- if (this.disposed) return;
335
+ if (this.disposed || this.#quotaExhausted || this.#halted) return;
251
336
  const all = messages ?? this.host.snapshotMessages();
252
337
  this.#latestMessages = all;
253
338
  const wip = opts?.willContinue ?? false;
254
- const render = this.#renderDelta(all, wip);
255
- if (render) {
256
- this.#pending.push({ text: render, turns: 1, wip });
339
+ let rendered: Omit<PendingDelta, "turns" | "overflowRecovery"> | null = null;
340
+ // #renderDelta advances the cursor/prefix/dedup state before formatting
341
+ // can throw; snapshot them so a formatter bug loses NOTHING — the next
342
+ // turn re-renders this delta (a prefix change mid-render self-heals via
343
+ // the fingerprint scan, at worst costing one full replay).
344
+ const cursorBefore = this.#lastCount;
345
+ const prefixBefore = this.#deliveredPrefix.slice();
346
+ const seenBefore = [...this.#seenContext];
347
+ try {
348
+ rendered = this.#renderDelta(all, wip);
349
+ } catch (err) {
350
+ // A render bug must never propagate into the primary agent's
351
+ // turn-end callback: the advisor skips this delta and stops gating
352
+ // the catch-up wait until a turn succeeds.
353
+ this.#lastCount = cursorBefore;
354
+ this.#deliveredPrefix = prefixBefore;
355
+ this.#seenContext.clear();
356
+ for (const [key, value] of seenBefore) this.#seenContext.set(key, value);
357
+ this.#failing = true;
358
+ this.#wakeAllWaiters();
359
+ logger.warn("advisor delta render failed", { err: String(err) });
360
+ }
361
+ if (rendered) {
362
+ this.#pending.push({ ...rendered, turns: 1 });
257
363
  this.#backlog++;
258
364
  this.#notifyWaiters();
259
365
  void this.#drain();
@@ -261,7 +367,18 @@ export class AdvisorRuntime {
261
367
  }
262
368
 
263
369
  waitForCatchup(maxMs: number, threshold: number, signal?: AbortSignal): Promise<void> {
264
- if (this.disposed || signal?.aborted || this.#backlog < threshold) return Promise.resolve();
370
+ if (
371
+ this.disposed ||
372
+ signal?.aborted ||
373
+ this.#backlog < threshold ||
374
+ this.#quotaExhausted ||
375
+ this.#halted ||
376
+ // An advisor mid-failure/retry must NEVER gate the primary agent:
377
+ // its backlog cannot drain until the retry cycle resolves, and the
378
+ // primary would otherwise park for the full catch-up budget.
379
+ this.#failing
380
+ )
381
+ return Promise.resolve();
265
382
  const { promise, resolve } = Promise.withResolvers<void>();
266
383
  let waiter!: CatchupWaiter;
267
384
  const finish = (): void => {
@@ -293,24 +410,53 @@ export class AdvisorRuntime {
293
410
  } catch {}
294
411
  }
295
412
 
413
+ #clearSeenContext(): void {
414
+ this.#seenContext.clear();
415
+ this.#renderRevision++;
416
+ }
417
+
418
+ #clearAdvisorContextAtCurrentCursor(): void {
419
+ this.#consecutiveFailures = 0;
420
+ this.#failureNotified = false;
421
+ this.#clearSeenContext();
422
+ try {
423
+ this.agent.reset();
424
+ } catch {}
425
+ try {
426
+ this.agent.abort("advisor reset");
427
+ } catch {}
428
+ }
429
+
296
430
  #resetAdvisorContext(clearBacklog: boolean, wakeWaiters: boolean): void {
297
431
  this.#lastCount = 0;
432
+ this.#deliveredPrefix = [];
298
433
  this.#pending = [];
299
- this.#consecutiveFailures = 0;
300
- this.#failureNotified = false;
301
- this.#seenContext.clear();
434
+ this.#clearAdvisorContextAtCurrentCursor();
302
435
  if (clearBacklog) {
303
436
  this.#backlog = 0;
304
437
  }
305
438
  if (wakeWaiters) {
306
439
  this.#wakeAllWaiters();
307
440
  }
308
- try {
309
- this.agent.reset();
310
- } catch {}
311
- try {
312
- this.agent.abort("advisor reset");
313
- } catch {}
441
+ }
442
+
443
+ /**
444
+ * Account one completed 3-failure backlog-drop cycle. Repeated cycles (or a
445
+ * single permanent request rejection, e.g. "model not supported") hard-stop
446
+ * the runtime: in a shared daemon, unbounded advisor churn re-builds heavy
447
+ * context on every new delta and starves every hosted session's event loop.
448
+ * Only an explicit {@link reset} (config rebuild, /new, restart) resumes.
449
+ */
450
+ #noteDroppedBacklog(error: unknown): void {
451
+ this.#droppedBacklogs++;
452
+ if (this.#droppedBacklogs < 3 && !isPermanentAdvisorError(error)) return;
453
+ this.#halted = true;
454
+ this.#pending = [];
455
+ this.#wakeAllWaiters();
456
+ logger.warn("advisor halted after repeated failures; use /advisor or reload config to re-enable", {
457
+ droppedBacklogs: this.#droppedBacklogs,
458
+ err: String(error),
459
+ });
314
460
  }
315
461
 
316
462
  /**
@@ -322,6 +468,10 @@ export class AdvisorRuntime {
322
468
  */
323
469
  reset(): void {
324
470
  this.#epoch++;
471
+ this.#quotaExhausted = false;
472
+ this.#halted = false;
473
+ this.#failing = false;
474
+ this.#droppedBacklogs = 0;
325
475
  this.#resetAdvisorContext(true, true);
326
476
  }
327
477
 
@@ -331,42 +481,72 @@ export class AdvisorRuntime {
331
481
  * advisor (which would be expensive and likely stale).
332
482
  */
333
483
  seedTo(count: number): void {
334
- this.#lastCount = count;
484
+ const messages = this.host.snapshotMessages().slice(0, count);
485
+ this.#lastCount = messages.length;
486
+ this.#deliveredPrefix = messages.map(message => ({
487
+ message,
488
+ fingerprint: fingerprintMessage(message),
489
+ }));
335
490
  this.#pending = [];
336
491
  this.#backlog = 0;
337
492
  this.#consecutiveFailures = 0;
493
+ this.#failing = false;
494
+ this.#droppedBacklogs = 0;
338
495
  this.#failureNotified = false;
339
- this.#seenContext.clear();
496
+ this.#clearSeenContext();
340
497
  this.#wakeAllWaiters();
341
498
  }
342
499
 
343
- #renderDelta(messages?: AgentMessage[], wip = false): string | null {
344
- const all = messages ?? this.#latestMessages ?? this.host.snapshotMessages();
345
- if (all.length < this.#lastCount) {
346
- this.#lastCount = all.length;
347
- this.#seenContext.clear();
348
- return null;
349
- }
350
- const delta = all
351
- .slice(this.#lastCount)
352
- .filter(m => !(m.role === "custom" && m.customType === "advisor"))
353
- .map(m => this.#dedupContextMessage(m));
354
- this.#lastCount = all.length;
500
+ #formatRawDelta(rawMessages: AgentMessage[], wip = false): string | null {
501
+ const delta = rawMessages
502
+ .filter(message => !(message.role === "custom" && message.customType === "advisor"))
503
+ .map(message => this.#dedupContextMessage(message));
355
504
  if (delta.length === 0) return null;
356
505
  const obfuscator = this.host.obfuscator;
357
506
  const formattedDelta = obfuscator?.hasSecrets() ? obfuscateAdvisorDelta(obfuscator, delta) : delta;
358
- const md = formatSessionHistoryMarkdown(formattedDelta, {
359
- includeThinking: true,
360
- includeToolIntent: true,
361
- watchedRoles: true,
362
- expandPrimaryContext: true,
363
- expandEditDiffs: true,
364
- });
507
+ const md = formatSessionHistoryMarkdown(formattedDelta, ADVISOR_RENDER_OPTIONS);
365
508
  if (!md.trim()) return null;
366
509
  const heading = wip ? "### Session update [in progress — more steps follow]" : "### Session update";
367
510
  return `${heading}\n\n${md}`;
368
511
  }
369
512
 
513
+ #renderDelta(messages?: AgentMessage[], wip = false): Omit<PendingDelta, "turns" | "overflowRecovery"> | null {
514
+ const all = messages ?? this.#latestMessages ?? this.host.snapshotMessages();
515
+ let prefixChanged = all.length < this.#lastCount;
516
+ for (let i = 0; !prefixChanged && i < this.#lastCount; i++) {
517
+ const delivered = this.#deliveredPrefix[i];
518
+ const current = all[i];
519
+ if (delivered === undefined || current === undefined) {
520
+ prefixChanged = true;
521
+ break;
522
+ }
523
+ if (delivered.message === current) continue;
524
+ const fingerprint = fingerprintMessage(current);
525
+ if (
526
+ delivered.fingerprint === undefined ||
527
+ fingerprint === undefined ||
528
+ delivered.fingerprint !== fingerprint
529
+ ) {
530
+ prefixChanged = true;
531
+ break;
532
+ }
533
+ delivered.message = current;
534
+ }
535
+ if (prefixChanged) {
536
+ this.#epoch++;
537
+ this.#resetAdvisorContext(true, true);
538
+ }
539
+ const rawMessages = all.slice(this.#lastCount);
540
+ for (let i = this.#lastCount; i < all.length; i++) {
541
+ const message = all[i];
542
+ if (message === undefined) continue;
543
+ this.#deliveredPrefix.push({ message, fingerprint: fingerprintMessage(message) });
544
+ }
545
+ this.#lastCount = all.length;
546
+ const text = this.#formatRawDelta(rawMessages, wip);
547
+ return text ? { text, rawMessages, renderRevision: this.#renderRevision, wip } : null;
548
+ }
549
+
370
550
  /**
371
551
  * Collapse a re-injected primary-context prompt (plan/goal mode rules, the
372
552
  * approved plan) to a short marker when its body is byte-identical to the
@@ -425,40 +605,50 @@ export class AdvisorRuntime {
425
605
  }
426
606
 
427
607
  /**
428
- * Collect all currently pending deltas into one batch, running
429
- * `maintainContext` for correct token budgeting. Loops until the pending
430
- * queue is stable (no new deltas arrived during a maintenance check) or a
431
- * reprime is triggered. Every `await` inside the loop has an epoch guard so
432
- * a reset/dispose mid-await cannot leak a stale batch into the post-reset
433
- * conversation.
608
+ * Collect the popped deltas into one batch, running `maintainContext` for
609
+ * correct token budgeting. Loops until the pending queue is stable (no new
610
+ * deltas arrived during a maintenance check) or the round cap is reached.
611
+ * Every `await` inside the loop has an epoch guard so a reset/dispose
612
+ * mid-await cannot leak a stale batch into the post-reset conversation.
613
+ *
614
+ * When maintenance requests recovery, only the advisor Agent/log is reset
615
+ * (at the current primary cursor) and the already-collected raw batch is
616
+ * re-rendered — older, already-delivered primary transcript is never
617
+ * replayed.
434
618
  *
435
619
  * The coalescing loop is capped at {@link MAX_COALESCE_ROUNDS} iterations so
436
620
  * a pathologically fast primary combined with a slow `maintainContext` cannot
437
621
  * stall dispatch indefinitely — any items still in `#pending` after the cap
438
- * are left for the next drain iteration.
622
+ * are left for the next drain iteration. Overflow-recovery batches skip
623
+ * coalescing entirely: they retry exactly the bounded batch that overflowed.
439
624
  *
440
625
  * Returns `null` when the epoch was invalidated — caller should `continue`.
441
- * Returns `{ batch: null, finalTurns }` when there is nothing to render but
442
- * backlog still needs to be decremented.
443
626
  */
444
627
  async #collectAndMaintainBatch(
445
628
  epoch: number,
446
- ): Promise<{ batch: string | null; finalTurns: number; wip: boolean } | null> {
447
- const initial = this.#pending.splice(0);
629
+ initial: PendingDelta[],
630
+ recoveringOverflow: boolean,
631
+ ): Promise<{
632
+ batch: string | null;
633
+ rawMessages: AgentMessage[];
634
+ finalTurns: number;
635
+ wip: boolean;
636
+ resetContext: boolean;
637
+ } | null> {
448
638
  let batchText = initial.map(b => b.text).join("\n\n");
639
+ let rawMessages = initial.flatMap(b => b.rawMessages);
449
640
  let turns = initial.reduce((sum, b) => sum + b.turns, 0);
450
- // Track WIP state of the most recent delta — forwarded to the reprime
451
- // #renderDelta so a willContinue:true turn keeps its [in progress] heading
452
- // even when the full transcript is replayed from scratch. Also returned to
453
- // #drain so the retry-requeue path preserves it on failed turns.
641
+ // Track WIP state of the most recent delta — forwarded to the re-render
642
+ // so a willContinue:true turn keeps its [in progress] heading. Also
643
+ // returned to #drain so the retry-requeue path preserves it on failed turns.
454
644
  let wip = initial.at(-1)?.wip ?? false;
455
645
 
456
646
  for (let round = 0; round < MAX_COALESCE_ROUNDS; round++) {
457
647
  if (this.host.maintainContext) {
458
648
  const incomingTokens = estimateTokens({ role: "user", content: batchText, timestamp: Date.now() });
459
- let shouldReprime = false;
649
+ let shouldResetContext = false;
460
650
  try {
461
- shouldReprime = await this.host.maintainContext(incomingTokens);
651
+ shouldResetContext = await this.host.maintainContext(incomingTokens);
462
652
  } catch (err) {
463
653
  logger.debug("advisor context maintenance failed", { err: String(err) });
464
654
  }
@@ -466,18 +656,41 @@ export class AdvisorRuntime {
466
656
  // invalidates this batch.
467
657
  if (this.#epoch !== epoch) return null;
468
658
 
469
- if (shouldReprime) {
470
- // Tally deltas that arrived during this await before #resetAdvisorContext
471
- // wipes #pending, so finalTurns stays accurate for backlog accounting.
472
- // Also capture the latest WIP state before the queue is cleared.
473
- const lateItems = this.#pending.splice(0);
474
- turns += lateItems.reduce((sum, b) => sum + b.turns, 0);
475
- if (lateItems.length > 0) wip = lateItems.at(-1)!.wip;
476
- this.#resetAdvisorContext(false, false);
477
- return { batch: this.#renderDelta(this.#latestMessages, wip), finalTurns: turns, wip };
659
+ if (shouldResetContext) {
660
+ // Once coalescing has begun (round > 0), deltas that arrived during
661
+ // this await are part of the coalescing window: tally them so
662
+ // finalTurns stays accurate for backlog accounting and their raw
663
+ // messages join the bounded re-render. On the initial round the
664
+ // popped batch stays bounded exactly as dispatched — later arrivals
665
+ // remain queued and ship as their own subsequent batch.
666
+ if (round > 0) {
667
+ const lateItems = this.#pending.splice(0);
668
+ turns += lateItems.reduce((sum, b) => sum + b.turns, 0);
669
+ if (lateItems.length > 0) {
670
+ wip = lateItems.at(-1)!.wip;
671
+ rawMessages = rawMessages.concat(lateItems.flatMap(b => b.rawMessages));
672
+ }
673
+ }
674
+ // Reset only the advisor Agent/log. The primary cursor, backlog,
675
+ // waiters, latest snapshot, and epoch stay untouched. Re-render only
676
+ // this already-popped raw batch so active plan/reference bodies are
677
+ // restored without replaying any older primary transcript.
678
+ this.#clearAdvisorContextAtCurrentCursor();
679
+ const rerendered = this.#formatRawDelta(rawMessages, wip);
680
+ return {
681
+ batch: rerendered ?? (batchText || null),
682
+ rawMessages,
683
+ finalTurns: turns,
684
+ wip,
685
+ resetContext: true,
686
+ };
478
687
  }
479
688
  }
480
689
 
690
+ // Overflow-recovery batches retry exactly the bounded batch that
691
+ // overflowed; pending updates stay queued behind them.
692
+ if (recoveringOverflow) break;
693
+
481
694
  // On the final round stop here — any late arrivals would ship without
482
695
  // a subsequent maintainContext budget check. Leave them in #pending for
483
696
  // the next drain iteration where they will be properly budgeted.
@@ -489,11 +702,31 @@ export class AdvisorRuntime {
489
702
  const late = this.#pending.splice(0);
490
703
  if (late.length === 0) break;
491
704
  batchText = [batchText, ...late.map(b => b.text)].join("\n\n");
705
+ rawMessages = rawMessages.concat(late.flatMap(b => b.rawMessages));
492
706
  turns += late.reduce((sum, b) => sum + b.turns, 0);
493
707
  wip = late.at(-1)!.wip;
494
708
  }
495
709
 
496
- return { batch: batchText || null, finalTurns: turns, wip };
710
+ return { batch: batchText || null, rawMessages, finalTurns: turns, wip, resetContext: false };
711
+ }
712
+
713
+ #terminalAssistantFailure(snapshot: number): AssistantMessage | undefined {
714
+ const messages = this.agent.state.messages;
715
+ for (let i = messages.length - 1; i >= snapshot; i--) {
716
+ const message = messages[i];
717
+ if (message.role === "assistant" && message.stopReason === "error") return message;
718
+ }
719
+ return undefined;
720
+ }
721
+
722
+ #notifyFailureOnce(error: unknown): void {
723
+ if (this.#failureNotified) return;
724
+ this.#failureNotified = true;
725
+ try {
726
+ this.host.notifyFailure?.(error);
727
+ } catch (notifyErr) {
728
+ logger.warn("advisor failure notification failed", { err: String(notifyErr) });
729
+ }
497
730
  }
498
731
 
499
732
  async #drain(): Promise<void> {
@@ -501,13 +734,28 @@ export class AdvisorRuntime {
501
734
  this.#busy = true;
502
735
  try {
503
736
  while (!this.disposed && this.#pending.length) {
737
+ let popped: PendingDelta[];
738
+ if (this.#pending[0]?.overflowRecovery) {
739
+ const recovery = this.#pending.shift();
740
+ if (!recovery) continue;
741
+ popped = [recovery];
742
+ } else {
743
+ popped = this.#pending.splice(0);
744
+ }
504
745
  const epoch = this.#epoch;
505
- const result = await this.#collectAndMaintainBatch(epoch);
746
+ for (const delta of popped) {
747
+ if (delta.renderRevision === this.#renderRevision) continue;
748
+ const refreshed = this.#formatRawDelta(delta.rawMessages, delta.wip);
749
+ if (refreshed) delta.text = refreshed;
750
+ delta.renderRevision = this.#renderRevision;
751
+ }
752
+ const recoveringOverflow = popped.some(delta => delta.overflowRecovery === true);
753
+ const result = await this.#collectAndMaintainBatch(epoch, popped, recoveringOverflow);
506
754
 
507
755
  // Epoch was invalidated during batch collection; restart the loop.
508
756
  if (result === null) continue;
509
757
 
510
- const { batch, finalTurns, wip } = result;
758
+ const { batch, rawMessages, finalTurns, wip, resetContext } = result;
511
759
 
512
760
  if (this.disposed || batch === null) {
513
761
  this.#backlog = Math.max(0, this.#backlog - finalTurns);
@@ -522,6 +770,7 @@ export class AdvisorRuntime {
522
770
  // batch on top of stale turns and the dropped-after-3 path would leak
523
771
  // orphan failures into the next successful run's context.
524
772
  const messageSnapshot = this.agent.state.messages.length;
773
+ const contextWasFresh = resetContext || recoveringOverflow || messageSnapshot === 0;
525
774
  try {
526
775
  // Reset the host's per-update advisor state (one-advise-per-update
527
776
  // gate) before each model cycle so the new batch starts fresh.
@@ -540,16 +789,47 @@ export class AdvisorRuntime {
540
789
  const turnError = getAdvisorTurnError(this.agent.state.messages.slice(messageSnapshot));
541
790
  if (turnError) throw turnError;
542
791
  success = true;
792
+ this.#failing = false;
543
793
  this.#consecutiveFailures = 0;
544
794
  this.#failureNotified = false;
795
+ this.#droppedBacklogs = 0;
796
+ if (this.host.onTurnSuccess) {
797
+ try {
798
+ await this.host.onTurnSuccess();
799
+ } catch (hookErr) {
800
+ logger.debug("advisor onTurnSuccess hook failed", { err: String(hookErr) });
801
+ }
802
+ }
545
803
  } catch (err) {
546
804
  // reset()/dispose() aborts the in-flight prompt; treat it as a
547
805
  // reset, not a transient failure — drop the stale batch.
548
806
  if (this.#epoch !== epoch) continue;
807
+ // Release any parked primary-agent waiters IMMEDIATELY — before
808
+ // the async onTurnError hook or any retry sleep — and refuse new
809
+ // parks until a turn succeeds. A failing advisor must never hold
810
+ // the primary on the catch-up gate.
811
+ this.#failing = true;
812
+ this.#wakeAllWaiters();
813
+ const failedMessages = this.agent.state.messages.slice(messageSnapshot);
814
+ const terminalFailure = this.#terminalAssistantFailure(messageSnapshot);
815
+ const terminalFailureId =
816
+ terminalFailure === undefined ? undefined : AIError.classifyMessage(terminalFailure);
817
+ const contextOverflow =
818
+ (terminalFailureId !== undefined && AIError.is(terminalFailureId, AIError.Flag.ContextOverflow)) ||
819
+ AIError.is(AIError.classify(err), AIError.Flag.ContextOverflow);
820
+ // A terminal provider failure that is neither retriable nor an
821
+ // overflow (e.g. a blocked prompt) will fail identically on every
822
+ // retry — classify it before rollback so the batch is dropped after
823
+ // one attempt instead of burning the 3-attempt budget (#5468).
824
+ const terminalFailureRetriable =
825
+ terminalFailureId === undefined ||
826
+ AIError.retriable(terminalFailureId) ||
827
+ AIError.is(terminalFailureId, AIError.Flag.ContextOverflow);
549
828
  this.#rollbackFailedTurn(messageSnapshot);
550
829
  logger.debug("advisor turn failed", { err: String(err) });
830
+ let recovered = false;
551
831
  try {
552
- await this.host.onTurnError?.(err);
832
+ recovered = (await this.host.onTurnError?.(err, failedMessages)) === true;
553
833
  } catch (hookErr) {
554
834
  logger.debug("advisor onTurnError hook failed", { err: String(hookErr) });
555
835
  }
@@ -563,26 +843,99 @@ export class AdvisorRuntime {
563
843
  }
564
844
  // Epoch guard after the async error hook.
565
845
  if (this.#epoch !== epoch) continue;
566
- this.#consecutiveFailures++;
567
- if (this.#consecutiveFailures >= 3) {
568
- logger.warn("advisor failed consecutively 3 times; dropping backlog to prevent stall");
569
- if (!this.#failureNotified) {
570
- this.#failureNotified = true;
571
- try {
572
- this.host.notifyFailure?.(err);
573
- } catch (notifyErr) {
574
- logger.warn("advisor failure notification failed", { err: String(notifyErr) });
575
- }
846
+ if (recovered) {
847
+ this.#consecutiveFailures = 0;
848
+ this.#failureNotified = false;
849
+ this.#pending.unshift({
850
+ text: batch,
851
+ rawMessages,
852
+ renderRevision: this.#renderRevision,
853
+ turns: finalTurns,
854
+ wip,
855
+ overflowRecovery: recoveringOverflow || undefined,
856
+ });
857
+ continue;
858
+ }
859
+ if (AIError.isUsageLimit(err)) {
860
+ // Host recovery (credential switch / fallback chain) declined:
861
+ // pause on the quota latch instead of burning retries — provider
862
+ // quota windows (5h/7d) outlast any retry budget. The batch is
863
+ // requeued and the backlog stays visible so reset() replays it.
864
+ logger.warn("advisor quota exhausted", { err: String(err) });
865
+ this.#quotaExhausted = true;
866
+ this.#consecutiveFailures = 0;
867
+ this.#failureNotified = false;
868
+ this.#clearSeenContext();
869
+ this.#pending.unshift({
870
+ text: batch,
871
+ rawMessages,
872
+ renderRevision: this.#renderRevision,
873
+ turns: finalTurns,
874
+ wip,
875
+ overflowRecovery: recoveringOverflow || undefined,
876
+ });
877
+ this.#wakeAllWaiters();
878
+ try {
879
+ this.host.notifyQuotaExhausted?.();
880
+ } catch (notifyErr) {
881
+ logger.warn("advisor quota notification failed", { err: String(notifyErr) });
576
882
  }
883
+ break;
884
+ }
885
+ if (!terminalFailureRetriable) {
886
+ logger.warn("advisor terminal failure is non-retriable; dropping bounded batch");
887
+ this.#notifyFailureOnce(err);
577
888
  this.#consecutiveFailures = 0;
578
- // Drop the seen-context so the next turn re-expands primary-context
579
- // prompts instead of marking them "unchanged" against content the
580
- // advisor never received.
581
- this.#seenContext.clear();
889
+ // The dropped batch may carry primary-context we never delivered; drop
890
+ // the seen-state too so queued raw deltas re-expand before delivery.
891
+ this.#clearSeenContext();
892
+ this.#noteDroppedBacklog(err);
582
893
  success = true;
894
+ } else if (contextOverflow) {
895
+ this.#clearAdvisorContextAtCurrentCursor();
896
+ if (contextWasFresh) {
897
+ // The bounded update cannot fit even with no advisor history. Drop
898
+ // only this batch after its one fresh-context retry; pending and later
899
+ // deltas remain eligible so one oversized update cannot disable the advisor.
900
+ logger.warn("advisor update overflowed a fresh context; dropping bounded batch");
901
+ this.#notifyFailureOnce(err);
902
+ success = true;
903
+ } else {
904
+ // Retry once against the fresh advisor context, using only the same
905
+ // bounded raw batch. Pending updates remain queued behind it.
906
+ const recoveryBatch = this.#formatRawDelta(rawMessages, wip) ?? batch;
907
+ this.#pending.unshift({
908
+ text: recoveryBatch,
909
+ rawMessages,
910
+ renderRevision: this.#renderRevision,
911
+ turns: finalTurns,
912
+ wip,
913
+ overflowRecovery: true,
914
+ });
915
+ logger.debug("advisor context overflow recovered at current primary cursor");
916
+ }
583
917
  } else {
584
- this.#pending.unshift({ text: batch, turns: finalTurns, wip });
585
- await Bun.sleep(this.retryDelayMs);
918
+ this.#consecutiveFailures++;
919
+ if (this.#consecutiveFailures >= 3) {
920
+ logger.warn("advisor failed consecutively 3 times; dropping backlog to prevent stall");
921
+ this.#notifyFailureOnce(err);
922
+ this.#consecutiveFailures = 0;
923
+ // The dropped batch may carry primary-context we never delivered; drop
924
+ // the seen-state too so queued raw deltas re-expand before delivery.
925
+ this.#clearSeenContext();
926
+ this.#noteDroppedBacklog(err);
927
+ success = true;
928
+ } else {
929
+ this.#pending.unshift({
930
+ text: batch,
931
+ rawMessages,
932
+ renderRevision: this.#renderRevision,
933
+ turns: finalTurns,
934
+ wip,
935
+ overflowRecovery: recoveringOverflow || undefined,
936
+ });
937
+ await Bun.sleep(this.retryDelayMs);
938
+ }
586
939
  }
587
940
  }
588
941