@oh-my-pi/pi-coding-agent 15.10.4 → 15.10.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/dist/types/capability/rule-buckets.d.ts +1 -1
  3. package/dist/types/capability/rule.d.ts +6 -1
  4. package/dist/types/cli/update-cli.d.ts +11 -1
  5. package/dist/types/config/model-registry.d.ts +18 -1
  6. package/dist/types/discovery/at-imports.d.ts +15 -0
  7. package/dist/types/edit/diff.d.ts +3 -2
  8. package/dist/types/eval/__tests__/helpers-local-roots.test.d.ts +1 -0
  9. package/dist/types/eval/backend.d.ts +7 -0
  10. package/dist/types/eval/js/context-manager.d.ts +1 -0
  11. package/dist/types/eval/js/executor.d.ts +2 -0
  12. package/dist/types/eval/js/index.d.ts +1 -1
  13. package/dist/types/eval/js/shared/helpers.d.ts +6 -0
  14. package/dist/types/eval/js/shared/runtime.d.ts +5 -0
  15. package/dist/types/eval/js/worker-protocol.d.ts +6 -0
  16. package/dist/types/eval/py/executor.d.ts +7 -0
  17. package/dist/types/eval/py/index.d.ts +1 -1
  18. package/dist/types/exa/index.d.ts +1 -19
  19. package/dist/types/exa/mcp-client.d.ts +10 -3
  20. package/dist/types/exa/types.d.ts +0 -83
  21. package/dist/types/export/ttsr.d.ts +14 -0
  22. package/dist/types/extensibility/extensions/types.d.ts +8 -1
  23. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +1 -1
  24. package/dist/types/internal-urls/local-protocol.d.ts +10 -0
  25. package/dist/types/mcp/oauth-flow.d.ts +2 -2
  26. package/dist/types/modes/components/custom-editor.d.ts +3 -0
  27. package/dist/types/modes/components/{status-line.d.ts → status-line/component.d.ts} +2 -32
  28. package/dist/types/modes/components/status-line/index.d.ts +1 -0
  29. package/dist/types/modes/components/status-line/types.d.ts +31 -2
  30. package/dist/types/modes/controllers/mcp-command-controller.d.ts +8 -0
  31. package/dist/types/modes/image-references.d.ts +8 -3
  32. package/dist/types/modes/interactive-mode.d.ts +9 -1
  33. package/dist/types/modes/theme/theme.d.ts +2 -1
  34. package/dist/types/modes/types.d.ts +3 -1
  35. package/dist/types/modes/utils/ui-helpers.d.ts +2 -2
  36. package/dist/types/session/agent-session.d.ts +0 -2
  37. package/dist/types/task/render.d.ts +1 -0
  38. package/dist/types/tools/ask.d.ts +1 -0
  39. package/dist/types/tools/browser/tab-worker.d.ts +15 -0
  40. package/dist/types/tools/index.d.ts +17 -2
  41. package/dist/types/tools/render-utils.d.ts +1 -1
  42. package/dist/types/tools/tool-timeouts.d.ts +1 -1
  43. package/dist/types/utils/block-context.d.ts +35 -0
  44. package/dist/types/utils/git.d.ts +6 -0
  45. package/dist/types/utils/image-loading.d.ts +12 -0
  46. package/package.json +29 -9
  47. package/src/capability/rule-buckets.ts +4 -2
  48. package/src/capability/rule.ts +10 -1
  49. package/src/cli/auth-broker-cli.ts +6 -7
  50. package/src/cli/auth-gateway-cli.ts +4 -3
  51. package/src/cli/list-models.ts +5 -0
  52. package/src/cli/update-cli.ts +138 -16
  53. package/src/commit/agentic/tools/split-commit.ts +8 -1
  54. package/src/config/model-provider-priority.ts +1 -0
  55. package/src/config/model-registry.ts +81 -2
  56. package/src/debug/index.ts +4 -8
  57. package/src/discovery/at-imports.ts +273 -0
  58. package/src/discovery/builtin-rules/index.ts +4 -0
  59. package/src/discovery/builtin-rules/ts-no-test-timers.md +55 -0
  60. package/src/discovery/builtin-rules/ts-redundant-clear-guard.md +75 -0
  61. package/src/discovery/helpers.ts +2 -1
  62. package/src/edit/diff.ts +114 -4
  63. package/src/edit/hashline/diff.ts +1 -1
  64. package/src/edit/hashline/execute.ts +1 -1
  65. package/src/edit/modes/patch.ts +6 -2
  66. package/src/edit/modes/replace.ts +1 -1
  67. package/src/edit/renderer.ts +12 -2
  68. package/src/eval/__tests__/helpers-local-roots.test.ts +58 -0
  69. package/src/eval/backend.ts +15 -0
  70. package/src/eval/js/context-manager.ts +4 -2
  71. package/src/eval/js/executor.ts +3 -0
  72. package/src/eval/js/index.ts +7 -1
  73. package/src/eval/js/shared/helpers.ts +53 -6
  74. package/src/eval/js/shared/runtime.ts +8 -0
  75. package/src/eval/js/worker-core.ts +1 -0
  76. package/src/eval/js/worker-protocol.ts +6 -0
  77. package/src/eval/py/executor.ts +12 -0
  78. package/src/eval/py/index.ts +7 -1
  79. package/src/eval/py/prelude.py +43 -4
  80. package/src/eval/py/runner.py +1 -0
  81. package/src/exa/index.ts +1 -26
  82. package/src/exa/mcp-client.ts +10 -10
  83. package/src/exa/types.ts +0 -97
  84. package/src/export/ttsr.ts +122 -1
  85. package/src/extensibility/extensions/types.ts +8 -1
  86. package/src/extensibility/legacy-pi-ai-shim.ts +1 -1
  87. package/src/extensibility/plugins/doctor.ts +1 -1
  88. package/src/extensibility/plugins/legacy-pi-compat.ts +6 -5
  89. package/src/goals/tools/goal-tool.ts +1 -1
  90. package/src/internal-urls/docs-index.generated.ts +7 -6
  91. package/src/internal-urls/local-protocol.ts +13 -0
  92. package/src/lsp/render.ts +8 -6
  93. package/src/mcp/oauth-flow.ts +3 -3
  94. package/src/mcp/render.ts +7 -1
  95. package/src/modes/components/agent-dashboard.ts +6 -4
  96. package/src/modes/components/custom-editor.ts +12 -6
  97. package/src/modes/components/login-dialog.ts +1 -1
  98. package/src/modes/components/oauth-selector.ts +4 -4
  99. package/src/modes/components/read-tool-group.ts +10 -3
  100. package/src/modes/components/{status-line.ts → status-line/component.ts} +18 -40
  101. package/src/modes/components/status-line/index.ts +1 -0
  102. package/src/modes/components/status-line/types.ts +23 -8
  103. package/src/modes/components/tool-execution.ts +1 -1
  104. package/src/modes/components/transcript-container.ts +17 -10
  105. package/src/modes/components/user-message.ts +6 -3
  106. package/src/modes/components/welcome.ts +1 -1
  107. package/src/modes/controllers/event-controller.ts +8 -0
  108. package/src/modes/controllers/extension-ui-controller.ts +143 -127
  109. package/src/modes/controllers/input-controller.ts +60 -11
  110. package/src/modes/controllers/mcp-command-controller.ts +52 -17
  111. package/src/modes/controllers/selector-controller.ts +4 -11
  112. package/src/modes/controllers/ssh-command-controller.ts +2 -2
  113. package/src/modes/image-references.ts +13 -7
  114. package/src/modes/interactive-mode.ts +35 -3
  115. package/src/modes/rpc/rpc-mode.ts +1 -1
  116. package/src/modes/setup-wizard/scenes/sign-in.ts +3 -11
  117. package/src/modes/theme/theme.ts +95 -1
  118. package/src/modes/types.ts +3 -1
  119. package/src/modes/utils/ui-helpers.ts +14 -5
  120. package/src/prompts/tools/bash.md +1 -1
  121. package/src/prompts/tools/eval.md +4 -4
  122. package/src/sdk.ts +31 -14
  123. package/src/session/agent-session.ts +290 -196
  124. package/src/session/session-manager.ts +1 -1
  125. package/src/slash-commands/builtin-registry.ts +9 -1
  126. package/src/system-prompt.ts +15 -9
  127. package/src/task/index.ts +9 -1
  128. package/src/task/render.ts +36 -14
  129. package/src/tools/ask.ts +14 -5
  130. package/src/tools/bash-interactive.ts +1 -1
  131. package/src/tools/bash.ts +14 -2
  132. package/src/tools/browser/render.ts +5 -2
  133. package/src/tools/browser/tab-worker.ts +211 -91
  134. package/src/tools/debug.ts +5 -2
  135. package/src/tools/eval-render.ts +6 -3
  136. package/src/tools/eval.ts +1 -1
  137. package/src/tools/gh-renderer.ts +29 -15
  138. package/src/tools/index.ts +32 -4
  139. package/src/tools/inspect-image-renderer.ts +12 -5
  140. package/src/tools/job.ts +9 -6
  141. package/src/tools/memory-render.ts +19 -5
  142. package/src/tools/read.ts +165 -18
  143. package/src/tools/render-utils.ts +3 -1
  144. package/src/tools/resolve.ts +1 -1
  145. package/src/tools/review.ts +1 -1
  146. package/src/tools/ssh.ts +4 -1
  147. package/src/tools/todo.ts +8 -1
  148. package/src/tools/tool-timeouts.ts +1 -1
  149. package/src/tools/write.ts +1 -1
  150. package/src/tui/code-cell.ts +1 -1
  151. package/src/utils/block-context.ts +312 -0
  152. package/src/utils/git.ts +41 -0
  153. package/src/utils/image-loading.ts +31 -1
  154. package/src/web/search/providers/codex.ts +1 -1
  155. package/src/web/search/render.ts +14 -6
  156. package/dist/types/exa/factory.d.ts +0 -13
  157. package/dist/types/exa/render.d.ts +0 -19
  158. package/dist/types/exa/researcher.d.ts +0 -9
  159. package/dist/types/exa/search.d.ts +0 -9
  160. package/dist/types/exa/websets.d.ts +0 -9
  161. package/src/exa/factory.ts +0 -60
  162. package/src/exa/render.ts +0 -244
  163. package/src/exa/researcher.ts +0 -36
  164. package/src/exa/search.ts +0 -47
  165. package/src/exa/websets.ts +0 -248
@@ -2,9 +2,10 @@
2
2
  * Update CLI command handler.
3
3
  *
4
4
  * Handles `omp update` to check for and install updates.
5
- * Uses bun if available, otherwise downloads binary from GitHub releases.
5
+ * Uses the installer that owns the active omp executable when it can be detected.
6
6
  */
7
7
  import * as fs from "node:fs";
8
+ import * as os from "node:os";
8
9
  import * as path from "node:path";
9
10
  import { pipeline } from "node:stream/promises";
10
11
  import { $which, APP_NAME, isEnoent, VERSION } from "@oh-my-pi/pi-utils";
@@ -14,6 +15,8 @@ import { theme } from "../modes/theme/theme";
14
15
 
15
16
  const REPO = "can1357/oh-my-pi";
16
17
  const PACKAGE = "@oh-my-pi/pi-coding-agent";
18
+ const HOMEBREW_FORMULA = "can1357/tap/omp";
19
+ const MISE_TOOL = "github:can1357/oh-my-pi";
17
20
  /**
18
21
  * Official npm registry origin.
19
22
  *
@@ -102,6 +105,46 @@ async function getBunGlobalBinDir(): Promise<string | undefined> {
102
105
  }
103
106
  }
104
107
 
108
+ async function getHomebrewFormulaPrefix(): Promise<string | undefined> {
109
+ if (!$which("brew")) return undefined;
110
+ for (const formula of [HOMEBREW_FORMULA, APP_NAME]) {
111
+ try {
112
+ const result = await $`brew --prefix ${formula}`.quiet().nothrow();
113
+ if (result.exitCode !== 0) continue;
114
+ const output = result.text().trim();
115
+ if (output.length > 0) return output;
116
+ } catch {}
117
+ }
118
+ return undefined;
119
+ }
120
+
121
+ async function getMiseBinDirs(): Promise<string[]> {
122
+ if (!$which("mise")) return [];
123
+ try {
124
+ const result = await $`mise bin-paths ${MISE_TOOL}`.quiet().nothrow();
125
+ if (result.exitCode !== 0) return [];
126
+ return result
127
+ .text()
128
+ .split(/\r?\n/)
129
+ .map(line => line.trim())
130
+ .filter(line => line.length > 0);
131
+ } catch {
132
+ return [];
133
+ }
134
+ }
135
+
136
+ function getMiseDataDir(): string {
137
+ const override = process.env.MISE_DATA_DIR;
138
+ if (override && override.length > 0) return override;
139
+ if (process.platform === "win32") {
140
+ const localAppData = process.env.LOCALAPPDATA;
141
+ if (localAppData && localAppData.length > 0) return path.join(localAppData, "mise");
142
+ }
143
+ const xdgDataHome = process.env.XDG_DATA_HOME;
144
+ if (xdgDataHome && xdgDataHome.length > 0) return path.join(xdgDataHome, "mise");
145
+ return path.join(os.homedir(), ".local", "share", "mise");
146
+ }
147
+
105
148
  function normalizePathForComparison(filePath: string): string {
106
149
  const normalized = path.normalize(filePath);
107
150
  if (process.platform === "win32") return normalized.toLowerCase();
@@ -129,34 +172,61 @@ function isPathInDirectory(filePath: string, directoryPath: string): boolean {
129
172
  // is a junction when Bun is installed via Scoop, so `bun pm bin -g` and the
130
173
  // PATH-resolved omp path can refer to the same directory through different
131
174
  // strings. path.resolve does not traverse junctions/symlinks; realpath does.
132
- // Resolve the file's parent directory to tolerate the file itself not yet
133
- // existing (e.g. a fresh install path) while still catching link-traversed
134
- // equality once the directory exists.
135
- const fileDir = tryRealpath(path.dirname(path.resolve(filePath)));
175
+ // Resolve both the file and its parent directory: the file catches manager
176
+ // links like Homebrew's `bin/omp -> Cellar/.../bin/omp`; the parent fallback
177
+ // still tolerates fresh install paths where the file does not exist yet.
136
178
  const dirReal = tryRealpath(path.resolve(directoryPath));
137
- if (!fileDir || !dirReal) return false;
179
+ if (!dirReal) return false;
180
+ const fileReal = tryRealpath(path.resolve(filePath));
181
+ if (fileReal && isPathInDirectoryLexical(fileReal, dirReal)) return true;
182
+ const fileDir = tryRealpath(path.dirname(path.resolve(filePath)));
183
+ if (!fileDir) return false;
138
184
  const resolvedFile = path.join(fileDir, path.basename(filePath));
139
185
  return isPathInDirectoryLexical(resolvedFile, dirReal);
140
186
  }
141
187
 
142
- type UpdateTarget = { method: "bun" } | { method: "binary"; path: string };
188
+ type UpdateMethod = "brew" | "mise" | "bun" | "binary";
189
+
190
+ interface UpdateMethodResolutionOptions {
191
+ homebrewPrefix?: string;
192
+ miseBinDirs?: readonly string[];
193
+ miseDataDir?: string;
194
+ }
143
195
 
144
- function resolveUpdateMethod(ompPath: string, bunBinDir: string | undefined): "bun" | "binary" {
145
- if (!bunBinDir) return "binary";
146
- return isPathInDirectory(ompPath, bunBinDir) ? "bun" : "binary";
196
+ type UpdateTarget = { method: "brew" } | { method: "mise" } | { method: "bun" } | { method: "binary"; path: string };
197
+
198
+ function resolveUpdateMethod(
199
+ ompPath: string,
200
+ bunBinDir: string | undefined,
201
+ options: UpdateMethodResolutionOptions = {},
202
+ ): UpdateMethod {
203
+ const { homebrewPrefix, miseBinDirs = [], miseDataDir } = options;
204
+ if (homebrewPrefix && isPathInDirectory(ompPath, path.join(homebrewPrefix, "bin"))) return "brew";
205
+ if (miseBinDirs.some(dir => isPathInDirectory(ompPath, dir))) return "mise";
206
+ if (miseDataDir && isPathInDirectory(ompPath, path.join(miseDataDir, "shims"))) return "mise";
207
+ if (bunBinDir && isPathInDirectory(ompPath, bunBinDir)) return "bun";
208
+ return "binary";
147
209
  }
148
210
 
149
- export function resolveUpdateMethodForTest(ompPath: string, bunBinDir: string | undefined): "bun" | "binary" {
150
- return resolveUpdateMethod(ompPath, bunBinDir);
211
+ export function resolveUpdateMethodForTest(
212
+ ompPath: string,
213
+ bunBinDir: string | undefined,
214
+ options: UpdateMethodResolutionOptions = {},
215
+ ): UpdateMethod {
216
+ return resolveUpdateMethod(ompPath, bunBinDir, options);
151
217
  }
152
218
  async function resolveUpdateTarget(): Promise<UpdateTarget> {
153
219
  const bunBinDir = await getBunGlobalBinDir();
220
+ const homebrewPrefix = await getHomebrewFormulaPrefix();
221
+ const miseAvailable = $which("mise") !== undefined;
222
+ const miseBinDirs = miseAvailable ? await getMiseBinDirs() : [];
223
+ const miseDataDir = miseAvailable ? getMiseDataDir() : undefined;
154
224
  const ompPath = resolveOmpPath();
155
225
 
156
226
  if (ompPath) {
157
- const method = resolveUpdateMethod(ompPath, bunBinDir);
158
- if (method === "bun") return { method };
159
- return { method, path: ompPath };
227
+ const method = resolveUpdateMethod(ompPath, bunBinDir, { homebrewPrefix, miseBinDirs, miseDataDir });
228
+ if (method === "binary") return { method, path: ompPath };
229
+ return { method };
160
230
  }
161
231
 
162
232
  if (bunBinDir) return { method: "bun" };
@@ -376,6 +446,18 @@ export function buildBunInstallArgs(expectedVersion: string, nativeTag: string =
376
446
  return args;
377
447
  }
378
448
 
449
+ export function buildHomebrewUpdateArgs(force: boolean): string[] {
450
+ return [force ? "reinstall" : "upgrade", HOMEBREW_FORMULA];
451
+ }
452
+
453
+ export function buildMiseUpgradeArgs(): string[] {
454
+ return ["upgrade", MISE_TOOL, "--bump"];
455
+ }
456
+
457
+ export function buildMiseForceInstallArgs(expectedVersion: string): string[] {
458
+ return ["install", "--force", `${MISE_TOOL}@${expectedVersion}`];
459
+ }
460
+
379
461
  /**
380
462
  * Update via bun package manager.
381
463
  */
@@ -390,6 +472,42 @@ async function updateViaBun(expectedVersion: string): Promise<void> {
390
472
  await printVerification(expectedVersion);
391
473
  }
392
474
 
475
+ async function updateViaHomebrew(expectedVersion: string, force: boolean): Promise<void> {
476
+ console.log(chalk.dim("Updating Homebrew formulae..."));
477
+ const update = await $`brew update`.nothrow();
478
+ if (update.exitCode !== 0) {
479
+ throw new Error(`brew update failed with exit code ${update.exitCode}`);
480
+ }
481
+
482
+ console.log(chalk.dim("Updating via Homebrew..."));
483
+ const args = buildHomebrewUpdateArgs(force);
484
+ const result = await $`brew ${args}`.nothrow();
485
+ if (result.exitCode !== 0) {
486
+ throw new Error(`brew ${args[0]} failed with exit code ${result.exitCode}`);
487
+ }
488
+
489
+ await printVerification(expectedVersion);
490
+ }
491
+
492
+ async function updateViaMise(expectedVersion: string, force: boolean): Promise<void> {
493
+ console.log(chalk.dim("Updating via mise..."));
494
+ const args = buildMiseUpgradeArgs();
495
+ const result = await $`mise ${args}`.nothrow();
496
+ if (result.exitCode !== 0) {
497
+ throw new Error(`mise upgrade failed with exit code ${result.exitCode}`);
498
+ }
499
+
500
+ if (force) {
501
+ const forceArgs = buildMiseForceInstallArgs(expectedVersion);
502
+ const forceResult = await $`mise ${forceArgs}`.nothrow();
503
+ if (forceResult.exitCode !== 0) {
504
+ throw new Error(`mise install --force failed with exit code ${forceResult.exitCode}`);
505
+ }
506
+ }
507
+
508
+ await printVerification(expectedVersion);
509
+ }
510
+
393
511
  /**
394
512
  * Download a release binary to a target path, replacing an existing file.
395
513
  */
@@ -457,7 +575,11 @@ export async function runUpdateCommand(opts: { force: boolean; check: boolean })
457
575
  // Choose update method based on the prioritized omp binary in PATH
458
576
  try {
459
577
  const target = await resolveUpdateTarget();
460
- if (target.method === "bun") {
578
+ if (target.method === "brew") {
579
+ await updateViaHomebrew(release.version, opts.force);
580
+ } else if (target.method === "mise") {
581
+ await updateViaMise(release.version, opts.force);
582
+ } else if (target.method === "bun") {
461
583
  await updateViaBun(release.version);
462
584
  } else {
463
585
  await updateViaBinaryAt(target.path, release.version);
@@ -68,6 +68,7 @@ export function createSplitCommitTool(
68
68
  const errors: string[] = [];
69
69
  const warnings: string[] = [];
70
70
  const diffText = await git.diff(cwd, { cached: true });
71
+ const validateHunksForDiff = git.createHunkSelectionValidator(diffText);
71
72
 
72
73
  const commits: SplitCommitGroup[] = params.commits.map((commit, index) => {
73
74
  const scope = commit.scope?.trim() || null;
@@ -102,7 +103,7 @@ export function createSplitCommitTool(
102
103
  }
103
104
  warnings.push(...summaryValidation.warnings.map(warning => `Commit ${index + 1}: ${warning}`));
104
105
  warnings.push(...typeValidation.warnings.map(warning => `Commit ${index + 1}: ${warning}`));
105
- const hunkValidation = validateHunkSelectors(index, changes, files);
106
+ const hunkValidation = validateHunkSelectors(index, changes, files, validateHunksForDiff);
106
107
  warnings.push(...hunkValidation.warnings);
107
108
  errors.push(...hunkValidation.errors);
108
109
  errors.push(...validateDependencies(index, dependencies, params.commits.length));
@@ -186,6 +187,7 @@ function validateHunkSelectors(
186
187
  commitIndex: number,
187
188
  changes: SplitCommitGroup["changes"],
188
189
  files: string[],
190
+ validateHunksForDiff: (changes: SplitCommitGroup["changes"]) => git.HunkSelectionValidationError[],
189
191
  ): { errors: string[]; warnings: string[] } {
190
192
  const errors: string[] = [];
191
193
  const warnings: string[] = [];
@@ -215,6 +217,11 @@ function validateHunkSelectors(
215
217
  }
216
218
  }
217
219
  }
220
+ if (errors.length === 0) {
221
+ for (const error of validateHunksForDiff(changes)) {
222
+ errors.push(`${prefix}: ${error.message}`);
223
+ }
224
+ }
218
225
  return { errors, warnings };
219
226
  }
220
227
 
@@ -21,6 +21,7 @@ const DEFAULT_MODEL_PROVIDER_ORDER = [
21
21
  "fireworks",
22
22
  "cerebras",
23
23
  "openrouter",
24
+ "aimlapi",
24
25
  "together",
25
26
 
26
27
  // Generic gateways and editor/proxy providers. These are useful when picked
@@ -96,8 +96,8 @@ const STARTUP_MODEL_CACHE_PROVIDER_IDS: readonly string[] = [
96
96
  ];
97
97
 
98
98
  import type { ApiKeyResolver } from "@oh-my-pi/pi-ai";
99
- import { registerOAuthProvider, unregisterOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth";
100
- import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/utils/oauth/types";
99
+ import { registerOAuthProvider, unregisterOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
100
+ import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/oauth/types";
101
101
  import { isRecord, logger } from "@oh-my-pi/pi-utils";
102
102
  import { parseModelString, resolveProviderModelReference } from "../config/model-resolver";
103
103
  import { isValidThemeColor, type ThemeColor } from "../modes/theme/theme";
@@ -922,6 +922,9 @@ export class ModelRegistry {
922
922
  #runtimeProviderOverrides: Map<string, ProviderOverride> = new Map();
923
923
  #runtimeProvidersBySource: Map<string, Set<string>> = new Map();
924
924
  #runtimeProviderSourceByName: Map<string, string> = new Map();
925
+ // Runtime model managers registered by extensions via fetchDynamicModels.
926
+ // Keyed by provider name; use the same SQLite cache path as builtins.
927
+ #runtimeModelManagers: Map<string, { options: ModelManagerOptions<Api>; sourceId: string }> = new Map();
925
928
  #rebuildPending: boolean = false;
926
929
  #rebuildSuspended: number = 0;
927
930
 
@@ -999,6 +1002,27 @@ export class ModelRegistry {
999
1002
  }
1000
1003
  }
1001
1004
 
1005
+ /**
1006
+ * Discover models for providers registered at runtime via `fetchDynamicModels`
1007
+ * (extension providers). Merges the discovered catalog into the existing model
1008
+ * set without reloading static models, so dynamically-discovered models from
1009
+ * other providers are preserved. No-op when no runtime providers are registered.
1010
+ *
1011
+ * Drives the same SQLite model cache as built-in providers, so the default
1012
+ * `online-if-uncached` strategy fetches at most once per cache TTL (24 h).
1013
+ */
1014
+ async refreshRuntimeProviders(strategy: ModelRefreshStrategy = "online-if-uncached"): Promise<void> {
1015
+ if (this.#runtimeModelManagers.size === 0) {
1016
+ return;
1017
+ }
1018
+ this.#suspendRebuild();
1019
+ try {
1020
+ await this.#refreshRuntimeDiscoveries(strategy, new Set(this.#runtimeModelManagers.keys()));
1021
+ } finally {
1022
+ this.#resumeRebuild();
1023
+ }
1024
+ }
1025
+
1002
1026
  #reloadStaticModels(): void {
1003
1027
  const currentMtime = this.#modelsConfigFile.getMtimeMs();
1004
1028
  if (currentMtime !== null && currentMtime === this.#lastStaticLoadMtime) {
@@ -1665,6 +1689,10 @@ export class ModelRegistry {
1665
1689
  }
1666
1690
  options.push(descriptor.createOptions(key));
1667
1691
  }
1692
+ // Append runtime model managers registered by extensions via fetchDynamicModels.
1693
+ for (const { options: managerOpts } of this.#runtimeModelManagers.values()) {
1694
+ options.push(managerOpts);
1695
+ }
1668
1696
  return options;
1669
1697
  }
1670
1698
 
@@ -2396,6 +2424,7 @@ export class ModelRegistry {
2396
2424
  this.#runtimeProviderApiKeys.delete(providerName);
2397
2425
  this.#runtimeProviderOverrides.delete(providerName);
2398
2426
  this.#runtimeModelOverlays = this.#runtimeModelOverlays.filter(overlay => overlay.provider !== providerName);
2427
+ this.#runtimeModelManagers.delete(providerName);
2399
2428
  this.authStorage.removeConfigApiKey(providerName);
2400
2429
  }
2401
2430
 
@@ -2559,6 +2588,47 @@ export class ModelRegistry {
2559
2588
  return;
2560
2589
  }
2561
2590
 
2591
+ if (config.fetchDynamicModels) {
2592
+ const fetcher = config.fetchDynamicModels;
2593
+ const providerBaseUrl = config.baseUrl ?? "";
2594
+ const providerApi = config.api;
2595
+ const providerHeaders = config.headers;
2596
+ const providerApiKey = config.apiKey;
2597
+ const providerAuthHeader = config.authHeader;
2598
+ const providerCompat = config.compat;
2599
+ const managerOptions: ModelManagerOptions<Api> = {
2600
+ providerId: providerName as Parameters<typeof createModelManager>[0]["providerId"],
2601
+ staticModels: [],
2602
+ cacheDbPath: this.#cacheDbPath,
2603
+ cacheTtlMs: 24 * 60 * 60 * 1000,
2604
+ dynamicModelsAuthoritative: true,
2605
+ fetchDynamicModels: async () => {
2606
+ const apiKey = await this.authStorage.peekApiKey(providerName);
2607
+ const resolvedKey = isAuthenticated(apiKey) ? apiKey : undefined;
2608
+ const modelDefs = await fetcher(resolvedKey);
2609
+ const results: Model<Api>[] = [];
2610
+ for (const modelDef of modelDefs) {
2611
+ const overlay = buildCustomModelOverlay(
2612
+ providerName,
2613
+ modelDef.baseUrl ?? providerBaseUrl,
2614
+ modelDef.api ?? providerApi,
2615
+ providerHeaders,
2616
+ providerApiKey,
2617
+ providerAuthHeader,
2618
+ providerCompat,
2619
+ undefined,
2620
+ modelDef as CustomModelDefinitionLike,
2621
+ );
2622
+ if (overlay) results.push(finalizeCustomModel(overlay, { useDefaults: true }));
2623
+ }
2624
+ return results;
2625
+ },
2626
+ };
2627
+ this.#runtimeModelManagers.set(providerName, { options: managerOptions, sourceId: sourceId ?? "" });
2628
+ // Discovery is driven by refreshRuntimeProviders() after the drain — not
2629
+ // here, so registration has no network side effect and callers can await.
2630
+ }
2631
+
2562
2632
  if (
2563
2633
  config.baseUrl ||
2564
2634
  config.headers ||
@@ -2636,6 +2706,15 @@ export interface ProviderConfigInput {
2636
2706
  getApiKey?(credentials: OAuthCredentials): string;
2637
2707
  modifyModels?(models: Model<Api>[], credentials: OAuthCredentials): Model<Api>[];
2638
2708
  };
2709
+ /**
2710
+ * Async factory that fetches the live model list from the provider endpoint.
2711
+ * When present, the result is run through the same SQLite model-cache as
2712
+ * built-in providers (keyed by provider name, default 24 h TTL).
2713
+ * The factory receives the resolved API key (undefined when unauthenticated).
2714
+ */
2715
+ fetchDynamicModels?: (
2716
+ apiKey: string | undefined,
2717
+ ) => Promise<readonly NonNullable<ProviderConfigInput["models"]>[number][]>;
2639
2718
  models?: Array<{
2640
2719
  id: string;
2641
2720
  name: string;
@@ -204,7 +204,7 @@ export class DebugSelectorComponent extends Container {
204
204
  this.ctx.statusContainer.clear();
205
205
 
206
206
  const block = new TranscriptBlock();
207
- block.addChild(new Text(theme.fg("success", `${theme.status.success} Performance report saved`), 1, 0));
207
+ block.addChild(new Text(theme.fg("success", `+ Performance report saved`), 1, 0));
208
208
  block.addChild(new Text(theme.fg("dim", formatFileHyperlink(result.path)), 1, 0));
209
209
  block.addChild(new Text(theme.fg("dim", `Files: ${result.files.length}`), 1, 0));
210
210
  this.ctx.present(block);
@@ -261,7 +261,7 @@ export class DebugSelectorComponent extends Container {
261
261
  this.ctx.statusContainer.clear();
262
262
 
263
263
  const block = new TranscriptBlock();
264
- block.addChild(new Text(theme.fg("success", `${theme.status.success} Report bundle saved`), 1, 0));
264
+ block.addChild(new Text(theme.fg("success", `+ Report bundle saved`), 1, 0));
265
265
  block.addChild(new Text(theme.fg("dim", formatFileHyperlink(result.path)), 1, 0));
266
266
  block.addChild(new Text(theme.fg("dim", `Files: ${result.files.length}`), 1, 0));
267
267
  this.ctx.present(block);
@@ -298,7 +298,7 @@ export class DebugSelectorComponent extends Container {
298
298
  this.ctx.statusContainer.clear();
299
299
 
300
300
  const block = new TranscriptBlock();
301
- block.addChild(new Text(theme.fg("success", `${theme.status.success} Memory report saved`), 1, 0));
301
+ block.addChild(new Text(theme.fg("success", `+ Memory report saved`), 1, 0));
302
302
  block.addChild(new Text(theme.fg("dim", formatFileHyperlink(result.path)), 1, 0));
303
303
  block.addChild(new Text(theme.fg("dim", `Files: ${result.files.length}`), 1, 0));
304
304
  this.ctx.present(block);
@@ -480,11 +480,7 @@ export class DebugSelectorComponent extends Container {
480
480
 
481
481
  this.ctx.present([
482
482
  new Spacer(1),
483
- new Text(
484
- theme.fg("success", `${theme.status.success} Cleared ${result.removed} artifact directories`),
485
- 1,
486
- 0,
487
- ),
483
+ new Text(theme.fg("success", `- Cleared ${result.removed} artifact directories`), 1, 0),
488
484
  ]);
489
485
  } catch (err) {
490
486
  loader.stop();