@gajae-code/coding-agent 0.16.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/CHANGELOG.md +41 -1
  2. package/dist/types/async/job-manager.d.ts +32 -1
  3. package/dist/types/browser-backend.d.ts +12 -0
  4. package/dist/types/cli/update-cli.d.ts +1 -0
  5. package/dist/types/commands/sdk.d.ts +2 -0
  6. package/dist/types/config/billing-path.d.ts +25 -0
  7. package/dist/types/config/config-schema-version.d.ts +13 -1
  8. package/dist/types/config/model-registry.d.ts +16 -0
  9. package/dist/types/config/settings-schema.d.ts +17 -7
  10. package/dist/types/extensibility/extensions/types.d.ts +8 -3
  11. package/dist/types/gjc-runtime/launch-worktree.d.ts +25 -1
  12. package/dist/types/gjc-runtime/session-state-lock.d.ts +22 -0
  13. package/dist/types/main.d.ts +1 -0
  14. package/dist/types/modes/acp/acp-agent.d.ts +21 -21
  15. package/dist/types/modes/components/model-selector.d.ts +4 -0
  16. package/dist/types/modes/components/session-selector.d.ts +1 -1
  17. package/dist/types/modes/components/sessions-dashboard.d.ts +2 -1
  18. package/dist/types/modes/interactive-mode.d.ts +16 -2
  19. package/dist/types/modes/theme/theme.d.ts +2 -1
  20. package/dist/types/modes/types.d.ts +2 -0
  21. package/dist/types/sdk/broker/broker.d.ts +2 -1
  22. package/dist/types/sdk/broker/ensure.d.ts +3 -0
  23. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +6 -0
  24. package/dist/types/sdk/broker/lifecycle.d.ts +14 -0
  25. package/dist/types/sdk/broker/startup-budget.d.ts +52 -0
  26. package/dist/types/sdk/broker/state-version.d.ts +17 -0
  27. package/dist/types/sdk/bus/discord-daemon.d.ts +1 -1
  28. package/dist/types/sdk/bus/index.d.ts +3 -1
  29. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +1 -1
  30. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +1 -1
  31. package/dist/types/sdk/bus/slack-conversation.d.ts +1 -1
  32. package/dist/types/sdk/bus/slack-daemon.d.ts +1 -1
  33. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  34. package/dist/types/sdk/cli/rows.d.ts +8 -2
  35. package/dist/types/sdk/cli/session-cli.d.ts +2 -1
  36. package/dist/types/sdk/host/events.d.ts +1 -0
  37. package/dist/types/sdk/host/query/cursor.d.ts +4 -2
  38. package/dist/types/sdk/host/query/handlers.d.ts +1 -0
  39. package/dist/types/sdk/host/query/revision-store.d.ts +1 -0
  40. package/dist/types/sdk/host/session-runtime.d.ts +18 -17
  41. package/dist/types/sdk/lifecycle-session.d.ts +1 -1
  42. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -5
  43. package/dist/types/sdk/receipt-state.d.ts +2 -0
  44. package/dist/types/sdk/router/session-router.d.ts +4 -0
  45. package/dist/types/sdk/turn-result.d.ts +6 -0
  46. package/dist/types/session/agent-session.d.ts +47 -7
  47. package/dist/types/session/fold-coordinator.d.ts +29 -2
  48. package/dist/types/session/session-manager.d.ts +14 -2
  49. package/dist/types/tools/ask.d.ts +1 -0
  50. package/dist/types/tools/bash.d.ts +12 -0
  51. package/dist/types/tools/browser/launch.d.ts +10 -0
  52. package/dist/types/tools/browser/registry.d.ts +8 -0
  53. package/dist/types/tools/index.d.ts +6 -10
  54. package/dist/types/tools/job.d.ts +3 -1
  55. package/dist/types/utils/external-editor.d.ts +7 -0
  56. package/examples/extensions/README.md +1 -1
  57. package/examples/extensions/pirate.ts +7 -4
  58. package/package.json +7 -7
  59. package/scripts/generate-sdk-operation-inventory.ts +7 -1
  60. package/src/async/job-manager.ts +107 -9
  61. package/src/browser-backend.ts +29 -0
  62. package/src/cli/plugin-cli.ts +29 -0
  63. package/src/cli/update-cli.ts +63 -12
  64. package/src/commands/sdk.ts +22 -7
  65. package/src/config/billing-path.ts +42 -0
  66. package/src/config/config-schema-version.ts +18 -1
  67. package/src/config/model-preset-registry.ts +177 -18
  68. package/src/config/model-registry.ts +268 -42
  69. package/src/config/settings-schema.ts +19 -7
  70. package/src/config/settings.ts +46 -15
  71. package/src/coordinator-mcp/projection-scan.ts +6 -6
  72. package/src/coordinator-mcp/server.ts +21 -1
  73. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  74. package/src/extensibility/extensions/runner.ts +27 -0
  75. package/src/extensibility/extensions/types.ts +8 -3
  76. package/src/extensibility/gjc-plugins/installer.ts +51 -11
  77. package/src/extensibility/runtime-skill-discovery.ts +110 -3
  78. package/src/extensibility/skills.ts +22 -12
  79. package/src/gjc-runtime/launch-worktree.ts +285 -4
  80. package/src/gjc-runtime/session-state-lock.ts +154 -28
  81. package/src/gjc-runtime/session-state-sidecar.ts +10 -17
  82. package/src/gjc-runtime/tmux-owner-isolation.ts +16 -1
  83. package/src/internal-urls/docs-index.generated.ts +10 -10
  84. package/src/main.ts +4 -1
  85. package/src/modes/acp/acp-agent.ts +5 -5
  86. package/src/modes/components/model-selector.ts +76 -8
  87. package/src/modes/components/session-selector.ts +23 -15
  88. package/src/modes/components/sessions-dashboard.ts +8 -5
  89. package/src/modes/controllers/command-controller.ts +1 -1
  90. package/src/modes/controllers/event-controller.ts +30 -4
  91. package/src/modes/controllers/extension-ui-controller.ts +13 -13
  92. package/src/modes/controllers/input-controller.ts +2 -2
  93. package/src/modes/controllers/selector-controller.ts +10 -3
  94. package/src/modes/interactive-mode.ts +36 -12
  95. package/src/modes/prompt-action-autocomplete.ts +30 -4
  96. package/src/modes/runtime-init.ts +12 -9
  97. package/src/modes/theme/theme.ts +5 -0
  98. package/src/modes/types.ts +2 -0
  99. package/src/modes/utils/ui-helpers.ts +22 -3
  100. package/src/prompts/agent-fragments/browser-aside.md +22 -0
  101. package/src/prompts/tools/ask.md +1 -1
  102. package/src/prompts/tools/bash.md +1 -0
  103. package/src/prompts/tools/job.md +1 -1
  104. package/src/sdk/acp/adapter.ts +5 -0
  105. package/src/sdk/broker/broker.ts +10 -1
  106. package/src/sdk/broker/ensure.ts +76 -3
  107. package/src/sdk/broker/lifecycle-ledger.ts +6 -0
  108. package/src/sdk/broker/lifecycle.ts +254 -34
  109. package/src/sdk/broker/spawn-substrate.ts +45 -2
  110. package/src/sdk/broker/startup-budget.ts +144 -2
  111. package/src/sdk/broker/state-version.ts +17 -0
  112. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  113. package/src/sdk/bus/chat-daemon-runtime.ts +111 -24
  114. package/src/sdk/bus/discord-daemon.ts +50 -10
  115. package/src/sdk/bus/index.ts +33 -3
  116. package/src/sdk/bus/kind-aware-reconciliation.ts +296 -48
  117. package/src/sdk/bus/lifecycle-commands.ts +4 -0
  118. package/src/sdk/bus/notification-service.ts +22 -5
  119. package/src/sdk/bus/prompt-reconciliation.ts +7 -2
  120. package/src/sdk/bus/reconciliation-store.ts +55 -31
  121. package/src/sdk/bus/slack-conversation.ts +1 -1
  122. package/src/sdk/bus/slack-daemon.ts +72 -38
  123. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  124. package/src/sdk/cli/rows.ts +30 -7
  125. package/src/sdk/cli/session-cli.ts +33 -21
  126. package/src/sdk/host/control/dispatch.ts +2 -2
  127. package/src/sdk/host/events.ts +1 -0
  128. package/src/sdk/host/host.ts +2 -0
  129. package/src/sdk/host/query/cursor.ts +52 -7
  130. package/src/sdk/host/query/handlers.ts +33 -12
  131. package/src/sdk/host/query/revision-store.ts +12 -0
  132. package/src/sdk/host/session-runtime.test.ts +463 -8
  133. package/src/sdk/host/session-runtime.ts +383 -54
  134. package/src/sdk/host/surface-policy.ts +3 -3
  135. package/src/sdk/lifecycle-session.ts +7 -2
  136. package/src/sdk/prompt-deadline-manager.ts +2 -1
  137. package/src/sdk/protocol/operation-inventory.generated.json +54 -3
  138. package/src/sdk/protocol/operation-registry.ts +1 -1
  139. package/src/sdk/receipt-state.ts +10 -0
  140. package/src/sdk/router/session-router.ts +152 -59
  141. package/src/sdk/session.ts +60 -41
  142. package/src/sdk/turn-result.ts +22 -0
  143. package/src/session/agent-session.ts +1033 -260
  144. package/src/session/fold-coordinator.ts +40 -3
  145. package/src/session/session-manager.ts +517 -77
  146. package/src/slash-commands/builtin-registry.ts +41 -0
  147. package/src/task/executor.ts +9 -4
  148. package/src/tools/ask-contract.test.ts +64 -0
  149. package/src/tools/ask-contract.ts +36 -1
  150. package/src/tools/ask.ts +10 -8
  151. package/src/tools/bash-interactive.ts +11 -2
  152. package/src/tools/bash.ts +156 -33
  153. package/src/tools/browser/launch.ts +37 -0
  154. package/src/tools/browser/registry.ts +99 -9
  155. package/src/tools/cron.ts +6 -4
  156. package/src/tools/descriptors.test.ts +15 -0
  157. package/src/tools/descriptors.ts +5 -1
  158. package/src/tools/index.ts +6 -6
  159. package/src/tools/job.ts +4 -1
  160. package/src/tools/monitor.ts +12 -9
  161. package/src/tools/resolve.ts +30 -10
  162. package/src/tools/tool-catalog.generated.ts +2 -2
  163. package/src/utils/external-editor.ts +72 -1
@@ -39,7 +39,11 @@ import {
39
39
  UNK_MAX_TOKENS,
40
40
  unregisterCustomApis,
41
41
  } from "@gajae-code/ai/core";
42
- import { resolveLoopbackOpenAIBaseUrl } from "@gajae-code/ai/utils/discovery/openai-compatible";
42
+ import { fetchModelsDevPayload } from "@gajae-code/ai/provider-models/openai-compat";
43
+ import {
44
+ detectDiscoveredApiFamily,
45
+ resolveLoopbackOpenAIBaseUrl,
46
+ } from "@gajae-code/ai/utils/discovery/openai-compatible";
43
47
 
44
48
  // Sentinels for local-only OAuth tokens — declared inline to avoid loading provider
45
49
  // modules at startup. Must match the provider OAuth modules.
@@ -66,6 +70,7 @@ import {
66
70
  } from "../sdk/providers";
67
71
  import type { AuthStorage, OAuthCredential } from "../session/auth-storage";
68
72
  import type { ActiveSearchModelContext, WebSearchMode } from "../web/search/types";
73
+ import { type BillingPath, deriveBillingPath } from "./billing-path";
69
74
  import { ConfigError, ConfigFile } from "./config-file";
70
75
  import { isAuthenticated, kNoAuth } from "./model-auth";
71
76
  import { type ConfiguredModelBindings, ModelBindingsApplier } from "./model-bindings-applier";
@@ -112,6 +117,7 @@ import {
112
117
  } from "./provider-selection-policy";
113
118
  import { type Settings, settings } from "./settings";
114
119
 
120
+ export type { BillingPath, BillingPathKind } from "./billing-path";
115
121
  export type { EffectiveProviderAuth, ProviderSelectionPolicy } from "./provider-selection-policy";
116
122
  export type { CanonicalModelIndex, CanonicalModelRecord, CanonicalModelVariant, ModelEquivalenceConfig };
117
123
 
@@ -135,6 +141,69 @@ function redactDiscoveryUrl(value: string | URL): string {
135
141
  return "(invalid URL)";
136
142
  }
137
143
  }
144
+
145
+ /** Whether a structured transport code proves that no listener accepted the connection. */
146
+ function isConnectionRefusedDiscoveryCode(value: unknown): boolean {
147
+ if (typeof value === "string") {
148
+ const normalized = value.replace(/[^A-Za-z0-9]/gu, "").toUpperCase();
149
+ return (
150
+ normalized === "ECONNREFUSED" || normalized === "CONNECTIONREFUSED" || normalized === "ERRCONNECTIONREFUSED"
151
+ );
152
+ }
153
+ if (typeof value !== "number" || !Number.isInteger(value)) return false;
154
+ // ECONNREFUSED errno values used by the supported runtimes/OSes.
155
+ return value === 61 || value === -61 || value === 111 || value === -111 || value === 10061 || value === -10061;
156
+ }
157
+
158
+ function isConnectionRefusedDiscoveryEvidence(error: unknown): boolean {
159
+ const seen = new Set<unknown>();
160
+ const queue: unknown[] = [error];
161
+ while (queue.length > 0) {
162
+ const current = queue.shift();
163
+ if (current === null || typeof current !== "object" || seen.has(current)) continue;
164
+ seen.add(current);
165
+ for (const field of ["code", "errno", "errorCode", "transportCode"]) {
166
+ let value: unknown;
167
+ try {
168
+ value = Reflect.get(current, field);
169
+ } catch {
170
+ value = undefined;
171
+ }
172
+ if (isConnectionRefusedDiscoveryCode(value)) return true;
173
+ }
174
+ for (const field of ["cause", "error", "transport"]) {
175
+ let nested: unknown;
176
+ try {
177
+ nested = Reflect.get(current, field);
178
+ } catch {
179
+ nested = undefined;
180
+ }
181
+ if (nested !== undefined) queue.push(nested);
182
+ }
183
+ }
184
+ return false;
185
+ }
186
+
187
+ /** Whether `hostname` (as `URL.hostname` reports it) names this machine's loopback. */
188
+ function isLoopbackDiscoveryHost(hostname: string): boolean {
189
+ if (hostname === "localhost" || hostname === "[::1]" || hostname === "::1") return true;
190
+ return /^127(?:\.\d{1,3}){3}$/u.test(hostname);
191
+ }
192
+
193
+ /**
194
+ * A loopback discovery endpoint that is proven to have no listener is the normal state of
195
+ * a machine without a local model server. Structured transport evidence is required when
196
+ * available; DNS, routing, proxy, TLS, and HTTP failures remain warnings.
197
+ */
198
+ export function isQuietLocalDiscoveryFailure(baseUrl: string, _warning: string, evidence?: unknown): boolean {
199
+ let host: string;
200
+ try {
201
+ host = new URL(baseUrl).hostname;
202
+ } catch {
203
+ return false;
204
+ }
205
+ return isLoopbackDiscoveryHost(host) && evidence !== undefined && isConnectionRefusedDiscoveryEvidence(evidence);
206
+ }
138
207
  function stripUrlQuery(value: string): string {
139
208
  const queryStart = value.indexOf("?");
140
209
  if (queryStart < 0) return value;
@@ -272,6 +341,44 @@ type ProviderValidationMode = "models-config" | "runtime-register";
272
341
 
273
342
  const OPENAI_REQUEST_TRANSFORM_APIS = new Set<Api>(["openai-completions", "openai-responses"]);
274
343
 
344
+ const OPENAI_FAMILY_APIS = new Set<Api>([
345
+ "openai-completions",
346
+ "openai-responses",
347
+ "openai-codex-responses",
348
+ "azure-openai-responses",
349
+ ]);
350
+
351
+ /** Whether an api uses an OpenAI-shaped request/response transport. */
352
+ function isOpenAIFamilyApi(api: Api): boolean {
353
+ return OPENAI_FAMILY_APIS.has(api);
354
+ }
355
+
356
+ /**
357
+ * The most frequent OpenAI-family api among statically-configured models, used
358
+ * as the default api for auto-enabled `/v1/models` discovery on a custom
359
+ * provider that sets `api` only per model. Returns `undefined` when no
360
+ * configured model uses an OpenAI-family api (e.g. an Anthropic-only base),
361
+ * which suppresses auto-discovery for that provider.
362
+ */
363
+ function dominantOpenAIFamilyModelApi(models: readonly { api?: string }[] | undefined): Api | undefined {
364
+ if (!models || models.length === 0) return undefined;
365
+ const counts = new Map<Api, number>();
366
+ for (const model of models) {
367
+ const api = model.api as Api | undefined;
368
+ if (api === undefined || !isOpenAIFamilyApi(api)) continue;
369
+ counts.set(api, (counts.get(api) ?? 0) + 1);
370
+ }
371
+ let best: Api | undefined;
372
+ let bestCount = 0;
373
+ for (const [api, count] of counts) {
374
+ if (count > bestCount) {
375
+ best = api;
376
+ bestCount = count;
377
+ }
378
+ }
379
+ return best;
380
+ }
381
+
275
382
  function getKnownProviderApis(providerName: string): Set<Api> {
276
383
  const apis = new Set<Api>();
277
384
  for (const model of getBundledModels(providerName as Parameters<typeof getBundledModels>[0])) {
@@ -2674,17 +2781,48 @@ export class ModelRegistry {
2674
2781
  keylessProviders.add(providerName);
2675
2782
  }
2676
2783
 
2677
- if (providerConfig.discovery && providerConfig.api) {
2784
+ // Explicit discovery config is authoritative. Otherwise auto-enable
2785
+ // OpenAI-compatible `/v1/models` discovery for any custom provider that
2786
+ // declares an OpenAI-family `api` and a `baseUrl`: a subscription-style
2787
+ // gateway (e.g. CLIProxyAPI) then auto-populates `/model` with its live
2788
+ // catalog and auto-routes each model's wire family, with no manual
2789
+ // `discovery:` block. It stays optional so an endpoint without
2790
+ // `/v1/models` degrades to the configured static models instead of
2791
+ // failing. Providers that route through the local `openaiCompat` proxy
2792
+ // already register their own discovery above, so skip them here.
2793
+ const effectiveDiscoveryBaseUrl = providerConfig.baseUrl ?? resolveProviderBaseUrlFromEnv(providerName);
2794
+ // The default api for discovered models: the provider-level `api` when
2795
+ // set, otherwise the most common OpenAI-family `api` among the
2796
+ // statically-configured models. For withfox-style configs that only set
2797
+ // `api` per model (`openai-responses` for gpt, `anthropic-messages` for
2798
+ // claude), this preserves the intended OpenAI transport for discovered
2799
+ // OpenAI models while `detectDiscoveredApiFamily` still flips claude ids
2800
+ // to Anthropic.
2801
+ const providerApi =
2802
+ (providerConfig.api as Api | undefined) ??
2803
+ dominantOpenAIFamilyModelApi(providerConfig.models as { api?: string }[] | undefined);
2804
+ const autoDiscovery: ProviderDiscovery | undefined =
2805
+ !providerConfig.discovery &&
2806
+ !localOpenAICompat &&
2807
+ providerName !== "vllm" &&
2808
+ providerName !== "sglang" &&
2809
+ providerApi !== undefined &&
2810
+ isOpenAIFamilyApi(providerApi) &&
2811
+ effectiveDiscoveryBaseUrl !== undefined
2812
+ ? { type: "openai-models-list" }
2813
+ : undefined;
2814
+ const effectiveDiscovery = providerConfig.discovery ?? autoDiscovery;
2815
+ if (effectiveDiscovery && providerApi) {
2678
2816
  discoverableProviders.push({
2679
2817
  provider: providerName,
2680
- api: providerConfig.api as Api,
2681
- baseUrl: providerConfig.baseUrl ?? resolveProviderBaseUrlFromEnv(providerName),
2818
+ api: providerApi,
2819
+ baseUrl: effectiveDiscoveryBaseUrl,
2682
2820
  headers: providerConfig.headers,
2683
2821
  compat: providerConfig.compat,
2684
2822
  requestTransform: providerConfig.requestTransform,
2685
2823
  cacheRetention: providerConfig.cacheRetention,
2686
- discovery: providerConfig.discovery,
2687
- optional: false,
2824
+ discovery: effectiveDiscovery,
2825
+ optional: !providerConfig.discovery,
2688
2826
  });
2689
2827
  }
2690
2828
 
@@ -2962,27 +3100,71 @@ export class ModelRegistry {
2962
3100
  ? { ...result, invalidatesPublishedState }
2963
3101
  : { ...result, current: false, models: [], fetched: false, invalidatesPublishedState };
2964
3102
  });
3103
+ // Every field below except `model.baseUrl` and `model.id` depends only on
3104
+ // `model.provider`. The built-in catalog carries thousands of models across
3105
+ // only a few dozen providers, and `#getProviderEvidenceGeneration` reaches
3106
+ // into AuthStorage while `#getProviderBaseUrlForDiscovery` parses URLs, so
3107
+ // recomputing per model turned this filter into a per-refresh UI-thread
3108
+ // stall. Memoize the provider-keyed work once per provider.
3109
+ interface BuiltInProviderEvidenceState {
3110
+ evidence:
3111
+ | {
3112
+ fresh: boolean;
3113
+ modelIds: ReadonlySet<string>;
3114
+ profileModelIds?: ReadonlySet<string>;
3115
+ profileFresh?: boolean;
3116
+ profileEndpoint?: string;
3117
+ authGeneration: string;
3118
+ endpoint: string;
3119
+ }
3120
+ | undefined;
3121
+ expectedAuthGeneration: string | undefined;
3122
+ authGenerationThrew: boolean;
3123
+ providerBaseEndpoint: string | undefined;
3124
+ canUseCredentialDerivedXiaomiEndpoint: boolean;
3125
+ }
3126
+ const builtInProviderStates = new Map<string, BuiltInProviderEvidenceState>();
3127
+ const builtInProviderStateFor = (provider: string): BuiltInProviderEvidenceState => {
3128
+ const cached = builtInProviderStates.get(provider);
3129
+ if (cached) return cached;
3130
+ let expectedAuthGeneration: string | undefined;
3131
+ let authGenerationThrew = false;
3132
+ try {
3133
+ expectedAuthGeneration = this.#getProviderEvidenceGeneration(
3134
+ provider,
3135
+ this.#providerEvidenceApiKeys.get(provider),
3136
+ );
3137
+ } catch {
3138
+ authGenerationThrew = true;
3139
+ }
3140
+ const providerBaseUrl = this.#getProviderBaseUrlForDiscovery(provider);
3141
+ const state: BuiltInProviderEvidenceState = {
3142
+ evidence: this.#descriptorDiscoveryEvidence.get(provider),
3143
+ expectedAuthGeneration,
3144
+ authGenerationThrew,
3145
+ providerBaseEndpoint:
3146
+ providerBaseUrl !== undefined ? this.#normalizeDiscoveryEvidenceEndpoint(providerBaseUrl) : undefined,
3147
+ canUseCredentialDerivedXiaomiEndpoint:
3148
+ provider === "xiaomi" &&
3149
+ this.#providerEvidenceApiKeys.get("xiaomi")?.startsWith("tp-") === true &&
3150
+ this.#runtimeProviderOverrides.get("xiaomi")?.baseUrl === undefined &&
3151
+ this.#providerOverrides.get("xiaomi")?.baseUrl === undefined &&
3152
+ resolveProviderBaseUrlFromEnv("xiaomi") === undefined,
3153
+ };
3154
+ builtInProviderStates.set(provider, state);
3155
+ return state;
3156
+ };
2965
3157
  const currentBuiltInDiscovered = builtInDiscovered.filter(model => {
2966
- const evidence = this.#descriptorDiscoveryEvidence.get(model.provider);
2967
- const currentEndpoint = this.#normalizeDiscoveryEvidenceEndpoint(
2968
- this.#getProviderBaseUrlForDiscovery(model.provider) ?? model.baseUrl ?? "",
2969
- );
2970
- const canUseCredentialDerivedXiaomiEndpoint =
2971
- model.provider === "xiaomi" &&
2972
- this.#providerEvidenceApiKeys.get("xiaomi")?.startsWith("tp-") === true &&
2973
- this.#runtimeProviderOverrides.get("xiaomi")?.baseUrl === undefined &&
2974
- this.#providerOverrides.get("xiaomi")?.baseUrl === undefined &&
2975
- resolveProviderBaseUrlFromEnv("xiaomi") === undefined;
3158
+ const state = builtInProviderStateFor(model.provider);
3159
+ const { evidence } = state;
3160
+ if (evidence === undefined || state.authGenerationThrew) return false;
3161
+ if (evidence.authGeneration !== state.expectedAuthGeneration) return false;
2976
3162
  try {
3163
+ const currentEndpoint =
3164
+ state.providerBaseEndpoint ?? this.#normalizeDiscoveryEvidenceEndpoint(model.baseUrl ?? "");
2977
3165
  return (
2978
- evidence !== undefined &&
2979
- evidence.authGeneration ===
2980
- this.#getProviderEvidenceGeneration(
2981
- model.provider,
2982
- this.#providerEvidenceApiKeys.get(model.provider),
2983
- ) &&
2984
3166
  (evidence.endpoint === currentEndpoint ||
2985
- (canUseCredentialDerivedXiaomiEndpoint &&
3167
+ (state.canUseCredentialDerivedXiaomiEndpoint &&
2986
3168
  evidence.endpoint === this.#normalizeDiscoveryEvidenceEndpoint(model.baseUrl ?? ""))) &&
2987
3169
  evidence.modelIds.has(model.id)
2988
3170
  );
@@ -3227,6 +3409,8 @@ export class ModelRegistry {
3227
3409
  (evidence.authGeneration !== authGenerationBeforeDiscovery || evidence.endpoint !== endpoint)))
3228
3410
  ? "online"
3229
3411
  : strategy;
3412
+ let discoveryFailureEvidence: unknown;
3413
+
3230
3414
  const mergeInput = await this.#discoveryManager.discover(effectiveProviderConfig, refreshStrategy, {
3231
3415
  cacheDbPath: this.#cacheDbPath,
3232
3416
  requiresAuth: provider =>
@@ -3239,11 +3423,17 @@ export class ModelRegistry {
3239
3423
  baseUrl: provider.baseUrl,
3240
3424
  }),
3241
3425
  isAuthenticated,
3242
- fetchModels: async (provider, apiKey) =>
3243
- this.#sanitizeDiscoverableModelsForCache(
3244
- provider,
3245
- await this.#discoverModelsByProviderType(provider, apiKey),
3246
- ),
3426
+ fetchModels: async (provider, apiKey) => {
3427
+ try {
3428
+ return this.#sanitizeDiscoverableModelsForCache(
3429
+ provider,
3430
+ await this.#discoverModelsByProviderType(provider, apiKey),
3431
+ );
3432
+ } catch (error) {
3433
+ discoveryFailureEvidence = error;
3434
+ throw error;
3435
+ }
3436
+ },
3247
3437
  getEvidenceGeneration: provider => this.#getProviderEvidenceGeneration(provider.provider, preflightApiKey),
3248
3438
  cacheDynamicModelProvenance: cacheLookupProvenance,
3249
3439
  canPublishCache: () => isCurrentEndpoint() && isCurrentProviderRefresh(),
@@ -3267,11 +3457,15 @@ export class ModelRegistry {
3267
3457
  };
3268
3458
  }
3269
3459
  if (mergeInput.warning) {
3270
- logger.warn("model discovery failed for provider", {
3460
+ const baseUrl = effectiveProviderConfig.baseUrl ?? "";
3461
+ const fields = {
3271
3462
  provider: effectiveProviderConfig.provider,
3272
- url: redactDiscoveryUrl(effectiveProviderConfig.baseUrl ?? ""),
3463
+ url: redactDiscoveryUrl(baseUrl),
3273
3464
  error: mergeInput.warning,
3274
- });
3465
+ };
3466
+ if (isQuietLocalDiscoveryFailure(baseUrl, mergeInput.warning, discoveryFailureEvidence))
3467
+ logger.debug("model discovery failed for provider", fields);
3468
+ else logger.warn("model discovery failed for provider", fields);
3275
3469
  }
3276
3470
  this.#providerEvidenceApiKeys.set(effectiveProviderConfig.provider, preflightApiKey);
3277
3471
  return {
@@ -3831,27 +4025,47 @@ export class ModelRegistry {
3831
4025
  return this.#applyProviderModelOverrides(providerConfig.provider, discovered);
3832
4026
  }
3833
4027
 
3834
- #resolveDiscoveredModelApi(providerConfig: DiscoveryProviderConfig, modelId: string): Api {
3835
- let api = providerConfig.api;
4028
+ #resolveDiscoveredModelApi(
4029
+ providerConfig: DiscoveryProviderConfig,
4030
+ modelId: string,
4031
+ entry?: { id?: unknown; owned_by?: unknown },
4032
+ ): Api {
4033
+ // 1. Explicit per-prefix routing from models.yml always wins — the user
4034
+ // stated the transport for these ids, so never second-guess it.
3836
4035
  let matchedPrefixLength = -1;
4036
+ let prefixApi: Api | undefined;
3837
4037
  for (const [prefix, routedApi] of Object.entries(providerConfig.discovery.apiByModelPrefix ?? {})) {
3838
4038
  if (modelId.startsWith(prefix) && prefix.length > matchedPrefixLength) {
3839
- api = routedApi;
4039
+ prefixApi = routedApi;
3840
4040
  matchedPrefixLength = prefix.length;
3841
4041
  }
3842
4042
  }
3843
- return api;
4043
+ if (prefixApi !== undefined) return prefixApi;
4044
+ // 2. For OpenAI-compatible gateways that can front both OpenAI and
4045
+ // Anthropic upstreams (e.g. CLIProxyAPI), auto-detect the wire family
4046
+ // per model from the `/v1/models` `owned_by` owner and the model id.
4047
+ // A mixed gateway then routes `claude-*` through Anthropic Messages
4048
+ // with zero manual config. OpenAI-family models keep the provider's
4049
+ // configured OpenAI transport (e.g. `openai-responses` vs
4050
+ // `openai-completions`) rather than being forced to chat-completions,
4051
+ // because a `/v1/models` list cannot reveal which OpenAI API the
4052
+ // gateway expects. Only override to `openai-completions` when the
4053
+ // provider default is not itself an OpenAI-family api.
4054
+ if (providerConfig.discovery.type === "openai-models-list") {
4055
+ const detected = detectDiscoveredApiFamily(entry ?? { id: modelId });
4056
+ if (detected === "anthropic-messages") return "anthropic-messages";
4057
+ if (detected === "openai-completions") {
4058
+ return isOpenAIFamilyApi(providerConfig.api) ? providerConfig.api : "openai-completions";
4059
+ }
4060
+ }
4061
+ // 3. Fall back to the provider-level default api.
4062
+ return providerConfig.api;
3844
4063
  }
3845
4064
 
3846
4065
  async #discoverModelsDevProvider(providerConfig: DiscoveryProviderConfig): Promise<Model<Api>[]> {
3847
4066
  const baseUrl = providerConfig.baseUrl;
3848
4067
  if (!baseUrl) throw new Error(`Provider "${providerConfig.provider}" requires baseUrl for models.dev discovery.`);
3849
- const response = await fetch("https://models.dev/api.json", {
3850
- headers: { Accept: "application/json" },
3851
- signal: AbortSignal.timeout(5_000),
3852
- });
3853
- if (!response.ok) throw new Error(`HTTP ${response.status} from https://models.dev/api.json`);
3854
- const payload: unknown = await response.json();
4068
+ const payload: unknown = await fetchModelsDevPayload();
3855
4069
  if (!isRecord(payload)) return [];
3856
4070
  const catalogProvider = payload[providerConfig.discovery.modelsDevProvider ?? providerConfig.provider];
3857
4071
  if (!isRecord(catalogProvider) || !isRecord(catalogProvider.models)) return [];
@@ -3957,7 +4171,7 @@ export class ModelRegistry {
3957
4171
  item.max_tokens,
3958
4172
  item.max_output_tokens,
3959
4173
  );
3960
- const api = this.#resolveDiscoveredModelApi(providerConfig, id);
4174
+ const api = this.#resolveDiscoveredModelApi(providerConfig, id, item);
3961
4175
  discovered.push(
3962
4176
  enrichModelThinking({
3963
4177
  id,
@@ -4842,6 +5056,18 @@ export class ModelRegistry {
4842
5056
  return this.#effectiveProviderAuth(provider, sessionId);
4843
5057
  }
4844
5058
 
5059
+ /**
5060
+ * Billing path for a resolved binding — derived at the point the binding
5061
+ * resolves credentials (model.api + credential provenance). Not a
5062
+ * provider-name allowlist: a custom OpenAI-compatible provider bound
5063
+ * to a metered gateway is "metered-api" exactly like a first-party
5064
+ * key. Only the billing category is ever disclosed.
5065
+ */
5066
+ getBillingPath(model: Model<Api>, sessionId?: string): BillingPath | undefined {
5067
+ const effectiveAuth = this.#effectiveProviderAuth(model.provider, sessionId);
5068
+ return deriveBillingPath(model.provider, model.api as Api, effectiveAuth);
5069
+ }
5070
+
4845
5071
  /**
4846
5072
  * Forget a session's remembered canonical variant so the next resolution
4847
5073
  * for that session re-ranks from scratch (explicit reselection
@@ -1579,14 +1579,15 @@ export const SETTINGS_SCHEMA = {
1579
1579
  },
1580
1580
  },
1581
1581
 
1582
- interruptMode: {
1582
+ toolInterruptPolicy: {
1583
1583
  type: "enum",
1584
- values: ["immediate", "wait"] as const,
1585
- default: "immediate",
1584
+ values: ["abort_tools", "finish_tools"] as const,
1585
+ default: "abort_tools",
1586
1586
  ui: {
1587
1587
  tab: "interaction",
1588
- label: "Interrupt Mode",
1589
- description: "When steering messages interrupt tool execution",
1588
+ label: "Tool Interrupt Policy",
1589
+ description:
1590
+ "Whether a steering message aborts the tools still running in the current batch (abort_tools) or lets them finish first (finish_tools). Never changes when a steer is consumed: it is always picked up at the next tool/turn boundary either way.",
1590
1591
  },
1591
1592
  },
1592
1593
 
@@ -3043,6 +3044,17 @@ export const SETTINGS_SCHEMA = {
3043
3044
  description: "Enable the browser tool (Ulixee Hero)",
3044
3045
  },
3045
3046
  },
3047
+ "browser.backend": {
3048
+ type: "enum",
3049
+ values: ["native", "aside"] as const,
3050
+ default: "native",
3051
+ ui: {
3052
+ tab: "tools",
3053
+ label: "Browser Backend",
3054
+ description:
3055
+ "Browser surface offered to the model. 'native' exposes the built-in Puppeteer browser tool. 'aside' hides it and requires every browser task to run through the Aside CLI in your live logged-in browser (requires the Aside CLI).",
3056
+ },
3057
+ },
3046
3058
 
3047
3059
  "browser.headless": {
3048
3060
  type: "boolean",
@@ -3072,7 +3084,7 @@ export const SETTINGS_SCHEMA = {
3072
3084
  tab: "tools",
3073
3085
  label: "Profile reuse posture",
3074
3086
  description:
3075
- "'auto' (default): when a usable real Chrome profile is available, the browser tool uses an isolated copy of it (cookies/session/cache) for stronger stealth, warns, and falls back to synthetic. 'opt-in': stay synthetic unless a real profile is explicitly requested.",
3087
+ "'auto' (default): when a usable real Chrome profile is available, the browser tool uses an isolated copy of it (cookies/session/cache) for stronger stealth, warns, and falls back to synthetic. 'opt-in': stay synthetic unless a real profile is explicitly requested. On macOS, cookie decryption is brand-specific (Chrome Safe Storage vs Chromium Safe Storage Keychain); a Chrome profile copied into bundled Chromium decrypts to garbage, so auto reuses the profile only when the headless executable can decrypt it — detected system Chrome/Chromium or PUPPETEER_EXECUTABLE_PATH — otherwise it falls back to synthetic with a warning.",
3076
3088
  },
3077
3089
  },
3078
3090
 
@@ -4124,7 +4136,7 @@ export function getEnumValues(path: SettingPath): readonly string[] | undefined
4124
4136
  return "values" in def ? (def.values as readonly string[]) : undefined;
4125
4137
  }
4126
4138
 
4127
- export { CONFIG_SCHEMA_VERSION } from "./config-schema-version";
4139
+ export { CONFIG_SCHEMA_VERSION, normalizeConfigSchemaVersion } from "./config-schema-version";
4128
4140
 
4129
4141
  export type SettingsSchemaIssue = {
4130
4142
  path: string;
@@ -70,6 +70,7 @@ import {
70
70
  type GroupPrefix,
71
71
  type GroupTypeMap,
72
72
  getDefault,
73
+ normalizeConfigSchemaVersion,
73
74
  reconcileSettingsSchema,
74
75
  SETTINGS_SCHEMA,
75
76
  type SettingPath,
@@ -1525,15 +1526,19 @@ export class Settings implements NotificationSettingsReader {
1525
1526
  this.#hasInvalidNotificationGlobal = true;
1526
1527
  }
1527
1528
  }
1529
+ // ONE normalized marker feeds every version decision (future-schema,
1530
+ // migration-pending, and the ordered registry in #migrateRawSettings): a
1531
+ // malformed value must never be read as a future schema here — which would
1532
+ // skip legacy migration entirely — and as unmigrated by the registry.
1533
+ const configSchemaVersion = normalizeConfigSchemaVersion(parsedRaw.configSchemaVersion);
1528
1534
  this.#futureSchemaVersion =
1529
1535
  filePath === this.#configPath &&
1530
- typeof parsedRaw.configSchemaVersion === "number" &&
1531
- parsedRaw.configSchemaVersion > CONFIG_SCHEMA_VERSION;
1536
+ configSchemaVersion !== undefined &&
1537
+ configSchemaVersion > CONFIG_SCHEMA_VERSION;
1532
1538
 
1533
- const configSchemaVersion = parsedRaw.configSchemaVersion;
1534
1539
  if (
1535
1540
  filePath === this.#configPath &&
1536
- (typeof configSchemaVersion !== "number" || configSchemaVersion < CONFIG_SCHEMA_VERSION)
1541
+ (configSchemaVersion === undefined || configSchemaVersion < CONFIG_SCHEMA_VERSION)
1537
1542
  ) {
1538
1543
  this.#schemaMigrationPending = true;
1539
1544
  }
@@ -2087,7 +2092,7 @@ export class Settings implements NotificationSettingsReader {
2087
2092
  return false;
2088
2093
  }
2089
2094
  const schemaVersion = (root as Record<string, unknown> | null | undefined)?.configSchemaVersion;
2090
- if (typeof schemaVersion === "number" && schemaVersion > CONFIG_SCHEMA_VERSION) {
2095
+ if ((normalizeConfigSchemaVersion(schemaVersion) ?? 0) > CONFIG_SCHEMA_VERSION) {
2091
2096
  this.#warnLegacyFallbackMigration(
2092
2097
  `Settings: database workflow migration skipped: ${this.#configPath} is a future config schema (configSchemaVersion ${schemaVersion} > ${CONFIG_SCHEMA_VERSION}); leaving legacy rows for the next load`,
2093
2098
  );
@@ -2338,7 +2343,7 @@ export class Settings implements NotificationSettingsReader {
2338
2343
  // sets #futureSchemaVersion, so it must check the target schema
2339
2344
  // itself and never patch it or consume the legacy source.
2340
2345
  const targetSchemaVersion = (tx.root as Record<string, unknown> | null | undefined)?.configSchemaVersion;
2341
- if (typeof targetSchemaVersion === "number" && targetSchemaVersion > CONFIG_SCHEMA_VERSION) {
2346
+ if ((normalizeConfigSchemaVersion(targetSchemaVersion) ?? 0) > CONFIG_SCHEMA_VERSION) {
2342
2347
  this.#warnLegacyFallbackMigration(
2343
2348
  `Settings: config-root workflow migration skipped: ${target} is a future config schema (configSchemaVersion ${targetSchemaVersion} > ${CONFIG_SCHEMA_VERSION})`,
2344
2349
  );
@@ -3580,7 +3585,7 @@ export class Settings implements NotificationSettingsReader {
3580
3585
  // read-only across Settings; never patch it (mirrors the config-root
3581
3586
  // migration guard) so a future schema cannot inherit stale keys.
3582
3587
  const targetSchemaVersion = (tx.root as Record<string, unknown> | null | undefined)?.configSchemaVersion;
3583
- if (typeof targetSchemaVersion === "number" && targetSchemaVersion > CONFIG_SCHEMA_VERSION) {
3588
+ if ((normalizeConfigSchemaVersion(targetSchemaVersion) ?? 0) > CONFIG_SCHEMA_VERSION) {
3584
3589
  this.#warnLegacyFallbackMigration(
3585
3590
  `Settings: project workflow migration skipped: ${target} is a future config schema (configSchemaVersion ${targetSchemaVersion} > ${CONFIG_SCHEMA_VERSION})`,
3586
3591
  );
@@ -4097,7 +4102,7 @@ export class Settings implements NotificationSettingsReader {
4097
4102
  markerPath = `${source}.fallback-invalid`,
4098
4103
  ): Promise<void> {
4099
4104
  const targetSchemaVersion = (tx.root as Record<string, unknown> | null | undefined)?.configSchemaVersion;
4100
- if (typeof targetSchemaVersion === "number" && targetSchemaVersion > CONFIG_SCHEMA_VERSION) return;
4105
+ if ((normalizeConfigSchemaVersion(targetSchemaVersion) ?? 0) > CONFIG_SCHEMA_VERSION) return;
4101
4106
  if (tx.root !== undefined && (tx.root === null || typeof tx.root !== "object" || Array.isArray(tx.root))) {
4102
4107
  return;
4103
4108
  }
@@ -4826,7 +4831,7 @@ export class Settings implements NotificationSettingsReader {
4826
4831
  // guard) would rewrite configuration an older binary treats as
4827
4832
  // read-only.
4828
4833
  const targetSchemaVersion = (tx.root as Record<string, unknown> | null | undefined)?.configSchemaVersion;
4829
- if (typeof targetSchemaVersion === "number" && targetSchemaVersion > CONFIG_SCHEMA_VERSION) {
4834
+ if ((normalizeConfigSchemaVersion(targetSchemaVersion) ?? 0) > CONFIG_SCHEMA_VERSION) {
4830
4835
  return;
4831
4836
  }
4832
4837
  // Unset only while the target still matches the migration's fallback
@@ -5404,11 +5409,41 @@ export class Settings implements NotificationSettingsReader {
5404
5409
 
5405
5410
  /** Apply registered schema migrations once, using configSchemaVersion as the durable marker. */
5406
5411
  #migrateRawSettings(raw: RawSettings): RawSettings {
5407
- const configuredVersion = raw.configSchemaVersion;
5412
+ const rawVersion = raw.configSchemaVersion;
5413
+ const configuredVersion = normalizeConfigSchemaVersion(rawVersion);
5414
+ if (rawVersion !== undefined && rawVersion !== configuredVersion) {
5415
+ logger.warn("Settings: config has a malformed configSchemaVersion; migrating from the normalized version", {
5416
+ configSchemaVersion: String(rawVersion),
5417
+ normalized: String(configuredVersion),
5418
+ });
5419
+ }
5408
5420
  if (configuredVersion === CONFIG_SCHEMA_VERSION) return raw;
5409
5421
  if (typeof configuredVersion === "number" && configuredVersion > CONFIG_SCHEMA_VERSION) return raw;
5410
5422
 
5411
- // Migration registry v0 -> v1.
5423
+ // Ordered migration registry. Each step runs ONLY for files below its
5424
+ // target version: the v0 steps are not all idempotent (ask.timeout's
5425
+ // milliseconds-to-seconds conversion would re-divide a valid v1 value),
5426
+ // so a v1 file must skip straight to the v2 step.
5427
+ const fromVersion = typeof configuredVersion === "number" ? configuredVersion : 0;
5428
+ if (fromVersion < 1) this.#migrateRawSettingsV0ToV1(raw);
5429
+ // v1 -> v2: interruptMode -> toolInterruptPolicy.
5430
+ if (fromVersion < 2 && "interruptMode" in raw) {
5431
+ if (!("toolInterruptPolicy" in raw)) {
5432
+ const legacy = raw.interruptMode;
5433
+ if (legacy === "wait") raw.toolInterruptPolicy = "finish_tools";
5434
+ else if (legacy === "immediate") raw.toolInterruptPolicy = "abort_tools";
5435
+ // Any other value is malformed: drop it and let the schema default apply.
5436
+ }
5437
+ delete raw.interruptMode;
5438
+ }
5439
+
5440
+ raw.configSchemaVersion = CONFIG_SCHEMA_VERSION;
5441
+
5442
+ return raw;
5443
+ }
5444
+
5445
+ /** v0 -> v1 transforms. Runs only for files that predate schema version 1. */
5446
+ #migrateRawSettingsV0ToV1(raw: RawSettings): void {
5412
5447
  // queueMode -> steeringMode
5413
5448
  normalizeSessionDirectoryMigration(raw);
5414
5449
  if ("queueMode" in raw && !("steeringMode" in raw)) {
@@ -5573,10 +5608,6 @@ export class Settings implements NotificationSettingsReader {
5573
5608
  delete providersObj.imageCustomKeyEnv;
5574
5609
  }
5575
5610
  }
5576
-
5577
- raw.configSchemaVersion = CONFIG_SCHEMA_VERSION;
5578
-
5579
- return raw;
5580
5611
  }
5581
5612
 
5582
5613
  // ─────────────────────────────────────────────────────────────────────────
@@ -195,10 +195,10 @@ function trimProjectionRootPath(dir: string): string {
195
195
  }
196
196
 
197
197
  /**
198
- * Windows has no descriptor-relative `openat` binding in Node. Keep the root pathname
199
- * authoritative by checking its identity before and after every mutable-path operation.
198
+ * Node exposes no descriptor-relative `openat` binding on Windows or macOS. Keep the
199
+ * root pathname authoritative by checking its identity around every path operation.
200
200
  */
201
- async function openProjectionDirectoryWindowsSafe(dir: string): Promise<ProjectionScanDirectory> {
201
+ async function openProjectionDirectoryPathBracketed(dir: string): Promise<ProjectionScanDirectory> {
202
202
  const rootPath = trimProjectionRootPath(dir);
203
203
  let opened: import("node:fs").BigIntStats;
204
204
  try {
@@ -226,8 +226,8 @@ async function openProjectionDirectoryWindowsSafe(dir: string): Promise<Projecti
226
226
  };
227
227
 
228
228
  const entryPath = (entry: string): string => {
229
- // Directory entries come from readdir and are therefore single components on
230
- // Windows. Keep the invariant explicit so an injected or corrupted entry can
229
+ // Directory entries come from readdir and are therefore single components.
230
+ // Keep the invariant explicit so an injected or corrupted entry can
231
231
  // never turn a child operation into mutable pathname traversal.
232
232
  if (
233
233
  entry.length === 0 ||
@@ -290,7 +290,7 @@ async function openProjectionDirectoryWindowsSafe(dir: string): Promise<Projecti
290
290
  */
291
291
  async function openProjectionDirectorySafe(dir: string): Promise<ProjectionScanDirectory> {
292
292
  const platform = projectionPlatform();
293
- if (platform === "win32") return openProjectionDirectoryWindowsSafe(dir);
293
+ if (platform === "win32" || platform === "darwin") return openProjectionDirectoryPathBracketed(dir);
294
294
  if (platform !== "linux") throw new ProjectionScanUnsupportedError();
295
295
  const noFollow = fs.constants.O_NOFOLLOW;
296
296
  const nonBlock = fs.constants.O_NONBLOCK;