@oh-my-pi/pi-coding-agent 13.16.4 → 13.16.5

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [13.16.5] - 2026-03-29
6
+
7
+ ### Fixed
8
+
9
+ - Fixed `--model provider/id` resolving to wrong provider when model ID exists in multiple catalogs ([#560](https://github.com/can1357/oh-my-pi/issues/560))
10
+
5
11
  ## [13.16.4] - 2026-03-28
6
12
  ### Changed
7
13
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "13.16.4",
4
+ "version": "13.16.5",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -42,12 +42,12 @@
42
42
  "dependencies": {
43
43
  "@agentclientprotocol/sdk": "0.16.1",
44
44
  "@mozilla/readability": "^0.6",
45
- "@oh-my-pi/omp-stats": "13.16.4",
46
- "@oh-my-pi/pi-agent-core": "13.16.4",
47
- "@oh-my-pi/pi-ai": "13.16.4",
48
- "@oh-my-pi/pi-natives": "13.16.4",
49
- "@oh-my-pi/pi-tui": "13.16.4",
50
- "@oh-my-pi/pi-utils": "13.16.4",
45
+ "@oh-my-pi/omp-stats": "13.16.5",
46
+ "@oh-my-pi/pi-agent-core": "13.16.5",
47
+ "@oh-my-pi/pi-ai": "13.16.5",
48
+ "@oh-my-pi/pi-natives": "13.16.5",
49
+ "@oh-my-pi/pi-tui": "13.16.5",
50
+ "@oh-my-pi/pi-utils": "13.16.5",
51
51
  "@sinclair/typebox": "^0.34",
52
52
  "@xterm/headless": "^6.0",
53
53
  "ajv": "^8.18",
@@ -28,6 +28,7 @@ import {
28
28
  import { isRecord, logger } from "@oh-my-pi/pi-utils";
29
29
  import { type Static, Type } from "@sinclair/typebox";
30
30
  import { type ConfigError, ConfigFile } from "../config";
31
+ import { parseModelString } from "../config/model-resolver";
31
32
  import { isValidThemeColor, type ThemeColor } from "../modes/theme/theme";
32
33
  import type { AuthStorage, OAuthCredential } from "../session/auth-storage";
33
34
  import type { Settings } from "./settings";
@@ -721,6 +722,14 @@ function buildCustomModel(
721
722
  return finalizeCustomModel(model, options);
722
723
  }
723
724
 
725
+ function normalizeSuppressedSelector(selector: string): string {
726
+ const trimmed = selector.trim();
727
+ if (!trimmed) return trimmed;
728
+ const parsed = parseModelString(trimmed);
729
+ if (!parsed) return trimmed;
730
+ return `${parsed.provider}/${parsed.id}`;
731
+ }
732
+
724
733
  /**
725
734
  * Model registry - loads and manages models, resolves API keys via AuthStorage.
726
735
  */
@@ -737,6 +746,7 @@ export class ModelRegistry {
737
746
  #registeredProviderSources: Set<string> = new Set();
738
747
  #providerDiscoveryStates: Map<string, ProviderDiscoveryState> = new Map();
739
748
  #cacheDbPath?: string;
749
+ #suppressedSelectors: Map<string, number> = new Map();
740
750
  #backgroundRefresh?: Promise<void>;
741
751
  #lastDiscoveryWarnings: Map<string, string> = new Map();
742
752
 
@@ -766,6 +776,7 @@ export class ModelRegistry {
766
776
  */
767
777
  async refresh(strategy: ModelRefreshStrategy = "online-if-uncached"): Promise<void> {
768
778
  this.#reloadStaticModels();
779
+ this.#suppressedSelectors.clear();
769
780
  await this.#refreshRuntimeDiscoveries(strategy);
770
781
  }
771
782
 
@@ -789,6 +800,11 @@ export class ModelRegistry {
789
800
 
790
801
  async refreshProvider(providerId: string, strategy: ModelRefreshStrategy = "online"): Promise<void> {
791
802
  this.#reloadStaticModels();
803
+ for (const selector of this.#suppressedSelectors.keys()) {
804
+ if (selector.startsWith(`${providerId}/`)) {
805
+ this.#suppressedSelectors.delete(selector);
806
+ }
807
+ }
792
808
  await this.#refreshRuntimeDiscoveries(strategy, new Set([providerId]));
793
809
  }
794
810
 
@@ -1825,6 +1841,27 @@ export class ModelRegistry {
1825
1841
  });
1826
1842
  }
1827
1843
  }
1844
+
1845
+ /**
1846
+ * Suppress a specific model selector (e.g., "provider/id") until a specific timestamp.
1847
+ */
1848
+ suppressSelector(selector: string, untilMs: number): void {
1849
+ this.#suppressedSelectors.set(normalizeSuppressedSelector(selector), untilMs);
1850
+ }
1851
+
1852
+ /**
1853
+ * Check if a model selector is currently suppressed due to rate limits.
1854
+ */
1855
+ isSelectorSuppressed(selector: string): boolean {
1856
+ const normalizedSelector = normalizeSuppressedSelector(selector);
1857
+ const suppressedUntil = this.#suppressedSelectors.get(normalizedSelector);
1858
+ if (!suppressedUntil) return false;
1859
+ if (suppressedUntil <= Date.now()) {
1860
+ this.#suppressedSelectors.delete(normalizedSelector);
1861
+ return false;
1862
+ }
1863
+ return true;
1864
+ }
1828
1865
  }
1829
1866
 
1830
1867
  /**
@@ -730,9 +730,24 @@ export function resolveCliModel(options: {
730
730
 
731
731
  if (!provider) {
732
732
  const lower = cliModel.toLowerCase();
733
- const exact = availableModels.find(
734
- model => model.id.toLowerCase() === lower || `${model.provider}/${model.id}`.toLowerCase() === lower,
735
- );
733
+ // When input has provider/id format (e.g. "zai/glm-5"), prefer decomposed
734
+ // provider+id match over flat id match. Without this, a model with id
735
+ // "zai/glm-5" on provider "vercel-ai-gateway" wins over provider "zai"
736
+ // with id "glm-5", because Array.find returns the first catalog hit.
737
+ const slashIdx = lower.indexOf("/");
738
+ let exact: (typeof availableModels)[number] | undefined;
739
+ if (slashIdx !== -1) {
740
+ const prefix = lower.substring(0, slashIdx);
741
+ const suffix = lower.substring(slashIdx + 1);
742
+ exact = availableModels.find(
743
+ model => model.provider.toLowerCase() === prefix && model.id.toLowerCase() === suffix,
744
+ );
745
+ }
746
+ if (!exact) {
747
+ exact = availableModels.find(
748
+ model => model.id.toLowerCase() === lower || `${model.provider}/${model.id}`.toLowerCase() === lower,
749
+ );
750
+ }
736
751
  if (exact) {
737
752
  return { model: exact, warning: undefined, thinkingLevel: undefined, error: undefined };
738
753
  }
@@ -525,6 +525,18 @@ export const SETTINGS_SCHEMA = {
525
525
  },
526
526
 
527
527
  "retry.baseDelayMs": { type: "number", default: 2000 },
528
+ "retry.fallbackChains": { type: "record", default: {} as Record<string, string[]> },
529
+ "retry.fallbackRevertPolicy": {
530
+ type: "enum",
531
+ values: ["cooldown-expiry", "never"] as const,
532
+ default: "cooldown-expiry",
533
+ ui: {
534
+ tab: "model",
535
+ label: "Fallback Revert Policy",
536
+ description: "When to return to the primary model after a fallback",
537
+ submenu: true,
538
+ },
539
+ },
528
540
 
529
541
  // ────────────────────────────────────────────────────────────────────────
530
542
  // Interaction
@@ -54,7 +54,7 @@ export async function loadSkillsFromDir(options: LoadSkillsFromDirOptions): Prom
54
54
  name: capSkill.name,
55
55
  description: typeof capSkill.frontmatter?.description === "string" ? capSkill.frontmatter.description : "",
56
56
  filePath: capSkill.path,
57
- baseDir: capSkill.path.replace(/\/SKILL\.md$/, ""),
57
+ baseDir: capSkill.path.replace(/[\\/]SKILL\.md$/, ""),
58
58
  source: options.source,
59
59
  _source: capSkill._source,
60
60
  })),
@@ -168,7 +168,7 @@ export async function loadSkills(options: LoadSkillsOptions = {}): Promise<LoadS
168
168
  name: capSkill.name,
169
169
  description: typeof capSkill.frontmatter?.description === "string" ? capSkill.frontmatter.description : "",
170
170
  filePath: capSkill.path,
171
- baseDir: capSkill.path.replace(/\/SKILL\.md$/, ""),
171
+ baseDir: capSkill.path.replace(/[\\/]SKILL\.md$/, ""),
172
172
  source: `${capSkill._source.provider}:${capSkill.level}`,
173
173
  _source: capSkill._source,
174
174
  });
@@ -204,7 +204,7 @@ export async function loadSkills(options: LoadSkillsOptions = {}): Promise<LoadS
204
204
  description:
205
205
  typeof capSkill.frontmatter?.description === "string" ? capSkill.frontmatter.description : "",
206
206
  filePath: capSkill.path,
207
- baseDir: capSkill.path.replace(/\/SKILL\.md$/, ""),
207
+ baseDir: capSkill.path.replace(/[\\/]SKILL\.md$/, ""),
208
208
  source: "custom:user",
209
209
  _source: { ...capSkill._source, providerName: "Custom" },
210
210
  },
@@ -117,6 +117,15 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
117
117
  { value: "5", label: "5 retries" },
118
118
  { value: "10", label: "10 retries" },
119
119
  ],
120
+ // Retry fallback revert policy
121
+ "retry.fallbackRevertPolicy": [
122
+ {
123
+ value: "cooldown-expiry",
124
+ label: "Cooldown expiry",
125
+ description: "Return to the primary model after its suppression window ends",
126
+ },
127
+ { value: "never", label: "Never", description: "Stay on the fallback model until manually changed" },
128
+ ],
120
129
  // Task max concurrency
121
130
  "task.maxConcurrency": [
122
131
  { value: "0", label: "Unlimited" },
@@ -534,6 +534,16 @@ export class EventController {
534
534
  break;
535
535
  }
536
536
 
537
+ case "retry_fallback_applied": {
538
+ this.ctx.showWarning(`Fallback: ${event.from} -> ${event.to}`);
539
+ break;
540
+ }
541
+
542
+ case "retry_fallback_succeeded": {
543
+ this.ctx.showStatus(`Fallback succeeded on ${event.model}`);
544
+ break;
545
+ }
546
+
537
547
  case "ttsr_triggered": {
538
548
  const component = new TtsrNotificationComponent(event.rules);
539
549
  component.setExpanded(this.ctx.toolOutputExpanded);
@@ -306,6 +306,11 @@ export class InteractiveMode implements InteractiveModeContext {
306
306
 
307
307
  const startupQuiet = settings.get("startup.quiet");
308
308
 
309
+ for (const warning of this.session.configWarnings) {
310
+ this.ui.addChild(new Text(theme.fg("warning", `Warning: ${warning}`), 1, 0));
311
+ this.ui.addChild(new Spacer(1));
312
+ }
313
+
309
314
  if (!startupQuiet) {
310
315
  // Add welcome header
311
316
  const welcome = new WelcomeComponent(this.#version, modelName, providerName, recentSessions, lspServerInfo);
@@ -30,10 +30,10 @@ You **MUST** use specialized tools instead of bash for ALL file operations:
30
30
  |---|---|
31
31
  |`cat file`, `head -n N file`|`read(path="file", limit=N)`|
32
32
  |`cat -n file \|sed -n '50,150p'`|`read(path="file", offset=50, limit=100)`|
33
- |`grep -A 20 'pat' file`|`grep(pattern="pat", path="file", post=20)`|
33
+ {{#if hasGrep}}|`grep -A 20 'pat' file`|`grep(pattern="pat", path="file", post=20)`|
34
34
  |`grep -rn 'pat' dir/`|`grep(pattern="pat", path="dir/")`|
35
- |`rg 'pattern' dir/`|`grep(pattern="pattern", path="dir/")`|
36
- |`find dir -name '*.ts'`|`find(pattern="dir/**/*.ts")`|
35
+ |`rg 'pattern' dir/`|`grep(pattern="pattern", path="dir/")`|{{/if}}
36
+ {{#if hasFind}}|`find dir -name '*.ts'`|`find(pattern="dir/**/*.ts")`|{{/if}}
37
37
  |`ls dir/`|`read(path="dir/")`|
38
38
  |`cat <<'EOF' > file`|`write(path="file", content="…")`|
39
39
  |`sed -i 's/old/new/' file`|`edit(path="file", edits=[…])`|
@@ -55,7 +55,12 @@ import { abortableSleep, getAgentDbPath, isEnoent, logger } from "@oh-my-pi/pi-u
55
55
  import type { AsyncJob, AsyncJobManager } from "../async";
56
56
  import type { Rule } from "../capability/rule";
57
57
  import { MODEL_ROLE_IDS, type ModelRegistry } from "../config/model-registry";
58
- import { extractExplicitThinkingSelector, parseModelString, resolveModelRoleValue } from "../config/model-resolver";
58
+ import {
59
+ extractExplicitThinkingSelector,
60
+ formatModelString,
61
+ parseModelString,
62
+ resolveModelRoleValue,
63
+ } from "../config/model-resolver";
59
64
  import { expandPromptTemplate, type PromptTemplate, renderPromptTemplate } from "../config/prompt-templates";
60
65
  import type { Settings, SkillsSettings } from "../config/settings";
61
66
  import { type BashResult, executeBash as executeBashCommand } from "../exec/bash-executor";
@@ -170,6 +175,8 @@ export type AgentSessionEvent =
170
175
  }
171
176
  | { type: "auto_retry_start"; attempt: number; maxAttempts: number; delayMs: number; errorMessage: string }
172
177
  | { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }
178
+ | { type: "retry_fallback_applied"; from: string; to: string; role: string }
179
+ | { type: "retry_fallback_succeeded"; model: string; role: string }
173
180
  | { type: "ttsr_triggered"; rules: Rule[] }
174
181
  | { type: "todo_reminder"; todos: TodoItem[]; attempt: number; maxAttempts: number }
175
182
  | { type: "todo_auto_clear" };
@@ -315,6 +322,46 @@ interface HandoffOptions {
315
322
 
316
323
  const AUTO_HANDOFF_THRESHOLD_FOCUS = renderPromptTemplate(autoHandoffThresholdFocusPrompt);
317
324
 
325
+ type RetryFallbackChains = Record<string, string[]>;
326
+
327
+ type RetryFallbackRevertPolicy = "never" | "cooldown-expiry";
328
+
329
+ interface RetryFallbackSelector {
330
+ raw: string;
331
+ provider: string;
332
+ id: string;
333
+ thinkingLevel: ThinkingLevel | undefined;
334
+ }
335
+
336
+ interface ActiveRetryFallbackState {
337
+ role: string;
338
+ originalSelector: string;
339
+ originalThinkingLevel: ThinkingLevel | undefined;
340
+ lastAppliedFallbackThinkingLevel: ThinkingLevel | undefined;
341
+ }
342
+
343
+ function parseRetryFallbackSelector(selector: string): RetryFallbackSelector | undefined {
344
+ const trimmed = selector.trim();
345
+ if (!trimmed) return undefined;
346
+ const parsed = parseModelString(trimmed);
347
+ if (!parsed) return undefined;
348
+ return {
349
+ raw: trimmed,
350
+ provider: parsed.provider,
351
+ id: parsed.id,
352
+ thinkingLevel: parsed.thinkingLevel,
353
+ };
354
+ }
355
+
356
+ function formatRetryFallbackSelector(model: Model, thinkingLevel: ThinkingLevel | undefined): string {
357
+ const selector = formatModelString(model);
358
+ return thinkingLevel ? `${selector}:${thinkingLevel}` : selector;
359
+ }
360
+
361
+ function formatRetryFallbackBaseSelector(selector: RetryFallbackSelector): string {
362
+ return `${selector.provider}/${selector.id}`;
363
+ }
364
+
318
365
  const noOpUIContext: ExtensionUIContext = {
319
366
  select: async (_title, _options, _dialogOptions) => undefined,
320
367
  confirm: async (_title, _message, _dialogOptions) => false,
@@ -352,6 +399,7 @@ export class AgentSession {
352
399
  readonly sessionManager: SessionManager;
353
400
  readonly settings: Settings;
354
401
  readonly searchDb: SearchDb | undefined;
402
+ readonly configWarnings: string[] = [];
355
403
 
356
404
  #asyncJobManager: AsyncJobManager | undefined = undefined;
357
405
  #scopedModels: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>;
@@ -391,7 +439,7 @@ export class AgentSession {
391
439
  #retryAttempt = 0;
392
440
  #retryPromise: Promise<void> | undefined = undefined;
393
441
  #retryResolve: (() => void) | undefined = undefined;
394
-
442
+ #activeRetryFallback: ActiveRetryFallbackState | undefined = undefined;
395
443
  // Todo completion reminder state
396
444
  #todoReminderCount = 0;
397
445
  #todoPhases: TodoPhase[] = [];
@@ -478,6 +526,7 @@ export class AgentSession {
478
526
  this.#customCommands = config.customCommands ?? [];
479
527
  this.#skillsSettings = config.skillsSettings;
480
528
  this.#modelRegistry = config.modelRegistry;
529
+ this.#validateRetryFallbackChains();
481
530
  this.#toolRegistry = config.toolRegistry ?? new Map();
482
531
  this.#transformContext = config.transformContext ?? (messages => messages);
483
532
  this.#onPayload = config.onPayload;
@@ -787,6 +836,13 @@ export class AgentSession {
787
836
  assistantMsg.stopReason !== "aborted" &&
788
837
  this.#retryAttempt > 0
789
838
  ) {
839
+ if (this.#activeRetryFallback && this.model) {
840
+ await this.#emitSessionEvent({
841
+ type: "retry_fallback_succeeded",
842
+ model: formatRetryFallbackSelector(this.model, this.thinkingLevel),
843
+ role: this.#activeRetryFallback.role,
844
+ });
845
+ }
790
846
  await this.#emitSessionEvent({
791
847
  type: "auto_retry_end",
792
848
  success: true,
@@ -985,6 +1041,7 @@ export class AgentSession {
985
1041
  return;
986
1042
  }
987
1043
  try {
1044
+ await this.#maybeRestoreRetryFallbackPrimary();
988
1045
  await this.agent.continue();
989
1046
  } catch {
990
1047
  options?.onError?.();
@@ -2288,6 +2345,8 @@ export class AgentSession {
2288
2345
  // Reset todo reminder count on new user prompt
2289
2346
  this.#todoReminderCount = 0;
2290
2347
 
2348
+ await this.#maybeRestoreRetryFallbackPrimary();
2349
+
2291
2350
  // Validate model
2292
2351
  if (!this.model) {
2293
2352
  throw new Error(
@@ -3108,6 +3167,7 @@ export class AgentSession {
3108
3167
  throw new Error(`No API key for ${model.provider}/${model.id}`);
3109
3168
  }
3110
3169
 
3170
+ this.#clearActiveRetryFallback();
3111
3171
  this.#setModelWithProviderSessionReset(model);
3112
3172
  this.sessionManager.appendModelChange(`${model.provider}/${model.id}`, role);
3113
3173
  this.settings.setModelRole(role, this.#formatRoleModelValue(role, model));
@@ -3128,6 +3188,7 @@ export class AgentSession {
3128
3188
  throw new Error(`No API key for ${model.provider}/${model.id}`);
3129
3189
  }
3130
3190
 
3191
+ this.#clearActiveRetryFallback();
3131
3192
  this.#setModelWithProviderSessionReset(model);
3132
3193
  this.sessionManager.appendModelChange(`${model.provider}/${model.id}`, "temporary");
3133
3194
  this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`);
@@ -3253,6 +3314,7 @@ export class AgentSession {
3253
3314
  const next = scopedModels[nextIndex];
3254
3315
 
3255
3316
  // Apply model
3317
+ this.#clearActiveRetryFallback();
3256
3318
  this.#setModelWithProviderSessionReset(next.model);
3257
3319
  this.sessionManager.appendModelChange(`${next.model.provider}/${next.model.id}`);
3258
3320
  this.settings.setModelRole("default", this.#formatRoleModelValue("default", next.model));
@@ -3281,11 +3343,11 @@ export class AgentSession {
3281
3343
  throw new Error(`No API key for ${nextModel.provider}/${nextModel.id}`);
3282
3344
  }
3283
3345
 
3346
+ this.#clearActiveRetryFallback();
3284
3347
  this.#setModelWithProviderSessionReset(nextModel);
3285
3348
  this.sessionManager.appendModelChange(`${nextModel.provider}/${nextModel.id}`);
3286
3349
  this.settings.setModelRole("default", this.#formatRoleModelValue("default", nextModel));
3287
3350
  this.settings.getStorage()?.recordModelUsage(`${nextModel.provider}/${nextModel.id}`);
3288
-
3289
3351
  // Re-apply the current thinking level for the newly selected model
3290
3352
  this.setThinkingLevel(this.thinkingLevel);
3291
3353
 
@@ -4768,6 +4830,217 @@ export class AgentSession {
4768
4830
  );
4769
4831
  }
4770
4832
 
4833
+ #getRetryFallbackChains(): RetryFallbackChains {
4834
+ const configuredChains = this.settings.get("retry.fallbackChains");
4835
+ if (!configuredChains || typeof configuredChains !== "object") return {};
4836
+ return configuredChains as RetryFallbackChains;
4837
+ }
4838
+
4839
+ #validateRetryFallbackChains(): void {
4840
+ const configuredChains = this.settings.get("retry.fallbackChains");
4841
+ if (configuredChains === undefined) return;
4842
+ if (!configuredChains || typeof configuredChains !== "object" || Array.isArray(configuredChains)) {
4843
+ const msg = "retry.fallbackChains must be a mapping of role names to selector arrays.";
4844
+ logger.warn(msg);
4845
+ this.configWarnings.push(msg);
4846
+ return;
4847
+ }
4848
+
4849
+ for (const [role, chain] of Object.entries(configuredChains)) {
4850
+ if (!Array.isArray(chain)) {
4851
+ const msg = `Fallback chain for role '${role}' must be an array of selector strings.`;
4852
+ logger.warn(msg);
4853
+ this.configWarnings.push(msg);
4854
+ continue;
4855
+ }
4856
+ for (const selectorStr of chain) {
4857
+ if (typeof selectorStr !== "string") {
4858
+ const msg = `Fallback chain for role '${role}' contains a non-string selector.`;
4859
+ logger.warn(msg);
4860
+ this.configWarnings.push(msg);
4861
+ continue;
4862
+ }
4863
+ const parsed = parseRetryFallbackSelector(selectorStr);
4864
+ if (!parsed) {
4865
+ const msg = `Invalid fallback selector format in role '${role}': ${selectorStr}`;
4866
+ logger.warn(msg);
4867
+ this.configWarnings.push(msg);
4868
+ continue;
4869
+ }
4870
+ const exists = this.#modelRegistry.find(parsed.provider, parsed.id);
4871
+ if (!exists) {
4872
+ const msg = `Fallback chain for role '${role}' references unknown model: ${selectorStr}`;
4873
+ logger.warn(msg);
4874
+ this.configWarnings.push(msg);
4875
+ }
4876
+ }
4877
+ }
4878
+ }
4879
+
4880
+ #getRetryFallbackRevertPolicy(): RetryFallbackRevertPolicy {
4881
+ return this.settings.get("retry.fallbackRevertPolicy") === "never" ? "never" : "cooldown-expiry";
4882
+ }
4883
+
4884
+ #getRetryFallbackPrimarySelector(role: string): RetryFallbackSelector | undefined {
4885
+ const configuredSelector = this.settings.getModelRole(role);
4886
+ return configuredSelector ? parseRetryFallbackSelector(configuredSelector) : undefined;
4887
+ }
4888
+
4889
+ #clearActiveRetryFallback(): void {
4890
+ this.#activeRetryFallback = undefined;
4891
+ }
4892
+
4893
+ #isRetryFallbackSelectorSuppressed(selector: RetryFallbackSelector): boolean {
4894
+ return this.#modelRegistry.isSelectorSuppressed(selector.raw);
4895
+ }
4896
+
4897
+ #noteRetryFallbackCooldown(currentSelector: string, retryAfterMs: number | undefined, errorMessage: string): void {
4898
+ let cooldownMs = retryAfterMs;
4899
+ if (!cooldownMs || cooldownMs <= 0) {
4900
+ const reason = parseRateLimitReason(errorMessage);
4901
+ cooldownMs = reason === "UNKNOWN" ? 5 * 60 * 1000 : calculateRateLimitBackoffMs(reason);
4902
+ }
4903
+ this.#modelRegistry.suppressSelector(currentSelector, Date.now() + cooldownMs);
4904
+ }
4905
+
4906
+ #resolveRetryFallbackRole(currentSelector: string): string | undefined {
4907
+ const parsedCurrent = parseRetryFallbackSelector(currentSelector);
4908
+ if (!parsedCurrent) return undefined;
4909
+ const currentBaseSelector = formatRetryFallbackBaseSelector(parsedCurrent);
4910
+ for (const role of Object.keys(this.#getRetryFallbackChains())) {
4911
+ const primarySelector = this.#getRetryFallbackPrimarySelector(role);
4912
+ if (!primarySelector) continue;
4913
+ if (primarySelector.raw === currentSelector) return role;
4914
+ if (formatRetryFallbackBaseSelector(primarySelector) === currentBaseSelector) return role;
4915
+ }
4916
+ return undefined;
4917
+ }
4918
+
4919
+ #getRetryFallbackEffectiveChain(role: string): RetryFallbackSelector[] {
4920
+ const primarySelector = this.#getRetryFallbackPrimarySelector(role);
4921
+ if (!primarySelector) return [];
4922
+ const chain = [primarySelector];
4923
+ const seen = new Set<string>([primarySelector.raw]);
4924
+ for (const selector of this.#getRetryFallbackChains()[role] ?? []) {
4925
+ const parsed = parseRetryFallbackSelector(selector);
4926
+ if (!parsed || seen.has(parsed.raw)) continue;
4927
+ seen.add(parsed.raw);
4928
+ chain.push(parsed);
4929
+ }
4930
+ return chain;
4931
+ }
4932
+
4933
+ #findRetryFallbackCandidates(role: string, currentSelector: string): RetryFallbackSelector[] {
4934
+ const chain = this.#getRetryFallbackEffectiveChain(role);
4935
+ if (chain.length <= 1) return [];
4936
+ const parsedCurrent = parseRetryFallbackSelector(currentSelector);
4937
+ const currentBaseSelector = parsedCurrent ? formatRetryFallbackBaseSelector(parsedCurrent) : undefined;
4938
+ const exactIndex = chain.findIndex(selector => selector.raw === currentSelector);
4939
+ if (exactIndex >= 0) return chain.slice(exactIndex + 1);
4940
+ const baseIndex = currentBaseSelector
4941
+ ? chain.findIndex(selector => formatRetryFallbackBaseSelector(selector) === currentBaseSelector)
4942
+ : -1;
4943
+ if (baseIndex >= 0) return chain.slice(baseIndex + 1);
4944
+ return chain.slice(1);
4945
+ }
4946
+
4947
+ async #applyRetryFallbackCandidate(
4948
+ role: string,
4949
+ selector: RetryFallbackSelector,
4950
+ currentSelector: string,
4951
+ ): Promise<void> {
4952
+ const candidate = this.#modelRegistry.find(selector.provider, selector.id);
4953
+ if (!candidate) {
4954
+ throw new Error(`Retry fallback model not found: ${selector.raw}`);
4955
+ }
4956
+ const apiKey = await this.#modelRegistry.getApiKey(candidate, this.sessionId);
4957
+ if (!apiKey) {
4958
+ throw new Error(`No API key for retry fallback ${selector.raw}`);
4959
+ }
4960
+
4961
+ const currentThinkingLevel = this.thinkingLevel;
4962
+ const nextThinkingLevel = selector.thinkingLevel ?? currentThinkingLevel;
4963
+
4964
+ this.#setModelWithProviderSessionReset(candidate);
4965
+ this.sessionManager.appendModelChange(`${candidate.provider}/${candidate.id}`, "temporary");
4966
+ this.settings.getStorage()?.recordModelUsage(`${candidate.provider}/${candidate.id}`);
4967
+ this.setThinkingLevel(nextThinkingLevel);
4968
+ if (!this.#activeRetryFallback) {
4969
+ this.#activeRetryFallback = {
4970
+ role,
4971
+ originalSelector: currentSelector,
4972
+ originalThinkingLevel: currentThinkingLevel,
4973
+ lastAppliedFallbackThinkingLevel: nextThinkingLevel,
4974
+ };
4975
+ } else {
4976
+ this.#activeRetryFallback.lastAppliedFallbackThinkingLevel = nextThinkingLevel;
4977
+ }
4978
+ await this.#emitSessionEvent({
4979
+ type: "retry_fallback_applied",
4980
+ from: currentSelector,
4981
+ to: selector.raw,
4982
+ role,
4983
+ });
4984
+ }
4985
+
4986
+ async #tryRetryModelFallback(currentSelector: string): Promise<boolean> {
4987
+ const role = this.#activeRetryFallback?.role ?? this.#resolveRetryFallbackRole(currentSelector);
4988
+ if (!role) return false;
4989
+
4990
+ for (const selector of this.#findRetryFallbackCandidates(role, currentSelector)) {
4991
+ if (this.#isRetryFallbackSelectorSuppressed(selector)) continue;
4992
+ const candidate = this.#modelRegistry.find(selector.provider, selector.id);
4993
+ if (!candidate) continue;
4994
+ const apiKey = await this.#modelRegistry.getApiKey(candidate, this.sessionId);
4995
+ if (!apiKey) continue;
4996
+ await this.#applyRetryFallbackCandidate(role, selector, currentSelector);
4997
+ return true;
4998
+ }
4999
+
5000
+ return false;
5001
+ }
5002
+
5003
+ async #maybeRestoreRetryFallbackPrimary(): Promise<void> {
5004
+ if (!this.#activeRetryFallback) return;
5005
+ if (this.#getRetryFallbackRevertPolicy() !== "cooldown-expiry") return;
5006
+
5007
+ const {
5008
+ originalSelector: originalSelectorRaw,
5009
+ originalThinkingLevel,
5010
+ lastAppliedFallbackThinkingLevel,
5011
+ } = this.#activeRetryFallback;
5012
+ const originalSelector = parseRetryFallbackSelector(originalSelectorRaw);
5013
+ if (!originalSelector) {
5014
+ this.#clearActiveRetryFallback();
5015
+ return;
5016
+ }
5017
+
5018
+ const currentModel = this.model;
5019
+ if (!currentModel) return;
5020
+ const currentSelector = formatRetryFallbackSelector(currentModel, this.thinkingLevel);
5021
+ if (currentSelector === originalSelector.raw) {
5022
+ if (!this.#isRetryFallbackSelectorSuppressed(originalSelector)) {
5023
+ this.#clearActiveRetryFallback();
5024
+ }
5025
+ return;
5026
+ }
5027
+ if (this.#isRetryFallbackSelectorSuppressed(originalSelector)) return;
5028
+
5029
+ const primaryModel = this.#modelRegistry.find(originalSelector.provider, originalSelector.id);
5030
+ if (!primaryModel) return;
5031
+ const apiKey = await this.#modelRegistry.getApiKey(primaryModel, this.sessionId);
5032
+ if (!apiKey) return;
5033
+
5034
+ const currentThinkingLevel = this.thinkingLevel;
5035
+ const thinkingToApply =
5036
+ currentThinkingLevel === lastAppliedFallbackThinkingLevel ? originalThinkingLevel : currentThinkingLevel;
5037
+ this.#setModelWithProviderSessionReset(primaryModel);
5038
+ this.sessionManager.appendModelChange(`${primaryModel.provider}/${primaryModel.id}`, "temporary");
5039
+ this.settings.getStorage()?.recordModelUsage(`${primaryModel.provider}/${primaryModel.id}`);
5040
+ this.setThinkingLevel(thinkingToApply);
5041
+ this.#clearActiveRetryFallback();
5042
+ }
5043
+
4771
5044
  #parseRetryAfterMsFromError(errorMessage: string): number | undefined {
4772
5045
  const now = Date.now();
4773
5046
  const retryAfterMsMatch = /retry-after-ms\s*[:=]\s*(\d+)/i.exec(errorMessage);
@@ -4847,12 +5120,13 @@ export class AgentSession {
4847
5120
  }
4848
5121
 
4849
5122
  const errorMessage = message.errorMessage || "Unknown error";
5123
+ const parsedRetryAfterMs = this.#parseRetryAfterMsFromError(errorMessage);
4850
5124
  let delayMs = retrySettings.baseDelayMs * 2 ** (this.#retryAttempt - 1);
5125
+ let switchedCredential = false;
5126
+ let switchedModel = false;
4851
5127
 
4852
5128
  if (this.model && isUsageLimitError(errorMessage)) {
4853
- const retryAfterMs =
4854
- this.#parseRetryAfterMsFromError(errorMessage) ??
4855
- calculateRateLimitBackoffMs(parseRateLimitReason(errorMessage));
5129
+ const retryAfterMs = parsedRetryAfterMs ?? calculateRateLimitBackoffMs(parseRateLimitReason(errorMessage));
4856
5130
  const switched = await this.#modelRegistry.authStorage.markUsageLimitReached(
4857
5131
  this.model.provider,
4858
5132
  this.sessionId,
@@ -4862,6 +5136,7 @@ export class AgentSession {
4862
5136
  },
4863
5137
  );
4864
5138
  if (switched) {
5139
+ switchedCredential = true;
4865
5140
  delayMs = 0;
4866
5141
  } else if (retryAfterMs > delayMs) {
4867
5142
  // No more accounts to switch to — wait out the backoff
@@ -4869,6 +5144,17 @@ export class AgentSession {
4869
5144
  }
4870
5145
  }
4871
5146
 
5147
+ const currentSelector = this.model ? formatRetryFallbackSelector(this.model, this.thinkingLevel) : undefined;
5148
+ if (!switchedCredential && currentSelector) {
5149
+ this.#noteRetryFallbackCooldown(currentSelector, parsedRetryAfterMs, errorMessage);
5150
+ switchedModel = await this.#tryRetryModelFallback(currentSelector);
5151
+ if (switchedModel) {
5152
+ delayMs = 0;
5153
+ } else if (parsedRetryAfterMs && parsedRetryAfterMs > delayMs) {
5154
+ delayMs = parsedRetryAfterMs;
5155
+ }
5156
+ }
5157
+
4872
5158
  await this.#emitSessionEvent({
4873
5159
  type: "auto_retry_start",
4874
5160
  attempt: this.#retryAttempt,
package/src/tools/bash.ts CHANGED
@@ -222,6 +222,8 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
222
222
  asyncEnabled: this.#asyncEnabled,
223
223
  hasAstGrep: this.session.settings.get("astGrep.enabled"),
224
224
  hasAstEdit: this.session.settings.get("astEdit.enabled"),
225
+ hasGrep: this.session.settings.get("grep.enabled"),
226
+ hasFind: this.session.settings.get("find.enabled"),
225
227
  });
226
228
  }
227
229