@oh-my-pi/pi-coding-agent 13.16.3 → 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,23 @@
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
+
11
+ ## [13.16.4] - 2026-03-28
12
+ ### Changed
13
+
14
+ - Renamed hashline helper functions from `hlineref`/`hlinefull` to `href`/`hline` for brevity
15
+ - Simplified hashline edit location API: replaced separate `line` and `block` properties with unified `range` property accepting `{ pos, end }` for all range-based edits
16
+ - Updated hashline prompt documentation to reflect new `range` syntax and clarified editing guidelines
17
+
18
+ ### Fixed
19
+
20
+ - Added detection for `kysely-codegen` generated files in auto-generated file guard
21
+
5
22
  ## [13.16.1] - 2026-03-27
6
23
 
7
24
  ### Added
@@ -17,6 +34,7 @@
17
34
  - Updated grep tool description to remove ripgrep-specific implementation detail
18
35
 
19
36
  ## [13.16.0] - 2026-03-27
37
+
20
38
  ### Added
21
39
 
22
40
  - Implemented root path alias: bare `/` in tool inputs now resolves to the session working directory instead of the filesystem root
@@ -36,6 +54,7 @@
36
54
  - Added configurable `app.model.selectTemporary` keybinding for temporary model selection.
37
55
 
38
56
  ## [13.15.0] - 2026-03-23
57
+
39
58
  ### Breaking Changes
40
59
 
41
60
  - Changed hashline edit schema from flat `op`/`pos`/`end`/`lines` fields to structured `loc`/`content` format with location-specific objects
@@ -162,10 +181,10 @@
162
181
  - Fixed autoresearch logging to require durable ASI metadata (hypothesis, rollback_reason, next_action_hint) for every run including rollback context for discarded, crashed, and checks-failed experiments
163
182
  - Fixed autoresearch logging to require durable ASI metadata for every run, including rollback context for discarded, crashed, and checks-failed experiments
164
183
 
165
-
166
184
  ### Fixed
167
185
 
168
186
  - Fixed resumed and session-switched GitHub Copilot/OpenAI Responses conversations replaying stale assistant native history from older saved sessions by sanitizing persisted assistant replay metadata on rehydration and resetting provider session state across live session boundaries ([#505](https://github.com/can1357/oh-my-pi/issues/505))
187
+
169
188
  ## [13.14.0] - 2026-03-20
170
189
 
171
190
  ### Added
@@ -200,6 +219,7 @@
200
219
  - Added `mcpServerName` and `mcpToolName` optional properties to custom tools for MCP server discovery and search metadata
201
220
 
202
221
  ## [13.13.1] - 2026-03-18
222
+
203
223
  ### Added
204
224
 
205
225
  - Automatic deduplication of identical context files by content, keeping the closest (lowest depth) copy when duplicates are discovered
@@ -218,6 +238,7 @@
218
238
  - Enhanced auto-generated marker detection to only scan leading header comments rather than entire file prefix, improving accuracy for files with generated markers in code
219
239
 
220
240
  ## [13.12.10] - 2026-03-17
241
+
221
242
  ### Added
222
243
 
223
244
  - Added `args` field to ShellResult to capture the executed command
@@ -230,6 +251,7 @@
230
251
  - Updated `run()` command documentation to clarify available ShellResult fields
231
252
 
232
253
  ## [13.12.9] - 2026-03-17
254
+
233
255
  ### Added
234
256
 
235
257
  - Added `/session delete` command to delete current session with confirmation and return to session selector
@@ -274,6 +296,7 @@
274
296
  - Fixed session directory resolution to correctly handle symlink-equivalent paths, ensuring aliased home and temp directories resolve to the same session storage location as their real targets
275
297
 
276
298
  ## [13.12.7] - 2026-03-16
299
+
277
300
  ### Changed
278
301
 
279
302
  - Modified `getSelectedMCPToolNames()` to return only active MCP tools in non-discovery sessions, filtering by tool registry availability
@@ -285,6 +308,7 @@
285
308
  - Fixed MCP tool selection tracking to properly distinguish between discovery-enabled and non-discovery sessions, preventing orphaned tool selections after manual deactivation
286
309
 
287
310
  ## [13.12.6] - 2026-03-15
311
+
288
312
  ### Changed
289
313
 
290
314
  - Updated llama.cpp model discovery to read context window from the `/props` endpoint's `default_generation_settings.n_ctx` field instead of using hardcoded 128000 default
@@ -320,6 +344,7 @@
320
344
  - Fixed automatic migration of legacy session directories to new `-tmp-` prefixed naming scheme for temp-root sessions
321
345
 
322
346
  ## [13.12.4] - 2026-03-15
347
+
323
348
  ### Added
324
349
 
325
350
  - Exposed `settings` instance in `CustomToolContext` for session-specific configuration access
@@ -357,6 +382,7 @@
357
382
  - Fixed skill loading to properly respect disabled skill names when loading from custom directories
358
383
 
359
384
  ## [13.12.1] - 2026-03-15
385
+
360
386
  ### Added
361
387
 
362
388
  - Support for move-only operations that preserve exact bytes including binary files
@@ -396,7 +422,7 @@
396
422
  - Moved LSP settings (lsp.enabled, lsp.formatOnWrite, lsp.diagnosticsOnWrite, lsp.diagnosticsOnEdit) to the Editing tab
397
423
  - Moved bash interceptor settings to the Editing tab
398
424
  - Moved Python settings (python.toolMode, python.kernelMode, python.sharedGateway) to the Editing tab
399
- - Moved task delegation settings (task.isolation.*, task.eager, task.maxConcurrency, task.maxRecursionDepth) to the Tasks tab
425
+ - Moved task delegation settings (task.isolation.\*, task.eager, task.maxConcurrency, task.maxRecursionDepth) to the Tasks tab
400
426
  - Moved skill and command settings to the Tasks tab
401
427
  - Moved provider selection settings (providers.webSearch, providers.codeSearch, providers.image, etc.) to the Providers tab
402
428
  - Moved Exa settings to the Providers tab
@@ -452,6 +478,7 @@
452
478
  - Fixed line number parsing in compact diff preview to handle variable-width line number fields with leading whitespace
453
479
 
454
480
  ## [13.11.0] - 2026-03-12
481
+
455
482
  ### Added
456
483
 
457
484
  - Added Parallel as a web search provider with support for fast and research modes
@@ -494,6 +521,7 @@
494
521
  - Per-role `modelRoles` thinking selectors now propagate through commit/title helper model selection, legacy commit analysis, and agentic commit sessions while preserving default thinking inheritance when no role override is configured
495
522
 
496
523
  ## [13.10.1] - 2026-03-10
524
+
497
525
  ### Added
498
526
 
499
527
  - Exported `submitInteractiveInput()` function for programmatic submission of user input in interactive mode
@@ -501,15 +529,19 @@
501
529
  - Added reactive 401/403 retry with automatic token refresh on HTTP MCP transports
502
530
  - Added `refreshMCPOAuthToken()` for standard OAuth 2.0 refresh_token grants
503
531
  - Persisted `tokenUrl`, `clientId`, and `clientSecret` in MCP auth config for cross-session token refresh
532
+
504
533
  ### Fixed
534
+
505
535
  - Respected `PI_CONFIG_DIR` when discovering native user config paths for slash commands and related config directories ([#349](https://github.com/can1357/oh-my-pi/issues/349))
506
536
 
507
537
  ## [13.10.0] - 2026-03-10
538
+
508
539
  ### Fixed
509
540
 
510
541
  - Preserved text signature metadata (id and phase) when building OpenAI native history during session compaction
511
542
 
512
543
  ## [13.9.16] - 2026-03-10
544
+
513
545
  ### Breaking Changes
514
546
 
515
547
  - Web search tool no longer accepts `provider` parameter in tool calls; use internal provider resolution instead
@@ -548,6 +580,7 @@
548
580
  - Fixed model selector to show discovery status messages when provider has no models
549
581
 
550
582
  ## [13.9.15] - 2026-03-10
583
+
551
584
  ### Added
552
585
 
553
586
  - Added `ensureLoadingAnimation()` method to manage loading animation lifecycle and prevent duplicate spinners
@@ -558,6 +591,7 @@
558
591
  - Updated `showError()` to properly clean up loading animation state when errors occur
559
592
 
560
593
  ## [13.9.12] - 2026-03-09
594
+
561
595
  ### Added
562
596
 
563
597
  - Added Tavily as a supported web search provider with `TAVILY_API_KEY` credential discovery and provider fallback support
@@ -575,6 +609,7 @@
575
609
  - Canonicalized bash executor working directories before handing them to brush so `pwd` stays aligned with canonical Git worktree paths in symlinked workspaces
576
610
 
577
611
  ## [13.9.10] - 2026-03-08
612
+
578
613
  ### Added
579
614
 
580
615
  - Added `env` parameter to bash tool to pass environment variables safely without shell re-parsing, preventing quote and special character bugs with multiline or untrusted values
@@ -586,6 +621,7 @@
586
621
  - Changed bash tool to display environment variable assignments in command preview when `env` parameter is used
587
622
 
588
623
  ## [13.9.8] - 2026-03-08
624
+
589
625
  ### Added
590
626
 
591
627
  - Added docs.rs scraper for extracting Rust crate documentation from rustdoc JSON, including support for modules, functions, structs, traits, enums, and other Rust items with caching
@@ -620,6 +656,7 @@
620
656
  - Updated `grep` tool to combine glob patterns from `path` and `glob` parameters instead of throwing an error when both are provided
621
657
 
622
658
  ## [13.9.4] - 2026-03-07
659
+
623
660
  ### Added
624
661
 
625
662
  - Automatic detection of Ollama model capabilities including reasoning/thinking support and vision input via the `/api/show` endpoint
@@ -708,6 +745,7 @@
708
745
  - Auto-corrected off-by-one range start errors in hashline edits that would duplicate preceding lines
709
746
 
710
747
  ## [13.9.0] - 2026-03-05
748
+
711
749
  ### Added
712
750
 
713
751
  - Added `read.defaultLimit` setting to configure default number of lines returned by read tool when no limit is specified (default: 300 lines)
@@ -736,6 +774,7 @@
736
774
  - Fixed provider session state not being cleared when branching or navigating tree history, preventing resource leaks with codex provider sessions
737
775
 
738
776
  ## [13.8.0] - 2026-03-04
777
+
739
778
  ### Added
740
779
 
741
780
  - Added `buildCompactHashlineDiffPreview()` function to generate compact diff previews for model-visible tool responses, collapsing long unchanged runs and consecutive additions/removals to show edit shape without full file content
@@ -751,6 +790,7 @@
751
790
  - Fixed `:thinking` suffix in `modelRoles` config values silently breaking model resolution (e.g., `slow: anthropic/claude-opus-4-6:high`) and being stripped on Ctrl+P role cycling
752
791
 
753
792
  ## [13.7.6] - 2026-03-04
793
+
754
794
  ### Added
755
795
 
756
796
  - Exported `dedupeParseErrors` utility function to deduplicate parse error messages while preserving order
@@ -761,7 +801,9 @@
761
801
  - Normalized parse error output in ast-grep to remove pattern-specific prefixes and show only file-level errors
762
802
 
763
803
  ## [13.7.4] - 2026-03-04
804
+
764
805
  ### Added
806
+
765
807
  - Added `fetch.useKagiSummarizer` setting to toggle Kagi Universal Summarizer usage in the fetch tool.
766
808
 
767
809
  ### Fixed
@@ -781,7 +823,7 @@
781
823
  ### Changed
782
824
 
783
825
  - Updated hashline prompt documentation with clearer operation syntax and improved examples showing full edit structure with path and edits array
784
- - Refactored `hlineref` Handlebars helper to return JSON-quoted strings for safer embedding in JSON blocks within prompts
826
+ - Refactored `href` Handlebars helper to return JSON-quoted strings for safer embedding in JSON blocks within prompts
785
827
  - Improved `hashlineParseText` to correctly preserve blank lines and trailing empty strings in array input while stripping trailing newlines from string input
786
828
  - Optimized duplicate line detection in range replacements to use trimmed comparison, reducing false positives from whitespace differences
787
829
  - Refactored Kagi search provider to use shared Kagi API utilities from `web/kagi` module
@@ -792,6 +834,7 @@
792
834
  - Fixed `isEscapedTabAutocorrectEnabled` environment variable parsing to use switch statement for clearer logic and consistent default behavior
793
835
 
794
836
  ## [13.7.2] - 2026-03-04
837
+
795
838
  ### Added
796
839
 
797
840
  - Added support for direct OAuth provider login via `/login <provider>` command (e.g., `/login kagi`)
@@ -808,6 +851,7 @@
808
851
  - Fixed `ask` timeout handling to auto-select the recommended option instead of aborting the turn, while preserving explicit user-cancel abort behavior ([#266](https://github.com/can1357/oh-my-pi/issues/266))
809
852
 
810
853
  ## [13.6.2] - 2026-03-03
854
+
811
855
  ### Fixed
812
856
 
813
857
  - Fixed LM Studio API key retrieval to use configured provider name instead of hardcoded 'lm-studio'
@@ -822,7 +866,9 @@
822
866
  - Fixed `omp update` silently succeeding without actually updating the binary when the update channel (bun global vs compiled binary) doesn't match the installation method ([#247](https://github.com/can1357/oh-my-pi/issues/247))
823
867
  - Added post-update verification that checks the resolved `omp` binary reports the expected version, with actionable warnings on mismatch
824
868
  - `omp update` now detects when the `omp` in PATH is not managed by bun and falls back to binary replacement instead of updating the wrong location
869
+
825
870
  ## [13.6.0] - 2026-03-03
871
+
826
872
  ### Added
827
873
 
828
874
  - Added `mcp://` internal URL protocol for reading MCP server resources directly via the read tool (e.g., `read(path="mcp://resource-uri")`)
@@ -853,9 +899,11 @@
853
899
  - Fixed URI template matching to correctly handle expressions that expand to empty strings
854
900
 
855
901
  ## [13.5.6] - 2026-03-01
902
+
856
903
  ### Changed
857
904
 
858
905
  - Updated OAuth client name from 'oh-my-pi MCP' to 'Codex' for dynamic client registration
906
+
859
907
  ### Fixed
860
908
 
861
909
  - Fixed exit_plan_mode handler to abort active agent turn before opening plan approval selector, ensuring proper session cleanup
@@ -867,6 +915,7 @@
867
915
  - Added Kagi web search provider (Search API v0) with related searches support and automatic `KAGI_API_KEY` detection
868
916
 
869
917
  ## [13.5.4] - 2026-03-01
918
+
870
919
  ### Added
871
920
 
872
921
  - Added `authServerUrl` field to `AuthDetectionResult` to capture OAuth server metadata from `Mcp-Auth-Server` headers
@@ -875,6 +924,7 @@
875
924
  - Added recursive auth server discovery to follow `authorization_servers` references when discovering OAuth endpoints
876
925
 
877
926
  - Added `omp agents unpack` CLI subcommand to export bundled subagent definitions to `~/.omp/agent/agents` by default, with `--project` support for `./.omp/agents`
927
+
878
928
  ### Changed
879
929
 
880
930
  - Enhanced `discoverOAuthEndpoints()` to accept optional `authServerUrl` parameter and query both auth server and resource server for OAuth metadata
@@ -1569,7 +1619,7 @@
1569
1619
  - Updated hashline reference format from `LINE:HASH` to `LINE#ID` throughout the codebase for improved clarity
1570
1620
  - Renamed hashline edit operations: `set_line` → `set`, `replace_lines` → `set_range`, `insert_after` → `insert` with support for `before` and `between` anchors
1571
1621
  - Changed hashline edit `body` field from string to array of strings for clearer multiline handling
1572
- - Updated handlebars helpers: renamed `hashline` to `hlineref` and added `hlinefull` for formatted line output
1622
+ - Updated handlebars helpers: renamed `hashline` to `href` and added `hline` for formatted line output
1573
1623
  - Improved insert operation to support `before`, `after`, and `between` (both anchors) positioning modes
1574
1624
  - Made autocorrect heuristics (boundary echo stripping, indent restoration) conditional on `PI_HL_AUTOCORRECT` environment variable
1575
1625
  - Updated SSH host discovery to load from managed omp config paths (.omp/ssh.json and ~/.omp/agent/ssh.json) in addition to legacy root-level ssh.json and .ssh.json files
@@ -1901,7 +1951,6 @@
1901
1951
  - Improved error reporting in fetch tool to include HTTP status codes when URL fetching fails
1902
1952
  - Fixed fetch tool to preserve actual response metadata (finalUrl, contentType) instead of defaults when requests fail
1903
1953
 
1904
-
1905
1954
  ## [12.1.0] - 2026-02-13
1906
1955
 
1907
1956
  ### Added
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.3",
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.3",
46
- "@oh-my-pi/pi-agent-core": "13.16.3",
47
- "@oh-my-pi/pi-ai": "13.16.3",
48
- "@oh-my-pi/pi-natives": "13.16.3",
49
- "@oh-my-pi/pi-tui": "13.16.3",
50
- "@oh-my-pi/pi-utils": "13.16.3",
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
  }
@@ -258,19 +258,19 @@ function formatHashlineRef(lineNum: unknown, content: unknown): { num: number; t
258
258
  }
259
259
 
260
260
  /**
261
- * {{hlineref lineNum "content"}} — compute a real hashline ref for prompt examples.
261
+ * {{href lineNum "content"}} — compute a real hashline ref for prompt examples.
262
262
  * Returns `"lineNum#hash"` using the actual hash algorithm.
263
263
  */
264
- handlebars.registerHelper("hlineref", (lineNum: unknown, content: unknown): string => {
264
+ handlebars.registerHelper("href", (lineNum: unknown, content: unknown): string => {
265
265
  const { ref } = formatHashlineRef(lineNum, content);
266
266
  return JSON.stringify(ref);
267
267
  });
268
268
 
269
269
  /**
270
- * {{hlinefull lineNum "content"}} — format a full read-style line with prefix.
270
+ * {{hline lineNum "content"}} — format a full read-style line with prefix.
271
271
  * Returns `"lineNum#hash:content"`.
272
272
  */
273
- handlebars.registerHelper("hlinefull", (lineNum: unknown, content: unknown): string => {
273
+ handlebars.registerHelper("hline", (lineNum: unknown, content: unknown): string => {
274
274
  const { ref, text } = formatHashlineRef(lineNum, content);
275
275
  return `${ref}:${text}`;
276
276
  });
@@ -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);
@@ -187,12 +187,9 @@ const locSchema = Type.Union(
187
187
  Type.Object({ append: Type.String({ description: "anchor" }) }),
188
188
  Type.Object({ prepend: Type.String({ description: "anchor" }) }),
189
189
  Type.Object({
190
- line: Type.String({ description: "anchor" }),
191
- }),
192
- Type.Object({
193
- block: Type.Object({
194
- pos: Type.String({ description: "anchor" }),
195
- end: Type.String({ description: "limit position" }),
190
+ range: Type.Object({
191
+ pos: Type.String({ description: "first line to edit (inclusive)" }),
192
+ end: Type.String({ description: "last line to edit (inclusive)" }),
196
193
  }),
197
194
  }),
198
195
  ],
@@ -231,8 +228,7 @@ export type HashlineParams = Static<typeof hashlineEditParamsSchema>;
231
228
  * loc can be:
232
229
  * - "append" / "prepend" — file-level insert
233
230
  * - { append: anchor } / { prepend: anchor } — insert relative to anchor
234
- * - { replace_line: anchor } — replace one line
235
- * - { replace_block: { pos, end } } — replace inclusive range
231
+ * - { range: { pos, end } } — replace inclusive range
236
232
  */
237
233
  function resolveEditAnchors(edits: HashlineToolEdit[]): HashlineEdit[] {
238
234
  const result: HashlineEdit[] = [];
@@ -253,17 +249,13 @@ function resolveEditAnchors(edits: HashlineToolEdit[]): HashlineEdit[] {
253
249
  const anchor = tryParseTag(loc.prepend);
254
250
  if (!anchor) throw new Error("prepend requires a valid anchor.");
255
251
  result.push({ op: "prepend_at", pos: anchor, lines });
256
- } else if ("line" in loc) {
257
- const anchor = tryParseTag(loc.line);
258
- if (!anchor) throw new Error("line requires a valid anchor.");
259
- result.push({ op: "replace_line", pos: anchor, lines });
260
- } else if ("block" in loc) {
261
- const posAnchor = tryParseTag(loc.block.pos);
262
- const endAnchor = tryParseTag(loc.block.end);
263
- if (!posAnchor || !endAnchor) throw new Error("block requires valid pos and end anchors.");
252
+ } else if ("range" in loc) {
253
+ const posAnchor = tryParseTag(loc.range.pos);
254
+ const endAnchor = tryParseTag(loc.range.end);
255
+ if (!posAnchor || !endAnchor) throw new Error("range requires valid pos and end anchors.");
264
256
  result.push({ op: "replace_range", pos: posAnchor, end: endAnchor, lines });
265
257
  } else {
266
- throw new Error("Unknown loc shape. Expected append, prepend, line, or block.");
258
+ throw new Error("Unknown loc shape. Expected append, prepend, or range.");
267
259
  }
268
260
  } else {
269
261
  throw new Error(`Invalid loc value: ${JSON.stringify(loc)}`);
@@ -164,9 +164,8 @@ function formatStreamingHashlineEdits(edits: Partial<HashlineToolEdit>[], uiThem
164
164
  return { srcLabel: `• ${loc} (file-level)`, dst: contentLines };
165
165
  }
166
166
  if (typeof loc === "object" && loc) {
167
- if ("block" in loc && typeof loc.block === "object" && loc.block) {
168
- const rb = loc.block as { pos?: string; end?: string };
169
- return { srcLabel: `• block ${rb.pos ?? "?"}…${rb.end ?? "?"}`, dst: contentLines };
167
+ if ("range" in loc && typeof loc.range === "object" && loc.range) {
168
+ return { srcLabel: `• range ${loc.range.pos ?? "?"}…${loc.range.end ?? "?"}`, dst: contentLines };
170
169
  }
171
170
  if ("line" in loc) {
172
171
  return { srcLabel: `• line ${(loc as { line: string }).line}`, dst: contentLines };
@@ -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=[…])`|
@@ -16,40 +16,39 @@ Read the file first. Copy anchors exactly from the latest `read` output. In one
16
16
  **`loc` values**
17
17
  - `"append"` / `"prepend"` — insert at end/start of file
18
18
  - `{ append: "N#ID" }` / `{ prepend: "N#ID" }` — insert after/before anchored line
19
- - `{ line: "N#ID" }` — replace exactly one anchored line
20
- - `{ block: { pos: "N#ID", end: "N#ID" } }` — replace inclusive `pos..end`
21
- </operations>
19
+ - `{ range: { pos: "N#ID", end: "N#ID" } }` — replace inclusive range of lines `pos..end` with new content
20
+ </operations>
22
21
 
23
22
  <examples>
24
- All examples below reference the same file, `util.ts`:
25
- ```ts
26
- {{hlinefull 1 "// @ts-ignore"}}
27
- {{hlinefull 2 "const timeout = 5000;"}}
28
- {{hlinefull 3 "const tag = \"DO NOT SHIP\";"}}
29
- {{hlinefull 4 ""}}
30
- {{hlinefull 5 "function alpha() {"}}
31
- {{hlinefull 6 "\tlog();"}}
32
- {{hlinefull 7 "}"}}
33
- {{hlinefull 8 ""}}
34
- {{hlinefull 9 "function beta() {"}}
35
- {{hlinefull 10 "\t// TODO: remove after migration"}}
36
- {{hlinefull 11 "\tlegacy();"}}
37
- {{hlinefull 12 "\ttry {"}}
38
- {{hlinefull 13 "\t\treturn parse(data);"}}
39
- {{hlinefull 14 "\t} catch (err) {"}}
40
- {{hlinefull 15 "\t\tconsole.error(err);"}}
41
- {{hlinefull 16 "\t\treturn null;"}}
42
- {{hlinefull 17 "\t}"}}
43
- {{hlinefull 18 "}"}}
23
+ All examples below reference the same file:
24
+ ```ts title="a.ts"
25
+ {{hline 1 "// @ts-ignore"}}
26
+ {{hline 2 "const timeout = 5000;"}}
27
+ {{hline 3 "const tag = \"DO NOT SHIP\";"}}
28
+ {{hline 4 ""}}
29
+ {{hline 5 "function alpha() {"}}
30
+ {{hline 6 "\tlog();"}}
31
+ {{hline 7 "}"}}
32
+ {{hline 8 ""}}
33
+ {{hline 9 "function beta() {"}}
34
+ {{hline 10 "\t// TODO: remove after migration"}}
35
+ {{hline 11 "\tlegacy();"}}
36
+ {{hline 12 "\ttry {"}}
37
+ {{hline 13 "\t\treturn parse(data);"}}
38
+ {{hline 14 "\t} catch (err) {"}}
39
+ {{hline 15 "\t\tconsole.error(err);"}}
40
+ {{hline 16 "\t\treturn null;"}}
41
+ {{hline 17 "\t}"}}
42
+ {{hline 18 "}"}}
44
43
  ```
45
44
 
46
45
  <example name="replace a block body">
47
46
  Replace only the catch body. Do not target the shared boundary line `} catch (err) {`.
48
47
  ```
49
48
  {
50
- path: "util.ts",
49
+ path: "a.ts",
51
50
  edits: [{
52
- loc: { block: { pos: {{hlineref 15 "\t\tconsole.error(err);"}}, end: {{hlineref 16 "\t\treturn null;"}} } },
51
+ loc: { range: { pos: {{href 15 "\t\tconsole.error(err);"}}, end: {{href 16 "\t\treturn null;"}} } },
53
52
  content: [
54
53
  "\t\tif (isEnoent(err)) return null;",
55
54
  "\t\tthrow err;"
@@ -60,12 +59,12 @@ Replace only the catch body. Do not target the shared boundary line `} catch (er
60
59
  </example>
61
60
 
62
61
  <example name="replace whole block including closing brace">
63
- Replace the entire body of `alpha`, including its closing `}`. `end` **MUST** be {{hlineref 7 "}"}} because `content` includes `}`.
62
+ Replace the entire body of `alpha`, including its closing `}`. `end` **MUST** be {{href 7 "}"}} because `content` includes `}`.
64
63
  ```
65
64
  {
66
- path: "util.ts",
65
+ path: "a.ts",
67
66
  edits: [{
68
- loc: { block: { pos: {{hlineref 6 "\tlog();"}}, end: {{hlineref 7 "}"}} } },
67
+ loc: { range: { pos: {{href 6 "\tlog();"}}, end: {{href 7 "}"}} } },
69
68
  content: [
70
69
  "\tvalidate();",
71
70
  "\tlog();",
@@ -74,15 +73,15 @@ Replace the entire body of `alpha`, including its closing `}`. `end` **MUST** be
74
73
  }]
75
74
  }
76
75
  ```
77
- **Wrong**: using `end: {{hlineref 6 "\tlog();"}}` with the same content — line 7 (`}`) survives the replacement AND content emits `}`, producing two closing braces.
76
+ **Wrong**: using `end: {{href 6 "\tlog();"}}` with the same content — line 7 (`}`) survives the replacement AND content emits `}`, producing two closing braces.
78
77
  </example>
79
78
 
80
79
  <example name="replace one line">
81
80
  ```
82
81
  {
83
- path: "util.ts",
82
+ path: "a.ts",
84
83
  edits: [{
85
- loc: { line: {{hlineref 2 "const timeout = 5000;"}} },
84
+ loc: { range: { pos: {{href 2 "const timeout = 5000;"}}, end: {{href 2 "const timeout = 5000;"}} } },
86
85
  content: ["const timeout = 30_000;"]
87
86
  }]
88
87
  }
@@ -92,9 +91,9 @@ Replace the entire body of `alpha`, including its closing `}`. `end` **MUST** be
92
91
  <example name="delete a range">
93
92
  ```
94
93
  {
95
- path: "util.ts",
94
+ path: "a.ts",
96
95
  edits: [{
97
- loc: { block: { pos: {{hlineref 10 "\t// TODO: remove after migration"}}, end: {{hlineref 11 "\tlegacy();"}} } },
96
+ loc: { range: { pos: {{href 10 "\t// TODO: remove after migration"}}, end: {{href 11 "\tlegacy();"}} } },
98
97
  content: null
99
98
  }]
100
99
  }
@@ -105,9 +104,9 @@ Replace the entire body of `alpha`, including its closing `}`. `end` **MUST** be
105
104
  When adding a sibling declaration, prefer `prepend` on the next declaration.
106
105
  ```
107
106
  {
108
- path: "util.ts",
107
+ path: "a.ts",
109
108
  edits: [{
110
- loc: { prepend: {{hlineref 9 "function beta() {"}} },
109
+ loc: { prepend: {{href 9 "function beta() {"}} },
111
110
  content: [
112
111
  "function gamma() {",
113
112
  "\tvalidate();",
@@ -123,10 +122,10 @@ When adding a sibling declaration, prefer `prepend` on the next declaration.
123
122
  <critical>
124
123
  - Make the minimum exact edit. Do not rewrite nearby code unless the consumed range requires it.
125
124
  - Use anchors exactly as `N#ID` from the latest `read` output.
126
- - `block` requires both `pos` and `end`. Other anchored ops require one anchor.
125
+ - `range` requires both `pos` and `end`.
127
126
  - When your replacement `content` ends with a closing delimiter (`}`, `*/`, `)`, `]`), verify `end` includes the original line carrying that delimiter. If `end` stops one line too early, the original delimiter survives and your content adds a second copy.
128
127
  - **Self-check**: compare the last line of `content` with the line immediately after `end` in the file. If they match (e.g., both are `}`), extend `end` to include that line.
129
- - For a block, either replace only the body or replace the whole block. Do not split block boundaries.
128
+ - For a range, either replace only the body or replace the whole range. Do not split range boundaries.
130
129
  - `content` must be literal file content with matching indentation. If the file uses tabs, use real tabs.
131
- - Do not use this tool to reformat or clean up unrelated code.
132
- </critical>
130
+ - You **MUST NOT** use this tool to reformat or clean up unrelated code. **ALWAYS** use project-specific tooling like linters or code formatters which are much more efficient and reliable.
131
+ </critical>
@@ -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,
@@ -16,7 +16,7 @@ const CHECK_BYTE_COUNT = 1024;
16
16
  const HEADER_LINE_LIMIT = 40;
17
17
 
18
18
  const KNOWN_GENERATOR_PATTERN =
19
- "(?:protoc(?:-gen-[\\w-]+)?|sqlc|buf|swagger(?:-codegen)?|openapi(?:-generator)?|grpc-gateway|mockery|stringer|easyjson|deepcopy-gen|defaulter-gen|conversion-gen|client-gen|lister-gen|informer-gen)";
19
+ "(?:protoc(?:-gen-[\\w-]+)?|sqlc|buf|swagger(?:-codegen)?|openapi(?:-generator)?|grpc-gateway|mockery|stringer|easyjson|deepcopy-gen|defaulter-gen|conversion-gen|client-gen|lister-gen|informer-gen|kysely-codegen)";
20
20
 
21
21
  /**
22
22
  * Strong marker patterns for generated-file headers.
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