@oh-my-pi/pi-coding-agent 17.2.8 → 17.2.9

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 (91) hide show
  1. package/CHANGELOG.md +51 -1
  2. package/dist/{CHANGELOG-0b0w17se.md → CHANGELOG-8yy8h3bc.md} +51 -1
  3. package/dist/cli.js +4443 -4444
  4. package/dist/types/async/job-manager.d.ts +12 -0
  5. package/dist/types/discovery/agents.d.ts +11 -0
  6. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +10 -0
  7. package/dist/types/hindsight/state.d.ts +0 -1
  8. package/dist/types/lsp/utils.d.ts +8 -0
  9. package/dist/types/modes/session-observer-registry.d.ts +2 -0
  10. package/dist/types/registry/agent-lifecycle.d.ts +1 -1
  11. package/dist/types/session/agent-session-types.d.ts +7 -0
  12. package/dist/types/session/agent-session.d.ts +5 -3
  13. package/dist/types/session/turn-recovery.d.ts +6 -2
  14. package/dist/types/task/executor.d.ts +4 -0
  15. package/dist/types/tools/browser/launch.d.ts +2 -0
  16. package/dist/types/tools/path-utils.d.ts +7 -0
  17. package/dist/types/tools/read.d.ts +2 -0
  18. package/dist/types/tools/shell-tokenize.d.ts +21 -3
  19. package/dist/types/utils/changelog.d.ts +3 -2
  20. package/dist/types/utils/late-cleanup.d.ts +2 -0
  21. package/dist/types/vibe/runtime.d.ts +2 -0
  22. package/package.json +13 -13
  23. package/scripts/omp +11 -2
  24. package/src/async/job-manager.ts +26 -0
  25. package/src/cli/stats-cli.ts +3 -3
  26. package/src/cli/update-cli.ts +2 -56
  27. package/src/config/config-file.ts +2 -2
  28. package/src/config/keybindings.ts +3 -3
  29. package/src/config/model-discovery.ts +36 -2
  30. package/src/config/model-registry.ts +4 -1
  31. package/src/discovery/agents.ts +71 -3
  32. package/src/discovery/claude.ts +8 -5
  33. package/src/discovery/cursor.ts +8 -5
  34. package/src/discovery/gemini.ts +11 -8
  35. package/src/discovery/vscode.ts +1 -0
  36. package/src/discovery/windsurf.ts +4 -2
  37. package/src/edit/hashline/filesystem.ts +7 -7
  38. package/src/eval/agent-bridge.ts +1 -3
  39. package/src/eval/jl/prelude.jl +4 -4
  40. package/src/eval/js/shared/prelude.txt +2 -2
  41. package/src/eval/py/prelude.py +0 -3
  42. package/src/eval/rb/prelude.rb +1 -2
  43. package/src/extensibility/legacy-pi-coding-agent-shim.ts +23 -0
  44. package/src/hindsight/state.ts +2 -22
  45. package/src/internal-urls/memory-protocol.ts +29 -0
  46. package/src/lsp/client.ts +2 -2
  47. package/src/lsp/utils.ts +29 -0
  48. package/src/modes/acp/acp-agent.ts +2 -1
  49. package/src/modes/components/agent-hub.ts +36 -20
  50. package/src/modes/components/agent-transcript-viewer.ts +1 -3
  51. package/src/modes/components/bordered-loader.ts +1 -1
  52. package/src/modes/components/custom-editor.ts +2 -2
  53. package/src/modes/components/hook-input.ts +1 -1
  54. package/src/modes/components/hook-selector.ts +2 -2
  55. package/src/modes/components/session-selector.ts +2 -2
  56. package/src/modes/controllers/event-controller.ts +159 -2
  57. package/src/modes/controllers/extension-ui-controller.ts +2 -1
  58. package/src/modes/print-mode.ts +29 -12
  59. package/src/modes/session-observer-registry.ts +5 -0
  60. package/src/registry/agent-lifecycle.ts +21 -3
  61. package/src/sdk.ts +11 -5
  62. package/src/session/agent-session-types.ts +8 -0
  63. package/src/session/agent-session.ts +156 -186
  64. package/src/session/session-paths.ts +68 -121
  65. package/src/session/settings-stream-fn.ts +7 -3
  66. package/src/session/turn-recovery.ts +234 -16
  67. package/src/session/unexpected-stop-classifier.ts +12 -3
  68. package/src/slash-commands/helpers/stats-dashboard.ts +2 -1
  69. package/src/task/agents.ts +1 -1
  70. package/src/task/executor.ts +105 -29
  71. package/src/task/isolation-runner.ts +19 -1
  72. package/src/task/structured-subagent.ts +17 -2
  73. package/src/tiny/worker.ts +1 -1
  74. package/src/tools/bash-interceptor.ts +8 -4
  75. package/src/tools/browser/launch.ts +37 -9
  76. package/src/tools/glob.ts +4 -2
  77. package/src/tools/path-utils.ts +13 -3
  78. package/src/tools/read.ts +6 -0
  79. package/src/tools/shell-tokenize.ts +38 -9
  80. package/src/utils/changelog.ts +5 -4
  81. package/src/utils/late-cleanup.ts +17 -0
  82. package/src/vibe/runtime.ts +197 -53
  83. package/src/web/scrapers/hackage.ts +1 -12
  84. package/src/web/search/providers/duckduckgo.ts +1 -1
  85. package/src/web/search/providers/ecosia.ts +1 -1
  86. package/src/web/search/providers/exa.ts +1 -1
  87. package/src/web/search/providers/firecrawl.ts +1 -1
  88. package/src/web/search/providers/mojeek.ts +1 -1
  89. package/src/web/search/providers/perplexity.ts +1 -1
  90. package/src/web/search/providers/public.ts +1 -1
  91. package/src/web/search/providers/startpage.ts +1 -1
@@ -58,7 +58,6 @@ import type {
58
58
  ImageContent,
59
59
  Message,
60
60
  Model,
61
- ModelUsageHealth,
62
61
  OAuthAccountIdentity,
63
62
  ProviderSessionState,
64
63
  ResetCreditAccountStatus,
@@ -101,7 +100,7 @@ import { type AdvisorConfig, type AdvisorRuntimeStatus, loadAdvisorTranscriptCos
101
100
  import { type AsyncJob, AsyncJobManager } from "../async";
102
101
  import { shouldEnableAppendOnlyContext } from "../config/append-only-context-mode";
103
102
  import type { ModelRegistry } from "../config/model-registry";
104
- import { type ResolvedModelRoleValue, resolveModelOverride } from "../config/model-resolver";
103
+ import type { ResolvedModelRoleValue } from "../config/model-resolver";
105
104
  import { expandPromptTemplate, type PromptTemplate } from "../config/prompt-templates";
106
105
  import { buildServiceTierByFamily } from "../config/service-tier";
107
106
  import type { Settings, SkillsSettings } from "../config/settings";
@@ -230,7 +229,7 @@ import type {
230
229
  SessionHandoffOptions,
231
230
  SessionOAuthAccountList,
232
231
  SessionStats,
233
- UsageFallbackConfirmation,
232
+ UsageFallbackConfirmer,
234
233
  } from "./agent-session-types";
235
234
  import {
236
235
  ASYNC_INLINE_RESULT_MAX_CHARS,
@@ -312,7 +311,6 @@ import {
312
311
  queueChipText,
313
312
  toRestoredQueuedMessage,
314
313
  } from "./queued-messages";
315
- import { formatRetryFallbackSelector, type RetryFallbackSelector } from "./retry-fallback-chains";
316
314
  import { type AdvisorStats, SessionAdvisors, type SessionAdvisorsHost } from "./session-advisors";
317
315
  import type { BuildSessionContextOptions, SessionContext } from "./session-context";
318
316
  import { getRestorableSessionModels } from "./session-context";
@@ -485,6 +483,7 @@ export class AgentSession {
485
483
 
486
484
  // Retry state
487
485
  readonly #recovery: TurnRecovery;
486
+ #textOutputCommitted = true;
488
487
  #planModeReminderCount = 0;
489
488
  #planModeReminderAwaitingProgress = false;
490
489
  readonly #todo: TodoTracker;
@@ -558,9 +557,13 @@ export class AgentSession {
558
557
 
559
558
  // Model registry for API key resolution
560
559
  #modelRegistry: ModelRegistry;
561
- #usageFallbackConfirmer: ((confirmation: UsageFallbackConfirmation) => Promise<boolean>) | undefined;
562
- #usageReserveApprovedSelector: string | undefined;
560
+ #usageFallbackConfirmer: UsageFallbackConfirmer | undefined;
563
561
  #usagePreflightAbortControllers = new Set<AbortController>();
562
+ #queuedMessageDrainBlocked = false;
563
+ #usagePreflightReadyForNextModelCall = false;
564
+ #usagePreflightReadyModel: Model | undefined;
565
+ #detachUsageBeforeQueueDequeue: (() => void) | undefined;
566
+ #detachUsageBeforeModelCall: (() => void) | undefined;
564
567
 
565
568
  #transformContext: (messages: AgentMessage[], signal?: AbortSignal) => AgentMessage[] | Promise<AgentMessage[]>;
566
569
  #onPayload: SimpleStreamOptions["onPayload"] | undefined;
@@ -768,8 +771,10 @@ export class AgentSession {
768
771
  !this.#canAutoContinueForFollowUp()
769
772
  ? [...this.agent.peekFollowUpQueue()]
770
773
  : [];
774
+ const parkedQueueDrainBlocked = parkedFollowUps.length > 0 && this.#queuedMessageDrainBlocked;
771
775
  if (parkedFollowUps.length > 0) {
772
776
  this.agent.replaceQueues([...this.agent.peekSteeringQueue()], []);
777
+ if (parkedQueueDrainBlocked) this.#queuedMessageDrainBlocked = false;
773
778
  }
774
779
  let finishObservation: ((error?: unknown) => void | Promise<void>) | undefined;
775
780
  try {
@@ -805,6 +810,7 @@ export class AgentSession {
805
810
  [...this.agent.peekSteeringQueue()],
806
811
  [...parkedFollowUps, ...this.agent.peekFollowUpQueue()],
807
812
  );
813
+ this.#queuedMessageDrainBlocked ||= parkedQueueDrainBlocked;
808
814
  }
809
815
  this.#endInFlight(async () => {
810
816
  try {
@@ -830,6 +836,7 @@ export class AgentSession {
830
836
  steering.filter(m => !isAdvisorCard(m)),
831
837
  followUp.filter(m => !isAdvisorCard(m)),
832
838
  );
839
+ this.#reconcileQueuedMessageDrain();
833
840
  return cards;
834
841
  }
835
842
 
@@ -1025,6 +1032,7 @@ export class AgentSession {
1025
1032
  modelRegistry: this.#modelRegistry,
1026
1033
  configWarnings: this.configWarnings,
1027
1034
  model: () => this.model,
1035
+ textOutputCommitted: () => this.#textOutputCommitted,
1028
1036
  thinkingLevel: () => this.thinkingLevel,
1029
1037
  configuredThinkingLevel: () => this.configuredThinkingLevel(),
1030
1038
  setThinkingLevel: level => this.setThinkingLevel(level),
@@ -1050,6 +1058,31 @@ export class AgentSession {
1050
1058
  withBashBranchTransition: operation => this.#bash.withBranchTransition(operation),
1051
1059
  };
1052
1060
  this.#recovery = new TurnRecovery(recoveryHost, { initialRetryFallback: config.initialRetryFallback });
1061
+ this.#detachUsageBeforeQueueDequeue = this.agent.addBeforeQueuedMessageDequeueHook(async signal => {
1062
+ if (
1063
+ !this.settings.get("retry.usageAwareFallback") ||
1064
+ (this.#usagePreflightReadyForNextModelCall && this.#usagePreflightReadyModel === this.model)
1065
+ ) {
1066
+ return;
1067
+ }
1068
+ if (!(await this.#runQueuedUsageAwarePreflight(signal))) {
1069
+ signal?.throwIfAborted();
1070
+ throw new DOMException("Usage preflight cancelled", "AbortError");
1071
+ }
1072
+ });
1073
+ this.#detachUsageBeforeModelCall = this.agent.addBeforeModelCallHook(async signal => {
1074
+ if (!this.settings.get("retry.usageAwareFallback")) return;
1075
+ if (this.#usagePreflightReadyForNextModelCall) {
1076
+ const checkedModel = this.#usagePreflightReadyModel;
1077
+ this.#usagePreflightReadyForNextModelCall = false;
1078
+ this.#usagePreflightReadyModel = undefined;
1079
+ if (checkedModel === this.model) return;
1080
+ }
1081
+ if (!(await this.#runUsageAwarePreflight(signal))) {
1082
+ signal?.throwIfAborted();
1083
+ throw new DOMException("Usage preflight cancelled", "AbortError");
1084
+ }
1085
+ });
1053
1086
  const statsHost: SessionStatsTrackerHost = {
1054
1087
  session: this,
1055
1088
  agent: this.agent,
@@ -2658,14 +2691,21 @@ export class AgentSession {
2658
2691
  // outside the session transcript (issue #6177).
2659
2692
  logProviderTurnError(msg);
2660
2693
 
2661
- // Invalidate GitHub Copilot credentials on auth failure so stale tokens
2662
- // aren't reused on the next request
2663
- if (
2664
- msg.stopReason === "error" &&
2665
- msg.provider === "github-copilot" &&
2666
- AIError.is(AIError.classifyMessage(msg), AIError.Flag.AuthFailed)
2667
- ) {
2668
- await this.#modelRegistry.authStorage.remove("github-copilot");
2694
+ // Invalidate GitHub Copilot credentials on a hard auth failure (401, or an
2695
+ // expired/revoked token) so stale tokens aren't reused on the next request.
2696
+ // Account usage caps and concurrency caps leave the credential valid: the
2697
+ // former rotates until its reset window, while the latter is retried after
2698
+ // a short backoff without touching the credential pool.
2699
+ if (msg.stopReason === "error" && msg.provider === "github-copilot") {
2700
+ const errorId = AIError.classifyMessage(msg);
2701
+ const isConcurrencyCap = AIError.parseRateLimitReason(msg.errorMessage ?? "") === "CONCURRENT_LIMIT";
2702
+ if (
2703
+ AIError.is(errorId, AIError.Flag.AuthFailed) &&
2704
+ !AIError.is(errorId, AIError.Flag.UsageLimit) &&
2705
+ !isConcurrencyCap
2706
+ ) {
2707
+ await this.#modelRegistry.authStorage.remove("github-copilot");
2708
+ }
2669
2709
  }
2670
2710
 
2671
2711
  if (this.#maintenance.skipPostTurnMaintenanceAssistantTimestamp === msg.timestamp) {
@@ -2967,19 +3007,17 @@ export class AgentSession {
2967
3007
  this.#beginInFlight();
2968
3008
  try {
2969
3009
  await this.#recovery.maybeRestoreRetryFallbackPrimary();
2970
- if (
2971
- this.settings.get("retry.modelFallback") &&
2972
- this.settings.get("retry.usageAwareFallback") &&
2973
- !(await this.#runUsageAwarePreflight())
2974
- ) {
2975
- this.#skipAgentContinue("session-unavailable", options);
2976
- return;
2977
- }
2978
3010
  if (signal.aborted || this.#isDisposed) {
2979
3011
  this.#skipAgentContinue("post-restore-unavailable", options);
2980
3012
  return;
2981
3013
  }
2982
- await this.agent.continue();
3014
+ if (this.settings.get("retry.usageAwareFallback")) {
3015
+ if (!(await this.#runQueuedUsageAwarePreflight(signal))) {
3016
+ this.#skipAgentContinue("session-unavailable", options);
3017
+ return;
3018
+ }
3019
+ }
3020
+ await this.agent.continue(signal);
2983
3021
  } catch (error) {
2984
3022
  logger.warn("agent.continue failed after scheduling", {
2985
3023
  error: error instanceof Error ? error.message : String(error),
@@ -2987,6 +3025,7 @@ export class AgentSession {
2987
3025
  });
2988
3026
  options?.onError?.(error);
2989
3027
  } finally {
3028
+ this.#usagePreflightReadyForNextModelCall = false;
2990
3029
  this.#endInFlight();
2991
3030
  }
2992
3031
  },
@@ -3605,6 +3644,12 @@ export class AgentSession {
3605
3644
  */
3606
3645
  beginDispose(): void {
3607
3646
  this.#isDisposed = true;
3647
+ this.#queuedMessageDrainBlocked = false;
3648
+ this.#usagePreflightReadyForNextModelCall = false;
3649
+ this.#detachUsageBeforeQueueDequeue?.();
3650
+ this.#detachUsageBeforeQueueDequeue = undefined;
3651
+ this.#detachUsageBeforeModelCall?.();
3652
+ this.#detachUsageBeforeModelCall = undefined;
3608
3653
  this.#memory.cancelLocalMemoryStartup();
3609
3654
  this.#titleGenerationAbortController.abort();
3610
3655
  this.#abortAutolearnCapture();
@@ -3926,174 +3971,69 @@ export class AgentSession {
3926
3971
  }
3927
3972
 
3928
3973
  /** Install the interactive decision surface for reserve-triggered model changes. */
3929
- setUsageFallbackConfirmer(
3930
- confirmer: ((confirmation: UsageFallbackConfirmation) => Promise<boolean>) | undefined,
3931
- ): void {
3974
+ setUsageFallbackConfirmer(confirmer: UsageFallbackConfirmer | undefined): void {
3932
3975
  this.#usageFallbackConfirmer = confirmer;
3933
3976
  }
3934
3977
 
3935
- async #runUsageAwarePreflight(): Promise<boolean> {
3936
- const generation = this.#promptGeneration;
3937
- const controller = new AbortController();
3938
- this.#usagePreflightAbortControllers.add(controller);
3939
- try {
3940
- await this.#maybeApplyUsageAwareFallback(controller.signal);
3941
- return !controller.signal.aborted && this.#promptGeneration === generation;
3942
- } catch (error) {
3943
- if (controller.signal.aborted || this.#promptGeneration !== generation) return false;
3944
- throw error;
3945
- } finally {
3946
- this.#usagePreflightAbortControllers.delete(controller);
3947
- }
3978
+ #allowQueuedMessageDrainRetry(): void {
3979
+ this.#queuedMessageDrainBlocked = false;
3948
3980
  }
3949
3981
 
3950
- async #confirmUsageFallback(confirmation: UsageFallbackConfirmation, signal: AbortSignal): Promise<boolean> {
3951
- const confirmer = this.#usageFallbackConfirmer;
3952
- if (!confirmer || signal.aborted) return false;
3953
- const aborted = Promise.withResolvers<boolean>();
3954
- const onAbort = () => aborted.resolve(false);
3955
- signal.addEventListener("abort", onAbort, { once: true });
3956
- try {
3957
- return await Promise.race([confirmer(confirmation), aborted.promise]);
3958
- } finally {
3959
- signal.removeEventListener("abort", onAbort);
3982
+ #reconcileQueuedMessageDrain(): void {
3983
+ if (!this.agent.hasQueuedMessages()) {
3984
+ this.#queuedMessageDrainBlocked = false;
3960
3985
  }
3961
3986
  }
3962
3987
 
3963
- async #maybeApplyUsageAwareFallback(signal: AbortSignal): Promise<void> {
3964
- if (!this.settings.get("retry.modelFallback") || !this.settings.get("retry.usageAwareFallback")) return;
3965
- const currentModel = this.model;
3966
- if (!currentModel) return;
3967
- const currentSelector = formatRetryFallbackSelector(currentModel, this.thinkingLevel);
3968
- let health: ModelUsageHealth;
3988
+ async #runQueuedUsageAwarePreflight(signal?: AbortSignal): Promise<boolean> {
3969
3989
  try {
3970
- health = await this.#modelRegistry.authStorage.getModelUsageHealth(currentModel.provider, {
3971
- modelId: currentModel.id,
3972
- sessionId: this.sessionId,
3973
- baseUrl: currentModel.baseUrl,
3974
- reserveFraction: this.settings.get("retry.usageReservePct") / 100,
3975
- signal,
3976
- });
3990
+ const allowed = await this.#runUsageAwarePreflight(signal);
3991
+ this.#usagePreflightReadyForNextModelCall = allowed;
3992
+ this.#usagePreflightReadyModel = allowed ? this.model : undefined;
3993
+ this.#queuedMessageDrainBlocked = !allowed && this.agent.hasQueuedMessages();
3994
+ return allowed;
3977
3995
  } catch (error) {
3978
- logger.debug("Usage-aware runtime preflight failed open", {
3979
- provider: currentModel.provider,
3980
- model: currentModel.id,
3981
- error: String(error),
3982
- });
3983
- return;
3996
+ this.#queuedMessageDrainBlocked = this.agent.hasQueuedMessages();
3997
+ throw error;
3984
3998
  }
3985
- if (signal.aborted) return;
3999
+ }
3986
4000
 
3987
- if (health.state === "healthy") {
3988
- this.#usageReserveApprovedSelector = undefined;
3989
- const selected = health.accounts.find(account => account.selected);
3990
- if (selected && selected.state !== "healthy" && health.accounts.some(account => account.state === "healthy")) {
3991
- this.#modelRegistry.authStorage.releaseSessionCredentialForReselection(
3992
- currentModel.provider,
3993
- this.sessionId,
3994
- );
3995
- }
3996
- return;
3997
- }
3998
- if (health.state === "unknown") {
3999
- this.#usageReserveApprovedSelector = undefined;
4000
- return;
4001
- }
4002
- const reservePolicy = this.settings.get("retry.usageReservePolicy");
4003
- if (reservePolicy === "fail-closed") {
4004
- const condition = health.state === "reserve" ? "reserve reached" : "usage depleted";
4005
- throw new Error(`${condition} for ${currentSelector}; reserve policy is fail-closed.`);
4006
- }
4007
-
4008
- const role = this.#recovery.resolveRetryFallbackRole(currentSelector, currentModel);
4009
- if (!role) return;
4010
- let fallback: { selector: RetryFallbackSelector; apiKey: string } | undefined;
4011
- for (const candidate of this.#recovery.findRetryFallbackCandidates(role, currentSelector, currentModel)) {
4012
- if (this.#recovery.isRetryFallbackSelectorSuppressed(candidate)) continue;
4013
- const resolved = resolveModelOverride([candidate.raw], this.#modelRegistry, this.settings);
4014
- const candidateModel = resolved.model ?? this.#modelRegistry.find(candidate.provider, candidate.id);
4015
- if (!candidateModel) continue;
4016
- if (!this.#modelRegistry.hasConfiguredAuth(candidateModel)) continue;
4017
- try {
4018
- const candidateHealth = await this.#modelRegistry.authStorage.getModelUsageHealth(candidateModel.provider, {
4019
- modelId: candidateModel.id,
4020
- sessionId: this.sessionId,
4021
- baseUrl: candidateModel.baseUrl,
4022
- reserveFraction: this.settings.get("retry.usageReservePct") / 100,
4023
- signal,
4024
- });
4025
- if (signal.aborted) return;
4026
- if (candidateHealth.state === "depleted" || candidateHealth.state === "reserve") continue;
4027
- if (candidateHealth.state === "healthy") {
4028
- const selected = candidateHealth.accounts.find(account => account.selected);
4029
- if (
4030
- selected &&
4031
- selected.state !== "healthy" &&
4032
- candidateHealth.accounts.some(account => account.state === "healthy")
4033
- ) {
4034
- this.#modelRegistry.authStorage.releaseSessionCredentialForReselection(
4035
- candidateModel.provider,
4036
- this.sessionId,
4037
- );
4038
- }
4039
- }
4040
- } catch {
4041
- if (signal.aborted) return;
4042
- // Unknown usage fails open for an otherwise valid fallback.
4043
- }
4044
- if (signal.aborted) return;
4045
- let apiKey: string | undefined;
4046
- try {
4047
- apiKey = await this.#modelRegistry.getApiKey(candidateModel, this.sessionId, { signal });
4048
- } catch {
4049
- if (signal.aborted) return;
4050
- continue;
4051
- }
4052
- if (signal.aborted) return;
4053
- if (!apiKey) continue;
4054
- fallback = { selector: candidate, apiKey };
4055
- break;
4056
- }
4057
- if (!fallback) return;
4058
-
4059
- if (health.state === "reserve") {
4060
- if (reservePolicy === "confirm" && this.#usageFallbackConfirmer) {
4061
- if (this.#usageReserveApprovedSelector === currentSelector) return;
4062
- const selected = health.accounts.find(account => account.selected);
4063
- const remainingFraction =
4064
- selected?.remainingFraction ??
4065
- health.accounts.reduce<number | undefined>(
4066
- (minimum, account) =>
4067
- account.remainingFraction === undefined
4068
- ? minimum
4069
- : minimum === undefined
4070
- ? account.remainingFraction
4071
- : Math.min(minimum, account.remainingFraction),
4072
- undefined,
4001
+ async #runUsageAwarePreflightForNextModelCall(signal?: AbortSignal): Promise<boolean> {
4002
+ const allowed = await this.#runUsageAwarePreflight(signal);
4003
+ this.#usagePreflightReadyForNextModelCall = allowed;
4004
+ this.#usagePreflightReadyModel = allowed ? this.model : undefined;
4005
+ return allowed;
4006
+ }
4007
+
4008
+ async #runUsageAwarePreflight(signal?: AbortSignal): Promise<boolean> {
4009
+ if (signal?.aborted) return false;
4010
+ const generation = this.#promptGeneration;
4011
+
4012
+ const controller = new AbortController();
4013
+ const onAbort = () => controller.abort(signal?.reason);
4014
+ signal?.addEventListener("abort", onAbort, { once: true });
4015
+ this.#usagePreflightAbortControllers.add(controller);
4016
+ try {
4017
+ while (true) {
4018
+ const model = this.model;
4019
+ try {
4020
+ const fallbackCommitted = await this.#recovery.maybeApplyUsageAwareFallback(
4021
+ controller.signal,
4022
+ this.#usageFallbackConfirmer,
4073
4023
  );
4074
- const shouldFallback = await this.#confirmUsageFallback(
4075
- {
4076
- from: currentSelector,
4077
- to: fallback.selector.raw,
4078
- remainingPercent: remainingFraction === undefined ? undefined : Math.max(0, remainingFraction * 100),
4079
- },
4080
- signal,
4081
- );
4082
- if (signal.aborted) return;
4083
- if (!shouldFallback) {
4084
- this.#usageReserveApprovedSelector = currentSelector;
4085
- return;
4024
+ if (fallbackCommitted) return true;
4025
+ if (controller.signal.aborted || this.#promptGeneration !== generation) return false;
4026
+ if (this.model === model || modelsAreEqual(this.model, model)) return true;
4027
+ } catch (error) {
4028
+ if (controller.signal.aborted || this.#promptGeneration !== generation) return false;
4029
+ if (this.model !== model && !modelsAreEqual(this.model, model)) continue;
4030
+ throw error;
4086
4031
  }
4087
4032
  }
4033
+ } finally {
4034
+ signal?.removeEventListener("abort", onAbort);
4035
+ this.#usagePreflightAbortControllers.delete(controller);
4088
4036
  }
4089
-
4090
- if (signal.aborted) return;
4091
- this.#usageReserveApprovedSelector = undefined;
4092
- await this.#recovery.applyRetryFallbackCandidate(role, fallback.selector, currentSelector, {
4093
- pinFallback: true,
4094
- apiKey: fallback.apiKey,
4095
- signal,
4096
- });
4097
4037
  }
4098
4038
 
4099
4039
  /** Effective thinking level applied to the agent (the resolved level when `auto`). */
@@ -4184,6 +4124,11 @@ export class AgentSession {
4184
4124
  return this.agent.state.systemPrompt;
4185
4125
  }
4186
4126
 
4127
+ /** Marks streamed text as committed or buffered for turn-recovery replay decisions. */
4128
+ setTextOutputCommitted(committed: boolean): void {
4129
+ this.#textOutputCommitted = committed;
4130
+ }
4131
+
4187
4132
  /** Current retry attempt (0 if not retrying) */
4188
4133
  get retryAttempt(): number {
4189
4134
  return this.#recovery.attempt;
@@ -5026,7 +4971,7 @@ export class AgentSession {
5026
4971
  if (this.isStreaming) {
5027
4972
  const streamingBehavior = options?.streamingBehavior;
5028
4973
  if (!streamingBehavior) throw new AgentBusyError();
5029
- if (!(await this.#runUsageAwarePreflight())) return false;
4974
+
5030
4975
  // Steer/follow-up the keyword notices BEFORE the queued user message so the
5031
4976
  // model reads the steering notice ahead of the prompt it modifies.
5032
4977
  for (const notice of keywordNotices) {
@@ -5121,7 +5066,7 @@ export class AgentSession {
5121
5066
  if (options?.queueOnly) {
5122
5067
  const streamingBehavior = options?.streamingBehavior;
5123
5068
  if (!streamingBehavior) throw new AgentBusyError();
5124
- if (!(await this.#runUsageAwarePreflight())) return;
5069
+
5125
5070
  for (const notice of keywordNotices) {
5126
5071
  await this.#queueCustomMessage(notice, streamingBehavior);
5127
5072
  }
@@ -5131,7 +5076,7 @@ export class AgentSession {
5131
5076
  if (this.isStreaming) {
5132
5077
  const streamingBehavior = options?.streamingBehavior;
5133
5078
  if (!streamingBehavior) throw new AgentBusyError();
5134
- if (!(await this.#runUsageAwarePreflight())) return;
5079
+
5135
5080
  for (const notice of keywordNotices) {
5136
5081
  await this.#queueCustomMessage(notice, streamingBehavior);
5137
5082
  }
@@ -5168,7 +5113,7 @@ export class AgentSession {
5168
5113
  const generation = this.#promptGeneration;
5169
5114
  try {
5170
5115
  await this.#recovery.maybeRestoreRetryFallbackPrimary();
5171
- if (!(await this.#runUsageAwarePreflight())) return;
5116
+ if (!(await this.#runUsageAwarePreflightForNextModelCall())) return;
5172
5117
  // Flush any pending bash messages before the new prompt
5173
5118
  await this.#bash.flushPending();
5174
5119
  this.#eval.flushPending();
@@ -5379,6 +5324,7 @@ export class AgentSession {
5379
5324
  await this.#waitForPostPromptRecovery(generation);
5380
5325
  }
5381
5326
  } finally {
5327
+ this.#usagePreflightReadyForNextModelCall = false;
5382
5328
  this.#endInFlight();
5383
5329
  }
5384
5330
  }
@@ -5544,7 +5490,6 @@ export class AgentSession {
5544
5490
  }
5545
5491
 
5546
5492
  const expandedText = expandPromptTemplate(text, [...this.#promptTemplates]);
5547
- if (!(await this.#runUsageAwarePreflight())) return;
5548
5493
  await this.#queueUserMessage(expandedText, images, "steer");
5549
5494
  }
5550
5495
 
@@ -5562,7 +5507,6 @@ export class AgentSession {
5562
5507
 
5563
5508
  const expandedText =
5564
5509
  options?.expandPromptTemplates === false ? text : expandPromptTemplate(text, [...this.#promptTemplates]);
5565
- if (!(await this.#runUsageAwarePreflight())) return;
5566
5510
  if (!options?.synthetic) {
5567
5511
  await this.#queueUserMessage(expandedText, images, "followUp");
5568
5512
  return;
@@ -5579,6 +5523,7 @@ export class AgentSession {
5579
5523
  const imageDescriptionNotice = normalizedImages?.length
5580
5524
  ? await this.#buildImageDescriptionNotice(normalizedImages)
5581
5525
  : undefined;
5526
+ this.#allowQueuedMessageDrainRetry();
5582
5527
  if (imageDescriptionNotice) this.agent.followUp(imageDescriptionNotice);
5583
5528
  this.agent.followUp({
5584
5529
  role: "developer",
@@ -5608,6 +5553,7 @@ export class AgentSession {
5608
5553
  const imageDescriptionNotice = normalizedImages?.length
5609
5554
  ? await this.#buildImageDescriptionNotice(normalizedImages)
5610
5555
  : undefined;
5556
+ this.#allowQueuedMessageDrainRetry();
5611
5557
  if (mode === "followUp") {
5612
5558
  if (imageDescriptionNotice) this.agent.followUp(imageDescriptionNotice);
5613
5559
  this.agent.followUp({
@@ -5634,7 +5580,12 @@ export class AgentSession {
5634
5580
  }
5635
5581
 
5636
5582
  #scheduleQueuedMessageDrain(): void {
5637
- if (this.#queuedMessageDrainScheduled || !this.#canAutoContinueForFollowUp() || !this.agent.hasQueuedMessages()) {
5583
+ if (
5584
+ this.#queuedMessageDrainScheduled ||
5585
+ this.#queuedMessageDrainBlocked ||
5586
+ !this.#canAutoContinueForFollowUp() ||
5587
+ !this.agent.hasQueuedMessages()
5588
+ ) {
5638
5589
  return;
5639
5590
  }
5640
5591
  this.#queuedMessageDrainScheduled = true;
@@ -5648,6 +5599,7 @@ export class AgentSession {
5648
5599
  },
5649
5600
  onError: () => {
5650
5601
  this.#queuedMessageDrainScheduled = false;
5602
+ this.#queuedMessageDrainBlocked = this.agent.hasQueuedMessages();
5651
5603
  },
5652
5604
  });
5653
5605
  }
@@ -5784,7 +5736,7 @@ export class AgentSession {
5784
5736
  ): Promise<void> {
5785
5737
  this.#beginInFlight();
5786
5738
  try {
5787
- if (!(await this.#runUsageAwarePreflight())) return;
5739
+ if (!(await this.#runUsageAwarePreflightForNextModelCall())) return;
5788
5740
  const acceptTerminalEmptyStop = options?.acceptTerminalEmptyStop === true;
5789
5741
  if (acceptTerminalEmptyStop) {
5790
5742
  this.#resetPromptMaintenanceState();
@@ -5793,6 +5745,7 @@ export class AgentSession {
5793
5745
  await this.agent.prompt(message);
5794
5746
  await this.#waitForPostPromptRecovery();
5795
5747
  } finally {
5748
+ this.#usagePreflightReadyForNextModelCall = false;
5796
5749
  this.#recovery.setAcceptTerminalEmptyStop(false);
5797
5750
  this.#endInFlight();
5798
5751
  }
@@ -5824,6 +5777,7 @@ export class AgentSession {
5824
5777
  timestamp: Date.now(),
5825
5778
  };
5826
5779
  const normalizedAppMessage = await this.#normalizeAgentMessageImages(appMessage);
5780
+ this.#allowQueuedMessageDrainRetry();
5827
5781
  if (deliverAs === "followUp") {
5828
5782
  this.agent.followUp(normalizedAppMessage);
5829
5783
  } else {
@@ -5880,7 +5834,7 @@ export class AgentSession {
5880
5834
  this.#queueHiddenNextTurnMessage(normalizedAppMessage, options?.triggerTurn ?? false);
5881
5835
  return false;
5882
5836
  }
5883
- if (!(await this.#runUsageAwarePreflight())) return false;
5837
+ this.#allowQueuedMessageDrainRetry();
5884
5838
 
5885
5839
  if (options?.deliverAs === "followUp") {
5886
5840
  this.agent.followUp(normalizedAppMessage);
@@ -5963,8 +5917,6 @@ export class AgentSession {
5963
5917
  if (images.length === 0) images = undefined;
5964
5918
  }
5965
5919
 
5966
- if (options?.deliverAs && !(await this.#runUsageAwarePreflight())) return;
5967
-
5968
5920
  if (options?.deliverAs === "followUp") {
5969
5921
  await this.#queueUserMessage(text, images, "followUp");
5970
5922
  return;
@@ -6004,6 +5956,7 @@ export class AgentSession {
6004
5956
  ? isAdvisorCard
6005
5957
  : m => !isUserQueuedMessage(m) && !isHiddenUserCompanion(m);
6006
5958
  this.agent.replaceQueues(steeringAll.filter(keep), followUpAll.filter(keep));
5959
+ this.#reconcileQueuedMessageDrain();
6007
5960
  return { steering, followUp };
6008
5961
  }
6009
5962
 
@@ -6053,12 +6006,14 @@ export class AgentSession {
6053
6006
  if (fromSteer >= 0) {
6054
6007
  const removed = steering[fromSteer];
6055
6008
  this.agent.replaceQueues(removeWithCompanions(steering, fromSteer), followUp.slice());
6009
+ this.#reconcileQueuedMessageDrain();
6056
6010
  return toRestoredQueuedMessage(removed);
6057
6011
  }
6058
6012
  const fromFollowUp = lastUserIndex(followUp);
6059
6013
  if (fromFollowUp >= 0) {
6060
6014
  const removed = followUp[fromFollowUp];
6061
6015
  this.agent.replaceQueues(steering.slice(), removeWithCompanions(followUp, fromFollowUp));
6016
+ this.#reconcileQueuedMessageDrain();
6062
6017
  return toRestoredQueuedMessage(removed);
6063
6018
  }
6064
6019
  return undefined;
@@ -6348,6 +6303,8 @@ export class AgentSession {
6348
6303
  await this.#memory.resetContextForNewTranscript();
6349
6304
  this.#pendingNextTurnMessages = [];
6350
6305
  this.#scheduledHiddenNextTurnGeneration = undefined;
6306
+ this.#queuedMessageDrainBlocked = false;
6307
+ this.#usagePreflightReadyForNextModelCall = false;
6351
6308
 
6352
6309
  this.sessionManager.appendThinkingLevelChange(this.thinkingLevel, this.configuredThinkingLevel());
6353
6310
  this.sessionManager.appendServiceTierChange(this.#models.serviceTierEntry());
@@ -7372,6 +7329,9 @@ export class AgentSession {
7372
7329
  const previousFollowUpMessages = [...this.agent.peekFollowUpQueue()];
7373
7330
  const previousPendingNextTurnMessages = [...this.#pendingNextTurnMessages];
7374
7331
  const previousScheduledHiddenNextTurnGeneration = this.#scheduledHiddenNextTurnGeneration;
7332
+ const previousQueuedMessageDrainBlocked = this.#queuedMessageDrainBlocked;
7333
+ const previousUsagePreflightReadyForNextModelCall = this.#usagePreflightReadyForNextModelCall;
7334
+ const previousUsagePreflightReadyModel = this.#usagePreflightReadyModel;
7375
7335
  const previousModel = this.model;
7376
7336
  const previousThinkingLevel = this.thinkingLevel;
7377
7337
  const previousAutoThinking = this.isAutoThinking;
@@ -7396,6 +7356,9 @@ export class AgentSession {
7396
7356
  this.agent.clearAllQueues();
7397
7357
  this.#pendingNextTurnMessages = [];
7398
7358
  this.#scheduledHiddenNextTurnGeneration = undefined;
7359
+ this.#queuedMessageDrainBlocked = false;
7360
+ this.#usagePreflightReadyForNextModelCall = false;
7361
+ this.#usagePreflightReadyModel = undefined;
7399
7362
 
7400
7363
  try {
7401
7364
  if (switchingToDifferentSession) {
@@ -7564,6 +7527,9 @@ export class AgentSession {
7564
7527
  this.agent.replaceQueues(previousSteeringMessages, previousFollowUpMessages);
7565
7528
  this.#pendingNextTurnMessages = previousPendingNextTurnMessages;
7566
7529
  this.#scheduledHiddenNextTurnGeneration = previousScheduledHiddenNextTurnGeneration;
7530
+ this.#queuedMessageDrainBlocked = previousQueuedMessageDrainBlocked;
7531
+ this.#usagePreflightReadyForNextModelCall = previousUsagePreflightReadyForNextModelCall;
7532
+ this.#usagePreflightReadyModel = previousUsagePreflightReadyModel;
7567
7533
  this.#inheritedProviderPromptCacheKey = previousInheritedProviderPromptCacheKey;
7568
7534
  this.#checkpointState = previousCheckpointState;
7569
7535
  this.#pendingRewindReport = previousPendingRewindReport;
@@ -7652,6 +7618,8 @@ export class AgentSession {
7652
7618
  // Clear pending messages (bound to old session state)
7653
7619
  this.#pendingNextTurnMessages = [];
7654
7620
  this.#scheduledHiddenNextTurnGeneration = undefined;
7621
+ this.#queuedMessageDrainBlocked = false;
7622
+ this.#usagePreflightReadyForNextModelCall = false;
7655
7623
 
7656
7624
  await this.#bash.flushPending();
7657
7625
  // Flush pending writes before branching
@@ -7779,6 +7747,8 @@ export class AgentSession {
7779
7747
  this.#pendingNextTurnMessages = [];
7780
7748
  this.#scheduledHiddenNextTurnGeneration = undefined;
7781
7749
  this.agent.replaceQueues([], []);
7750
+ this.#queuedMessageDrainBlocked = false;
7751
+ this.#usagePreflightReadyForNextModelCall = false;
7782
7752
  await this.#bash.flushPending();
7783
7753
  await this.sessionManager.flush();
7784
7754
  const bashTransition = this.#bash.beginSessionTransition();