@oh-my-pi/pi-coding-agent 14.1.0 → 14.1.1

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 (82) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/package.json +8 -8
  3. package/src/async/job-manager.ts +43 -10
  4. package/src/commit/agentic/tools/analyze-file.ts +1 -2
  5. package/src/config/mcp-schema.json +1 -1
  6. package/src/config/model-equivalence.ts +1 -0
  7. package/src/config/model-registry.ts +63 -34
  8. package/src/config/model-resolver.ts +111 -15
  9. package/src/config/settings-schema.ts +4 -3
  10. package/src/config/settings.ts +1 -1
  11. package/src/cursor.ts +64 -23
  12. package/src/edit/index.ts +254 -89
  13. package/src/edit/modes/chunk.ts +336 -57
  14. package/src/edit/modes/hashline.ts +51 -26
  15. package/src/edit/modes/patch.ts +16 -10
  16. package/src/edit/modes/replace.ts +15 -7
  17. package/src/edit/renderer.ts +248 -94
  18. package/src/export/html/template.generated.ts +1 -1
  19. package/src/export/html/template.js +6 -4
  20. package/src/extensibility/custom-tools/types.ts +0 -3
  21. package/src/extensibility/extensions/loader.ts +16 -0
  22. package/src/extensibility/extensions/runner.ts +2 -7
  23. package/src/extensibility/extensions/types.ts +8 -4
  24. package/src/internal-urls/docs-index.generated.ts +3 -3
  25. package/src/ipy/executor.ts +447 -52
  26. package/src/ipy/kernel.ts +39 -13
  27. package/src/lsp/client.ts +54 -0
  28. package/src/lsp/index.ts +8 -0
  29. package/src/lsp/types.ts +6 -0
  30. package/src/main.ts +0 -1
  31. package/src/modes/acp/acp-agent.ts +4 -1
  32. package/src/modes/components/bash-execution.ts +16 -4
  33. package/src/modes/components/status-line/presets.ts +17 -6
  34. package/src/modes/components/status-line/segments.ts +15 -0
  35. package/src/modes/components/status-line-segment-editor.ts +1 -0
  36. package/src/modes/components/status-line.ts +7 -1
  37. package/src/modes/components/tool-execution.ts +145 -75
  38. package/src/modes/controllers/command-controller.ts +24 -1
  39. package/src/modes/controllers/event-controller.ts +4 -1
  40. package/src/modes/controllers/extension-ui-controller.ts +28 -5
  41. package/src/modes/controllers/input-controller.ts +9 -3
  42. package/src/modes/controllers/selector-controller.ts +4 -1
  43. package/src/modes/interactive-mode.ts +19 -3
  44. package/src/modes/print-mode.ts +13 -4
  45. package/src/modes/prompt-action-autocomplete.ts +3 -5
  46. package/src/modes/rpc/rpc-mode.ts +8 -2
  47. package/src/modes/shared.ts +2 -2
  48. package/src/modes/types.ts +1 -0
  49. package/src/modes/utils/ui-helpers.ts +1 -0
  50. package/src/prompts/tools/bash.md +2 -2
  51. package/src/prompts/tools/chunk-edit.md +191 -163
  52. package/src/prompts/tools/hashline.md +11 -11
  53. package/src/prompts/tools/patch.md +10 -5
  54. package/src/prompts/tools/{await.md → poll.md} +1 -1
  55. package/src/prompts/tools/read-chunk.md +3 -3
  56. package/src/prompts/tools/task.md +2 -2
  57. package/src/prompts/tools/vim.md +98 -0
  58. package/src/sdk.ts +754 -724
  59. package/src/session/agent-session.ts +164 -34
  60. package/src/session/session-manager.ts +50 -4
  61. package/src/slash-commands/builtin-registry.ts +17 -0
  62. package/src/task/executor.ts +4 -4
  63. package/src/task/index.ts +3 -5
  64. package/src/task/types.ts +2 -2
  65. package/src/tools/bash.ts +26 -8
  66. package/src/tools/find.ts +5 -2
  67. package/src/tools/grep.ts +77 -8
  68. package/src/tools/index.ts +48 -19
  69. package/src/tools/{await-tool.ts → poll-tool.ts} +36 -30
  70. package/src/tools/python.ts +293 -278
  71. package/src/tools/submit-result.ts +5 -2
  72. package/src/tools/todo-write.ts +8 -2
  73. package/src/tools/vim.ts +966 -0
  74. package/src/utils/edit-mode.ts +2 -1
  75. package/src/utils/session-color.ts +55 -0
  76. package/src/utils/title-generator.ts +15 -6
  77. package/src/vim/buffer.ts +309 -0
  78. package/src/vim/commands.ts +382 -0
  79. package/src/vim/engine.ts +2426 -0
  80. package/src/vim/parser.ts +151 -0
  81. package/src/vim/render.ts +252 -0
  82. package/src/vim/types.ts +197 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,80 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [14.1.1] - 2026-04-14
6
+
7
+ ### Breaking Changes
8
+
9
+ - Removed the standalone `vim` tool from built-in tool lists, so vim-style editing is now invoked through `edit` in `vim` mode
10
+ - Removed the `searchDb` field from session and extension tool contexts, so custom tools and extensions no longer receive a shared native search DB handle from `ToolSession`, `CustomToolContext`, `ExtensionContext`, and `CreateAgentSessionOptions`
11
+ - Changed the `vim` tool API to require either `open: "path"` or `kbd: [...]` per call and removed direct `line`/`col` cursor parameters from `open`, so callers must position the cursor via key sequences after opening
12
+ - Changed the `edit` schemas for patch, replace, hashline, and chunk modes from top-level request fields to `edits` array entries, requiring path/mode details on each edit and breaking callers that send legacy top-level `path`, `old_text`, `new_text`, `op`, `move`, or `delete` payloads
13
+
14
+ ### Added
15
+
16
+ - Added Vim ex aliases `:del`, `:ya`, `:co`, and `:mo` as shorthand for existing delete, yank, copy, and move commands
17
+ - Added support for additional Vim ex command aliases `:write`/`write!`, `:edit`/`edit!`, and `:update`/`:up` in command parsing
18
+ - Added support for vim `:global` and `:vglobal`/`/` variants as `:g/pattern/d` and `:v/pattern/d` parsing and execution
19
+ - Added support for extra Vim operations by treating `x`, `X`, `s`, `S`, `C`, and `D` as delete/change operator aliases
20
+ - Added support for new Vim motions `gE`/`ge`, `g_`, `g*`, `g#`, and `|`
21
+ - Added support for `C-f` and `C-b` page motions in vim mode
22
+ - Added `C-u` and `C-o` in vim insert mode to clear to line start and execute a one-off normal-mode command before returning to insert
23
+ - Added insert-mode visual operators `J`, `u`, `U`, `p`, and `P` to join lines, convert case, and replace the selected region with register content
24
+ - Added normal-mode line motions `+`, `-`, and `_` to move to line offsets at the first non-blank character
25
+ - Added `*` and `#` normal-mode commands to search forward or backward for the word under the cursor
26
+ - Added `gJ` to join a line range, `gv` to restore the last visual selection, and `ZZ`/`ZQ` shortcuts for save-and-exit or exit-without-save in vim mode
27
+ - Added paragraph text object `p` for `ip`/`ap`-style paragraph selection
28
+ - Added support for Vim ex line-address forms like `.`, `$`, `+N`/`-N`, destination addresses such as `:t$`, and ranged `:global` commands
29
+ - Added Vim ex `:join`/`:j` and `:join!`/`:j!` support to join addressed lines with or without whitespace normalization
30
+ - Added a warning when chunk edits write to the `~` selector with body lines that appear over-indented, instructing users to start top-level body text at column 0
31
+ - Added validation feedback for suspect indentation in chunk-mode `~` body writes so users can align content with the tool's automatic base indentation
32
+ - Added support for multi-file `edit` calls across replace, patch, hashline, and chunk modes by grouping `edits` entries by file path and returning combined per-file results
33
+ - Added per-edit `path` support in chunk entries so each operation can target explicit files when submitting mixed edits in a single request
34
+ - Added support for `computeHashlineDiff` to accept hashline edits with `loc` and `content` payloads without requiring pre-resolved `op` fields
35
+ - Added `/rename <title>` slash command to set an explicit session name, updating the session header and terminal tab title ([#658](https://github.com/can1357/oh-my-pi/issues/658))
36
+ - Added `session_name` status line segment: displays the session name in the status bar right side with a stable hash-derived accent color unique to each name; shown in all presets when a name is set
37
+
38
+ ### Changed
39
+
40
+ - Changed vim path normalization to accept colon-prefixed `path` values instead of rejecting them as Vim commands
41
+ - Changed default `providers.openaiWebsockets` setting to `off` when unset, so OpenAI websocket transport is now disabled unless explicitly enabled
42
+ - Changed Vim ex `:update`/`:up` execution to skip writing unchanged buffers and report buffer unchanged status
43
+ - Changed Vim page-scroll commands `C-f`, `C-b`, `C-u`, and `C-d` to move in viewport-height based increments instead of fixed constants
44
+ - Changed `z` command behavior so `zt`, `zb`, and `z.` now align cursor movement to first non-blank in the line
45
+ - Changed `:g`/`:v` global command handling to process matching lines safely by working in reverse order and preserving file structure
46
+ - Changed vim tab breadcrumb rendering from ` → ` to `→` in the editor view
47
+ - Changed custom tool and task execution contexts to no longer expose a shared `searchDb` accessor, removing direct access to native grep/glob/fuzzyFind search backends from extension callbacks
48
+ - Changed the `task` tool `schema` field to require JSON-encoded JTD schema text instead of a schema object, matching prompt guidance and task-subagent invocation
49
+ - Changed chunk edit payloads to encode selectors as `path: "file:selector"` and updated chunk tool guidance and examples to match
50
+ - Updated `edit` call/result rendering to show per-file diff sections and append a `(+N more)` hint when edits target multiple files
51
+ - Grouped chunk-mode `grep` results by directory, file, and chunk so directory searches now render as hierarchical sections (`#`/`##`) with per-chunk anchor lines
52
+ - Updated chunk-mode `grep` output to include match lines under their containing chunk entries with consistent line-number alignment based on file length
53
+ - Changed eager todo enforcement to only apply on the first user message of a conversation, skipping subsequent user turns that may correct, clarify, or redirect the prior task
54
+
55
+ ### Removed
56
+
57
+ - Removed live in-progress Vim tool previews during streaming call execution, so the TUI now shows only the last completed file viewport until the call finishes
58
+
59
+ ### Fixed
60
+
61
+ - Fixed vim-mode multi-step line edits by auto-reordering ascending line-positioned commands to descending order before execution
62
+ - Fixed Vim viewport rendering to display the inline highlighted cursor character and keep long cursor lines centered around the cursor in tool previews
63
+ - Fixed Vim `:global` command defaults to handle only supported subcommands and report unsupported ones explicitly
64
+ - Fixed Vim ex execution so parsed `:update`, `:yank`, and `:put` commands now run instead of falling through
65
+ - Fixed vim tool rendering so streamed calls preview the live target viewport and large insert payloads update incrementally instead of popping in all at once
66
+ - Fixed session event delivery so streaming `message_update`/tool-call previews reach the TUI immediately instead of waiting for extension handlers to finish
67
+ - Fixed HTML session export rendering so background-job wait calls render as `poll` instead of stale `await`, while still recognizing legacy exported sessions
68
+ - Fixed OpenRouter model resolution to accept dated routed selectors such as `openrouter/z-ai/glm-4.7-20251222:nitro`, inheriting metadata from the base catalog model when the exact variant is not listed yet
69
+ - Fixed pre-execution edit preview routing so replace/patch/hashline mode diffs are computed from the new structured edit entries
70
+ - Adjusted chunk/hashline/prompt guidance and validation to align with the refactored per-entry schema
71
+ - Fixed chunk streaming output detection to verify chunk edits with `chunkToolEditSchema`, preventing non-chunk edit payloads from being rendered as chunk diffs
72
+ - Fixed tool execution output to return the original `toolResult` text content from tools instead of sanitizing it before sending completion messages
73
+ - Fixed session accent rendering in the status line and editor to reset only foreground color (`\x1b[39m`) so applying a session color no longer clears other ANSI styles
74
+ - Session name sanitization: strip C0/C1 control characters (including ANSI ESC) from session names at storage time and in status line rendering, preventing escape sequence injection into TUI output
75
+ - Auto-generated session titles no longer overwrite a name set via `/rename`: `setSessionName` now tracks whether the name was set by the user or auto-generated and silently ignores auto titles once a user name is in place; terminal title follows the same guard
76
+ - Session accent border color now applied on session resume and after auto-title generation, not only after an explicit `/rename`
77
+ - Fixed retained Python kernel ownership so `AgentSession.dispose()` only shuts down kernels owned by that session, including warmup-created kernels
78
+
5
79
  ## [14.1.0] - 2026-04-11
6
80
  ### Added
7
81
 
@@ -85,6 +159,10 @@
85
159
  - Fixed stale diagnostics being reused after unrelated file publishes by clearing cached diagnostics before refreshing file state
86
160
  - Fixed Codex search to use streamed answer text when final answer is an image placeholder or empty
87
161
 
162
+ ### Fixed
163
+
164
+ - Fixed MCP config docs and schema to use `~/.omp/agent/mcp.json` for user-scoped OMP-native MCP config while keeping project config at `<cwd>/.omp/mcp.json`
165
+
88
166
  ## [14.0.4] - 2026-04-10
89
167
  ### Added
90
168
 
@@ -105,6 +183,7 @@
105
183
 
106
184
  - Fixed typo in system prompt: 'backwards compatibiltity' → 'backwards compatibility'
107
185
 
186
+
108
187
  ## [14.0.3] - 2026-04-09
109
188
 
110
189
  ### Fixed
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.1.0",
4
+ "version": "14.1.1",
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,19 +46,19 @@
46
46
  "dependencies": {
47
47
  "@agentclientprotocol/sdk": "0.16.1",
48
48
  "@mozilla/readability": "^0.6",
49
- "@oh-my-pi/omp-stats": "14.1.0",
50
- "@oh-my-pi/pi-agent-core": "14.1.0",
51
- "@oh-my-pi/pi-ai": "14.1.0",
52
- "@oh-my-pi/pi-natives": "14.1.0",
53
- "@oh-my-pi/pi-tui": "14.1.0",
54
- "@oh-my-pi/pi-utils": "14.1.0",
49
+ "@oh-my-pi/omp-stats": "workspace:*",
50
+ "@oh-my-pi/pi-agent-core": "workspace:*",
51
+ "@oh-my-pi/pi-ai": "workspace:*",
52
+ "@oh-my-pi/pi-natives": "workspace:*",
53
+ "@oh-my-pi/pi-tui": "workspace:*",
54
+ "@oh-my-pi/pi-utils": "workspace:*",
55
55
  "@sinclair/typebox": "^0.34",
56
56
  "@xterm/headless": "^6.0",
57
57
  "ajv": "^8.18",
58
58
  "chalk": "^5.6",
59
59
  "diff": "^8.0",
60
60
  "fflate": "0.8.2",
61
- "handlebars": "^4.7",
61
+ "handlebars": "^4.7.9",
62
62
  "linkedom": "^0.18",
63
63
  "lru-cache": "11.3.1",
64
64
  "markit-ai": "0.5.0",
@@ -1,4 +1,4 @@
1
- import { logger, Snowflake } from "@oh-my-pi/pi-utils";
1
+ import { logger } from "@oh-my-pi/pi-utils";
2
2
 
3
3
  const DELIVERY_RETRY_BASE_MS = 500;
4
4
  const DELIVERY_RETRY_MAX_MS = 30_000;
@@ -48,6 +48,7 @@ export class AsyncJobManager {
48
48
  readonly #jobs = new Map<string, AsyncJob>();
49
49
  readonly #deliveries: AsyncJobDelivery[] = [];
50
50
  readonly #suppressedDeliveries = new Set<string>();
51
+ readonly #watchedJobs = new Set<string>();
51
52
  readonly #evictionTimers = new Map<string, NodeJS.Timeout>();
52
53
  readonly #onJobComplete: AsyncJobManagerOptions["onJobComplete"];
53
54
  readonly #maxRunningJobs: number;
@@ -184,6 +185,25 @@ export class AsyncJobManager {
184
185
  return this.#deliveries.length > 0;
185
186
  }
186
187
 
188
+ watchJobs(jobIds: string[]): number {
189
+ const uniqueJobIds = Array.from(new Set(jobIds.map(id => id.trim()).filter(id => id.length > 0)));
190
+ for (const jobId of uniqueJobIds) {
191
+ this.#watchedJobs.add(jobId);
192
+ }
193
+ return uniqueJobIds.length;
194
+ }
195
+
196
+ unwatchJobs(jobIds: string[]): number {
197
+ const uniqueJobIds = Array.from(new Set(jobIds.map(id => id.trim()).filter(id => id.length > 0)));
198
+ let removed = 0;
199
+ for (const jobId of uniqueJobIds) {
200
+ if (this.#watchedJobs.delete(jobId)) {
201
+ removed += 1;
202
+ }
203
+ }
204
+ return removed;
205
+ }
206
+
187
207
  acknowledgeDeliveries(jobIds: string[]): number {
188
208
  const uniqueJobIds = Array.from(new Set(jobIds.map(id => id.trim()).filter(id => id.length > 0)));
189
209
  if (uniqueJobIds.length === 0) return 0;
@@ -196,7 +216,7 @@ export class AsyncJobManager {
196
216
  this.#deliveries.splice(
197
217
  0,
198
218
  this.#deliveries.length,
199
- ...this.#deliveries.filter(delivery => !this.#suppressedDeliveries.has(delivery.jobId)),
219
+ ...this.#deliveries.filter(delivery => !this.isDeliverySuppressed(delivery.jobId)),
200
220
  );
201
221
  return before - this.#deliveries.length;
202
222
  }
@@ -254,12 +274,21 @@ export class AsyncJobManager {
254
274
  this.#jobs.clear();
255
275
  this.#deliveries.length = 0;
256
276
  this.#suppressedDeliveries.clear();
277
+ this.#watchedJobs.clear();
257
278
  return drained;
258
279
  }
259
280
 
260
281
  #resolveJobId(preferredId?: string): string {
261
- if (!preferredId || preferredId.trim().length === 0) {
262
- return `bg_${Snowflake.next()}`;
282
+ preferredId = preferredId?.trim();
283
+ if (!preferredId) {
284
+ let candidate = 1;
285
+ while (true) {
286
+ const id = `bg_${candidate}`;
287
+ if (!this.#jobs.has(id)) {
288
+ return id;
289
+ }
290
+ candidate += 1;
291
+ }
263
292
  }
264
293
 
265
294
  const base = preferredId.trim();
@@ -278,6 +307,7 @@ export class AsyncJobManager {
278
307
  if (this.#retentionMs <= 0) {
279
308
  this.#jobs.delete(jobId);
280
309
  this.#suppressedDeliveries.delete(jobId);
310
+ this.#watchedJobs.delete(jobId);
281
311
  return;
282
312
  }
283
313
  const existing = this.#evictionTimers.get(jobId);
@@ -288,6 +318,7 @@ export class AsyncJobManager {
288
318
  this.#evictionTimers.delete(jobId);
289
319
  this.#jobs.delete(jobId);
290
320
  this.#suppressedDeliveries.delete(jobId);
321
+ this.#watchedJobs.delete(jobId);
291
322
  }, this.#retentionMs);
292
323
  timer.unref();
293
324
  this.#evictionTimers.set(jobId, timer);
@@ -300,12 +331,13 @@ export class AsyncJobManager {
300
331
  this.#evictionTimers.clear();
301
332
  }
302
333
 
303
- #isDeliverySuppressed(jobId: string): boolean {
304
- return this.#suppressedDeliveries.has(jobId);
334
+ isDeliverySuppressed(jobId: string): boolean {
335
+ return this.#suppressedDeliveries.has(jobId) || this.#watchedJobs.has(jobId);
305
336
  }
306
337
 
307
338
  #enqueueDelivery(jobId: string, text: string): void {
308
- if (this.#isDeliverySuppressed(jobId)) {
339
+ // Skip delivery if already acknowledged
340
+ if (this.isDeliverySuppressed(jobId)) {
309
341
  return;
310
342
  }
311
343
  this.#deliveries.push({
@@ -337,7 +369,7 @@ export class AsyncJobManager {
337
369
  async #runDeliveryLoop(): Promise<void> {
338
370
  while (this.#deliveries.length > 0) {
339
371
  const delivery = this.#deliveries[0];
340
- if (this.#isDeliverySuppressed(delivery.jobId)) {
372
+ if (this.isDeliverySuppressed(delivery.jobId)) {
341
373
  this.#deliveries.shift();
342
374
  continue;
343
375
  }
@@ -348,7 +380,8 @@ export class AsyncJobManager {
348
380
  if (this.#deliveries[0] !== delivery) {
349
381
  continue;
350
382
  }
351
- if (this.#isDeliverySuppressed(delivery.jobId)) {
383
+ // Check again after sleep
384
+ if (this.isDeliverySuppressed(delivery.jobId)) {
352
385
  this.#deliveries.shift();
353
386
  continue;
354
387
  }
@@ -361,7 +394,7 @@ export class AsyncJobManager {
361
394
  delivery.lastError = error instanceof Error ? error.message : String(error);
362
395
  delivery.nextAttemptAt = Date.now() + this.#getRetryDelay(delivery.attempt);
363
396
  this.#deliveries.shift();
364
- if (!this.#isDeliverySuppressed(delivery.jobId)) {
397
+ if (!this.isDeliverySuppressed(delivery.jobId)) {
365
398
  this.#deliveries.push(delivery);
366
399
  }
367
400
  logger.warn("Async job completion delivery failed", {
@@ -43,7 +43,6 @@ function buildToolSession(
43
43
  settings: options.settings,
44
44
  authStorage: options.authStorage,
45
45
  modelRegistry: options.modelRegistry,
46
- searchDb: ctx.searchDb,
47
46
  };
48
47
  }
49
48
 
@@ -79,7 +78,7 @@ export function createAnalyzeFileTool(options: {
79
78
  });
80
79
  const taskParams: TaskParams = {
81
80
  agent: "quick_task",
82
- schema: analyzeFileOutputSchema,
81
+ schema: JSON.stringify(analyzeFileOutputSchema),
83
82
  tasks,
84
83
  };
85
84
  return taskTool.execute(toolCallId, taskParams, signal, onUpdate);
@@ -2,7 +2,7 @@
2
2
  "$schema": "http://json-schema.org/draft-07/schema#",
3
3
  "$id": "https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/config/mcp-schema.json",
4
4
  "title": "OMP MCP configuration",
5
- "description": "Schema for mcp.json, .mcp.json, .omp/mcp.json, and ~/.omp/mcp.json used by the OMP coding agent.",
5
+ "description": "Schema for mcp.json, .mcp.json, .omp/mcp.json, and ~/.omp/agent/mcp.json used by the OMP coding agent.",
6
6
  "type": "object",
7
7
  "additionalProperties": false,
8
8
  "properties": {
@@ -51,6 +51,7 @@ const TRAILING_CANONICAL_MARKERS = [
51
51
  "xhigh",
52
52
  "free",
53
53
  "exacto",
54
+ "nitro",
54
55
  "original",
55
56
  "optimized",
56
57
  "nvfp4",
@@ -28,7 +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
+ import { parseModelString, resolveProviderModelReference } from "../config/model-resolver";
32
32
  import { isValidThemeColor, type ThemeColor } from "../modes/theme/theme";
33
33
  import type { AuthStorage, OAuthCredential } from "../session/auth-storage";
34
34
  import {
@@ -160,6 +160,7 @@ const OpenAICompatSchema = Type.Object({
160
160
  vercelGatewayRouting: Type.Optional(VercelGatewayRoutingSchema),
161
161
  extraBody: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
162
162
  supportsStrictMode: Type.Optional(Type.Boolean()),
163
+ toolStrictMode: Type.Optional(Type.Union([Type.Literal("all_strict"), Type.Literal("none")])),
163
164
  });
164
165
 
165
166
  const EffortSchema = Type.Union([
@@ -720,31 +721,6 @@ function finalizeCustomModel(model: CustomModelOverlay, options: CustomModelBuil
720
721
  } as Model<Api>);
721
722
  }
722
723
 
723
- function buildCustomModel(
724
- providerName: string,
725
- providerBaseUrl: string,
726
- providerApi: Api | undefined,
727
- providerHeaders: Record<string, string> | undefined,
728
- providerApiKey: string | undefined,
729
- authHeader: boolean | undefined,
730
- providerCompat: Model<Api>["compat"] | undefined,
731
- modelDef: CustomModelDefinitionLike,
732
- options: CustomModelBuildOptions,
733
- ): Model<Api> | undefined {
734
- const model = buildCustomModelOverlay(
735
- providerName,
736
- providerBaseUrl,
737
- providerApi,
738
- providerHeaders,
739
- providerApiKey,
740
- authHeader,
741
- providerCompat,
742
- modelDef,
743
- );
744
- if (!model) return undefined;
745
- return finalizeCustomModel(model, options);
746
- }
747
-
748
724
  function normalizeSuppressedSelector(selector: string): string {
749
725
  const trimmed = selector.trim();
750
726
  if (!trimmed) return trimmed;
@@ -790,6 +766,12 @@ export class ModelRegistry {
790
766
  #suppressedSelectors: Map<string, number> = new Map();
791
767
  #backgroundRefresh?: Promise<void>;
792
768
  #lastDiscoveryWarnings: Map<string, string> = new Map();
769
+ // Runtime extension model overlays — persist across refresh() cycles so that
770
+ // models registered by extensions survive the model selector's offline reload.
771
+ #runtimeModelOverlays: CustomModelOverlay[] = [];
772
+ #runtimeProviderApiKeys: Map<string, string> = new Map();
773
+ #runtimeProvidersBySource: Map<string, Set<string>> = new Map();
774
+ #runtimeProviderSourceByName: Map<string, string> = new Map();
793
775
 
794
776
  /**
795
777
  * @param authStorage - Auth storage for API key resolution
@@ -854,6 +836,11 @@ export class ModelRegistry {
854
836
  this.#customProviderApiKeys.clear();
855
837
  this.#keylessProviders.clear();
856
838
  this.#discoverableProviders = [];
839
+ // Restore runtime API keys before #loadModels — survives because
840
+ // #loadModels only calls .set() on #customProviderApiKeys, never reassigns it.
841
+ for (const [k, v] of this.#runtimeProviderApiKeys) {
842
+ this.#customProviderApiKeys.set(k, v);
843
+ }
857
844
  this.#providerOverrides.clear();
858
845
  this.#modelOverrides.clear();
859
846
  this.#equivalenceConfig = undefined;
@@ -893,7 +880,9 @@ export class ModelRegistry {
893
880
  const builtInModels = this.#applyHardcodedModelPolicies(this.#loadBuiltInModels(overrides));
894
881
  const cachedDiscoveries = this.#applyHardcodedModelPolicies(this.#loadCachedDiscoverableModels());
895
882
  const resolvedDefaults = this.#mergeResolvedModels(builtInModels, cachedDiscoveries);
896
- const combined = this.#mergeCustomModels(resolvedDefaults, this.#customModelOverlays);
883
+ const withConfigModels = this.#mergeCustomModels(resolvedDefaults, this.#customModelOverlays);
884
+ // Merge runtime extension models so they survive refresh() cycles
885
+ const combined = this.#mergeCustomModels(withConfigModels, this.#runtimeModelOverlays);
897
886
 
898
887
  this.#models = this.#applyModelOverrides(combined, this.#modelOverrides);
899
888
  this.#rebuildCanonicalIndex();
@@ -1183,7 +1172,9 @@ export class ModelRegistry {
1183
1172
  }),
1184
1173
  );
1185
1174
  const resolved = this.#mergeResolvedModels(this.#models, discoveredModels);
1186
- const combined = this.#mergeCustomModels(resolved, this.#customModelOverlays);
1175
+ const withConfigModels = this.#mergeCustomModels(resolved, this.#customModelOverlays);
1176
+ // Merge runtime extension models so they survive online discovery completion
1177
+ const combined = this.#mergeCustomModels(withConfigModels, this.#runtimeModelOverlays);
1187
1178
  this.#models = this.#applyModelOverrides(combined, this.#modelOverrides);
1188
1179
  this.#rebuildCanonicalIndex();
1189
1180
  }
@@ -1881,7 +1872,7 @@ export class ModelRegistry {
1881
1872
  * Find a model by provider and ID.
1882
1873
  */
1883
1874
  find(provider: string, modelId: string): Model<Api> | undefined {
1884
- return this.#models.find(m => m.provider === provider && m.id === modelId);
1875
+ return resolveProviderModelReference(provider, modelId, this.#models);
1885
1876
  }
1886
1877
 
1887
1878
  /**
@@ -1931,6 +1922,21 @@ export class ModelRegistry {
1931
1922
  clearSourceRegistrations(sourceId: string): void {
1932
1923
  unregisterCustomApis(sourceId);
1933
1924
  unregisterOAuthProviders(sourceId);
1925
+ const sourceProviders = this.#runtimeProvidersBySource.get(sourceId);
1926
+ if (!sourceProviders || sourceProviders.size === 0) {
1927
+ return;
1928
+ }
1929
+ this.#runtimeProvidersBySource.delete(sourceId);
1930
+ for (const providerName of sourceProviders) {
1931
+ if (this.#runtimeProviderSourceByName.get(providerName) !== sourceId) {
1932
+ continue;
1933
+ }
1934
+ this.#runtimeProviderSourceByName.delete(providerName);
1935
+ this.#runtimeProviderApiKeys.delete(providerName);
1936
+ this.#runtimeModelOverlays = this.#runtimeModelOverlays.filter(overlay => overlay.provider !== providerName);
1937
+ }
1938
+ this.#reloadStaticModels();
1939
+ this.#rebuildCanonicalIndex();
1934
1940
  }
1935
1941
 
1936
1942
  /**
@@ -1989,15 +1995,30 @@ export class ModelRegistry {
1989
1995
 
1990
1996
  if (sourceId) {
1991
1997
  this.#registeredProviderSources.add(sourceId);
1998
+ const previousSourceId = this.#runtimeProviderSourceByName.get(providerName);
1999
+ if (previousSourceId && previousSourceId !== sourceId) {
2000
+ const previousProviders = this.#runtimeProvidersBySource.get(previousSourceId);
2001
+ previousProviders?.delete(providerName);
2002
+ if (previousProviders && previousProviders.size === 0) {
2003
+ this.#runtimeProvidersBySource.delete(previousSourceId);
2004
+ }
2005
+ }
2006
+ const sourceProviders = this.#runtimeProvidersBySource.get(sourceId) ?? new Set<string>();
2007
+ sourceProviders.add(providerName);
2008
+ this.#runtimeProvidersBySource.set(sourceId, sourceProviders);
2009
+ this.#runtimeProviderSourceByName.set(providerName, sourceId);
1992
2010
  }
1993
2011
  if (config.apiKey) {
1994
2012
  this.#customProviderApiKeys.set(providerName, config.apiKey);
2013
+ // Persist runtime API keys so they survive #reloadStaticModels() cycles
2014
+ this.#runtimeProviderApiKeys.set(providerName, config.apiKey);
1995
2015
  }
1996
2016
 
1997
2017
  if (config.models && config.models.length > 0) {
1998
- const nextModels = this.#models.filter(m => m.provider !== providerName);
2018
+ // Build model overlays that persist across refresh() cycles
2019
+ const newOverlays: CustomModelOverlay[] = [];
1999
2020
  for (const modelDef of config.models) {
2000
- const model = buildCustomModel(
2021
+ const overlay = buildCustomModelOverlay(
2001
2022
  providerName,
2002
2023
  config.baseUrl!,
2003
2024
  config.api,
@@ -2006,12 +2027,20 @@ export class ModelRegistry {
2006
2027
  config.authHeader,
2007
2028
  config.compat,
2008
2029
  modelDef as CustomModelDefinitionLike,
2009
- { useDefaults: true },
2010
2030
  );
2011
- if (!model) {
2031
+ if (!overlay) {
2012
2032
  throw new Error(`Provider ${providerName}, model ${modelDef.id}: no "api" specified.`);
2013
2033
  }
2014
- nextModels.push(model);
2034
+ newOverlays.push(overlay);
2035
+ }
2036
+ // Store as runtime overlays so they survive #reloadStaticModels()
2037
+ this.#runtimeModelOverlays = this.#runtimeModelOverlays.filter(m => m.provider !== providerName);
2038
+ this.#runtimeModelOverlays.push(...newOverlays);
2039
+
2040
+ // Also update #models immediately for the current cycle
2041
+ const nextModels = this.#models.filter(m => m.provider !== providerName);
2042
+ for (const overlay of newOverlays) {
2043
+ nextModels.push(finalizeCustomModel(overlay, { useDefaults: true }));
2015
2044
  }
2016
2045
 
2017
2046
  if (config.oauth?.modifyModels) {
@@ -62,6 +62,101 @@ export function formatModelSelectorValue(selector: string, thinkingLevel: Thinki
62
62
  return thinkingLevel && thinkingLevel !== ThinkingLevel.Inherit ? `${selector}:${thinkingLevel}` : selector;
63
63
  }
64
64
 
65
+ function getOpenRouterRouteSuffix(modelId: string): { baseId: string; suffix: string } | undefined {
66
+ const colonIdx = modelId.lastIndexOf(":");
67
+ if (colonIdx === -1) {
68
+ return undefined;
69
+ }
70
+
71
+ const suffix = modelId.slice(colonIdx + 1).trim();
72
+ if (!suffix || parseThinkingLevel(suffix)) {
73
+ return undefined;
74
+ }
75
+
76
+ return { baseId: modelId.slice(0, colonIdx), suffix };
77
+ }
78
+
79
+ function stripOpenRouterDateSuffix(modelId: string): string | undefined {
80
+ const stripped = modelId.replace(/-\d{8}(?=$|:)/i, "");
81
+ return stripped !== modelId ? stripped : undefined;
82
+ }
83
+
84
+ function getOpenRouterFallbackModelIds(modelId: string): string[] {
85
+ const orderedCandidates: string[] = [];
86
+ const queue = [modelId];
87
+ const seen = new Set<string>();
88
+
89
+ while (queue.length > 0) {
90
+ const candidate = queue.shift();
91
+ if (!candidate || seen.has(candidate)) {
92
+ continue;
93
+ }
94
+ seen.add(candidate);
95
+ orderedCandidates.push(candidate);
96
+
97
+ const routedSuffix = getOpenRouterRouteSuffix(candidate);
98
+ if (routedSuffix) {
99
+ queue.push(routedSuffix.baseId);
100
+ }
101
+
102
+ const strippedDate = stripOpenRouterDateSuffix(candidate);
103
+ if (strippedDate) {
104
+ queue.push(strippedDate);
105
+ }
106
+ }
107
+
108
+ return orderedCandidates;
109
+ }
110
+
111
+ function cloneModelWithRequestedId(model: Model<Api>, requestedId: string): Model<Api> {
112
+ return {
113
+ ...model,
114
+ id: requestedId,
115
+ ...(model.name === model.id ? { name: requestedId } : {}),
116
+ };
117
+ }
118
+
119
+ export function resolveProviderModelReference(
120
+ provider: string,
121
+ modelId: string,
122
+ availableModels: readonly Model<Api>[],
123
+ ): Model<Api> | undefined {
124
+ const normalizedProvider = provider.trim().toLowerCase();
125
+ const normalizedModelId = modelId.trim().toLowerCase();
126
+ if (!normalizedProvider || !normalizedModelId) {
127
+ return undefined;
128
+ }
129
+
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];
135
+ }
136
+ if (exactMatches.length > 1) {
137
+ return undefined;
138
+ }
139
+
140
+ if (normalizedProvider !== "openrouter") {
141
+ return undefined;
142
+ }
143
+
144
+ 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) {
153
+ return undefined;
154
+ }
155
+ }
156
+
157
+ return undefined;
158
+ }
159
+
65
160
  export interface ModelMatchPreferences {
66
161
  /** Most-recently-used model keys (provider/modelId) to prefer when ambiguous. */
67
162
  usageOrder?: string[];
@@ -171,17 +266,7 @@ export function findExactModelReferenceMatch(
171
266
  const provider = trimmedReference.substring(0, slashIndex).trim();
172
267
  const modelId = trimmedReference.substring(slashIndex + 1).trim();
173
268
  if (provider && modelId) {
174
- const providerMatches = availableModels.filter(
175
- model =>
176
- model.provider.toLowerCase() === provider.toLowerCase() &&
177
- model.id.toLowerCase() === modelId.toLowerCase(),
178
- );
179
- if (providerMatches.length === 1) {
180
- return providerMatches[0];
181
- }
182
- if (providerMatches.length > 1) {
183
- return undefined;
184
- }
269
+ return resolveProviderModelReference(provider, modelId, availableModels);
185
270
  }
186
271
  }
187
272
  return undefined;
@@ -853,10 +938,8 @@ export function resolveCliModel(options: {
853
938
  let exact: (typeof availableModels)[number] | undefined;
854
939
  if (slashIdx !== -1) {
855
940
  const prefix = lower.substring(0, slashIdx);
856
- const suffix = lower.substring(slashIdx + 1);
857
- exact = availableModels.find(
858
- model => model.provider.toLowerCase() === prefix && model.id.toLowerCase() === suffix,
859
- );
941
+ const suffix = trimmedModel.substring(slashIdx + 1);
942
+ exact = resolveProviderModelReference(prefix, suffix, availableModels);
860
943
  }
861
944
  if (!exact && !trimmedModel.includes(":")) {
862
945
  const canonicalMatch = modelRegistry.resolveCanonicalModel?.(trimmedModel, { availableOnly: false });
@@ -905,6 +988,19 @@ export function resolveCliModel(options: {
905
988
  }
906
989
  }
907
990
 
991
+ if (provider) {
992
+ const exactProviderMatch = resolveProviderModelReference(provider, pattern, availableModels);
993
+ if (exactProviderMatch) {
994
+ return {
995
+ model: exactProviderMatch,
996
+ selector: formatModelString(exactProviderMatch),
997
+ warning: undefined,
998
+ thinkingLevel: undefined,
999
+ error: undefined,
1000
+ };
1001
+ }
1002
+ }
1003
+
908
1004
  const candidates = provider ? availableModels.filter(model => model.provider === provider) : availableModels;
909
1005
  const { model, thinkingLevel, warning } = parseModelPattern(pattern, candidates, preferences, {
910
1006
  allowInvalidThinkingSelectorFallback: false,
@@ -74,7 +74,8 @@ export type StatusLineSegmentId =
74
74
  | "session"
75
75
  | "hostname"
76
76
  | "cache_read"
77
- | "cache_write";
77
+ | "cache_write"
78
+ | "session_name";
78
79
 
79
80
  interface UiMetadata {
80
81
  tab: SettingTab;
@@ -951,12 +952,12 @@ export const SETTINGS_SCHEMA = {
951
952
  // Edit tool
952
953
  "edit.mode": {
953
954
  type: "enum",
954
- values: ["replace", "patch", "hashline", "chunk"] as const,
955
+ values: ["replace", "patch", "hashline", "chunk", "vim"] as const,
955
956
  default: "hashline",
956
957
  ui: {
957
958
  tab: "editing",
958
959
  label: "Edit Mode",
959
- description: "Select the edit tool variant (replace, patch, hashline, or chunk)",
960
+ description: "Select the edit tool variant (replace, patch, hashline, chunk, or vim)",
960
961
  },
961
962
  },
962
963
 
@@ -326,7 +326,7 @@ export class Settings {
326
326
 
327
327
  /**
328
328
  * Get the edit variant for a specific model.
329
- * Returns "patch", "replace", "hashline", "chunk", or null (use global default).
329
+ * Returns "patch", "replace", "hashline", "chunk", "vim", or null (use global default).
330
330
  */
331
331
  getEditVariantForModel(model: string | undefined): EditMode | null {
332
332
  if (!model) return null;