@oh-my-pi/pi-coding-agent 3.15.0 → 3.20.0

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 (193) hide show
  1. package/CHANGELOG.md +61 -1
  2. package/docs/extensions.md +1055 -0
  3. package/docs/rpc.md +69 -13
  4. package/docs/session-tree-plan.md +1 -1
  5. package/examples/extensions/README.md +141 -0
  6. package/examples/extensions/api-demo.ts +87 -0
  7. package/examples/extensions/chalk-logger.ts +26 -0
  8. package/examples/extensions/hello.ts +33 -0
  9. package/examples/extensions/pirate.ts +44 -0
  10. package/examples/extensions/plan-mode.ts +551 -0
  11. package/examples/extensions/subagent/agents/reviewer.md +35 -0
  12. package/examples/extensions/todo.ts +299 -0
  13. package/examples/extensions/tools.ts +145 -0
  14. package/examples/extensions/with-deps/index.ts +36 -0
  15. package/examples/extensions/with-deps/package-lock.json +31 -0
  16. package/examples/extensions/with-deps/package.json +16 -0
  17. package/examples/sdk/02-custom-model.ts +3 -3
  18. package/examples/sdk/05-tools.ts +7 -3
  19. package/examples/sdk/06-extensions.ts +81 -0
  20. package/examples/sdk/06-hooks.ts +14 -13
  21. package/examples/sdk/08-prompt-templates.ts +42 -0
  22. package/examples/sdk/08-slash-commands.ts +17 -12
  23. package/examples/sdk/09-api-keys-and-oauth.ts +2 -2
  24. package/examples/sdk/12-full-control.ts +6 -6
  25. package/package.json +11 -7
  26. package/src/capability/extension-module.ts +34 -0
  27. package/src/cli/args.ts +22 -7
  28. package/src/cli/file-processor.ts +38 -67
  29. package/src/cli/list-models.ts +1 -1
  30. package/src/config.ts +25 -14
  31. package/src/core/agent-session.ts +505 -242
  32. package/src/core/auth-storage.ts +33 -21
  33. package/src/core/compaction/branch-summarization.ts +4 -4
  34. package/src/core/compaction/compaction.ts +3 -3
  35. package/src/core/custom-commands/bundled/wt/index.ts +430 -0
  36. package/src/core/custom-commands/loader.ts +9 -0
  37. package/src/core/custom-tools/wrapper.ts +5 -0
  38. package/src/core/event-bus.ts +59 -0
  39. package/src/core/export-html/vendor/highlight.min.js +1213 -0
  40. package/src/core/export-html/vendor/marked.min.js +6 -0
  41. package/src/core/extensions/index.ts +100 -0
  42. package/src/core/extensions/loader.ts +501 -0
  43. package/src/core/extensions/runner.ts +477 -0
  44. package/src/core/extensions/types.ts +712 -0
  45. package/src/core/extensions/wrapper.ts +147 -0
  46. package/src/core/hooks/types.ts +2 -2
  47. package/src/core/index.ts +10 -21
  48. package/src/core/keybindings.ts +199 -0
  49. package/src/core/messages.ts +26 -7
  50. package/src/core/model-registry.ts +123 -46
  51. package/src/core/model-resolver.ts +7 -5
  52. package/src/core/prompt-templates.ts +242 -0
  53. package/src/core/sdk.ts +378 -295
  54. package/src/core/session-manager.ts +72 -58
  55. package/src/core/settings-manager.ts +118 -22
  56. package/src/core/system-prompt.ts +24 -1
  57. package/src/core/terminal-notify.ts +37 -0
  58. package/src/core/tools/context.ts +4 -4
  59. package/src/core/tools/exa/mcp-client.ts +5 -4
  60. package/src/core/tools/exa/render.ts +176 -131
  61. package/src/core/tools/gemini-image.ts +361 -0
  62. package/src/core/tools/git.ts +216 -0
  63. package/src/core/tools/index.ts +28 -15
  64. package/src/core/tools/lsp/config.ts +5 -4
  65. package/src/core/tools/lsp/index.ts +17 -12
  66. package/src/core/tools/lsp/render.ts +39 -47
  67. package/src/core/tools/read.ts +66 -29
  68. package/src/core/tools/render-utils.ts +268 -0
  69. package/src/core/tools/renderers.ts +243 -225
  70. package/src/core/tools/task/discovery.ts +2 -2
  71. package/src/core/tools/task/executor.ts +66 -58
  72. package/src/core/tools/task/index.ts +29 -10
  73. package/src/core/tools/task/model-resolver.ts +8 -13
  74. package/src/core/tools/task/omp-command.ts +24 -0
  75. package/src/core/tools/task/render.ts +35 -60
  76. package/src/core/tools/task/types.ts +3 -0
  77. package/src/core/tools/web-fetch.ts +29 -28
  78. package/src/core/tools/web-search/index.ts +6 -5
  79. package/src/core/tools/web-search/providers/exa.ts +6 -5
  80. package/src/core/tools/web-search/render.ts +66 -111
  81. package/src/core/voice-controller.ts +135 -0
  82. package/src/core/voice-supervisor.ts +1003 -0
  83. package/src/core/voice.ts +308 -0
  84. package/src/discovery/builtin.ts +75 -1
  85. package/src/discovery/claude.ts +47 -1
  86. package/src/discovery/codex.ts +54 -2
  87. package/src/discovery/gemini.ts +55 -2
  88. package/src/discovery/helpers.ts +100 -1
  89. package/src/discovery/index.ts +2 -0
  90. package/src/index.ts +14 -9
  91. package/src/lib/worktree/collapse.ts +179 -0
  92. package/src/lib/worktree/constants.ts +14 -0
  93. package/src/lib/worktree/errors.ts +23 -0
  94. package/src/lib/worktree/git.ts +110 -0
  95. package/src/lib/worktree/index.ts +23 -0
  96. package/src/lib/worktree/operations.ts +216 -0
  97. package/src/lib/worktree/session.ts +114 -0
  98. package/src/lib/worktree/stats.ts +67 -0
  99. package/src/main.ts +61 -37
  100. package/src/migrations.ts +37 -7
  101. package/src/modes/interactive/components/bash-execution.ts +6 -4
  102. package/src/modes/interactive/components/custom-editor.ts +55 -0
  103. package/src/modes/interactive/components/custom-message.ts +95 -0
  104. package/src/modes/interactive/components/extensions/extension-list.ts +5 -0
  105. package/src/modes/interactive/components/extensions/inspector-panel.ts +18 -12
  106. package/src/modes/interactive/components/extensions/state-manager.ts +12 -0
  107. package/src/modes/interactive/components/extensions/types.ts +1 -0
  108. package/src/modes/interactive/components/footer.ts +324 -0
  109. package/src/modes/interactive/components/hook-editor.ts +1 -0
  110. package/src/modes/interactive/components/hook-selector.ts +3 -3
  111. package/src/modes/interactive/components/model-selector.ts +7 -6
  112. package/src/modes/interactive/components/oauth-selector.ts +3 -3
  113. package/src/modes/interactive/components/settings-defs.ts +55 -6
  114. package/src/modes/interactive/components/status-line/separators.ts +4 -4
  115. package/src/modes/interactive/components/status-line.ts +45 -35
  116. package/src/modes/interactive/components/tool-execution.ts +95 -23
  117. package/src/modes/interactive/interactive-mode.ts +644 -113
  118. package/src/modes/interactive/theme/defaults/alabaster.json +99 -0
  119. package/src/modes/interactive/theme/defaults/amethyst.json +103 -0
  120. package/src/modes/interactive/theme/defaults/anthracite.json +100 -0
  121. package/src/modes/interactive/theme/defaults/basalt.json +90 -0
  122. package/src/modes/interactive/theme/defaults/birch.json +101 -0
  123. package/src/modes/interactive/theme/defaults/dark-abyss.json +97 -0
  124. package/src/modes/interactive/theme/defaults/dark-aurora.json +94 -0
  125. package/src/modes/interactive/theme/defaults/dark-cavern.json +97 -0
  126. package/src/modes/interactive/theme/defaults/dark-copper.json +94 -0
  127. package/src/modes/interactive/theme/defaults/dark-cosmos.json +96 -0
  128. package/src/modes/interactive/theme/defaults/dark-eclipse.json +97 -0
  129. package/src/modes/interactive/theme/defaults/dark-ember.json +94 -0
  130. package/src/modes/interactive/theme/defaults/dark-equinox.json +96 -0
  131. package/src/modes/interactive/theme/defaults/dark-lavender.json +94 -0
  132. package/src/modes/interactive/theme/defaults/dark-lunar.json +95 -0
  133. package/src/modes/interactive/theme/defaults/dark-midnight.json +94 -0
  134. package/src/modes/interactive/theme/defaults/dark-nebula.json +96 -0
  135. package/src/modes/interactive/theme/defaults/dark-rainforest.json +97 -0
  136. package/src/modes/interactive/theme/defaults/dark-reef.json +97 -0
  137. package/src/modes/interactive/theme/defaults/dark-sakura.json +94 -0
  138. package/src/modes/interactive/theme/defaults/dark-slate.json +94 -0
  139. package/src/modes/interactive/theme/defaults/dark-solstice.json +96 -0
  140. package/src/modes/interactive/theme/defaults/dark-starfall.json +97 -0
  141. package/src/modes/interactive/theme/defaults/dark-swamp.json +96 -0
  142. package/src/modes/interactive/theme/defaults/dark-taiga.json +97 -0
  143. package/src/modes/interactive/theme/defaults/dark-terminal.json +94 -0
  144. package/src/modes/interactive/theme/defaults/dark-tundra.json +97 -0
  145. package/src/modes/interactive/theme/defaults/dark-twilight.json +97 -0
  146. package/src/modes/interactive/theme/defaults/dark-volcanic.json +97 -0
  147. package/src/modes/interactive/theme/defaults/graphite.json +99 -0
  148. package/src/modes/interactive/theme/defaults/index.ts +128 -0
  149. package/src/modes/interactive/theme/defaults/light-aurora-day.json +97 -0
  150. package/src/modes/interactive/theme/defaults/light-canyon.json +97 -0
  151. package/src/modes/interactive/theme/defaults/light-cirrus.json +96 -0
  152. package/src/modes/interactive/theme/defaults/light-coral.json +94 -0
  153. package/src/modes/interactive/theme/defaults/light-dawn.json +96 -0
  154. package/src/modes/interactive/theme/defaults/light-dunes.json +97 -0
  155. package/src/modes/interactive/theme/defaults/light-eucalyptus.json +94 -0
  156. package/src/modes/interactive/theme/defaults/light-frost.json +94 -0
  157. package/src/modes/interactive/theme/defaults/light-glacier.json +97 -0
  158. package/src/modes/interactive/theme/defaults/light-haze.json +96 -0
  159. package/src/modes/interactive/theme/defaults/light-honeycomb.json +94 -0
  160. package/src/modes/interactive/theme/defaults/light-lagoon.json +97 -0
  161. package/src/modes/interactive/theme/defaults/light-lavender.json +94 -0
  162. package/src/modes/interactive/theme/defaults/light-meadow.json +97 -0
  163. package/src/modes/interactive/theme/defaults/light-mint.json +94 -0
  164. package/src/modes/interactive/theme/defaults/light-opal.json +97 -0
  165. package/src/modes/interactive/theme/defaults/light-orchard.json +97 -0
  166. package/src/modes/interactive/theme/defaults/light-paper.json +94 -0
  167. package/src/modes/interactive/theme/defaults/light-prism.json +96 -0
  168. package/src/modes/interactive/theme/defaults/light-sand.json +94 -0
  169. package/src/modes/interactive/theme/defaults/light-savanna.json +97 -0
  170. package/src/modes/interactive/theme/defaults/light-soleil.json +96 -0
  171. package/src/modes/interactive/theme/defaults/light-wetland.json +97 -0
  172. package/src/modes/interactive/theme/defaults/light-zenith.json +95 -0
  173. package/src/modes/interactive/theme/defaults/limestone.json +100 -0
  174. package/src/modes/interactive/theme/defaults/mahogany.json +104 -0
  175. package/src/modes/interactive/theme/defaults/marble.json +99 -0
  176. package/src/modes/interactive/theme/defaults/obsidian.json +90 -0
  177. package/src/modes/interactive/theme/defaults/onyx.json +90 -0
  178. package/src/modes/interactive/theme/defaults/pearl.json +99 -0
  179. package/src/modes/interactive/theme/defaults/porcelain.json +90 -0
  180. package/src/modes/interactive/theme/defaults/quartz.json +102 -0
  181. package/src/modes/interactive/theme/defaults/sandstone.json +101 -0
  182. package/src/modes/interactive/theme/defaults/titanium.json +89 -0
  183. package/src/modes/print-mode.ts +14 -72
  184. package/src/modes/rpc/rpc-client.ts +23 -9
  185. package/src/modes/rpc/rpc-mode.ts +137 -125
  186. package/src/modes/rpc/rpc-types.ts +46 -24
  187. package/src/prompts/task.md +1 -0
  188. package/src/prompts/tools/gemini-image.md +4 -0
  189. package/src/prompts/tools/git.md +9 -0
  190. package/src/prompts/voice-summary.md +12 -0
  191. package/src/utils/image-convert.ts +26 -0
  192. package/src/utils/image-resize.ts +215 -0
  193. package/src/utils/shell-snapshot.ts +22 -20
@@ -35,8 +35,10 @@ const ModelDefinitionSchema = Type.Object({
35
35
  Type.Union([
36
36
  Type.Literal("openai-completions"),
37
37
  Type.Literal("openai-responses"),
38
+ Type.Literal("openai-codex-responses"),
38
39
  Type.Literal("anthropic-messages"),
39
40
  Type.Literal("google-generative-ai"),
41
+ Type.Literal("google-vertex"),
40
42
  ]),
41
43
  ),
42
44
  reasoning: Type.Boolean(),
@@ -54,19 +56,21 @@ const ModelDefinitionSchema = Type.Object({
54
56
  });
55
57
 
56
58
  const ProviderConfigSchema = Type.Object({
57
- baseUrl: Type.String({ minLength: 1 }),
58
- apiKey: Type.String({ minLength: 1 }),
59
+ baseUrl: Type.Optional(Type.String({ minLength: 1 })),
60
+ apiKey: Type.Optional(Type.String({ minLength: 1 })),
59
61
  api: Type.Optional(
60
62
  Type.Union([
61
63
  Type.Literal("openai-completions"),
62
64
  Type.Literal("openai-responses"),
65
+ Type.Literal("openai-codex-responses"),
63
66
  Type.Literal("anthropic-messages"),
64
67
  Type.Literal("google-generative-ai"),
68
+ Type.Literal("google-vertex"),
65
69
  ]),
66
70
  ),
67
71
  headers: Type.Optional(Type.Record(Type.String(), Type.String())),
68
72
  authHeader: Type.Optional(Type.Boolean()),
69
- models: Type.Array(ModelDefinitionSchema),
73
+ models: Type.Optional(Type.Array(ModelDefinitionSchema)),
70
74
  });
71
75
 
72
76
  const ModelsConfigSchema = Type.Object({
@@ -75,6 +79,27 @@ const ModelsConfigSchema = Type.Object({
75
79
 
76
80
  type ModelsConfig = Static<typeof ModelsConfigSchema>;
77
81
 
82
+ /** Provider override config (baseUrl, headers, apiKey) without custom models */
83
+ interface ProviderOverride {
84
+ baseUrl?: string;
85
+ headers?: Record<string, string>;
86
+ apiKey?: string;
87
+ }
88
+
89
+ /** Result of loading custom models from models.json */
90
+ interface CustomModelsResult {
91
+ models: Model<Api>[];
92
+ /** Providers with custom models (full replacement) */
93
+ replacedProviders: Set<string>;
94
+ /** Providers with only baseUrl/headers override (no custom models) */
95
+ overrides: Map<string, ProviderOverride>;
96
+ error: string | undefined;
97
+ }
98
+
99
+ function emptyCustomModelsResult(error?: string): CustomModelsResult {
100
+ return { models: [], replacedProviders: new Set(), overrides: new Map(), error };
101
+ }
102
+
78
103
  /**
79
104
  * Resolve an API key config value to an actual key.
80
105
  * Checks environment variable first, then treats as literal.
@@ -111,8 +136,7 @@ export class ModelRegistry {
111
136
  }
112
137
  return undefined;
113
138
  });
114
-
115
- // Load models
139
+ // Load models synchronously in constructor
116
140
  this.loadModels();
117
141
  }
118
142
 
@@ -133,15 +157,10 @@ export class ModelRegistry {
133
157
  }
134
158
 
135
159
  private loadModels(): void {
136
- // Load built-in models
137
- const builtInModels: Model<Api>[] = [];
138
- for (const provider of getProviders()) {
139
- const providerModels = getModels(provider as KnownProvider);
140
- builtInModels.push(...(providerModels as Model<Api>[]));
141
- }
142
-
143
- // Load custom models from models.json (check primary path, then fallbacks)
160
+ // Load custom models from models.json first (to know which providers to skip/override)
144
161
  let customModels: Model<Api>[] = [];
162
+ let replacedProviders: Set<string> = new Set();
163
+ let overrides: Map<string, ProviderOverride> = new Map();
145
164
  const pathsToCheck = this.modelsJsonPath ? [this.modelsJsonPath, ...this.fallbackPaths] : this.fallbackPaths;
146
165
 
147
166
  if (pathsToCheck.length > 0) {
@@ -157,11 +176,14 @@ export class ModelRegistry {
157
176
  // Keep built-in models even if custom models failed to load
158
177
  } else {
159
178
  customModels = result.models;
179
+ replacedProviders = result.replacedProviders;
180
+ overrides = result.overrides;
160
181
  }
161
182
  break; // Use first existing file
162
183
  }
163
184
  }
164
185
 
186
+ const builtInModels = this.loadBuiltInModels(replacedProviders, overrides);
165
187
  const combined = [...builtInModels, ...customModels];
166
188
 
167
189
  // Update github-copilot base URL based on OAuth credentials
@@ -177,9 +199,27 @@ export class ModelRegistry {
177
199
  }
178
200
  }
179
201
 
180
- private loadCustomModels(modelsJsonPath: string): { models: Model<Api>[]; error: string | undefined } {
202
+ /** Load built-in models, skipping replaced providers and applying overrides */
203
+ private loadBuiltInModels(replacedProviders: Set<string>, overrides: Map<string, ProviderOverride>): Model<Api>[] {
204
+ return getProviders()
205
+ .filter((provider) => !replacedProviders.has(provider))
206
+ .flatMap((provider) => {
207
+ const models = getModels(provider as KnownProvider) as Model<Api>[];
208
+ const override = overrides.get(provider);
209
+ if (!override) return models;
210
+
211
+ // Apply baseUrl/headers override to all models of this provider
212
+ return models.map((m) => ({
213
+ ...m,
214
+ baseUrl: override.baseUrl ?? m.baseUrl,
215
+ headers: override.headers ? { ...m.headers, ...override.headers } : m.headers,
216
+ }));
217
+ });
218
+ }
219
+
220
+ private loadCustomModels(modelsJsonPath: string): CustomModelsResult {
181
221
  if (!existsSync(modelsJsonPath)) {
182
- return { models: [], error: undefined };
222
+ return emptyCustomModelsResult();
183
223
  }
184
224
 
185
225
  try {
@@ -193,38 +233,68 @@ export class ModelRegistry {
193
233
  const errors =
194
234
  validate.errors?.map((e: any) => ` - ${e.instancePath || "root"}: ${e.message}`).join("\n") ||
195
235
  "Unknown schema error";
196
- return {
197
- models: [],
198
- error: `Invalid models.json schema:\n${errors}\n\nFile: ${modelsJsonPath}`,
199
- };
236
+ return emptyCustomModelsResult(`Invalid models.json schema:\n${errors}\n\nFile: ${modelsJsonPath}`);
200
237
  }
201
238
 
202
239
  // Additional validation
203
240
  this.validateConfig(config);
204
241
 
205
- // Parse models
206
- return { models: this.parseModels(config), error: undefined };
242
+ // Separate providers into "full replacement" (has models) vs "override-only" (no models)
243
+ const replacedProviders = new Set<string>();
244
+ const overrides = new Map<string, ProviderOverride>();
245
+
246
+ for (const [providerName, providerConfig] of Object.entries(config.providers)) {
247
+ if (providerConfig.models && providerConfig.models.length > 0) {
248
+ // Has custom models -> full replacement
249
+ replacedProviders.add(providerName);
250
+ } else {
251
+ // No models -> just override baseUrl/headers on built-in
252
+ overrides.set(providerName, {
253
+ baseUrl: providerConfig.baseUrl,
254
+ headers: providerConfig.headers,
255
+ apiKey: providerConfig.apiKey,
256
+ });
257
+ // Store API key for fallback resolver
258
+ if (providerConfig.apiKey) {
259
+ this.customProviderApiKeys.set(providerName, providerConfig.apiKey);
260
+ }
261
+ }
262
+ }
263
+
264
+ return { models: this.parseModels(config), replacedProviders, overrides, error: undefined };
207
265
  } catch (error) {
208
266
  if (error instanceof SyntaxError) {
209
- return {
210
- models: [],
211
- error: `Failed to parse models.json: ${error.message}\n\nFile: ${modelsJsonPath}`,
212
- };
267
+ return emptyCustomModelsResult(`Failed to parse models.json: ${error.message}\n\nFile: ${modelsJsonPath}`);
213
268
  }
214
- return {
215
- models: [],
216
- error: `Failed to load models.json: ${
217
- error instanceof Error ? error.message : error
218
- }\n\nFile: ${modelsJsonPath}`,
219
- };
269
+ return emptyCustomModelsResult(
270
+ `Failed to load models.json: ${error instanceof Error ? error.message : error}\n\nFile: ${modelsJsonPath}`,
271
+ );
220
272
  }
221
273
  }
222
274
 
223
275
  private validateConfig(config: ModelsConfig): void {
224
276
  for (const [providerName, providerConfig] of Object.entries(config.providers)) {
225
277
  const hasProviderApi = !!providerConfig.api;
278
+ const models = providerConfig.models ?? [];
279
+
280
+ if (models.length === 0) {
281
+ // Override-only config: just needs baseUrl (to override built-in)
282
+ if (!providerConfig.baseUrl) {
283
+ throw new Error(
284
+ `Provider ${providerName}: must specify either "baseUrl" (for override) or "models" (for replacement).`,
285
+ );
286
+ }
287
+ } else {
288
+ // Full replacement: needs baseUrl and apiKey
289
+ if (!providerConfig.baseUrl) {
290
+ throw new Error(`Provider ${providerName}: "baseUrl" is required when defining custom models.`);
291
+ }
292
+ if (!providerConfig.apiKey) {
293
+ throw new Error(`Provider ${providerName}: "apiKey" is required when defining custom models.`);
294
+ }
295
+ }
226
296
 
227
- for (const modelDef of providerConfig.models) {
297
+ for (const modelDef of models) {
228
298
  const hasModelApi = !!modelDef.api;
229
299
 
230
300
  if (!hasProviderApi && !hasModelApi) {
@@ -247,10 +317,15 @@ export class ModelRegistry {
247
317
  const models: Model<Api>[] = [];
248
318
 
249
319
  for (const [providerName, providerConfig] of Object.entries(config.providers)) {
320
+ const modelDefs = providerConfig.models ?? [];
321
+ if (modelDefs.length === 0) continue; // Override-only, no custom models
322
+
250
323
  // Store API key config for fallback resolver
251
- this.customProviderApiKeys.set(providerName, providerConfig.apiKey);
324
+ if (providerConfig.apiKey) {
325
+ this.customProviderApiKeys.set(providerName, providerConfig.apiKey);
326
+ }
252
327
 
253
- for (const modelDef of providerConfig.models) {
328
+ for (const modelDef of modelDefs) {
254
329
  const api = modelDef.api || providerConfig.api;
255
330
  if (!api) continue;
256
331
 
@@ -261,19 +336,20 @@ export class ModelRegistry {
261
336
  : undefined;
262
337
 
263
338
  // If authHeader is true, add Authorization header with resolved API key
264
- if (providerConfig.authHeader) {
339
+ if (providerConfig.authHeader && providerConfig.apiKey) {
265
340
  const resolvedKey = resolveApiKeyConfig(providerConfig.apiKey);
266
341
  if (resolvedKey) {
267
342
  headers = { ...headers, Authorization: `Bearer ${resolvedKey}` };
268
343
  }
269
344
  }
270
345
 
346
+ // baseUrl is validated to exist for providers with models
271
347
  models.push({
272
348
  id: modelDef.id,
273
349
  name: modelDef.name,
274
350
  api: api as Api,
275
351
  provider: providerName,
276
- baseUrl: providerConfig.baseUrl,
352
+ baseUrl: providerConfig.baseUrl!,
277
353
  reasoning: modelDef.reasoning,
278
354
  input: modelDef.input as ("text" | "image")[],
279
355
  cost: modelDef.cost,
@@ -297,17 +373,11 @@ export class ModelRegistry {
297
373
  }
298
374
 
299
375
  /**
300
- * Get only models that have valid API keys available.
376
+ * Get only models that have auth configured.
377
+ * This is a fast check that doesn't refresh OAuth tokens.
301
378
  */
302
- async getAvailable(): Promise<Model<Api>[]> {
303
- const available: Model<Api>[] = [];
304
- for (const model of this.models) {
305
- const apiKey = await this.authStorage.getApiKey(model.provider);
306
- if (apiKey) {
307
- available.push(model);
308
- }
309
- }
310
- return available;
379
+ getAvailable(): Model<Api>[] {
380
+ return this.models.filter((m) => this.authStorage.hasAuth(m.provider));
311
381
  }
312
382
 
313
383
  /**
@@ -324,6 +394,13 @@ export class ModelRegistry {
324
394
  return this.authStorage.getApiKey(model.provider);
325
395
  }
326
396
 
397
+ /**
398
+ * Get API key for a provider (e.g., "openai").
399
+ */
400
+ async getApiKeyForProvider(provider: string): Promise<string | undefined> {
401
+ return this.authStorage.getApiKey(provider);
402
+ }
403
+
327
404
  /**
328
405
  * Check if a model is using OAuth credentials (subscription).
329
406
  */
@@ -13,9 +13,11 @@ import type { ModelRegistry } from "./model-registry";
13
13
  export const defaultModelPerProvider: Record<KnownProvider, string> = {
14
14
  anthropic: "claude-sonnet-4-5",
15
15
  openai: "gpt-5.1-codex",
16
+ "openai-codex": "codex-max",
16
17
  google: "gemini-2.5-pro",
17
18
  "google-gemini-cli": "gemini-2.5-pro",
18
19
  "google-antigravity": "gemini-3-pro-high",
20
+ "google-vertex": "gemini-2.5-pro",
19
21
  "github-copilot": "gpt-4o",
20
22
  openrouter: "openai/gpt-5.1-codex",
21
23
  xai: "grok-4-fast-non-reasoning",
@@ -192,7 +194,7 @@ export function parseModelPattern(pattern: string, availableModels: Model<Api>[]
192
194
  * strips colon-suffixes to find a match.
193
195
  */
194
196
  export async function resolveModelScope(patterns: string[], modelRegistry: ModelRegistry): Promise<ScopedModel[]> {
195
- const availableModels = await modelRegistry.getAvailable();
197
+ const availableModels = modelRegistry.getAvailable();
196
198
  const scopedModels: ScopedModel[] = [];
197
199
 
198
200
  for (const pattern of patterns) {
@@ -321,7 +323,7 @@ export async function findInitialModel(options: {
321
323
  }
322
324
 
323
325
  // 4. Try first available model with valid API key
324
- const availableModels = await modelRegistry.getAvailable();
326
+ const availableModels = modelRegistry.getAvailable();
325
327
 
326
328
  if (availableModels.length > 0) {
327
329
  // Try to find a default model from known providers
@@ -382,7 +384,7 @@ export async function restoreModelFromSession(
382
384
  }
383
385
 
384
386
  // Try to find any available model
385
- const availableModels = await modelRegistry.getAvailable();
387
+ const availableModels = modelRegistry.getAvailable();
386
388
 
387
389
  if (availableModels.length > 0) {
388
390
  // Try to find a default model from known providers
@@ -427,7 +429,7 @@ export async function findSmolModel(
427
429
  modelRegistry: ModelRegistry,
428
430
  savedModel?: string,
429
431
  ): Promise<Model<Api> | undefined> {
430
- const availableModels = await modelRegistry.getAvailable();
432
+ const availableModels = modelRegistry.getAvailable();
431
433
  if (availableModels.length === 0) return undefined;
432
434
 
433
435
  // 1. Try saved model from settings
@@ -466,7 +468,7 @@ export async function findSlowModel(
466
468
  modelRegistry: ModelRegistry,
467
469
  savedModel?: string,
468
470
  ): Promise<Model<Api> | undefined> {
469
- const availableModels = await modelRegistry.getAvailable();
471
+ const availableModels = modelRegistry.getAvailable();
470
472
  if (availableModels.length === 0) return undefined;
471
473
 
472
474
  // 1. Try saved model from settings
@@ -0,0 +1,242 @@
1
+ import { join, resolve } from "node:path";
2
+ import { CONFIG_DIR_NAME, getPromptsDir } from "../config";
3
+
4
+ /**
5
+ * Represents a prompt template loaded from a markdown file
6
+ */
7
+ export interface PromptTemplate {
8
+ name: string;
9
+ description: string;
10
+ content: string;
11
+ source: string; // e.g., "(user)", "(project)", "(project:frontend)"
12
+ }
13
+
14
+ /**
15
+ * Parse YAML frontmatter from markdown content
16
+ * Returns { frontmatter, content } where content has frontmatter stripped
17
+ */
18
+ function parseFrontmatter(content: string): { frontmatter: Record<string, string>; content: string } {
19
+ const frontmatter: Record<string, string> = {};
20
+
21
+ if (!content.startsWith("---")) {
22
+ return { frontmatter, content };
23
+ }
24
+
25
+ const endIndex = content.indexOf("\n---", 3);
26
+ if (endIndex === -1) {
27
+ return { frontmatter, content };
28
+ }
29
+
30
+ const frontmatterBlock = content.slice(4, endIndex);
31
+ const remainingContent = content.slice(endIndex + 4).trim();
32
+
33
+ // Simple YAML parsing - just key: value pairs
34
+ for (const line of frontmatterBlock.split("\n")) {
35
+ const match = line.match(/^(\w+):\s*(.*)$/);
36
+ if (match) {
37
+ frontmatter[match[1]] = match[2].trim();
38
+ }
39
+ }
40
+
41
+ return { frontmatter, content: remainingContent };
42
+ }
43
+
44
+ /**
45
+ * Parse command arguments respecting quoted strings (bash-style)
46
+ * Returns array of arguments
47
+ */
48
+ export function parseCommandArgs(argsString: string): string[] {
49
+ const args: string[] = [];
50
+ let current = "";
51
+ let inQuote: string | null = null;
52
+
53
+ for (let i = 0; i < argsString.length; i++) {
54
+ const char = argsString[i];
55
+
56
+ if (inQuote) {
57
+ if (char === inQuote) {
58
+ inQuote = null;
59
+ } else {
60
+ current += char;
61
+ }
62
+ } else if (char === '"' || char === "'") {
63
+ inQuote = char;
64
+ } else if (char === " " || char === "\t") {
65
+ if (current) {
66
+ args.push(current);
67
+ current = "";
68
+ }
69
+ } else {
70
+ current += char;
71
+ }
72
+ }
73
+
74
+ if (current) {
75
+ args.push(current);
76
+ }
77
+
78
+ return args;
79
+ }
80
+
81
+ /**
82
+ * Substitute argument placeholders in template content
83
+ * Supports $1, $2, ... for positional args, $@ and $ARGUMENTS for all args
84
+ *
85
+ * Note: Replacement happens on the template string only. Argument values
86
+ * containing patterns like $1, $@, or $ARGUMENTS are NOT recursively substituted.
87
+ */
88
+ export function substituteArgs(content: string, args: string[]): string {
89
+ let result = content;
90
+
91
+ // Replace $1, $2, etc. with positional args FIRST (before wildcards)
92
+ // This prevents wildcard replacement values containing $<digit> patterns from being re-substituted
93
+ result = result.replace(/\$(\d+)/g, (_, num) => {
94
+ const index = parseInt(num, 10) - 1;
95
+ return args[index] ?? "";
96
+ });
97
+
98
+ // Pre-compute all args joined (optimization)
99
+ const allArgs = args.join(" ");
100
+
101
+ // Replace $ARGUMENTS with all args joined (new syntax, aligns with Claude, Codex, OpenCode)
102
+ result = result.replace(/\$ARGUMENTS/g, allArgs);
103
+
104
+ // Replace $@ with all args joined (existing syntax)
105
+ result = result.replace(/\$@/g, allArgs);
106
+
107
+ return result;
108
+ }
109
+
110
+ /**
111
+ * Recursively scan a directory for .md files (and symlinks to .md files) and load them as prompt templates
112
+ */
113
+ async function loadTemplatesFromDir(
114
+ dir: string,
115
+ source: "user" | "project",
116
+ subdir: string = "",
117
+ ): Promise<PromptTemplate[]> {
118
+ const templates: PromptTemplate[] = [];
119
+
120
+ try {
121
+ const stat = await Bun.file(`${dir}/.`).exists();
122
+ if (!stat) return templates;
123
+ } catch {
124
+ return templates;
125
+ }
126
+
127
+ try {
128
+ const glob = new Bun.Glob("**/*");
129
+ const entries = [];
130
+ for await (const entry of glob.scan({ cwd: dir, absolute: false, onlyFiles: false })) {
131
+ entries.push(entry);
132
+ }
133
+
134
+ // Group by path depth to process directories before deeply nested files
135
+ entries.sort((a, b) => a.split("/").length - b.split("/").length);
136
+
137
+ for (const entry of entries) {
138
+ const fullPath = join(dir, entry);
139
+ const file = Bun.file(fullPath);
140
+
141
+ try {
142
+ const stat = await file.exists();
143
+ if (!stat) continue;
144
+
145
+ if (entry.endsWith(".md")) {
146
+ const rawContent = await file.text();
147
+ const { frontmatter, content } = parseFrontmatter(rawContent);
148
+
149
+ const name = entry.split("/").pop()!.slice(0, -3); // Remove .md extension
150
+
151
+ // Build source string based on subdirectory structure
152
+ const entryDir = entry.includes("/") ? entry.split("/").slice(0, -1).join(":") : "";
153
+ const fullSubdir = subdir && entryDir ? `${subdir}:${entryDir}` : entryDir || subdir;
154
+
155
+ let sourceStr: string;
156
+ if (source === "user") {
157
+ sourceStr = fullSubdir ? `(user:${fullSubdir})` : "(user)";
158
+ } else {
159
+ sourceStr = fullSubdir ? `(project:${fullSubdir})` : "(project)";
160
+ }
161
+
162
+ // Get description from frontmatter or first non-empty line
163
+ let description = frontmatter.description || "";
164
+ if (!description) {
165
+ const firstLine = content.split("\n").find((line) => line.trim());
166
+ if (firstLine) {
167
+ // Truncate if too long
168
+ description = firstLine.slice(0, 60);
169
+ if (firstLine.length > 60) description += "...";
170
+ }
171
+ }
172
+
173
+ // Append source to description
174
+ description = description ? `${description} ${sourceStr}` : sourceStr;
175
+
176
+ templates.push({
177
+ name,
178
+ description,
179
+ content,
180
+ source: sourceStr,
181
+ });
182
+ }
183
+ } catch (_error) {
184
+ // Silently skip files that can't be read
185
+ }
186
+ }
187
+ } catch (_error) {
188
+ // Silently skip directories that can't be read
189
+ }
190
+
191
+ return templates;
192
+ }
193
+
194
+ export interface LoadPromptTemplatesOptions {
195
+ /** Working directory for project-local templates. Default: process.cwd() */
196
+ cwd?: string;
197
+ /** Agent config directory for global templates. Default: from getPromptsDir() */
198
+ agentDir?: string;
199
+ }
200
+
201
+ /**
202
+ * Load all prompt templates from:
203
+ * 1. Global: agentDir/prompts/
204
+ * 2. Project: cwd/{CONFIG_DIR_NAME}/prompts/
205
+ */
206
+ export async function loadPromptTemplates(options: LoadPromptTemplatesOptions = {}): Promise<PromptTemplate[]> {
207
+ const resolvedCwd = options.cwd ?? process.cwd();
208
+ const resolvedAgentDir = options.agentDir ?? getPromptsDir();
209
+
210
+ const templates: PromptTemplate[] = [];
211
+
212
+ // 1. Load global templates from agentDir/prompts/
213
+ // Note: if agentDir is provided, it should be the agent dir, not the prompts dir
214
+ const globalPromptsDir = options.agentDir ? join(options.agentDir, "prompts") : resolvedAgentDir;
215
+ templates.push(...(await loadTemplatesFromDir(globalPromptsDir, "user")));
216
+
217
+ // 2. Load project templates from cwd/{CONFIG_DIR_NAME}/prompts/
218
+ const projectPromptsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "prompts");
219
+ templates.push(...(await loadTemplatesFromDir(projectPromptsDir, "project")));
220
+
221
+ return templates;
222
+ }
223
+
224
+ /**
225
+ * Expand a prompt template if it matches a template name.
226
+ * Returns the expanded content or the original text if not a template.
227
+ */
228
+ export function expandPromptTemplate(text: string, templates: PromptTemplate[]): string {
229
+ if (!text.startsWith("/")) return text;
230
+
231
+ const spaceIndex = text.indexOf(" ");
232
+ const templateName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
233
+ const argsString = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1);
234
+
235
+ const template = templates.find((t) => t.name === templateName);
236
+ if (template) {
237
+ const args = parseCommandArgs(argsString);
238
+ return substituteArgs(template.content, args);
239
+ }
240
+
241
+ return text;
242
+ }