@oh-my-pi/pi-coding-agent 16.4.4 → 16.4.6

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 (109) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/dist/cli.js +3799 -3729
  3. package/dist/types/async/job-manager.d.ts +8 -0
  4. package/dist/types/cli/bench-cli.d.ts +1 -7
  5. package/dist/types/cli/usage-cli.d.ts +1 -0
  6. package/dist/types/commands/usage.d.ts +7 -0
  7. package/dist/types/config/settings-schema.d.ts +19 -9
  8. package/dist/types/config/settings.d.ts +3 -2
  9. package/dist/types/discovery/helpers.d.ts +2 -2
  10. package/dist/types/extensibility/extensions/types.d.ts +36 -0
  11. package/dist/types/irc/bus.d.ts +4 -0
  12. package/dist/types/modes/components/__tests__/pause-screen.test.d.ts +1 -0
  13. package/dist/types/modes/components/ask-dialog.d.ts +27 -0
  14. package/dist/types/modes/components/custom-editor.d.ts +3 -8
  15. package/dist/types/modes/components/index.d.ts +2 -1
  16. package/dist/types/modes/components/model-browser.d.ts +100 -0
  17. package/dist/types/modes/components/model-hub.d.ts +52 -0
  18. package/dist/types/modes/components/pause-screen.d.ts +43 -0
  19. package/dist/types/modes/components/session-selector.d.ts +13 -0
  20. package/dist/types/modes/components/tool-execution.d.ts +2 -0
  21. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -1
  22. package/dist/types/modes/controllers/input-controller.d.ts +2 -0
  23. package/dist/types/modes/interactive-mode.d.ts +3 -0
  24. package/dist/types/modes/queue-input.d.ts +8 -0
  25. package/dist/types/modes/shared.d.ts +1 -1
  26. package/dist/types/modes/types.d.ts +4 -0
  27. package/dist/types/session/agent-session.d.ts +1 -1
  28. package/dist/types/session/agent-storage.d.ts +57 -0
  29. package/dist/types/session/session-context.d.ts +9 -0
  30. package/dist/types/task/executor.d.ts +26 -13
  31. package/dist/types/task/index.d.ts +12 -11
  32. package/dist/types/task/label.d.ts +4 -0
  33. package/dist/types/task/repair-args.d.ts +8 -8
  34. package/dist/types/task/types.d.ts +31 -56
  35. package/dist/types/tools/ask.d.ts +12 -0
  36. package/dist/types/tools/conflict-detect.d.ts +17 -1
  37. package/dist/types/tools/job.d.ts +16 -0
  38. package/package.json +12 -12
  39. package/scripts/build-binary.ts +0 -1
  40. package/scripts/compile-binary.ts +4 -3
  41. package/src/async/job-manager.ts +9 -0
  42. package/src/cli/bench-cli.ts +7 -26
  43. package/src/cli/usage-cli.ts +11 -0
  44. package/src/commands/usage.ts +13 -2
  45. package/src/commit/agentic/tools/analyze-file.ts +2 -3
  46. package/src/config/settings-schema.ts +18 -7
  47. package/src/config/settings.ts +13 -4
  48. package/src/discovery/helpers.ts +3 -4
  49. package/src/extensibility/custom-tools/loader.ts +70 -37
  50. package/src/extensibility/extensions/types.ts +46 -0
  51. package/src/irc/bus.ts +61 -20
  52. package/src/modes/components/__tests__/pause-screen.test.ts +143 -0
  53. package/src/modes/components/advisor-config.ts +32 -22
  54. package/src/modes/components/ask-dialog.ts +888 -0
  55. package/src/modes/components/custom-editor.test.ts +58 -1
  56. package/src/modes/components/custom-editor.ts +42 -11
  57. package/src/modes/components/index.ts +2 -1
  58. package/src/modes/components/model-browser.ts +769 -0
  59. package/src/modes/components/model-hub.ts +2002 -0
  60. package/src/modes/components/pause-screen.ts +208 -0
  61. package/src/modes/components/session-selector.ts +299 -42
  62. package/src/modes/components/tool-execution.ts +2 -0
  63. package/src/modes/components/usage-row.ts +5 -6
  64. package/src/modes/controllers/event-controller.ts +8 -2
  65. package/src/modes/controllers/extension-ui-controller.ts +252 -5
  66. package/src/modes/controllers/input-controller.ts +140 -6
  67. package/src/modes/controllers/selector-controller.ts +160 -97
  68. package/src/modes/controllers/tan-command-controller.ts +1 -1
  69. package/src/modes/controllers/todo-command-controller.ts +1 -2
  70. package/src/modes/interactive-mode.ts +8 -0
  71. package/src/modes/queue-input.ts +132 -0
  72. package/src/modes/shared.ts +1 -1
  73. package/src/modes/theme/theme.ts +3 -3
  74. package/src/modes/types.ts +4 -0
  75. package/src/modes/utils/ui-helpers.ts +50 -24
  76. package/src/prompts/agents/scout.md +0 -1
  77. package/src/prompts/agents/task.md +1 -1
  78. package/src/prompts/system/subagent-system-prompt.md +1 -5
  79. package/src/prompts/system/subagent-yield-reminder.md +10 -0
  80. package/src/prompts/system/task-label.md +23 -0
  81. package/src/prompts/tools/job.md +1 -1
  82. package/src/prompts/tools/task-summary.md +3 -0
  83. package/src/prompts/tools/task.md +17 -18
  84. package/src/session/agent-session.ts +186 -49
  85. package/src/session/agent-storage.ts +330 -3
  86. package/src/session/history-storage.ts +1 -34
  87. package/src/session/session-context.test.ts +73 -0
  88. package/src/session/session-context.ts +43 -26
  89. package/src/slash-commands/builtin-registry.ts +18 -0
  90. package/src/task/agents.ts +2 -0
  91. package/src/task/executor.ts +159 -46
  92. package/src/task/index.ts +377 -239
  93. package/src/task/label.ts +38 -0
  94. package/src/task/render.ts +74 -22
  95. package/src/task/repair-args.ts +20 -31
  96. package/src/task/spawn-policy.test.ts +4 -4
  97. package/src/task/types.ts +46 -66
  98. package/src/tools/ask.ts +233 -40
  99. package/src/tools/conflict-detect.ts +102 -5
  100. package/src/tools/index.ts +1 -0
  101. package/src/tools/irc.ts +20 -11
  102. package/src/tools/job.ts +158 -18
  103. package/src/tools/write.ts +70 -6
  104. package/src/vibe/runtime.ts +1 -1
  105. package/src/web/search/providers/browser-headers.ts +30 -13
  106. package/dist/types/modes/components/model-selector.d.ts +0 -37
  107. package/dist/types/tools/bash-command-fixup.d.ts +0 -3
  108. package/src/modes/components/model-selector.ts +0 -1291
  109. package/src/tools/bash-command-fixup.ts +0 -4
@@ -1060,6 +1060,7 @@ export interface FreshSessionResult {
1060
1060
 
1061
1061
  /** Standard thinking levels */
1062
1062
 
1063
+ /** `retry.fallbackChains` config: chain key (role name or model selector) → ordered fallback selectors. */
1063
1064
  type RetryFallbackChains = Record<string, string[]>;
1064
1065
 
1065
1066
  type RetryFallbackRevertPolicy = "never" | "cooldown-expiry";
@@ -1072,6 +1073,7 @@ interface RetryFallbackSelector {
1072
1073
  }
1073
1074
 
1074
1075
  interface ActiveRetryFallbackState {
1076
+ /** Chain key that produced this fallback: a model-role name or a model-selector key. */
1075
1077
  role: string;
1076
1078
  originalSelector: string;
1077
1079
  originalThinkingLevel: ConfiguredThinkingLevel | undefined;
@@ -1099,6 +1101,24 @@ function parseRetryFallbackSelector(
1099
1101
  };
1100
1102
  }
1101
1103
 
1104
+ /**
1105
+ * `retry.fallbackChains` keys are either model-role names (`smol`, `default`)
1106
+ * or model selectors (`provider/model-id[:thinking]`). Role names never
1107
+ * contain a slash, so its presence marks a model-keyed chain whose primary is
1108
+ * the key itself — the chain follows the model across role reassignments.
1109
+ */
1110
+ function isRetryFallbackModelKey(key: string): boolean {
1111
+ return key.includes("/");
1112
+ }
1113
+
1114
+ /**
1115
+ * A `provider/*` fallback-chain key: matches any active model of that provider,
1116
+ * so one entry covers every current and future model behind the provider.
1117
+ */
1118
+ function isRetryFallbackWildcardKey(key: string): boolean {
1119
+ return key.endsWith("/*");
1120
+ }
1121
+
1102
1122
  function formatRetryFallbackSelector(model: Model, thinkingLevel: ThinkingLevel | undefined): string {
1103
1123
  return formatModelSelectorValue(formatModelStringWithRouting(model), thinkingLevel);
1104
1124
  }
@@ -3820,6 +3840,16 @@ export class AgentSession {
3820
3840
  if (event.message.role === "assistant") {
3821
3841
  this.#lastAssistantMessage = event.message;
3822
3842
  const assistantMsg = event.message as AssistantMessage;
3843
+ // Fold this turn's timing into per-model perf aggregates (drives the
3844
+ // /models TPS/TTFT display). Errored turns measure nothing; aborted
3845
+ // turns with reported usage are still valid throughput samples.
3846
+ if (assistantMsg.stopReason !== "error" && assistantMsg.duration !== undefined) {
3847
+ this.settings.getStorage()?.recordModelPerf(`${assistantMsg.provider}/${assistantMsg.model}`, {
3848
+ outputTokens: assistantMsg.usage.output,
3849
+ durationMs: assistantMsg.duration,
3850
+ ttftMs: assistantMsg.ttft,
3851
+ });
3852
+ }
3823
3853
  if (
3824
3854
  assistantMsg.disabledFeatures?.includes("priority") &&
3825
3855
  this.#serviceTierByFamily.anthropic === "priority"
@@ -6891,12 +6921,13 @@ export class AgentSession {
6891
6921
  * `agent.replaceMessages` or a provider.
6892
6922
  */
6893
6923
  buildTranscriptSessionContext(
6894
- options?: Pick<BuildSessionContextOptions, "collapseCompactedHistory">,
6924
+ options?: Pick<BuildSessionContextOptions, "collapseCompactedHistory" | "keepDanglingToolCalls">,
6895
6925
  ): SessionContext {
6896
6926
  return deobfuscateSessionContext(
6897
6927
  this.sessionManager.buildSessionContext({
6898
6928
  transcript: true,
6899
6929
  collapseCompactedHistory: options?.collapseCompactedHistory,
6930
+ keepDanglingToolCalls: options?.keepDanglingToolCalls,
6900
6931
  }),
6901
6932
  this.#obfuscator,
6902
6933
  );
@@ -8723,12 +8754,7 @@ export class AgentSession {
8723
8754
 
8724
8755
  #syncTodoPhasesFromBranch(): void {
8725
8756
  const phases = getLatestTodoPhasesFromEntries(this.sessionManager.getBranch());
8726
- // Strip completed/abandoned tasks — they were done in a previous run,
8727
- // so they have no bearing on progress tracking for the new turn.
8728
- for (const phase of phases) {
8729
- phase.tasks = phase.tasks.filter(t => t.status !== "completed" && t.status !== "abandoned");
8730
- }
8731
- this.setTodoPhases(phases.filter(p => p.tasks.length > 0));
8757
+ this.setTodoPhases(phases);
8732
8758
  }
8733
8759
 
8734
8760
  #cloneTodoPhases(phases: TodoPhase[]): TodoPhase[] {
@@ -13479,36 +13505,68 @@ export class AgentSession {
13479
13505
  const configuredChains = this.settings.get("retry.fallbackChains");
13480
13506
  if (configuredChains === undefined) return;
13481
13507
  if (!configuredChains || typeof configuredChains !== "object" || Array.isArray(configuredChains)) {
13482
- const msg = "retry.fallbackChains must be a mapping of role names to selector arrays.";
13508
+ const msg = "retry.fallbackChains must be a mapping of role names or model selectors to selector arrays.";
13483
13509
  logger.warn(msg);
13484
13510
  this.configWarnings.push(msg);
13485
13511
  return;
13486
13512
  }
13487
13513
 
13488
- for (const [role, chain] of Object.entries(configuredChains)) {
13514
+ for (const key in configuredChains) {
13515
+ const chain = (configuredChains as RetryFallbackChains)[key];
13516
+ const keyKind = isRetryFallbackModelKey(key) ? "model" : "role";
13517
+ if (keyKind === "model") {
13518
+ if (isRetryFallbackWildcardKey(key)) {
13519
+ const provider = key.slice(0, -2);
13520
+ if (!this.#modelRegistry.getAll().some(model => model.provider === provider)) {
13521
+ const msg = `retry.fallbackChains wildcard key references unknown provider: ${key}`;
13522
+ logger.warn(msg);
13523
+ this.configWarnings.push(msg);
13524
+ }
13525
+ } else {
13526
+ const parsedKey = parseRetryFallbackSelector(key, this.#modelRegistry);
13527
+ if (!parsedKey) {
13528
+ const msg = `Invalid model selector key in retry.fallbackChains: ${key}`;
13529
+ logger.warn(msg);
13530
+ this.configWarnings.push(msg);
13531
+ } else if (!this.#modelRegistry.find(parsedKey.provider, parsedKey.id)) {
13532
+ const msg = `retry.fallbackChains key references unknown model: ${key}`;
13533
+ logger.warn(msg);
13534
+ this.configWarnings.push(msg);
13535
+ }
13536
+ }
13537
+ }
13489
13538
  if (!Array.isArray(chain)) {
13490
- const msg = `Fallback chain for role '${role}' must be an array of selector strings.`;
13539
+ const msg = `Fallback chain for ${keyKind} '${key}' must be an array of selector strings.`;
13491
13540
  logger.warn(msg);
13492
13541
  this.configWarnings.push(msg);
13493
13542
  continue;
13494
13543
  }
13495
13544
  for (const selectorStr of chain) {
13496
13545
  if (typeof selectorStr !== "string") {
13497
- const msg = `Fallback chain for role '${role}' contains a non-string selector.`;
13546
+ const msg = `Fallback chain for ${keyKind} '${key}' contains a non-string selector.`;
13498
13547
  logger.warn(msg);
13499
13548
  this.configWarnings.push(msg);
13500
13549
  continue;
13501
13550
  }
13551
+ if (isRetryFallbackWildcardKey(selectorStr)) {
13552
+ const provider = selectorStr.slice(0, -2);
13553
+ if (!this.#modelRegistry.getAll().some(model => model.provider === provider)) {
13554
+ const msg = `Fallback chain for ${keyKind} '${key}' references unknown provider: ${selectorStr}`;
13555
+ logger.warn(msg);
13556
+ this.configWarnings.push(msg);
13557
+ }
13558
+ continue;
13559
+ }
13502
13560
  const parsed = parseRetryFallbackSelector(selectorStr, this.#modelRegistry);
13503
13561
  if (!parsed) {
13504
- const msg = `Invalid fallback selector format in role '${role}': ${selectorStr}`;
13562
+ const msg = `Invalid fallback selector format in ${keyKind} '${key}': ${selectorStr}`;
13505
13563
  logger.warn(msg);
13506
13564
  this.configWarnings.push(msg);
13507
13565
  continue;
13508
13566
  }
13509
13567
  const exists = this.#modelRegistry.find(parsed.provider, parsed.id);
13510
13568
  if (!exists) {
13511
- const msg = `Fallback chain for role '${role}' references unknown model: ${selectorStr}`;
13569
+ const msg = `Fallback chain for ${keyKind} '${key}' references unknown model: ${selectorStr}`;
13512
13570
  logger.warn(msg);
13513
13571
  this.configWarnings.push(msg);
13514
13572
  }
@@ -13521,6 +13579,8 @@ export class AgentSession {
13521
13579
  }
13522
13580
 
13523
13581
  #getRetryFallbackPrimarySelector(role: string): RetryFallbackSelector | undefined {
13582
+ if (isRetryFallbackWildcardKey(role)) return undefined;
13583
+ if (isRetryFallbackModelKey(role)) return parseRetryFallbackSelector(role, this.#modelRegistry);
13524
13584
  const configuredSelector = this.settings.getModelRole(role);
13525
13585
  return configuredSelector ? parseRetryFallbackSelector(configuredSelector, this.#modelRegistry) : undefined;
13526
13586
  }
@@ -13542,6 +13602,13 @@ export class AgentSession {
13542
13602
  this.#modelRegistry.suppressSelector(currentSelector, Date.now() + cooldownMs);
13543
13603
  }
13544
13604
 
13605
+ /**
13606
+ * Map the failing model selector to the chain key that owns it, by
13607
+ * specificity: an exact model-selector key, then a `provider/*` wildcard,
13608
+ * then a model role whose current assignment matches, then `default`.
13609
+ * Model-oriented keys win over roles so a chain follows the model across
13610
+ * role reassignments.
13611
+ */
13545
13612
  #resolveRetryFallbackRole(currentSelector: string): string | undefined {
13546
13613
  const parsedCurrent = parseRetryFallbackSelector(currentSelector, this.#modelRegistry);
13547
13614
  if (!parsedCurrent) return undefined;
@@ -13555,18 +13622,33 @@ export class AgentSession {
13555
13622
  ? formatRetryFallbackBaseSelector(parseRetryFallbackSelector(currentPlainSelector) ?? parsedCurrent)
13556
13623
  : undefined;
13557
13624
 
13558
- for (const role of Object.keys(chains)) {
13559
- const primarySelector = this.#getRetryFallbackPrimarySelector(role);
13560
- if (primarySelector?.raw === currentSelector) return role;
13625
+ const exactModelKeys: string[] = [];
13626
+ const roleKeys: string[] = [];
13627
+ for (const key in chains) {
13628
+ if (!isRetryFallbackModelKey(key)) roleKeys.push(key);
13629
+ else if (!isRetryFallbackWildcardKey(key)) exactModelKeys.push(key);
13561
13630
  }
13562
- for (const role of Object.keys(chains)) {
13563
- const primarySelector = this.#getRetryFallbackPrimarySelector(role);
13564
- if (!primarySelector) continue;
13565
- if (currentPlainSelector && primarySelector.raw === currentPlainSelector) return role;
13566
- const primaryBaseSelector = formatRetryFallbackBaseSelector(primarySelector);
13567
- if (primaryBaseSelector === currentBaseSelector) return role;
13568
- if (currentPlainBaseSelector && primaryBaseSelector === currentPlainBaseSelector) return role;
13631
+ const matchesCurrent = (primary: RetryFallbackSelector | undefined): boolean => {
13632
+ if (!primary) return false;
13633
+ if (primary.raw === currentSelector || (currentPlainSelector && primary.raw === currentPlainSelector)) {
13634
+ return true;
13635
+ }
13636
+ const base = formatRetryFallbackBaseSelector(primary);
13637
+ return base === currentBaseSelector || (!!currentPlainBaseSelector && base === currentPlainBaseSelector);
13638
+ };
13639
+
13640
+ // 1. Exact model-selector keys — most specific.
13641
+ for (const key of exactModelKeys) {
13642
+ if (matchesCurrent(this.#getRetryFallbackPrimarySelector(key))) return key;
13643
+ }
13644
+ // 2. Provider wildcard (`provider/*`) — any active model of this provider.
13645
+ const wildcardKey = `${parsedCurrent.provider}/*`;
13646
+ if (Array.isArray(chains[wildcardKey])) return wildcardKey;
13647
+ // 3. Role keys — matched by the role's currently-assigned model.
13648
+ for (const key of roleKeys) {
13649
+ if (matchesCurrent(this.#getRetryFallbackPrimarySelector(key))) return key;
13569
13650
  }
13651
+ // 4. The default chain, when default has no explicit role primary.
13570
13652
  const defaultChain = chains.default;
13571
13653
  if (
13572
13654
  Array.isArray(defaultChain) &&
@@ -13578,13 +13660,45 @@ export class AgentSession {
13578
13660
  return undefined;
13579
13661
  }
13580
13662
 
13581
- #getRetryFallbackEffectiveChain(role: string): RetryFallbackSelector[] {
13582
- const primarySelector = this.#getRetryFallbackPrimarySelector(role);
13583
- if (!primarySelector) return [];
13584
- const chain = [primarySelector];
13585
- const seen = new Set<string>([primarySelector.raw]);
13663
+ /**
13664
+ * Parse one configured chain entry. A `provider/*` entry keeps the failing
13665
+ * model's id and swaps the provider (google-antigravity/x google/x);
13666
+ * ids the target provider lacks are skipped by the candidate loop's
13667
+ * registry lookup.
13668
+ */
13669
+ #parseRetryFallbackChainEntry(
13670
+ entry: string,
13671
+ current: RetryFallbackSelector | undefined,
13672
+ ): RetryFallbackSelector | undefined {
13673
+ if (isRetryFallbackWildcardKey(entry)) {
13674
+ if (!current) return undefined;
13675
+ const provider = entry.slice(0, -2);
13676
+ return { raw: `${provider}/${current.id}`, provider, id: current.id, thinkingLevel: undefined };
13677
+ }
13678
+ return parseRetryFallbackSelector(entry, this.#modelRegistry);
13679
+ }
13680
+
13681
+ #getRetryFallbackEffectiveChain(role: string, currentSelector?: string): RetryFallbackSelector[] {
13682
+ const parsedCurrent = currentSelector
13683
+ ? parseRetryFallbackSelector(currentSelector, this.#modelRegistry)
13684
+ : undefined;
13685
+ const seen = new Set<string>();
13686
+ const chain: RetryFallbackSelector[] = [];
13687
+ if (isRetryFallbackWildcardKey(role)) {
13688
+ // A wildcard key has no fixed primary: the active model is the
13689
+ // primary, followed by the configured provider-level fallbacks.
13690
+ if (parsedCurrent) {
13691
+ chain.push(parsedCurrent);
13692
+ seen.add(parsedCurrent.raw);
13693
+ }
13694
+ } else {
13695
+ const primarySelector = this.#getRetryFallbackPrimarySelector(role);
13696
+ if (!primarySelector) return [];
13697
+ chain.push(primarySelector);
13698
+ seen.add(primarySelector.raw);
13699
+ }
13586
13700
  for (const selector of this.#getRetryFallbackChains()[role] ?? []) {
13587
- const parsed = parseRetryFallbackSelector(selector, this.#modelRegistry);
13701
+ const parsed = this.#parseRetryFallbackChainEntry(selector, parsedCurrent);
13588
13702
  if (!parsed || seen.has(parsed.raw)) continue;
13589
13703
  seen.add(parsed.raw);
13590
13704
  chain.push(parsed);
@@ -13593,7 +13707,7 @@ export class AgentSession {
13593
13707
  }
13594
13708
 
13595
13709
  #findRetryFallbackCandidates(role: string, currentSelector: string): RetryFallbackSelector[] {
13596
- let chain = this.#getRetryFallbackEffectiveChain(role);
13710
+ let chain = this.#getRetryFallbackEffectiveChain(role, currentSelector);
13597
13711
  const parsedCurrent = parseRetryFallbackSelector(currentSelector, this.#modelRegistry);
13598
13712
  if (chain.length === 0 && role === "default" && parsedCurrent) {
13599
13713
  const chains = this.#getRetryFallbackChains();
@@ -13606,7 +13720,7 @@ export class AgentSession {
13606
13720
  const seen = new Set<string>([parsedCurrent.raw]);
13607
13721
  chain = [parsedCurrent];
13608
13722
  for (const selector of defaultChain) {
13609
- const parsed = parseRetryFallbackSelector(selector, this.#modelRegistry);
13723
+ const parsed = this.#parseRetryFallbackChainEntry(selector, parsedCurrent);
13610
13724
  if (!parsed || seen.has(parsed.raw)) continue;
13611
13725
  seen.add(parsed.raw);
13612
13726
  chain.push(parsed);
@@ -13879,20 +13993,13 @@ export class AgentSession {
13879
13993
  this.#retryResolve = resolve;
13880
13994
  }
13881
13995
 
13882
- if (this.#retryAttempt > retrySettings.maxRetries) {
13883
- await this.#persistRetryLifecycleErrorMessage(message);
13884
- // Max retries exceeded, emit final failure and reset
13885
- await this.#emitSessionEvent({
13886
- type: "auto_retry_end",
13887
- success: false,
13888
- attempt: this.#retryAttempt - 1,
13889
- finalError: message.errorMessage,
13890
- });
13891
- this.#clearPendingRecoveredRetryErrors();
13892
- this.#retryAttempt = 0;
13893
- this.#resolveRetry(); // Resolve so waitForRetry() completes
13894
- return false;
13895
- }
13996
+ // All attempts on the current model are spent. Don't fail yet: the
13997
+ // fallback chain below gets one last consult. Credential rotation can
13998
+ // consume the entire budget without the fallback branch ever running
13999
+ // (every rotation sets switchedCredential and skips it), so without
14000
+ // this last resort a provider-wide usage cap never fails over to the
14001
+ // configured chain.
14002
+ const retryBudgetExhausted = this.#retryAttempt > retrySettings.maxRetries;
13896
14003
 
13897
14004
  const errorMessage = message.errorMessage || "Unknown error";
13898
14005
  const id = this.#classifyRetryMessage(message);
@@ -13911,7 +14018,12 @@ export class AgentSession {
13911
14018
  this.#resetCurrentResponsesProviderSession("stale replay error");
13912
14019
  }
13913
14020
 
13914
- if (this.model && !staleOpenAIResponsesReplayError && AIError.is(id, AIError.Flag.UsageLimit)) {
14021
+ if (
14022
+ !retryBudgetExhausted &&
14023
+ this.model &&
14024
+ !staleOpenAIResponsesReplayError &&
14025
+ AIError.is(id, AIError.Flag.UsageLimit)
14026
+ ) {
13915
14027
  const retryAfterMs = parsedRetryAfterMs ?? calculateRateLimitBackoffMs(parseRateLimitReason(errorMessage));
13916
14028
  const outcome = await this.#modelRegistry.authStorage.markUsageLimitReached(
13917
14029
  this.model.provider,
@@ -13957,7 +14069,9 @@ export class AgentSession {
13957
14069
  const allowModelFallback = options?.allowModelFallback !== false;
13958
14070
  const currentSelector = this.model ? formatRetryFallbackSelector(this.model, this.thinkingLevel) : undefined;
13959
14071
  if (!staleOpenAIResponsesReplayError && !switchedCredential && currentSelector) {
13960
- if (allowModelFallback && retrySettings.modelFallback) {
14072
+ // A refusal chain stops at the retry budget: the exhausted-attempt
14073
+ // last resort is for provider failures, not classifier decisions.
14074
+ if (allowModelFallback && retrySettings.modelFallback && !(retryBudgetExhausted && classifierRefusal)) {
13961
14075
  if (!classifierRefusal) {
13962
14076
  this.#noteRetryFallbackCooldown(currentSelector, parsedRetryAfterMs, errorMessage);
13963
14077
  }
@@ -13976,6 +14090,26 @@ export class AgentSession {
13976
14090
  delayMs = parsedRetryAfterMs;
13977
14091
  }
13978
14092
  }
14093
+ if (retryBudgetExhausted) {
14094
+ if (!switchedModel) {
14095
+ await this.#persistRetryLifecycleErrorMessage(message);
14096
+ // Max retries exceeded and no fallback model to switch to: emit
14097
+ // final failure and reset.
14098
+ await this.#emitSessionEvent({
14099
+ type: "auto_retry_end",
14100
+ success: false,
14101
+ attempt: this.#retryAttempt - 1,
14102
+ finalError: message.errorMessage,
14103
+ });
14104
+ this.#clearPendingRecoveredRetryErrors();
14105
+ this.#retryAttempt = 0;
14106
+ this.#resolveRetry(); // Resolve so waitForRetry() completes
14107
+ return false;
14108
+ }
14109
+ // The fallback model gets a fresh retry budget — leaving the spent
14110
+ // counter in place would exhaust it again on its first error.
14111
+ this.#retryAttempt = 1;
14112
+ }
13979
14113
  if (classifierRefusal && !switchedModel) {
13980
14114
  this.#retryAttempt = 0;
13981
14115
  this.#resolveRetry();
@@ -14703,10 +14837,13 @@ export class AgentSession {
14703
14837
  // Side-channel turns must not share OpenAI/Codex append-only
14704
14838
  // conversation state with the main agent turn: IRC and /btw can run
14705
14839
  // while the main turn is mid-tool-call. Keep the prompt-cache key
14706
- // stable, but give provider routing a unique request lineage.
14840
+ // stable, but give provider routing a unique request lineage. The
14841
+ // shared provider state map is still required so Codex can allocate
14842
+ // websocket state under that side-channel session id.
14707
14843
  sessionId: `${cacheSessionId}:side:${Snowflake.next()}`,
14708
14844
  promptCacheKey: cacheSessionId,
14709
- preferWebsockets: false,
14845
+ preferWebsockets: this.#preferWebsockets,
14846
+ providerSessionState: this.#providerSessionState,
14710
14847
  reasoning: toReasoningEffort(this.thinkingLevel),
14711
14848
  disableReasoning: shouldDisableReasoning(this.thinkingLevel),
14712
14849
  hideThinkingSummary: this.agent.hideThinkingSummary,