@oh-my-pi/pi-coding-agent 12.5.0 → 12.6.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [12.6.0] - 2026-02-16
6
+ ### Added
7
+
8
+ - Added runtime tests covering extension provider registration and deferred model pattern resolution behavior.
9
+
10
+ ### Changed
11
+
12
+ - Improved welcome screen responsiveness to dynamically show or hide the right column based on available terminal width
13
+ - Extended extension `registerProvider()` typing with OAuth provider support and source-aware registration metadata.
14
+
15
+ ### Fixed
16
+
17
+ - Fixed welcome screen layout to gracefully handle small terminal widths and prevent rendering errors on narrow displays
18
+ - Fixed welcome screen title truncation to prevent overflow when content exceeds available width
19
+ - Fixed deferred `--model` resolution so extension-provided models are matched before fallback selection and unresolved explicit patterns no longer silently fallback.
20
+ - Fixed CLI `--api-key` handling for deferred model resolution by applying runtime API key overrides after extension model selection.
21
+ - Fixed extension provider registration cleanup to remove stale source-scoped custom API/OAuth providers across extension reloads.
22
+
23
+ ## [12.5.1] - 2026-02-15
24
+ ### Added
25
+
26
+ - Added `repeatToolDescriptions` setting to render full tool descriptions in the system prompt instead of a tool name list
27
+
5
28
  ## [12.5.0] - 2026-02-15
6
29
  ### Breaking Changes
7
30
 
@@ -886,6 +886,37 @@ pi.registerCommand("stats", {
886
886
  },
887
887
  });
888
888
  ```
889
+ ### pi.registerProvider(name, config)
890
+
891
+ Register or override providers/models at runtime:
892
+
893
+ ```typescript
894
+ pi.registerProvider("my-provider", {
895
+ baseUrl: "https://api.example.com/v1",
896
+ apiKey: "MY_PROVIDER_API_KEY",
897
+ api: "openai-completions",
898
+ models: [
899
+ {
900
+ id: "my-model",
901
+ name: "My Model",
902
+ reasoning: false,
903
+ input: ["text"],
904
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
905
+ contextWindow: 128000,
906
+ maxTokens: 8192,
907
+ },
908
+ ],
909
+ });
910
+ ```
911
+
912
+ `registerProvider()` also supports:
913
+
914
+ - `streamSimple` for custom API adapters
915
+ - `headers` / `authHeader` for request customization
916
+ - `oauth` for `/login <provider>` support with extension-defined login/refresh behavior
917
+
918
+ Provider registrations are queued during extension load and applied when the session initializes.
919
+
889
920
 
890
921
  ### pi.registerMessageRenderer(customType, renderer)
891
922
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "12.5.0",
3
+ "version": "12.6.0",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "bin": {
@@ -84,12 +84,12 @@
84
84
  },
85
85
  "dependencies": {
86
86
  "@mozilla/readability": "0.6.0",
87
- "@oh-my-pi/omp-stats": "12.5.0",
88
- "@oh-my-pi/pi-agent-core": "12.5.0",
89
- "@oh-my-pi/pi-ai": "12.5.0",
90
- "@oh-my-pi/pi-natives": "12.5.0",
91
- "@oh-my-pi/pi-tui": "12.5.0",
92
- "@oh-my-pi/pi-utils": "12.5.0",
87
+ "@oh-my-pi/omp-stats": "12.6.0",
88
+ "@oh-my-pi/pi-agent-core": "12.6.0",
89
+ "@oh-my-pi/pi-ai": "12.6.0",
90
+ "@oh-my-pi/pi-natives": "12.6.0",
91
+ "@oh-my-pi/pi-tui": "12.6.0",
92
+ "@oh-my-pi/pi-utils": "12.6.0",
93
93
  "@sinclair/typebox": "^0.34.48",
94
94
  "@xterm/headless": "^6.0.0",
95
95
  "ajv": "^8.18.0",
@@ -1,10 +1,19 @@
1
1
  import {
2
2
  type Api,
3
+ type AssistantMessageEventStream,
4
+ type Context,
3
5
  getGitHubCopilotBaseUrl,
4
6
  getModels,
5
7
  getProviders,
6
8
  type Model,
7
9
  normalizeDomain,
10
+ type OAuthCredentials,
11
+ type OAuthLoginCallbacks,
12
+ registerCustomApi,
13
+ registerOAuthProvider,
14
+ type SimpleStreamOptions,
15
+ unregisterCustomApis,
16
+ unregisterOAuthProviders,
8
17
  } from "@oh-my-pi/pi-ai";
9
18
  import { logger } from "@oh-my-pi/pi-utils";
10
19
  import { type Static, Type } from "@sinclair/typebox";
@@ -291,6 +300,7 @@ export class ModelRegistry {
291
300
  #modelOverrides: Map<string, Map<string, ModelOverride>> = new Map();
292
301
  #configError: ConfigError | undefined = undefined;
293
302
  #modelsConfigFile: ConfigFile<ModelsConfig>;
303
+ #registeredProviderSources: Set<string> = new Set();
294
304
 
295
305
  /**
296
306
  * @param authStorage - Auth storage for API key resolution
@@ -705,4 +715,162 @@ export class ModelRegistry {
705
715
  isUsingOAuth(model: Model<Api>): boolean {
706
716
  return this.authStorage.hasOAuth(model.provider);
707
717
  }
718
+
719
+ /**
720
+ * Remove custom API/OAuth registrations for a specific extension source.
721
+ */
722
+ clearSourceRegistrations(sourceId: string): void {
723
+ unregisterCustomApis(sourceId);
724
+ unregisterOAuthProviders(sourceId);
725
+ }
726
+
727
+ /**
728
+ * Remove registrations for extension sources that are no longer active.
729
+ */
730
+ syncExtensionSources(activeSourceIds: string[]): void {
731
+ const activeSources = new Set(activeSourceIds);
732
+ for (const sourceId of this.#registeredProviderSources) {
733
+ if (activeSources.has(sourceId)) {
734
+ continue;
735
+ }
736
+ this.clearSourceRegistrations(sourceId);
737
+ this.#registeredProviderSources.delete(sourceId);
738
+ }
739
+ }
740
+
741
+ /**
742
+ * Register a provider dynamically (from extensions).
743
+ *
744
+ * If provider has models: replaces all existing models for this provider.
745
+ * If provider has only baseUrl/headers: overrides existing models' URLs.
746
+ * If provider has streamSimple: registers a custom API streaming function.
747
+ * If provider has oauth: registers OAuth provider for /login support.
748
+ */
749
+ registerProvider(providerName: string, config: ProviderConfigInput, sourceId?: string): void {
750
+ if (config.streamSimple && !config.api) {
751
+ throw new Error(`Provider ${providerName}: "api" is required when registering streamSimple.`);
752
+ }
753
+
754
+ if (config.models && config.models.length > 0) {
755
+ if (!config.baseUrl) {
756
+ throw new Error(`Provider ${providerName}: "baseUrl" is required when defining models.`);
757
+ }
758
+ if (!config.apiKey && !config.oauth) {
759
+ throw new Error(`Provider ${providerName}: "apiKey" or "oauth" is required when defining models.`);
760
+ }
761
+ for (const modelDef of config.models) {
762
+ const api = modelDef.api || config.api;
763
+ if (!api) {
764
+ throw new Error(`Provider ${providerName}, model ${modelDef.id}: no "api" specified.`);
765
+ }
766
+ }
767
+ }
768
+
769
+ if (config.streamSimple && config.api) {
770
+ const streamSimple = config.streamSimple;
771
+ registerCustomApi(config.api, streamSimple, sourceId, (model, context, options) =>
772
+ streamSimple(model, context, options as SimpleStreamOptions),
773
+ );
774
+ }
775
+
776
+ if (config.oauth) {
777
+ registerOAuthProvider({
778
+ ...config.oauth,
779
+ id: providerName,
780
+ sourceId,
781
+ });
782
+ }
783
+
784
+ if (sourceId) {
785
+ this.#registeredProviderSources.add(sourceId);
786
+ }
787
+ if (config.apiKey) {
788
+ this.#customProviderApiKeys.set(providerName, config.apiKey);
789
+ }
790
+
791
+ if (config.models && config.models.length > 0) {
792
+ const nextModels = this.#models.filter(m => m.provider !== providerName);
793
+ for (const modelDef of config.models) {
794
+ const api = modelDef.api || config.api;
795
+ if (!api) {
796
+ throw new Error(`Provider ${providerName}, model ${modelDef.id}: no "api" specified.`);
797
+ }
798
+ let headers = config.headers || modelDef.headers ? { ...config.headers, ...modelDef.headers } : undefined;
799
+ if (config.authHeader && config.apiKey) {
800
+ const resolvedKey = resolveApiKeyConfig(config.apiKey);
801
+ if (resolvedKey) {
802
+ headers = { ...headers, Authorization: `Bearer ${resolvedKey}` };
803
+ }
804
+ }
805
+
806
+ nextModels.push({
807
+ id: modelDef.id,
808
+ name: modelDef.name,
809
+ api,
810
+ provider: providerName,
811
+ baseUrl: config.baseUrl!,
812
+ reasoning: modelDef.reasoning,
813
+ input: modelDef.input as ("text" | "image")[],
814
+ cost: modelDef.cost,
815
+ contextWindow: modelDef.contextWindow,
816
+ maxTokens: modelDef.maxTokens,
817
+ headers,
818
+ compat: modelDef.compat,
819
+ } as Model<Api>);
820
+ }
821
+
822
+ if (config.oauth?.modifyModels) {
823
+ const credential = this.authStorage.getOAuthCredential(providerName);
824
+ if (credential) {
825
+ this.#models = config.oauth.modifyModels(nextModels, credential);
826
+ return;
827
+ }
828
+ }
829
+
830
+ this.#models = nextModels;
831
+ return;
832
+ }
833
+
834
+ if (config.baseUrl) {
835
+ this.#models = this.#models.map(m => {
836
+ if (m.provider !== providerName) return m;
837
+ return {
838
+ ...m,
839
+ baseUrl: config.baseUrl ?? m.baseUrl,
840
+ headers: config.headers ? { ...m.headers, ...config.headers } : m.headers,
841
+ };
842
+ });
843
+ }
844
+ }
845
+ }
846
+
847
+ /**
848
+ * Input type for registerProvider API (from extensions).
849
+ */
850
+ export interface ProviderConfigInput {
851
+ baseUrl?: string;
852
+ apiKey?: string;
853
+ api?: Api;
854
+ streamSimple?: (model: Model<Api>, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream;
855
+ headers?: Record<string, string>;
856
+ authHeader?: boolean;
857
+ oauth?: {
858
+ name: string;
859
+ login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials | string>;
860
+ refreshToken?(credentials: OAuthCredentials): Promise<OAuthCredentials>;
861
+ getApiKey?(credentials: OAuthCredentials): string;
862
+ modifyModels?(models: Model<Api>[], credentials: OAuthCredentials): Model<Api>[];
863
+ };
864
+ models?: Array<{
865
+ id: string;
866
+ name: string;
867
+ api?: Api;
868
+ reasoning: boolean;
869
+ input: ("text" | "image")[];
870
+ cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
871
+ contextWindow: number;
872
+ maxTokens: number;
873
+ headers?: Record<string, string>;
874
+ compat?: Model<Api>["compat"];
875
+ }>;
708
876
  }
@@ -253,6 +253,15 @@ export const SETTINGS_SCHEMA = {
253
253
  description: "Rewrite tool call arguments to normalized format in session history",
254
254
  },
255
255
  },
256
+ repeatToolDescriptions: {
257
+ type: "boolean",
258
+ default: false,
259
+ ui: {
260
+ tab: "agent",
261
+ label: "Repeat tool descriptions",
262
+ description: "Render full tool descriptions in the system prompt instead of a tool name list",
263
+ },
264
+ },
256
265
  readLineNumbers: {
257
266
  type: "boolean",
258
267
  default: false,
@@ -70,6 +70,9 @@ export type {
70
70
  // Message Rendering
71
71
  MessageRenderer,
72
72
  MessageRenderOptions,
73
+ // Provider Registration
74
+ ProviderConfig,
75
+ ProviderModelConfig,
73
76
  ReadToolCallEvent,
74
77
  ReadToolResultEvent,
75
78
  // Commands
@@ -52,6 +52,8 @@ export class ExtensionRuntimeNotInitializedError extends Error {
52
52
  */
53
53
  export class ExtensionRuntime implements IExtensionRuntime {
54
54
  flagValues = new Map<string, boolean | string>();
55
+ pendingProviderRegistrations: Array<{ name: string; config: import("./types").ProviderConfig; sourceId: string }> =
56
+ [];
55
57
 
56
58
  sendMessage(): void {
57
59
  throw new ExtensionRuntimeNotInitializedError();
@@ -108,6 +110,11 @@ class ConcreteExtensionAPI implements ExtensionAPI, IExtensionRuntime {
108
110
  readonly typebox = TypeBox;
109
111
  readonly pi = piCodingAgent;
110
112
  readonly flagValues = new Map<string, boolean | string>();
113
+ readonly pendingProviderRegistrations: Array<{
114
+ name: string;
115
+ config: import("./types").ProviderConfig;
116
+ sourceId: string;
117
+ }> = [];
111
118
 
112
119
  constructor(
113
120
  private readonly extension: Extension,
@@ -222,6 +229,10 @@ class ConcreteExtensionAPI implements ExtensionAPI, IExtensionRuntime {
222
229
  setThinkingLevel(level: ThinkingLevel, persist?: boolean): void {
223
230
  this.runtime.setThinkingLevel(level, persist);
224
231
  }
232
+
233
+ registerProvider(name: string, config: import("./types").ProviderConfig): void {
234
+ this.runtime.pendingProviderRegistrations.push({ name, config, sourceId: this.extension.path });
235
+ }
225
236
  }
226
237
 
227
238
  /**
@@ -8,7 +8,18 @@
8
8
  * - Interact with the user via UI primitives
9
9
  */
10
10
  import type { AgentMessage, AgentToolResult, AgentToolUpdateCallback, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
11
- import type { ImageContent, Model, TextContent, ToolResultMessage } from "@oh-my-pi/pi-ai";
11
+ import type {
12
+ Api,
13
+ AssistantMessageEventStream,
14
+ Context,
15
+ ImageContent,
16
+ Model,
17
+ OAuthCredentials,
18
+ OAuthLoginCallbacks,
19
+ SimpleStreamOptions,
20
+ TextContent,
21
+ ToolResultMessage,
22
+ } from "@oh-my-pi/pi-ai";
12
23
  import type * as piCodingAgent from "@oh-my-pi/pi-coding-agent";
13
24
  import type { AutocompleteItem, Component, EditorComponent, EditorTheme, KeyId, TUI } from "@oh-my-pi/pi-tui";
14
25
  import type { Static, TSchema } from "@sinclair/typebox";
@@ -976,10 +987,108 @@ export interface ExtensionAPI {
976
987
  /** Set thinking level (clamped to model capabilities). */
977
988
  setThinkingLevel(level: ThinkingLevel): void;
978
989
 
990
+ // =========================================================================
991
+ // Provider Registration
992
+ // =========================================================================
993
+
994
+ /**
995
+ * Register or override a model provider.
996
+ *
997
+ * If `models` is provided: replaces all existing models for this provider.
998
+ * If only `baseUrl` is provided: overrides the URL for existing models.
999
+ * If `streamSimple` is provided: registers a custom API stream handler.
1000
+ *
1001
+ * @example
1002
+ * // Register a new provider with custom models and streaming
1003
+ * pi.registerProvider("google-vertex-claude", {
1004
+ * baseUrl: "https://us-east5-aiplatform.googleapis.com",
1005
+ * apiKey: "GOOGLE_CLOUD_PROJECT",
1006
+ * api: "vertex-claude-api",
1007
+ * streamSimple: myStreamFunction,
1008
+ * models: [
1009
+ * {
1010
+ * id: "claude-sonnet-4@20250514",
1011
+ * name: "Claude Sonnet 4 (Vertex)",
1012
+ * reasoning: true,
1013
+ * input: ["text", "image"],
1014
+ * cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
1015
+ * contextWindow: 200000,
1016
+ * maxTokens: 64000,
1017
+ * }
1018
+ * ]
1019
+ * });
1020
+ *
1021
+ * @example
1022
+ * // Override baseUrl for an existing provider
1023
+ * pi.registerProvider("anthropic", {
1024
+ * baseUrl: "https://proxy.example.com"
1025
+ * });
1026
+ */
1027
+ registerProvider(name: string, config: ProviderConfig): void;
1028
+
979
1029
  /** Shared event bus for extension communication. */
980
1030
  events: EventBus;
981
1031
  }
982
1032
 
1033
+ // ============================================================================
1034
+ // Provider Registration Types
1035
+ // ============================================================================
1036
+
1037
+ /** Configuration for registering a provider via pi.registerProvider(). */
1038
+ export interface ProviderConfig {
1039
+ /** Base URL for the API endpoint. Required when defining models. */
1040
+ baseUrl?: string;
1041
+ /** API key or environment variable name. Required when defining models unless oauth is provided. */
1042
+ apiKey?: string;
1043
+ /** API type identifier. Required when registering streamSimple or when models don't specify one. */
1044
+ api?: Api;
1045
+ /** Custom streaming function for non-built-in APIs. */
1046
+ streamSimple?: (model: Model<Api>, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream;
1047
+ /** Custom headers to include in requests. */
1048
+ headers?: Record<string, string>;
1049
+ /** If true, adds Authorization: Bearer header with the resolved API key. */
1050
+ authHeader?: boolean;
1051
+ /** Models to register. If provided, replaces all existing models for this provider. */
1052
+ models?: ProviderModelConfig[];
1053
+ /** OAuth provider for /login support. */
1054
+ oauth?: {
1055
+ /** Display name in login UI. */
1056
+ name: string;
1057
+ /** Run the provider login flow and return credentials (or a plain API key) to persist. */
1058
+ login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials | string>;
1059
+ /** Refresh expired credentials. */
1060
+ refreshToken?(credentials: OAuthCredentials): Promise<OAuthCredentials>;
1061
+ /** Convert credentials to an API key string for requests. */
1062
+ getApiKey?(credentials: OAuthCredentials): string;
1063
+ /** Optional model rewrite hook for credential-aware routing (e.g., enterprise URLs). */
1064
+ modifyModels?(models: Model<Api>[], credentials: OAuthCredentials): Model<Api>[];
1065
+ };
1066
+ }
1067
+
1068
+ /** Configuration for a model within a provider. */
1069
+ export interface ProviderModelConfig {
1070
+ /** Model ID (e.g., "claude-sonnet-4@20250514"). */
1071
+ id: string;
1072
+ /** Display name (e.g., "Claude Sonnet 4 (Vertex)"). */
1073
+ name: string;
1074
+ /** API type override for this model. */
1075
+ api?: Api;
1076
+ /** Whether the model supports extended thinking. */
1077
+ reasoning: boolean;
1078
+ /** Supported input types. */
1079
+ input: ("text" | "image")[];
1080
+ /** Cost per million tokens. */
1081
+ cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
1082
+ /** Maximum context window size in tokens. */
1083
+ contextWindow: number;
1084
+ /** Maximum output tokens. */
1085
+ maxTokens: number;
1086
+ /** Custom headers for this model. */
1087
+ headers?: Record<string, string>;
1088
+ /** OpenAI compatibility settings. */
1089
+ compat?: Model<Api>["compat"];
1090
+ }
1091
+
983
1092
  /** Extension factory function type. Supports both sync and async initialization. */
984
1093
  export type ExtensionFactory = (pi: ExtensionAPI) => void | Promise<void>;
985
1094
 
@@ -1038,6 +1147,8 @@ export type SetThinkingLevelHandler = (level: ThinkingLevel, persist?: boolean)
1038
1147
  /** Shared state created by loader, used during registration and runtime. */
1039
1148
  export interface ExtensionRuntimeState {
1040
1149
  flagValues: Map<string, boolean | string>;
1150
+ /** Provider registrations queued during extension loading, processed during session initialization */
1151
+ pendingProviderRegistrations: Array<{ name: string; config: ProviderConfig; sourceId: string }>;
1041
1152
  }
1042
1153
 
1043
1154
  /** Action implementations for ExtensionAPI methods. */
package/src/index.ts CHANGED
@@ -62,6 +62,8 @@ export type {
62
62
  LoadExtensionsResult,
63
63
  MessageRenderer,
64
64
  MessageRenderOptions,
65
+ ProviderConfig,
66
+ ProviderModelConfig,
65
67
  RegisteredCommand,
66
68
  ToolCallEvent,
67
69
  ToolResultEvent,
package/src/main.ts CHANGED
@@ -372,7 +372,7 @@ async function buildSessionOptions(
372
372
 
373
373
  // Model from CLI (--model) - uses same fuzzy matching as --models
374
374
  if (parsed.model) {
375
- const available = modelRegistry.getAvailable();
375
+ const available = modelRegistry.getAll();
376
376
  const modelMatchPreferences = {
377
377
  usageOrder: settings.getStorage()?.getModelUsageOrder(),
378
378
  };
@@ -381,11 +381,13 @@ async function buildSessionOptions(
381
381
  writeStderr(chalk.yellow(`Warning: ${warning}`));
382
382
  }
383
383
  if (!model) {
384
- writeStderr(chalk.red(`Model "${parsed.model}" not found`));
385
- process.exit(1);
384
+ // Model not found in built-in registry — defer resolution to after extensions load
385
+ // (extensions may register additional providers/models via registerProvider)
386
+ options.modelPattern = parsed.model;
387
+ } else {
388
+ options.model = model;
389
+ settings.overrideModelRoles({ default: `${model.provider}/${model.id}` });
386
390
  }
387
- options.model = model;
388
- settings.overrideModelRoles({ default: `${model.provider}/${model.id}` });
389
391
  } else if (scopedModels.length > 0 && !parsed.continue && !parsed.resume) {
390
392
  const remembered = settings.getModelRole("default");
391
393
  if (remembered) {
@@ -610,11 +612,13 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
610
612
 
611
613
  // Handle CLI --api-key as runtime override (not persisted)
612
614
  if (parsedArgs.apiKey) {
613
- if (!sessionOptions.model) {
615
+ if (!sessionOptions.model && !sessionOptions.modelPattern) {
614
616
  writeStderr(chalk.red("--api-key requires a model to be specified via --provider/--model or -m/--models"));
615
617
  process.exit(1);
616
618
  }
617
- authStorage.setRuntimeApiKey(sessionOptions.model.provider, parsedArgs.apiKey);
619
+ if (sessionOptions.model) {
620
+ authStorage.setRuntimeApiKey(sessionOptions.model.provider, parsedArgs.apiKey);
621
+ }
618
622
  }
619
623
 
620
624
  time("buildSessionOptions");
@@ -622,6 +626,9 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
622
626
  await createAgentSession(sessionOptions);
623
627
  debugStartup("main:createAgentSession");
624
628
  time("createAgentSession");
629
+ if (parsedArgs.apiKey && !sessionOptions.model && session.model) {
630
+ authStorage.setRuntimeApiKey(session.model.provider, parsedArgs.apiKey);
631
+ }
625
632
 
626
633
  if (modelFallbackMessage) {
627
634
  notifs.push({ kind: "warn", message: modelFallbackMessage });
@@ -660,7 +667,11 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
660
667
  debugStartup("main:applyExtensionFlags");
661
668
 
662
669
  if (!isInteractive && !session.model) {
663
- writeStderr(chalk.red("No models available."));
670
+ if (modelFallbackMessage) {
671
+ writeStderr(chalk.red(modelFallbackMessage));
672
+ } else {
673
+ writeStderr(chalk.red("No models available."));
674
+ }
664
675
  writeStderr(chalk.yellow("\nSet an API key environment variable:"));
665
676
  writeStderr(" ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, etc.");
666
677
  writeStderr(chalk.yellow(`\nOr create ${ModelsConfigFile.path()}`));
@@ -41,12 +41,31 @@ export class WelcomeComponent implements Component {
41
41
  }
42
42
 
43
43
  render(termWidth: number): string[] {
44
- // Box dimensions - responsive with min/max
45
- const minWidth = 80;
44
+ // Box dimensions - responsive with max width and small-terminal support
46
45
  const maxWidth = 100;
47
- const boxWidth = Math.max(minWidth, Math.min(termWidth - 2, maxWidth));
48
- const leftCol = 26;
49
- const rightCol = boxWidth - leftCol - 3; // 3 = │ + │ + │
46
+ const boxWidth = Math.min(maxWidth, Math.max(0, termWidth - 2));
47
+ if (boxWidth < 4) {
48
+ return [];
49
+ }
50
+ const dualContentWidth = boxWidth - 3; // 3 = │ + │ + │
51
+ const preferredLeftCol = 26;
52
+ const minLeftCol = 14; // logo width
53
+ const minRightCol = 20;
54
+ const leftMinContentWidth = Math.max(
55
+ minLeftCol,
56
+ visibleWidth("Welcome back!"),
57
+ visibleWidth(this.modelName),
58
+ visibleWidth(this.providerName),
59
+ );
60
+ const desiredLeftCol = Math.min(preferredLeftCol, Math.max(minLeftCol, Math.floor(dualContentWidth * 0.35)));
61
+ const dualLeftCol =
62
+ dualContentWidth >= minRightCol + 1
63
+ ? Math.min(desiredLeftCol, dualContentWidth - minRightCol)
64
+ : Math.max(1, dualContentWidth - 1);
65
+ const dualRightCol = Math.max(1, dualContentWidth - dualLeftCol);
66
+ const showRightColumn = dualLeftCol >= leftMinContentWidth && dualRightCol >= minRightCol;
67
+ const leftCol = showRightColumn ? dualLeftCol : boxWidth - 2;
68
+ const rightCol = showRightColumn ? dualRightCol : 0;
50
69
 
51
70
  // Block-based OMP logo (gradient: magenta → cyan)
52
71
  // biome-ignore format: preserve ASCII art layout
@@ -67,7 +86,7 @@ export class WelcomeComponent implements Component {
67
86
  ];
68
87
 
69
88
  // Right column separator
70
- const separatorWidth = rightCol - 2; // padding on each side
89
+ const separatorWidth = Math.max(0, rightCol - 2); // padding on each side
71
90
  const separator = ` ${theme.fg("dim", theme.boxRound.horizontal.repeat(separatorWidth))}`;
72
91
 
73
92
  // Recent sessions content
@@ -131,20 +150,31 @@ export class WelcomeComponent implements Component {
131
150
  const titlePrefixRaw = hChar.repeat(3);
132
151
  const titleStyled = theme.fg("dim", titlePrefixRaw) + theme.fg("muted", title);
133
152
  const titleVisLen = visibleWidth(titlePrefixRaw) + visibleWidth(title);
134
- const afterTitle = boxWidth - 2 - titleVisLen;
135
- const afterTitleText = afterTitle > 0 ? theme.fg("dim", hChar.repeat(afterTitle)) : "";
136
- lines.push(tl + titleStyled + afterTitleText + tr);
153
+ const titleSpace = boxWidth - 2;
154
+ if (titleVisLen >= titleSpace) {
155
+ lines.push(tl + truncateToWidth(titleStyled, titleSpace) + tr);
156
+ } else {
157
+ const afterTitle = titleSpace - titleVisLen;
158
+ lines.push(tl + titleStyled + theme.fg("dim", hChar.repeat(afterTitle)) + tr);
159
+ }
137
160
 
138
161
  // Content rows
139
- const maxRows = Math.max(leftLines.length, rightLines.length);
162
+ const maxRows = showRightColumn ? Math.max(leftLines.length, rightLines.length) : leftLines.length;
140
163
  for (let i = 0; i < maxRows; i++) {
141
164
  const left = this.#fitToWidth(leftLines[i] ?? "", leftCol);
142
- const right = this.#fitToWidth(rightLines[i] ?? "", rightCol);
143
- lines.push(v + left + v + right + v);
165
+ if (showRightColumn) {
166
+ const right = this.#fitToWidth(rightLines[i] ?? "", rightCol);
167
+ lines.push(v + left + v + right + v);
168
+ } else {
169
+ lines.push(v + left + v);
170
+ }
144
171
  }
145
-
146
172
  // Bottom border
147
- lines.push(bl + h.repeat(leftCol) + theme.fg("dim", theme.boxSharp.teeUp) + h.repeat(rightCol) + br);
173
+ if (showRightColumn) {
174
+ lines.push(bl + h.repeat(leftCol) + theme.fg("dim", theme.boxSharp.teeUp) + h.repeat(rightCol) + br);
175
+ } else {
176
+ lines.push(bl + h.repeat(leftCol) + br);
177
+ }
148
178
 
149
179
  return lines;
150
180
  }
@@ -688,6 +688,6 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
688
688
  }
689
689
  }
690
690
 
691
- // Keep process alive forever
692
- return new Promise(() => {});
691
+ // stdin closed RPC client is gone, exit cleanly
692
+ process.exit(0);
693
693
  }
@@ -56,11 +56,15 @@ The question is not "does this work?" but "under what conditions? What happens o
56
56
 
57
57
  <tools>
58
58
  ## Available Tools
59
+ {{#if repeatToolDescriptions}}
59
60
  {{#each toolDescriptions}}
60
61
  <tool name="{{name}}">
61
62
  {{description}}
62
63
  </tool>
63
64
  {{/each}}
65
+ {{else}}
66
+ {{#list tools join="\n"}}- {{this}}{{/list}}
67
+ {{/if}}
64
68
 
65
69
  {{#ifAny (includes tools "python") (includes tools "bash")}}
66
70
  ### Precedence: Specialized → Python → Bash
@@ -1,94 +1,85 @@
1
- # Edit (Hash anchored)
1
+ # Edit (Hash Anchored)
2
2
 
3
- Line-addressed edits using hash-verified line references. Read file in hashline mode, then edit by referencing `LINE:HASH` pairs.
3
+ Line-addressed edits using hash-verified line references. Read files in hashline mode, collect exact `LINE:HASH` references, and submit edits that change only the targeted token or expression.
4
+ **CRITICAL: Copy `LINE:HASH` refs verbatim from read output. Use only the anchor prefix (e.g., `{{hashline 42 "const x = 1"}}`), never the trailing source text after `|`.**
4
5
 
5
- <critical>
6
- - Copy `LINE:HASH` refs verbatim from read output never fabricate or guess hashes
7
- - `new_text` (set_line/replace_lines) or `text` (insert_after) contains plain replacement lines only — no `LINE:HASH` prefix, no diff `+` markers
8
- - On hash mismatch: use the updated `LINE:HASH` refs shown by `>>>` directly; only `read` again if you need additional lines/context
9
- - If you already edited a file in this turn, re-read that file before the next edit to it
10
- - For code-change requests, respond with tool calls, not prose
11
- - Edit only requested lines. Do not reformat unrelated code.
12
- - Direction-lock every mutation: replace the exact currently-present token/expression with the intended target token/expression; never reverse the change or "change something nearby".
13
- - `new_text` must differ from the current line content — sending identical content is rejected as a no-op
14
- </critical>
6
+ <workflow>
7
+ 1. Read the target file (`read`) to obtain `LINE:HASH` references
8
+ 2. Collect the exact `LINE:HASH` refs for lines you will change
9
+ 3. Direction-lock each mutation: identify the exact current token/expression the intended replacement
10
+ 4. Submit one `edit` call containing all operations for that file
11
+ 5. If another edit is needed on the same file: re-read first, then edit (hashes change after every edit)
12
+ 6. Respond with tool calls only no prose
13
+ </workflow>
15
14
 
16
- <instruction>
17
- **Workflow:**
18
- 1. Read target file (`read`)
19
- 2. Collect the exact `LINE:HASH` refs you need
20
- 3. Submit one `edit` call with all known operations for that file
21
- 4. If another change on same file is needed later: re-read first, then edit
22
- 5. Direction-lock each operation before submitting (`exact source token/expression on target line` → `intended replacement`) and keep the mutation to one logical locus. Do not output prose; submit only the tool call.
23
- **Atomicity:** All edits in one call are validated against the file as last read — line numbers and hashes refer to the original state, not after earlier edits in the same array. The applicator sorts and applies bottom-up automatically.
24
- **Edit variants:**
25
- - `{ set_line: { anchor: "LINE:HASH", new_text: "..." } }`
26
- - `{ replace_lines: { start_anchor: "LINE:HASH", end_anchor: "LINE:HASH", new_text: "..." } }`
27
- - `{ insert_after: { anchor: "LINE:HASH", text: "..." } }`
28
- - `{ replace: { old_text: "...", new_text: "...", all?: boolean } }` — substr-style fuzzy replace (no LINE:HASH; use when line refs unavailable)
15
+ <operations>
16
+ Four edit variants are available:
17
+ - **`set_line`**: Replace a single line
18
+ `{ set_line: { anchor: "LINE:HASH", new_text: "..." } }`
19
+ `new_text: ""` keeps the line but makes it blank.
20
+ - **`replace_lines`**: Replace a contiguous range (use for deletions with `new_text: ""`)
21
+ `{ replace_lines: { start_anchor: "LINE:HASH", end_anchor: "LINE:HASH", new_text: "..." } }`
22
+ - **`insert_after`**: Add new content after an anchor line
23
+ `{ insert_after: { anchor: "LINE:HASH", text: "..." } }`
24
+ - **`replace`**: Substring-style fuzzy match (when line refs are unavailable)
25
+ `{ replace: { old_text: "...", new_text: "...", all?: boolean } }`
26
+ **Atomicity:** All edits in one call validate against the file as last read. Line numbers and hashes refer to the original state, not post-edit state. The applicator sorts and applies bottom-up automatically.
27
+ </operations>
29
28
 
30
- `new_text: ""` means delete (for `set_line`/`replace_lines`).
31
- </instruction>
29
+ <rules>
30
+ 1. **Scope each operation minimally.** One logical change site per operation. Use separate `set_line` ops for non-adjacent lines instead of a wide `replace_lines` that spans unchanged code.
31
+ 2. **Preserve original formatting exactly.** Copy each line's whitespace, braces, semicolons, trailing commas, and style — then change only the targeted token/expression. Keep `import { foo }` as-is; keep indentation and line breaks as-is.
32
+ 3. **Use `insert_after` for additions.** When adding a field, argument, or import near existing lines, prefer `insert_after` over replacing a neighboring line.
33
+ 4. **Ensure `new_text` differs from current content.** Identical content is rejected as a no-op.
34
+ 5. **Edit only requested lines.** Leave unrelated code untouched.
35
+ 6. **Lock mutation direction.** Replace the exact currently-present token with the intended target. For swaps between two locations, use two `set_line` ops in one call.
36
+ </rules>
32
37
 
33
- <caution>
34
- **Preserve original formatting.** When writing `new_text`/`text`, copy each line's exact whitespace, braces, and style from the read output — then change *only* the targeted token/expression. Do not:
35
- - Restyle braces: `import { foo }` `import {foo}`
36
- - Reflow arguments onto multiple lines or collapse them onto one line
37
- - Change indentation style, trailing commas, or semicolons on lines you replace
38
- - Do NOT use `replace_lines` over a wide span when multiple `set_line` ops would work — wide ranges tempt reformatting everything in between
39
-
40
- If a change spans multiple non-adjacent lines, use separate `set_line` operations for each not a single `replace_lines` that includes unchanged lines in `new_text`.
41
- - Each edit operation must target one logical change site with minimal scope. If a fix requires two locations, use two operations; never span unrelated lines in one `replace_lines`.
42
- - Self-check before submitting: if your edit touches lines unrelated to the stated fix, split or narrow it.
43
- - Do NOT reformat lines you are replacing — preserve exact whitespace, braces (`{ foo }` not `{foo}`), arrow style, and line breaks. Change ONLY the targeted token/expression. Reformatting causes hash verification failure even when the logic is correct.
44
- - For swaps (exchanging content between two locations), use two `set_line` operations in one call — the applicator handles ordering. Do not try to account for line number shifts between operations.
45
- </caution>
46
- <instruction>
47
- **Recovery:**
48
- - Hash mismatch (`>>>` error): copy the updated `LINE:HASH` refs from the error verbatim and retry with the same intended mutation. Do NOT re-read unless you need lines not shown in the error.
49
- - If hash mismatch repeats after applying updated refs, stop blind retries and re-read the relevant region before retrying.
50
- - After a successful edit, always re-read the file before making another edit to the same file (hashes have changed).
51
- - No-op error ("identical content"): your replacement text matches what the file already contains. STOP and re-read the file — you are likely targeting the wrong line or your replacement is not actually different. Do NOT retry with the same content. After 2 consecutive no-op errors on the same line, re-read the entire function/block to understand the current file state.
52
- </instruction>
53
-
54
- <instruction>
55
- **Preflight schema and validation (required):**
56
- - Payload shape is `{"path": string, "edits": [operation, ...]}` with a non-empty `edits` array.
57
- - Each operation contains exactly one variant key: `set_line`, `replace_lines`, `insert_after`, or `replace`.
58
- - Required fields by variant:
59
- - `set_line`: `anchor`, `new_text`
60
- - `replace_lines`: `start_anchor`, `end_anchor`, `new_text`
61
- - `insert_after`: `anchor`, `text` (non-empty)
62
- - `replace`: `old_text`, `new_text` (fuzzy match; `all: true` for replace-all)
63
- - Each `anchor`/`start_anchor`/`end_anchor` ref matches `^\d+:[A-Za-z0-9]+$` (no spaces, no trailing source text).
64
- - `new_text`/`text` preserves original formatting and changes only the direction-locked target locus.
65
- </instruction>
66
-
67
- <input>
68
- - `path`: File path
69
- - `edits`: Array of edit operations (one of the variants above)
70
- </input>
38
+ <recovery>
39
+ **Hash mismatch (`>>>` error):**
40
+ Copy the updated `LINE:HASH` refs from the error output verbatim and retry with the same intended mutation.
41
+ → Re-read only if you need lines not shown in the error.
42
+ If mismatch repeats after applying updated refs, stop and re-read the relevant region.
43
+ **No-op error ("identical content"):**
44
+ → Stop. Re-read the file — you are targeting the wrong line or your replacement is not different.
45
+ After 2 consecutive no-op errors on the same line, re-read the entire function/block.
46
+ </recovery>
71
47
 
48
+ <examples>
72
49
  <example name="replace single line">
73
- edit {"path":"src/app.py","edits":[{"set_line":{"anchor":"{{hashline 2 'x = 42'}}","new_text":" x = 99"}}]}
50
+ set_line: { anchor: "{{hashline 2 " x"}}", new_text: " x = 99" }
74
51
  </example>
75
52
 
76
53
  <example name="replace range">
77
- edit {"path":"src/app.py","edits":[{"replace_lines":{"start_anchor":"{{hashline 5 'old_value = True'}}","end_anchor":"{{hashline 8 'return result'}}","new_text":" combined = True"}}]}
54
+ replace_lines: { start_anchor: "{{hashline 5 "old start line"}}", end_anchor: "{{hashline 8 "old end line"}}", new_text: " combined = True" }
78
55
  </example>
79
56
 
80
57
  <example name="delete lines">
81
- edit {"path":"src/app.py","edits":[{"replace_lines":{"start_anchor":"{{hashline 5 'old_value = True'}}","end_anchor":"{{hashline 6 'unused = None'}}","new_text":""}}]}
58
+ replace_lines: { start_anchor: "{{hashline 5 "line to delete A"}}", end_anchor: "{{hashline 6 "line to delete B"}}", new_text: "" }
82
59
  </example>
83
60
 
84
61
  <example name="insert after">
85
- edit {"path":"src/app.py","edits":[{"insert_after":{"anchor":"{{hashline 3 'def hello'}}","text":" # new comment"}}]}
62
+ insert_after: { anchor: "{{hashline 3 "anchor line content"}}", text: " # new comment" }
86
63
  </example>
87
64
 
88
65
  <example name="multiple edits (bottom-up safe)">
89
- edit {"path":"src/app.py","edits":[{"set_line":{"anchor":"{{hashline 10 'return True'}}","new_text":" return False"}},{"set_line":{"anchor":"{{hashline 3 'def hello'}}","new_text":" x = 42"}}]}
66
+ set_line: { anchor: "{{hashline 10 "old line 10"}}", new_text: " return False" }
67
+ set_line: { anchor: "{{hashline 3 "old line 3"}}", new_text: " x = 42" }
90
68
  </example>
91
69
 
92
70
  <example name="content replace (substr-style, no hashes)">
93
- edit {"path":"src/app.py","edits":[{"replace":{"old_text":"x = 42","new_text":"x = 99"}}]}
94
- </example>
71
+ replace: { old_text: "x = 42", new_text: "x = 99" }
72
+ </example>
73
+ </examples>
74
+
75
+ <validation>
76
+ Before submitting, verify:
77
+ - [ ] Payload shape: `{"path": string, "edits": [operation, ...]}` with non-empty `edits` array
78
+ - [ ] Each operation has exactly one variant key: `set_line` | `replace_lines` | `insert_after` | `replace`
79
+ - [ ] Each anchor is copied exactly from the `LINE:HASH` prefix (no spaces, no trailing source text)
80
+ - [ ] `new_text`/`text` contains plain replacement lines only — no `LINE:HASH` prefixes, no diff `+` markers
81
+ - [ ] Each replacement differs from the current line content
82
+ - [ ] Each operation targets one logical change site with minimal scope
83
+ - [ ] Formatting of replaced lines matches the original exactly, except for the targeted change
84
+ </validation>
85
+ **REMINDER: Copy `LINE:HASH` refs verbatim. Anchors are `LINE:HASH` only — never `LINE:HASH|content`. Preserve exact formatting. Change only the targeted token.**
package/src/sdk.ts CHANGED
@@ -8,7 +8,7 @@ import chalk from "chalk";
8
8
  import { loadCapability } from "./capability";
9
9
  import { type Rule, ruleCapability } from "./capability/rule";
10
10
  import { ModelRegistry } from "./config/model-registry";
11
- import { formatModelString, parseModelString } from "./config/model-resolver";
11
+ import { formatModelString, parseModelPattern, parseModelString } from "./config/model-resolver";
12
12
  import { loadPromptTemplates as loadPromptTemplatesInternal, type PromptTemplate } from "./config/prompt-templates";
13
13
  import { Settings, type SkillsSettings } from "./config/settings";
14
14
  import { CursorExecHandlers } from "./cursor";
@@ -101,6 +101,9 @@ export interface CreateAgentSessionOptions {
101
101
 
102
102
  /** Model to use. Default: from settings, else first available */
103
103
  model?: Model;
104
+ /** Raw model pattern string (e.g. from --model CLI flag) to resolve after extensions load.
105
+ * Used when model lookup is deferred because extension-provided models aren't registered yet. */
106
+ modelPattern?: string;
104
107
  /** Thinking level. Default: from settings, else 'off' (clamped to model capabilities) */
105
108
  thinkingLevel?: ThinkingLevel;
106
109
  /** Models available for cycling (Ctrl+P in interactive mode) */
@@ -323,6 +326,7 @@ export interface BuildSystemPromptOptions {
323
326
  contextFiles?: Array<{ path: string; content: string }>;
324
327
  cwd?: string;
325
328
  appendPrompt?: string;
329
+ repeatToolDescriptions?: boolean;
326
330
  }
327
331
 
328
332
  /**
@@ -334,6 +338,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
334
338
  skills: options.skills,
335
339
  contextFiles: options.contextFiles,
336
340
  appendSystemPrompt: options.appendPrompt,
341
+ repeatToolDescriptions: options.repeatToolDescriptions,
337
342
  });
338
343
  }
339
344
 
@@ -580,13 +585,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
580
585
  const hasExistingSession = existingSession.messages.length > 0;
581
586
  const hasThinkingEntry = sessionManager.getBranch().some(entry => entry.type === "thinking_level_change");
582
587
 
583
- const hasExplicitModel = options.model !== undefined;
588
+ const hasExplicitModel = options.model !== undefined || options.modelPattern !== undefined;
584
589
  let model = options.model;
585
590
  let modelFallbackMessage: string | undefined;
586
-
587
- // If session has data, try to restore model from it
591
+ // If session has data, try to restore model from it.
592
+ // Skip restore when an explicit model was requested.
588
593
  const defaultModelStr = existingSession.models.default;
589
- if (!model && hasExistingSession && defaultModelStr) {
594
+ if (!hasExplicitModel && !model && hasExistingSession && defaultModelStr) {
590
595
  const parsedModel = parseModelString(defaultModelStr);
591
596
  if (parsedModel) {
592
597
  const restoredModel = modelRegistry.find(parsedModel.provider, parsedModel.id);
@@ -599,8 +604,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
599
604
  }
600
605
  }
601
606
 
602
- // If still no model, try settings default
603
- if (!model) {
607
+ // If still no model, try settings default.
608
+ // Skip settings fallback when an explicit model was requested.
609
+ if (!hasExplicitModel && !model) {
604
610
  const settingsDefaultModel = settings.getModelRole("default");
605
611
  if (settingsDefaultModel) {
606
612
  const parsedModel = parseModelString(settingsDefaultModel);
@@ -613,29 +619,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
613
619
  }
614
620
  }
615
621
 
616
- // Fall back to first available model with a valid API key
617
- if (!model) {
618
- const allModels = modelRegistry.getAll();
619
- for (const candidate of allModels) {
620
- if (await hasModelApiKey(candidate)) {
621
- model = candidate;
622
- break;
623
- }
624
- }
625
- time("findAvailableModel");
626
- if (model) {
627
- if (modelFallbackMessage) {
628
- modelFallbackMessage += `. Using ${model.provider}/${model.id}`;
629
- }
630
- } else {
631
- // No models available - set message so user knows to /login or configure keys
632
- modelFallbackMessage =
633
- "No models available. Use /login or set an API key environment variable. Then use /model to select a model.";
634
- }
635
- }
636
-
637
- time("findModel");
638
-
639
622
  // For subagent sessions using GitHub Copilot, add X-Initiator header
640
623
  // to ensure proper billing (agent-initiated vs user-initiated)
641
624
  const taskDepth = options.taskDepth ?? 0;
@@ -897,6 +880,58 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
897
880
  }
898
881
  }
899
882
 
883
+ // Process provider registrations queued during extension loading.
884
+ // This must happen before the runner is created so that models registered by
885
+ // extensions are available for model selection on session resume / fallback.
886
+ const activeExtensionSources = extensionsResult.extensions.map(extension => extension.path);
887
+ modelRegistry.syncExtensionSources(activeExtensionSources);
888
+ for (const sourceId of new Set(activeExtensionSources)) {
889
+ modelRegistry.clearSourceRegistrations(sourceId);
890
+ }
891
+ if (extensionsResult.runtime.pendingProviderRegistrations.length > 0) {
892
+ for (const { name, config, sourceId } of extensionsResult.runtime.pendingProviderRegistrations) {
893
+ modelRegistry.registerProvider(name, config, sourceId);
894
+ }
895
+ extensionsResult.runtime.pendingProviderRegistrations = [];
896
+ }
897
+
898
+ // Resolve deferred --model pattern now that extension models are registered.
899
+ if (!model && options.modelPattern) {
900
+ const availableModels = modelRegistry.getAll();
901
+ const matchPreferences = {
902
+ usageOrder: settings.getStorage()?.getModelUsageOrder(),
903
+ };
904
+ const { model: resolved } = parseModelPattern(options.modelPattern, availableModels, matchPreferences);
905
+ if (resolved) {
906
+ model = resolved;
907
+ modelFallbackMessage = undefined;
908
+ } else {
909
+ modelFallbackMessage = `Model "${options.modelPattern}" not found`;
910
+ }
911
+ }
912
+
913
+ // Fall back to first available model with a valid API key.
914
+ // Skip fallback if the user explicitly requested a model via --model that wasn't found.
915
+ if (!model && !options.modelPattern) {
916
+ const allModels = modelRegistry.getAll();
917
+ for (const candidate of allModels) {
918
+ if (await hasModelApiKey(candidate)) {
919
+ model = candidate;
920
+ break;
921
+ }
922
+ }
923
+ time("findAvailableModel");
924
+ if (model) {
925
+ if (modelFallbackMessage) {
926
+ modelFallbackMessage += `. Using ${model.provider}/${model.id}`;
927
+ }
928
+ } else {
929
+ modelFallbackMessage =
930
+ "No models available. Use /login or set an API key environment variable. Then use /model to select a model.";
931
+ }
932
+ }
933
+
934
+ time("findModel");
900
935
  // Discover custom commands (TypeScript slash commands)
901
936
  const customCommandsResult: CustomCommandsLoadResult = options.disableExtensionDiscovery
902
937
  ? { commands: [], errors: [] }
@@ -986,6 +1021,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
986
1021
  emitEvent: event => cursorEventEmitter?.(event),
987
1022
  });
988
1023
 
1024
+ const repeatToolDescriptions = settings.get("repeatToolDescriptions");
989
1025
  const rebuildSystemPrompt = async (toolNames: string[], tools: Map<string, AgentTool>): Promise<string> => {
990
1026
  toolContextStore.setToolNames(toolNames);
991
1027
  const memoryInstructions = await buildMemoryToolDeveloperInstructions(agentDir, settings);
@@ -999,6 +1035,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
999
1035
  rules: rulebookRules,
1000
1036
  skillsSettings: settings.getGroup("skills") as SkillsSettings,
1001
1037
  appendSystemPrompt: memoryInstructions,
1038
+ repeatToolDescriptions,
1002
1039
  });
1003
1040
 
1004
1041
  if (options.systemPrompt === undefined) {
@@ -1016,6 +1053,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1016
1053
  skillsSettings: settings.getGroup("skills") as SkillsSettings,
1017
1054
  customPrompt: options.systemPrompt,
1018
1055
  appendSystemPrompt: memoryInstructions,
1056
+ repeatToolDescriptions,
1019
1057
  });
1020
1058
  }
1021
1059
  return options.systemPrompt(defaultPrompt);
@@ -7,6 +7,7 @@ import {
7
7
  claudeUsageProvider,
8
8
  getEnvApiKey,
9
9
  getOAuthApiKey,
10
+ getOAuthProvider,
10
11
  githubCopilotUsageProvider,
11
12
  googleGeminiCliUsageProvider,
12
13
  kimiUsageProvider,
@@ -25,6 +26,7 @@ import {
25
26
  type OAuthController,
26
27
  type OAuthCredentials,
27
28
  type OAuthProvider,
29
+ type OAuthProviderId,
28
30
  openaiCodexUsageProvider,
29
31
  type Provider,
30
32
  type UsageCache,
@@ -634,7 +636,7 @@ export class AuthStorage {
634
636
  * Login to an OAuth provider.
635
637
  */
636
638
  async login(
637
- provider: OAuthProvider,
639
+ provider: OAuthProviderId,
638
640
  ctrl: OAuthController & {
639
641
  /** onAuth is required by auth-storage but optional in OAuthController */
640
642
  onAuth: (info: { url: string; instructions?: string }) => void;
@@ -709,8 +711,26 @@ export class AuthStorage {
709
711
  await saveApiKeyCredential(apiKey);
710
712
  return;
711
713
  }
712
- default:
713
- throw new Error(`Unknown OAuth provider: ${provider}`);
714
+ default: {
715
+ const customProvider = getOAuthProvider(provider);
716
+ if (!customProvider) {
717
+ throw new Error(`Unknown OAuth provider: ${provider}`);
718
+ }
719
+ const customLoginResult = await customProvider.login({
720
+ onAuth: info => ctrl.onAuth(info),
721
+ onProgress: ctrl.onProgress,
722
+ onPrompt: ctrl.onPrompt,
723
+ onManualCodeInput: async () =>
724
+ ctrl.onPrompt({ message: "Paste the authorization code (or full redirect URL):" }),
725
+ signal: ctrl.signal,
726
+ });
727
+ if (typeof customLoginResult === "string") {
728
+ await saveApiKeyCredential(customLoginResult);
729
+ return;
730
+ }
731
+ credentials = customLoginResult;
732
+ break;
733
+ }
714
734
  }
715
735
  const newCredential: OAuthCredential = { type: "oauth", ...credentials };
716
736
  const existing = this.#getCredentialsForProvider(provider);
@@ -1170,14 +1190,28 @@ export class AuthStorage {
1170
1190
  }
1171
1191
  }
1172
1192
 
1173
- const oauthCreds: Record<string, OAuthCredentials> = {
1174
- [provider]: selection.credential,
1175
- };
1176
-
1177
1193
  try {
1178
- const result = await getOAuthApiKey(provider as OAuthProvider, oauthCreds);
1194
+ let result: { newCredentials: OAuthCredentials; apiKey: string } | null;
1195
+ const customProvider = getOAuthProvider(provider);
1196
+ if (customProvider) {
1197
+ let refreshedCredentials: OAuthCredentials = selection.credential;
1198
+ if (Date.now() >= refreshedCredentials.expires) {
1199
+ if (!customProvider.refreshToken) {
1200
+ throw new Error(`OAuth provider "${provider}" does not support token refresh`);
1201
+ }
1202
+ refreshedCredentials = await customProvider.refreshToken(refreshedCredentials);
1203
+ }
1204
+ const apiKey = customProvider.getApiKey
1205
+ ? customProvider.getApiKey(refreshedCredentials)
1206
+ : refreshedCredentials.access;
1207
+ result = { newCredentials: refreshedCredentials, apiKey };
1208
+ } else {
1209
+ const oauthCreds: Record<string, OAuthCredentials> = {
1210
+ [provider]: selection.credential,
1211
+ };
1212
+ result = await getOAuthApiKey(provider as OAuthProvider, oauthCreds);
1213
+ }
1179
1214
  if (!result) return undefined;
1180
-
1181
1215
  const updated: OAuthCredential = {
1182
1216
  type: "oauth",
1183
1217
  access: result.newCredentials.access,
@@ -1189,7 +1223,6 @@ export class AuthStorage {
1189
1223
  enterpriseUrl: result.newCredentials.enterpriseUrl ?? selection.credential.enterpriseUrl,
1190
1224
  };
1191
1225
  this.#replaceCredentialAt(provider, selection.index, updated);
1192
-
1193
1226
  if (checkUsage && !allowBlocked) {
1194
1227
  const sameAccount = selection.credential.accountId === updated.accountId;
1195
1228
  if (!usageChecked || !sameAccount) {
@@ -1205,7 +1238,6 @@ export class AuthStorage {
1205
1238
  return undefined;
1206
1239
  }
1207
1240
  }
1208
-
1209
1241
  this.#recordSessionCredential(provider, sessionId, "oauth", selection.index);
1210
1242
  return result.apiKey;
1211
1243
  } catch (error) {
@@ -430,6 +430,8 @@ export interface BuildSystemPromptOptions {
430
430
  toolNames?: string[];
431
431
  /** Text to append to system prompt. */
432
432
  appendSystemPrompt?: string;
433
+ /** Repeat full tool descriptions in system prompt. Default: false */
434
+ repeatToolDescriptions?: boolean;
433
435
  /** Skills settings for discovery. */
434
436
  skillsSettings?: SkillsSettings;
435
437
  /** Working directory. Default: getProjectDir() */
@@ -454,6 +456,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
454
456
  customPrompt,
455
457
  tools,
456
458
  appendSystemPrompt,
459
+ repeatToolDescriptions = false,
457
460
  skillsSettings,
458
461
  toolNames,
459
462
  cwd,
@@ -553,6 +556,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
553
556
  return renderPromptTemplate(systemPromptTemplate, {
554
557
  tools: toolNamesArray,
555
558
  toolDescriptions,
559
+ repeatToolDescriptions,
556
560
  environment,
557
561
  systemPromptCustomization: systemPromptCustomization ?? "",
558
562
  contextFiles,