@oh-my-pi/pi-coding-agent 12.5.1 → 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,24 @@
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
+
5
23
  ## [12.5.1] - 2026-02-15
6
24
  ### Added
7
25
 
@@ -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.1",
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.1",
88
- "@oh-my-pi/pi-agent-core": "12.5.1",
89
- "@oh-my-pi/pi-ai": "12.5.1",
90
- "@oh-my-pi/pi-natives": "12.5.1",
91
- "@oh-my-pi/pi-tui": "12.5.1",
92
- "@oh-my-pi/pi-utils": "12.5.1",
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
  }
@@ -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
  }
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) */
@@ -582,13 +585,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
582
585
  const hasExistingSession = existingSession.messages.length > 0;
583
586
  const hasThinkingEntry = sessionManager.getBranch().some(entry => entry.type === "thinking_level_change");
584
587
 
585
- const hasExplicitModel = options.model !== undefined;
588
+ const hasExplicitModel = options.model !== undefined || options.modelPattern !== undefined;
586
589
  let model = options.model;
587
590
  let modelFallbackMessage: string | undefined;
588
-
589
- // 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.
590
593
  const defaultModelStr = existingSession.models.default;
591
- if (!model && hasExistingSession && defaultModelStr) {
594
+ if (!hasExplicitModel && !model && hasExistingSession && defaultModelStr) {
592
595
  const parsedModel = parseModelString(defaultModelStr);
593
596
  if (parsedModel) {
594
597
  const restoredModel = modelRegistry.find(parsedModel.provider, parsedModel.id);
@@ -601,8 +604,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
601
604
  }
602
605
  }
603
606
 
604
- // If still no model, try settings default
605
- if (!model) {
607
+ // If still no model, try settings default.
608
+ // Skip settings fallback when an explicit model was requested.
609
+ if (!hasExplicitModel && !model) {
606
610
  const settingsDefaultModel = settings.getModelRole("default");
607
611
  if (settingsDefaultModel) {
608
612
  const parsedModel = parseModelString(settingsDefaultModel);
@@ -615,29 +619,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
615
619
  }
616
620
  }
617
621
 
618
- // Fall back to first available model with a valid API key
619
- if (!model) {
620
- const allModels = modelRegistry.getAll();
621
- for (const candidate of allModels) {
622
- if (await hasModelApiKey(candidate)) {
623
- model = candidate;
624
- break;
625
- }
626
- }
627
- time("findAvailableModel");
628
- if (model) {
629
- if (modelFallbackMessage) {
630
- modelFallbackMessage += `. Using ${model.provider}/${model.id}`;
631
- }
632
- } else {
633
- // No models available - set message so user knows to /login or configure keys
634
- modelFallbackMessage =
635
- "No models available. Use /login or set an API key environment variable. Then use /model to select a model.";
636
- }
637
- }
638
-
639
- time("findModel");
640
-
641
622
  // For subagent sessions using GitHub Copilot, add X-Initiator header
642
623
  // to ensure proper billing (agent-initiated vs user-initiated)
643
624
  const taskDepth = options.taskDepth ?? 0;
@@ -899,6 +880,58 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
899
880
  }
900
881
  }
901
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");
902
935
  // Discover custom commands (TypeScript slash commands)
903
936
  const customCommandsResult: CustomCommandsLoadResult = options.disableExtensionDiscovery
904
937
  ? { commands: [], errors: [] }
@@ -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) {