@oh-my-pi/pi-coding-agent 14.5.11 → 14.5.13

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 (89) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/package.json +18 -10
  3. package/src/cli/jupyter-cli.ts +1 -1
  4. package/src/config/model-equivalence.ts +49 -16
  5. package/src/config/model-registry.ts +100 -25
  6. package/src/config/model-resolver.ts +29 -15
  7. package/src/config/settings-schema.ts +20 -6
  8. package/src/config/settings.ts +9 -8
  9. package/src/config.ts +9 -0
  10. package/src/eval/backend.ts +43 -0
  11. package/src/eval/eval.lark +43 -0
  12. package/src/eval/index.ts +5 -0
  13. package/src/eval/js/context-manager.ts +717 -0
  14. package/src/eval/js/executor.ts +131 -0
  15. package/src/eval/js/index.ts +46 -0
  16. package/src/eval/js/prelude.ts +2 -0
  17. package/src/eval/js/prelude.txt +84 -0
  18. package/src/eval/js/tool-bridge.ts +124 -0
  19. package/src/eval/parse.ts +337 -0
  20. package/src/{ipy → eval/py}/executor.ts +2 -180
  21. package/src/{ipy → eval/py}/gateway-coordinator.ts +4 -3
  22. package/src/eval/py/index.ts +58 -0
  23. package/src/{ipy → eval/py}/kernel.ts +5 -41
  24. package/src/{ipy → eval/py}/prelude.py +39 -227
  25. package/src/eval/types.ts +48 -0
  26. package/src/export/html/template.generated.ts +1 -1
  27. package/src/export/html/template.js +23 -17
  28. package/src/extensibility/extensions/types.ts +2 -3
  29. package/src/internal-urls/docs-index.generated.ts +5 -5
  30. package/src/lsp/client.ts +9 -0
  31. package/src/lsp/index.ts +395 -0
  32. package/src/lsp/types.ts +15 -4
  33. package/src/main.ts +25 -14
  34. package/src/mcp/oauth-flow.ts +1 -1
  35. package/src/memories/index.ts +1 -1
  36. package/src/modes/acp/acp-event-mapper.ts +1 -1
  37. package/src/modes/components/{python-execution.ts → eval-execution.ts} +11 -4
  38. package/src/modes/components/login-dialog.ts +1 -1
  39. package/src/modes/components/oauth-selector.ts +2 -1
  40. package/src/modes/components/tool-execution.ts +3 -4
  41. package/src/modes/controllers/command-controller.ts +28 -8
  42. package/src/modes/controllers/input-controller.ts +4 -4
  43. package/src/modes/controllers/selector-controller.ts +2 -1
  44. package/src/modes/interactive-mode.ts +4 -5
  45. package/src/modes/types.ts +3 -3
  46. package/src/modes/utils/ui-helpers.ts +2 -2
  47. package/src/prompts/system/system-prompt.md +3 -3
  48. package/src/prompts/tools/atom.md +3 -2
  49. package/src/prompts/tools/browser.md +61 -16
  50. package/src/prompts/tools/eval.md +92 -0
  51. package/src/prompts/tools/lsp.md +7 -3
  52. package/src/sdk.ts +45 -31
  53. package/src/session/agent-session.ts +44 -54
  54. package/src/session/messages.ts +1 -1
  55. package/src/slash-commands/builtin-registry.ts +1 -1
  56. package/src/system-prompt.ts +34 -66
  57. package/src/task/executor.ts +5 -9
  58. package/src/tools/browser/attach.ts +175 -0
  59. package/src/tools/browser/launch.ts +576 -0
  60. package/src/tools/browser/readable.ts +90 -0
  61. package/src/tools/browser/registry.ts +198 -0
  62. package/src/tools/browser/render.ts +212 -0
  63. package/src/tools/browser/tab-protocol.ts +101 -0
  64. package/src/tools/browser/tab-supervisor.ts +429 -0
  65. package/src/tools/browser/tab-worker-entry.ts +21 -0
  66. package/src/tools/browser/tab-worker.ts +1006 -0
  67. package/src/tools/browser.ts +231 -1567
  68. package/src/tools/checkpoint.ts +2 -2
  69. package/src/tools/{python.ts → eval.ts} +324 -315
  70. package/src/tools/exit-plan-mode.ts +1 -1
  71. package/src/tools/index.ts +62 -100
  72. package/src/tools/plan-mode-guard.ts +27 -1
  73. package/src/tools/read.ts +0 -6
  74. package/src/tools/recipe/runners/pkg.ts +34 -32
  75. package/src/tools/renderers.ts +4 -2
  76. package/src/tools/resolve.ts +7 -2
  77. package/src/tools/todo-write.ts +0 -1
  78. package/src/tools/tool-timeouts.ts +2 -2
  79. package/src/utils/markit.ts +15 -7
  80. package/src/utils/tools-manager.ts +5 -5
  81. package/src/web/search/index.ts +5 -5
  82. package/src/web/search/provider.ts +121 -39
  83. package/src/web/search/providers/gemini.ts +2 -2
  84. package/src/web/search/render.ts +2 -2
  85. package/src/ipy/modules.ts +0 -144
  86. package/src/prompts/tools/python.md +0 -57
  87. /package/src/{ipy → eval/py}/cancellation.ts +0 -0
  88. /package/src/{ipy → eval/py}/prelude.ts +0 -0
  89. /package/src/{ipy → eval/py}/runtime.ts +0 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,64 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [14.5.13] - 2026-05-01
6
+
7
+ ### Breaking Changes
8
+
9
+ - Removed the built-in `python` tool in favor of `eval`, so tool allowlists and tool-call handlers referencing `python` need to migrate
10
+ - Removed the `python.toolMode` setting and replaced mode control with separate `eval.py` and `eval.js` toggles
11
+ - Changed the tool runtime config surface by migrating `python` execution timeout/export behavior to `eval` and replacing `./ipy/*` internal exports with `./eval/*` paths
12
+ - Changed the `eval` tool wire format to a single `input` string composed of markdown fenced code blocks (with per-fence language, timeout, title, and reset metadata in the info string) instead of top-level `cells`, `language`, `timeout`, and `reset` fields
13
+
14
+ ### Added
15
+
16
+ - Added a JavaScript backend to the `eval` tool with an in-process VM runtime and JS helper bridge (`read`, `write`, `glob`, etc.)
17
+ - Added `eval.py` and `eval.js` settings so Python and JavaScript `eval` backends can be enabled or disabled independently
18
+ - Added `rename_file` action to the Lsp tool to rename files and directories with LSP `workspace/willRenameFiles` and `workspace/didRenameFiles` flow, applying returned workspace edits before moving files
19
+ - Added `apply: false` preview mode for `rename_file` so users can see planned LSP edits without performing filesystem changes
20
+ - Added `request` action to invoke arbitrary LSP methods, with automatic `textDocument`/`position` parameter construction from `file`/`line`/`symbol` and support for explicit JSON `payload`
21
+ - Added `capabilities` action to display language server capabilities (for a file or all configured servers) through the LSP tool
22
+
23
+ ### Changed
24
+
25
+ - Changed AGENTS.md discovery to respect `.gitignore` files during project context collection so ignored context files are no longer loaded
26
+ - Changed eval tool initialization to skip Python kernel preflight when the JavaScript backend is enabled, avoiding unnecessary startup checks
27
+ - Changed model registry refresh flow to defer rebuilding the canonical model index until refresh operations complete, reducing refresh churn
28
+ - Changed execution/tool discovery flow so `exec` maps to `eval` when any `eval` backend is enabled, while `bash` stays independently available
29
+ - Changed `eval` dispatch to automatically fall back to JavaScript when Python is unavailable and JavaScript backend is enabled
30
+ - Parallelized plugin root preloading with other startup initialization in `runRootCommand` to reduce startup latency
31
+ - Parallelized session bootstrap work in `createAgentSession`, including AGENTS.md scanning, context discovery, prompt template loading, slash command loading, and skill discovery, to reduce time to first available session
32
+
33
+ ### Fixed
34
+
35
+ - Fixed eval startup messaging to report `eval` as unavailable when Python is unreachable and JavaScript backend is disabled
36
+
37
+ ## [14.5.12] - 2026-04-30
38
+
39
+ ### Breaking Changes
40
+
41
+ - Removed the legacy browser action verbs (`goto`, `observe`, `click`, `type`, `fill`, `press`, `scroll`, `drag`, `wait_for_selector`, `extract_readable`, and `screenshot`) in favor of invoking those workflows through `run`
42
+
43
+ ### Added
44
+
45
+ - Added a `browser` tool `open`/`run`/`close` flow with a `run` action that executes async JavaScript and provides `page`, `browser`, `tab`, `display`, `assert`, and `wait` in scope
46
+ - Added named tabs on `open` with default name `main` so browser state can be reused across `run` calls and subagents
47
+ - Added support for `app.path` and `app.cdp_url` on `open` to launch/connect to CDP-capable desktop apps
48
+
49
+ ### Changed
50
+
51
+ - Changed browser tool output rendering to display `run` calls as JavaScript code cells with status and output previews while showing `open`/`close` as compact status lines
52
+ - Changed `open` to open or reuse named tabs and `close` to support `all: true` and `kill`-based process termination behavior
53
+ - Changed app attachment behavior to reuse an existing CDP endpoint when available and avoid unnecessary respawn of matching app processes
54
+ - Changed tab closing so closing a tab no longer implicitly affects unnamed sessions when multiple tabs are used
55
+ - Changed browser export rendering to label outputs under the `browser` tool and include app metadata badges
56
+
57
+ ### Fixed
58
+
59
+ - Fixed Electron/CDP attachment target selection to skip helper windows and pick the most likely user-visible page target
60
+ - Fixed connection startup by waiting for the CDP endpoint and surfacing a timeout error when it does not become available
61
+ - Fixed plan mode to auto-redirect `write` and `edit` calls targeting a bare `PLAN.md` (or any same-basename cwd-relative path) to the canonical `local://PLAN.md` plan artifact instead of rejecting them
62
+
5
63
  ## [14.5.11] - 2026-04-30
6
64
  ### Breaking Changes
7
65
 
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": "14.5.11",
4
+ "version": "14.5.13",
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",
@@ -46,12 +46,12 @@
46
46
  "dependencies": {
47
47
  "@agentclientprotocol/sdk": "0.20.0",
48
48
  "@mozilla/readability": "^0.6.0",
49
- "@oh-my-pi/omp-stats": "14.5.11",
50
- "@oh-my-pi/pi-agent-core": "14.5.11",
51
- "@oh-my-pi/pi-ai": "14.5.11",
52
- "@oh-my-pi/pi-natives": "14.5.11",
53
- "@oh-my-pi/pi-tui": "14.5.11",
54
- "@oh-my-pi/pi-utils": "14.5.11",
49
+ "@oh-my-pi/omp-stats": "14.5.13",
50
+ "@oh-my-pi/pi-agent-core": "14.5.13",
51
+ "@oh-my-pi/pi-ai": "14.5.13",
52
+ "@oh-my-pi/pi-natives": "14.5.13",
53
+ "@oh-my-pi/pi-tui": "14.5.13",
54
+ "@oh-my-pi/pi-utils": "14.5.13",
55
55
  "@puppeteer/browsers": "^2.13.0",
56
56
  "@sinclair/typebox": "^0.34.49",
57
57
  "@xterm/headless": "^6.0.0",
@@ -319,9 +319,17 @@
319
319
  "types": "./src/internal-urls/*.ts",
320
320
  "import": "./src/internal-urls/*.ts"
321
321
  },
322
- "./ipy/*": {
323
- "types": "./src/ipy/*.ts",
324
- "import": "./src/ipy/*.ts"
322
+ "./eval": {
323
+ "types": "./src/eval/index.ts",
324
+ "import": "./src/eval/index.ts"
325
+ },
326
+ "./eval/js/*": {
327
+ "types": "./src/eval/js/*.ts",
328
+ "import": "./src/eval/js/*.ts"
329
+ },
330
+ "./eval/py/*": {
331
+ "types": "./src/eval/py/*.ts",
332
+ "import": "./src/eval/py/*.ts"
325
333
  },
326
334
  "./lsp": {
327
335
  "types": "./src/lsp/index.ts",
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { APP_NAME } from "@oh-my-pi/pi-utils";
8
8
  import chalk from "chalk";
9
- import { getGatewayStatus, shutdownSharedGateway } from "../ipy/gateway-coordinator";
9
+ import { getGatewayStatus, shutdownSharedGateway } from "../eval/py/gateway-coordinator";
10
10
 
11
11
  export type JupyterAction = "kill" | "status";
12
12
 
@@ -61,7 +61,25 @@ const TRAILING_CANONICAL_MARKERS = [
61
61
  "int8",
62
62
  "int4",
63
63
  ] as const;
64
+ const TRAILING_MARKER_SUFFIXES: readonly string[] = (() => {
65
+ const suffixes: string[] = [];
66
+ for (const marker of TRAILING_CANONICAL_MARKERS) {
67
+ const lower = marker.toLowerCase();
68
+ suffixes.push(`-${lower}`, `:${lower}`);
69
+ }
70
+ return suffixes;
71
+ })();
64
72
  const WRAPPER_PREFIXES = ["duo-chat-"] as const;
73
+
74
+ let __referenceDataCache: CanonicalReferenceData | undefined;
75
+ const EMPTY_COMPILED_EQUIVALENCE: CompiledEquivalenceConfig = {
76
+ overrides: new Map<string, string>(),
77
+ exclude: new Set<string>(),
78
+ };
79
+ const __resolutionCache: WeakMap<
80
+ CompiledEquivalenceConfig,
81
+ WeakMap<Model<Api>, ResolvedCanonicalModel>
82
+ > = new WeakMap();
65
83
  const FAMILY_EXTRACTION_PATTERNS = [
66
84
  /(?:^|[/:._-])((?:claude|gemini|gpt|grok|glm|qwen|minimax|kimi|deepseek|llama|gemma|nova|mistral|ministral|pixtral|codestral|devstral|magistral|ernie|doubao|seed|aion|olmo|molmo|nemotron|palmyra|command|codex|coder|o[1345])[-a-z0-9.]+)(?::|$)/i,
67
85
  /(?:^|[/:._-])((?:claude|gemini|gpt|grok|glm|qwen|minimax|kimi|deepseek|llama|gemma|nova|mistral|ministral|pixtral|codestral|devstral|magistral|ernie|doubao|seed|aion|olmo|molmo|nemotron|palmyra|command|codex|coder|o[1345])[-a-z0-9.]+(?:[-_/][a-z0-9.]+)*)(?::|$)/i,
@@ -79,6 +97,9 @@ function shouldReplaceReference(existing: Model<Api> | undefined, candidate: Mod
79
97
  }
80
98
 
81
99
  function createCanonicalReferenceData(): CanonicalReferenceData {
100
+ if (__referenceDataCache) {
101
+ return __referenceDataCache;
102
+ }
82
103
  const references = new Map<string, Model<Api>>();
83
104
  for (const provider of getBundledProviders()) {
84
105
  for (const model of getBundledModels(provider as Parameters<typeof getBundledModels>[0])) {
@@ -89,10 +110,12 @@ function createCanonicalReferenceData(): CanonicalReferenceData {
89
110
  }
90
111
  }
91
112
  }
92
- return {
93
- references,
94
- officialIds: new Set(references.keys()),
113
+ const officialIds = new Set(references.keys());
114
+ __referenceDataCache = {
115
+ references: Object.freeze(references) as Map<string, Model<Api>>,
116
+ officialIds: Object.freeze(officialIds) as Set<string>,
95
117
  };
118
+ return __referenceDataCache;
96
119
  }
97
120
 
98
121
  function normalizeSelectorKey(selector: string): string {
@@ -135,10 +158,12 @@ function buildExclusionSet(exclusions: readonly string[] | undefined): Set<strin
135
158
  }
136
159
 
137
160
  function compileEquivalenceConfig(config: ModelEquivalenceConfig | undefined): CompiledEquivalenceConfig {
138
- return {
139
- overrides: buildOverrideMap(config?.overrides),
140
- exclude: buildExclusionSet(config?.exclude),
141
- };
161
+ const overrides = buildOverrideMap(config?.overrides);
162
+ const exclude = buildExclusionSet(config?.exclude);
163
+ if (overrides.size === 0 && exclude.size === 0) {
164
+ return EMPTY_COMPILED_EQUIVALENCE;
165
+ }
166
+ return { overrides, exclude };
142
167
  }
143
168
 
144
169
  function addCanonicalCandidate(candidates: Set<string>, candidate: string): void {
@@ -149,12 +174,10 @@ function addCanonicalCandidate(candidates: Set<string>, candidate: string): void
149
174
  }
150
175
 
151
176
  function stripTrailingMarker(candidate: string): string | undefined {
152
- for (const marker of TRAILING_CANONICAL_MARKERS) {
153
- for (const separator of ["-", ":"] as const) {
154
- const suffix = `${separator}${marker}`;
155
- if (candidate.toLowerCase().endsWith(suffix)) {
156
- return candidate.slice(0, -suffix.length);
157
- }
177
+ const lower = candidate.toLowerCase();
178
+ for (const suffix of TRAILING_MARKER_SUFFIXES) {
179
+ if (lower.endsWith(suffix)) {
180
+ return candidate.slice(0, -suffix.length);
158
181
  }
159
182
  }
160
183
  return undefined;
@@ -450,8 +473,8 @@ function getHeuristicCanonicalCandidates(modelId: string): string[] {
450
473
  const queue = [modelId];
451
474
  const visited = new Set<string>();
452
475
 
453
- while (queue.length > 0) {
454
- const candidate = queue.shift();
476
+ for (let qi = 0; qi < queue.length; qi += 1) {
477
+ const candidate = queue[qi];
455
478
  if (!candidate) {
456
479
  continue;
457
480
  }
@@ -644,8 +667,18 @@ export function buildCanonicalModelIndex(
644
667
  const byId = new Map<string, CanonicalModelRecord>();
645
668
  const bySelector = new Map<string, string>();
646
669
 
670
+ let modelCache = __resolutionCache.get(compiledEquivalence);
671
+ if (!modelCache) {
672
+ modelCache = new WeakMap<Model<Api>, ResolvedCanonicalModel>();
673
+ __resolutionCache.set(compiledEquivalence, modelCache);
674
+ }
675
+
647
676
  for (const model of models) {
648
- const canonical = resolveCanonicalIdForModel(model, compiledEquivalence, referenceData);
677
+ let canonical = modelCache.get(model);
678
+ if (!canonical) {
679
+ canonical = resolveCanonicalIdForModel(model, compiledEquivalence, referenceData);
680
+ modelCache.set(model, canonical);
681
+ }
649
682
  const selector = formatCanonicalVariantSelector(model);
650
683
  const variant: CanonicalModelVariant = {
651
684
  canonicalId: canonical.id,
@@ -4,7 +4,6 @@ import {
4
4
  type AssistantMessageEventStream,
5
5
  type Context,
6
6
  createModelManager,
7
- DEFAULT_LOCAL_TOKEN,
8
7
  enrichModelThinking,
9
8
  getBundledModels,
10
9
  getBundledProviders,
@@ -13,18 +12,21 @@ import {
13
12
  type Model,
14
13
  type ModelManagerOptions,
15
14
  type ModelRefreshStrategy,
16
- type OAuthCredentials,
17
- type OAuthLoginCallbacks,
18
15
  openaiCodexModelManagerOptions,
19
16
  PROVIDER_DESCRIPTORS,
20
17
  readModelCache,
21
18
  registerCustomApi,
22
- registerOAuthProvider,
23
19
  type SimpleStreamOptions,
24
20
  type ThinkingConfig,
25
21
  unregisterCustomApis,
26
- unregisterOAuthProviders,
27
22
  } from "@oh-my-pi/pi-ai";
23
+
24
+ // Sentinel for local-only OAuth token (LM Studio, vLLM) — declared inline to avoid loading
25
+ // any provider module at startup. Must match `DEFAULT_LOCAL_TOKEN` in oauth/lm-studio.ts.
26
+ const DEFAULT_LOCAL_TOKEN = "lm-studio-local";
27
+
28
+ import { registerOAuthProvider, unregisterOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth";
29
+ import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/utils/oauth/types";
28
30
  import { isRecord, logger } from "@oh-my-pi/pi-utils";
29
31
  import { type Static, Type } from "@sinclair/typebox";
30
32
  import { type ConfigError, ConfigFile } from "../config";
@@ -281,6 +283,8 @@ const ProviderConfigSchema = Type.Object({
281
283
  discovery: Type.Optional(ProviderDiscoverySchema),
282
284
  models: Type.Optional(Type.Array(ModelDefinitionSchema)),
283
285
  modelOverrides: Type.Optional(Type.Record(Type.String(), ModelOverrideSchema)),
286
+ /** When true, disables strict tool schemas for this provider (for third-party Anthropic-compatible endpoints that reject the strict field). */
287
+ disableStrictTools: Type.Optional(Type.Boolean()),
284
288
  });
285
289
 
286
290
  const EquivalenceConfigSchema = Type.Object({
@@ -316,6 +320,7 @@ interface ProviderValidationConfig {
316
320
  oauthConfigured?: boolean;
317
321
  discovery?: ProviderDiscovery;
318
322
  compat?: Model<Api>["compat"];
323
+ disableStrictTools?: boolean;
319
324
  modelOverrides?: Record<string, unknown>;
320
325
  models: ProviderValidationModel[];
321
326
  }
@@ -331,9 +336,16 @@ function validateProviderConfiguration(
331
336
  if (models.length === 0) {
332
337
  if (mode === "models-config") {
333
338
  const hasModelOverrides = config.modelOverrides && Object.keys(config.modelOverrides).length > 0;
334
- if (!config.baseUrl && !config.headers && !config.compat && !hasModelOverrides && !config.discovery) {
339
+ if (
340
+ !config.baseUrl &&
341
+ !config.headers &&
342
+ !config.compat &&
343
+ !config.disableStrictTools &&
344
+ !hasModelOverrides &&
345
+ !config.discovery
346
+ ) {
335
347
  throw new Error(
336
- `Provider ${providerName}: must specify "baseUrl", "headers", "compat", "modelOverrides", "discovery", or "models"`,
348
+ `Provider ${providerName}: must specify "baseUrl", "headers", "compat", "disableStrictTools", "modelOverrides", "discovery", or "models"`,
337
349
  );
338
350
  }
339
351
  }
@@ -394,6 +406,7 @@ export const ModelsConfigFile = new ConfigFile<ModelsConfig>("models", ModelsCon
394
406
  auth: (providerConfig.auth ?? "apiKey") as ProviderAuthMode,
395
407
  discovery: providerConfig.discovery as ProviderDiscovery | undefined,
396
408
  compat: providerConfig.compat,
409
+ disableStrictTools: providerConfig.disableStrictTools,
397
410
  modelOverrides: providerConfig.modelOverrides,
398
411
  models: (providerConfig.models ?? []) as ProviderValidationModel[],
399
412
  },
@@ -784,6 +797,7 @@ export class ModelRegistry {
784
797
  #equivalenceConfig: ModelEquivalenceConfig | undefined;
785
798
  #configError: ConfigError | undefined = undefined;
786
799
  #modelsConfigFile: ConfigFile<ModelsConfig>;
800
+ #lastStaticLoadMtime: number | null = null;
787
801
  #registeredProviderSources: Set<string> = new Set();
788
802
  #providerDiscoveryStates: Map<string, ProviderDiscoveryState> = new Map();
789
803
  #cacheDbPath?: string;
@@ -797,6 +811,8 @@ export class ModelRegistry {
797
811
  #runtimeProviderOverrides: Map<string, ProviderOverride> = new Map();
798
812
  #runtimeProvidersBySource: Map<string, Set<string>> = new Map();
799
813
  #runtimeProviderSourceByName: Map<string, string> = new Map();
814
+ #rebuildPending: boolean = false;
815
+ #rebuildSuspended: number = 0;
800
816
 
801
817
  /**
802
818
  * @param authStorage - Auth storage for API key resolution
@@ -823,9 +839,14 @@ export class ModelRegistry {
823
839
  * Reload models from disk (built-in + custom from models.json).
824
840
  */
825
841
  async refresh(strategy: ModelRefreshStrategy = "online-if-uncached"): Promise<void> {
826
- this.#reloadStaticModels();
827
- this.#suppressedSelectors.clear();
828
- await this.#refreshRuntimeDiscoveries(strategy);
842
+ this.#suspendRebuild();
843
+ try {
844
+ this.#reloadStaticModels();
845
+ this.#suppressedSelectors.clear();
846
+ await this.#refreshRuntimeDiscoveries(strategy);
847
+ } finally {
848
+ this.#resumeRebuild();
849
+ }
829
850
  }
830
851
 
831
852
  refreshInBackground(strategy: ModelRefreshStrategy = "online-if-uncached"): void {
@@ -847,16 +868,26 @@ export class ModelRegistry {
847
868
  }
848
869
 
849
870
  async refreshProvider(providerId: string, strategy: ModelRefreshStrategy = "online"): Promise<void> {
850
- this.#reloadStaticModels();
851
- for (const selector of this.#suppressedSelectors.keys()) {
852
- if (selector.startsWith(`${providerId}/`)) {
853
- this.#suppressedSelectors.delete(selector);
871
+ this.#suspendRebuild();
872
+ try {
873
+ this.#reloadStaticModels();
874
+ for (const selector of this.#suppressedSelectors.keys()) {
875
+ if (selector.startsWith(`${providerId}/`)) {
876
+ this.#suppressedSelectors.delete(selector);
877
+ }
854
878
  }
879
+ await this.#refreshRuntimeDiscoveries(strategy, new Set([providerId]));
880
+ } finally {
881
+ this.#resumeRebuild();
855
882
  }
856
- await this.#refreshRuntimeDiscoveries(strategy, new Set([providerId]));
857
883
  }
858
884
 
859
885
  #reloadStaticModels(): void {
886
+ const currentMtime = this.#modelsConfigFile.getMtimeMs();
887
+ if (currentMtime !== null && currentMtime === this.#lastStaticLoadMtime) {
888
+ // models.json unchanged since last load; reload + canonical rebuild would be redundant.
889
+ return;
890
+ }
860
891
  this.#modelsConfigFile.invalidate();
861
892
  this.#customProviderApiKeys.clear();
862
893
  this.#keylessProviders.clear();
@@ -911,6 +942,7 @@ export class ModelRegistry {
911
942
  const withModelOverrides = this.#applyModelOverrides(combined, this.#modelOverrides);
912
943
  this.#models = this.#applyRuntimeProviderOverrides(withModelOverrides);
913
944
  this.#rebuildCanonicalIndex();
945
+ this.#lastStaticLoadMtime = this.#modelsConfigFile.getMtimeMs();
914
946
  }
915
947
 
916
948
  /** Load built-in models, applying provider-level overrides only.
@@ -934,14 +966,19 @@ export class ModelRegistry {
934
966
 
935
967
  #mergeResolvedModels(baseModels: Model<Api>[], replacementModels: Model<Api>[]): Model<Api>[] {
936
968
  const merged = [...baseModels];
969
+ const indexByKey = new Map<string, number>();
970
+ for (let i = 0; i < merged.length; i += 1) {
971
+ const m = merged[i];
972
+ indexByKey.set(`${m.provider}\u0000${m.id}`, i);
973
+ }
937
974
  for (const replacementModel of replacementModels) {
938
- const existingIndex = merged.findIndex(
939
- m => m.provider === replacementModel.provider && m.id === replacementModel.id,
940
- );
941
- if (existingIndex >= 0) {
975
+ const key = `${replacementModel.provider}\u0000${replacementModel.id}`;
976
+ const existingIndex = indexByKey.get(key);
977
+ if (existingIndex !== undefined) {
942
978
  merged[existingIndex] = replacementModel;
943
979
  } else {
944
980
  merged.push(replacementModel);
981
+ indexByKey.set(key, merged.length - 1);
945
982
  }
946
983
  }
947
984
  return merged;
@@ -950,9 +987,15 @@ export class ModelRegistry {
950
987
  /** Merge custom models with built-in, replacing by provider+id match */
951
988
  #mergeCustomModels(builtInModels: Model<Api>[], customModels: CustomModelOverlay[]): Model<Api>[] {
952
989
  const merged = [...builtInModels];
990
+ const indexByKey = new Map<string, number>();
991
+ for (let i = 0; i < merged.length; i += 1) {
992
+ const m = merged[i];
993
+ indexByKey.set(`${m.provider}\u0000${m.id}`, i);
994
+ }
953
995
  for (const customModel of customModels) {
954
- const existingIndex = merged.findIndex(m => m.provider === customModel.provider && m.id === customModel.id);
955
- if (existingIndex >= 0) {
996
+ const key = `${customModel.provider}\u0000${customModel.id}`;
997
+ const existingIndex = indexByKey.get(key);
998
+ if (existingIndex !== undefined) {
956
999
  const existingModel = merged[existingIndex];
957
1000
  merged[existingIndex] = enrichModelThinking({
958
1001
  ...existingModel,
@@ -977,6 +1020,7 @@ export class ModelRegistry {
977
1020
  } as Model<Api>);
978
1021
  } else {
979
1022
  merged.push(finalizeCustomModel(customModel, { useDefaults: true }));
1023
+ indexByKey.set(key, merged.length - 1);
980
1024
  }
981
1025
  }
982
1026
  return merged;
@@ -1099,13 +1143,20 @@ export class ModelRegistry {
1099
1143
  const configuredProviders = new Set(Object.keys(value.providers ?? {}));
1100
1144
 
1101
1145
  for (const [providerName, providerConfig] of providerEntries) {
1102
- // Always set overrides when baseUrl/headers/apiKey/compat are present
1103
- if (providerConfig.baseUrl || providerConfig.headers || providerConfig.apiKey || providerConfig.compat) {
1146
+ // Always set overrides when baseUrl/headers/apiKey/compat/disableStrictTools are present
1147
+ if (
1148
+ providerConfig.baseUrl ||
1149
+ providerConfig.headers ||
1150
+ providerConfig.apiKey ||
1151
+ providerConfig.compat ||
1152
+ providerConfig.disableStrictTools
1153
+ ) {
1154
+ const disableStrictCompat = providerConfig.disableStrictTools ? { disableStrictTools: true } : undefined;
1104
1155
  overrides.set(providerName, {
1105
1156
  baseUrl: providerConfig.baseUrl,
1106
1157
  headers: providerConfig.headers,
1107
1158
  apiKey: providerConfig.apiKey,
1108
- compat: providerConfig.compat,
1159
+ compat: mergeCompat(providerConfig.compat, disableStrictCompat),
1109
1160
  });
1110
1161
  }
1111
1162
 
@@ -1736,7 +1787,26 @@ export class ModelRegistry {
1736
1787
  }
1737
1788
 
1738
1789
  #rebuildCanonicalIndex(): void {
1790
+ if (this.#rebuildSuspended > 0) {
1791
+ this.#rebuildPending = true;
1792
+ return;
1793
+ }
1739
1794
  this.#canonicalIndex = buildCanonicalModelIndex(this.#models, this.#equivalenceConfig);
1795
+ this.#rebuildPending = false;
1796
+ }
1797
+
1798
+ #suspendRebuild(): void {
1799
+ this.#rebuildSuspended += 1;
1800
+ }
1801
+
1802
+ #resumeRebuild(): void {
1803
+ if (this.#rebuildSuspended > 0) {
1804
+ this.#rebuildSuspended -= 1;
1805
+ }
1806
+ if (this.#rebuildSuspended === 0 && this.#rebuildPending) {
1807
+ this.#rebuildPending = false;
1808
+ this.#canonicalIndex = buildCanonicalModelIndex(this.#models, this.#equivalenceConfig);
1809
+ }
1740
1810
  }
1741
1811
 
1742
1812
  #parseModels(config: ModelsConfig): CustomModelOverlay[] {
@@ -1749,6 +1819,9 @@ export class ModelRegistry {
1749
1819
  this.#customProviderApiKeys.set(providerName, providerConfig.apiKey);
1750
1820
  }
1751
1821
  for (const modelDef of modelDefs) {
1822
+ const providerCompat = providerConfig.disableStrictTools
1823
+ ? mergeCompat(providerConfig.compat, { disableStrictTools: true })
1824
+ : providerConfig.compat;
1752
1825
  const model = buildCustomModelOverlay(
1753
1826
  providerName,
1754
1827
  providerConfig.baseUrl!,
@@ -1756,7 +1829,7 @@ export class ModelRegistry {
1756
1829
  providerConfig.headers,
1757
1830
  providerConfig.apiKey,
1758
1831
  providerConfig.authHeader,
1759
- providerConfig.compat,
1832
+ providerCompat,
1760
1833
  (providerConfig.auth as ProviderAuthMode | undefined) ?? undefined,
1761
1834
  modelDef as CustomModelDefinitionLike,
1762
1835
  );
@@ -1993,6 +2066,7 @@ export class ModelRegistry {
1993
2066
  this.#runtimeProviderSourceByName.delete(providerName);
1994
2067
  this.#clearRuntimeProviderState(providerName);
1995
2068
  }
2069
+ this.#lastStaticLoadMtime = null;
1996
2070
  this.#reloadStaticModels();
1997
2071
  this.#rebuildCanonicalIndex();
1998
2072
  }
@@ -2071,6 +2145,7 @@ export class ModelRegistry {
2071
2145
  this.#runtimeProviderSourceByName.set(providerName, sourceId);
2072
2146
  }
2073
2147
  if (sourceHandoff) {
2148
+ this.#lastStaticLoadMtime = null;
2074
2149
  this.#reloadStaticModels();
2075
2150
  }
2076
2151
 
@@ -116,6 +116,24 @@ function cloneModelWithRequestedId(model: Model<Api>, requestedId: string): Mode
116
116
  };
117
117
  }
118
118
 
119
+ const providerModelIndexCache = new WeakMap<readonly Model<Api>[], Map<string, Model<Api> | null>>();
120
+
121
+ function getProviderModelIndex(availableModels: readonly Model<Api>[]): Map<string, Model<Api> | null> {
122
+ let index = providerModelIndexCache.get(availableModels);
123
+ if (index) return index;
124
+ index = new Map<string, Model<Api> | null>();
125
+ for (const m of availableModels) {
126
+ const key = `${m.provider.toLowerCase()}\u0000${m.id.toLowerCase()}`;
127
+ if (index.has(key)) {
128
+ index.set(key, null); // ambiguous sentinel; do not overwrite back
129
+ } else {
130
+ index.set(key, m);
131
+ }
132
+ }
133
+ providerModelIndexCache.set(availableModels, index);
134
+ return index;
135
+ }
136
+
119
137
  export function resolveProviderModelReference(
120
138
  provider: string,
121
139
  modelId: string,
@@ -127,14 +145,13 @@ export function resolveProviderModelReference(
127
145
  return undefined;
128
146
  }
129
147
 
130
- const exactMatches = availableModels.filter(
131
- model => model.provider.toLowerCase() === normalizedProvider && model.id.toLowerCase() === normalizedModelId,
132
- );
133
- if (exactMatches.length === 1) {
134
- return exactMatches[0];
148
+ const index = getProviderModelIndex(availableModels);
149
+ const exact = index.get(`${normalizedProvider}\u0000${normalizedModelId}`);
150
+ if (exact === null) {
151
+ return undefined; // ambiguous
135
152
  }
136
- if (exactMatches.length > 1) {
137
- return undefined;
153
+ if (exact !== undefined) {
154
+ return exact;
138
155
  }
139
156
 
140
157
  if (normalizedProvider !== "openrouter") {
@@ -142,16 +159,13 @@ export function resolveProviderModelReference(
142
159
  }
143
160
 
144
161
  for (const fallbackId of getOpenRouterFallbackModelIds(modelId).slice(1)) {
145
- const baseMatches = availableModels.filter(
146
- model =>
147
- model.provider.toLowerCase() === normalizedProvider && model.id.toLowerCase() === fallbackId.toLowerCase(),
148
- );
149
- if (baseMatches.length === 1) {
150
- return cloneModelWithRequestedId(baseMatches[0], modelId);
151
- }
152
- if (baseMatches.length > 1) {
162
+ const fallback = index.get(`${normalizedProvider}\u0000${fallbackId.toLowerCase()}`);
163
+ if (fallback === null) {
153
164
  return undefined;
154
165
  }
166
+ if (fallback !== undefined) {
167
+ return cloneModelWithRequestedId(fallback, modelId);
168
+ }
155
169
  }
156
170
 
157
171
  return undefined;
@@ -1137,14 +1137,28 @@ export const SETTINGS_SCHEMA = {
1137
1137
  },
1138
1138
  },
1139
1139
 
1140
- // Python
1141
- "python.toolMode": {
1142
- type: "enum",
1143
- values: ["ipy-only", "bash-only", "both"] as const,
1144
- default: "both",
1145
- ui: { tab: "editing", label: "Python Tool Mode", description: "How Python code is executed" },
1140
+ // Eval (per-backend toggles; add more as new backends ship, e.g. eval.ts)
1141
+ "eval.py": {
1142
+ type: "boolean",
1143
+ default: true,
1144
+ ui: {
1145
+ tab: "editing",
1146
+ label: "Eval: Python backend",
1147
+ description: "Allow the eval tool to dispatch to the IPython kernel",
1148
+ },
1149
+ },
1150
+
1151
+ "eval.js": {
1152
+ type: "boolean",
1153
+ default: true,
1154
+ ui: {
1155
+ tab: "editing",
1156
+ label: "Eval: JavaScript backend",
1157
+ description: "Allow the eval tool to dispatch to the in-process JavaScript runtime",
1158
+ },
1146
1159
  },
1147
1160
 
1161
+ // Python kernel knobs (consumed by the eval py backend and the /python slash command)
1148
1162
  "python.kernelMode": {
1149
1163
  type: "enum",
1150
1164
  values: ["session", "per-call"] as const,
@@ -398,21 +398,22 @@ export class Settings {
398
398
  // ─────────────────────────────────────────────────────────────────────────
399
399
 
400
400
  async #load(): Promise<Settings> {
401
+ // Project settings load (loadCapability scans cwd) is independent of the
402
+ // persist chain (storage open → legacy migration → global config.yml read),
403
+ // so kick it off first and await after the persist chain completes. The
404
+ // persist steps remain sequential: migration may write config.yml, which
405
+ // #loadYaml then reads; migration's db fallback needs #storage opened.
406
+ const projectPromise = this.#loadProjectSettings();
407
+
401
408
  if (this.#persist) {
402
- // Open storage
403
409
  this.#storage = await AgentStorage.open(getAgentDbPath(this.#agentDir));
404
-
405
- // Migrate from legacy formats if needed
406
410
  await this.#migrateFromLegacy();
407
-
408
- // Load global settings from config.yml
409
411
  this.#global = await this.#loadYaml(this.#configPath!);
410
412
  }
411
413
 
412
- // Load project settings
413
- this.#project = await this.#loadProjectSettings();
414
+ this.#project = await projectPromise;
414
415
 
415
- // Build merged view
416
+ // Build merged view (global → project → overrides; project wins over global)
416
417
  this.#rebuildMerged();
417
418
  this.#fireAllHooks();
418
419
  return this;
package/src/config.ts CHANGED
@@ -175,6 +175,15 @@ export class ConfigFile<T> implements IConfigFile<T> {
175
175
  return result;
176
176
  }
177
177
 
178
+ getMtimeMs(): number | null {
179
+ try {
180
+ return fs.statSync(this.path()).mtimeMs;
181
+ } catch (err) {
182
+ if (isEnoent(err)) return null;
183
+ throw err;
184
+ }
185
+ }
186
+
178
187
  withValidation(name: string, validate: (value: T) => void): this {
179
188
  const prev = this.#auxValidate;
180
189
  this.#auxValidate = (value: T) => {