@bubblebrain-ai/bubble 0.0.58 → 0.0.60

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 (177) hide show
  1. package/README.md +56 -6
  2. package/dist/agent/child-runner.js +5 -1
  3. package/dist/agent/internal-reminder-sanitizer.d.ts +1 -1
  4. package/dist/agent/internal-reminder-sanitizer.js +7 -1
  5. package/dist/agent/profiles.js +4 -4
  6. package/dist/agent/routing-catalog.d.ts +19 -1
  7. package/dist/agent/routing-catalog.js +32 -13
  8. package/dist/agent/subagent/runtime.d.ts +28 -0
  9. package/dist/agent/subagent/runtime.js +34 -30
  10. package/dist/agent/subagent-control.d.ts +7 -0
  11. package/dist/agent/subagent-control.js +2 -0
  12. package/dist/agent/subagent-lifecycle-reminder.js +2 -1
  13. package/dist/agent/subagent-wait-guidance.d.ts +2 -0
  14. package/dist/agent/subagent-wait-guidance.js +8 -0
  15. package/dist/agent/subtask-policy.js +4 -4
  16. package/dist/agent.d.ts +20 -11
  17. package/dist/agent.js +200 -117
  18. package/dist/anthropic-thinking.d.ts +4 -0
  19. package/dist/anthropic-thinking.js +11 -0
  20. package/dist/approval/controller.d.ts +20 -6
  21. package/dist/approval/controller.js +67 -8
  22. package/dist/approval/protected-paths.d.ts +1 -0
  23. package/dist/approval/protected-paths.js +35 -0
  24. package/dist/approval/session-cache.d.ts +12 -26
  25. package/dist/approval/session-cache.js +20 -50
  26. package/dist/approval/types.d.ts +27 -1
  27. package/dist/cli.d.ts +5 -0
  28. package/dist/cli.js +25 -2
  29. package/dist/config.d.ts +3 -0
  30. package/dist/config.js +7 -0
  31. package/dist/context/checkpoint.d.ts +18 -0
  32. package/dist/context/checkpoint.js +117 -0
  33. package/dist/context/compact-llm.js +7 -0
  34. package/dist/context/compact.d.ts +8 -0
  35. package/dist/context/compact.js +40 -7
  36. package/dist/context/llm-compactor.d.ts +36 -2
  37. package/dist/context/llm-compactor.js +227 -44
  38. package/dist/context/session-write-lock.d.ts +20 -0
  39. package/dist/context/session-write-lock.js +162 -0
  40. package/dist/context/tool-images.d.ts +3 -0
  41. package/dist/context/tool-images.js +35 -0
  42. package/dist/context/usage.js +22 -5
  43. package/dist/feishu/agent-host/run-driver.js +56 -9
  44. package/dist/lsp/service.d.ts +13 -1
  45. package/dist/lsp/service.js +163 -43
  46. package/dist/main.js +82 -16
  47. package/dist/mcp/config.d.ts +4 -0
  48. package/dist/mcp/config.js +17 -0
  49. package/dist/mcp/manager.d.ts +13 -1
  50. package/dist/mcp/manager.js +32 -1
  51. package/dist/mcp/transports.js +11 -0
  52. package/dist/memory/db.d.ts +1 -0
  53. package/dist/memory/db.js +20 -4
  54. package/dist/memory/phase1.js +122 -16
  55. package/dist/memory/prompts.js +3 -0
  56. package/dist/model-catalog.js +19 -3
  57. package/dist/model-config.d.ts +1 -0
  58. package/dist/model-config.js +5 -5
  59. package/dist/model-pricing.js +26 -9
  60. package/dist/network/provider-transport-log.d.ts +12 -0
  61. package/dist/network/provider-transport-log.js +32 -0
  62. package/dist/network/provider-transport.js +6 -1
  63. package/dist/network/retry.d.ts +1 -1
  64. package/dist/network/retry.js +1 -1
  65. package/dist/oauth/grok.d.ts +1 -0
  66. package/dist/oauth/grok.js +20 -16
  67. package/dist/oauth/openai-codex.d.ts +1 -0
  68. package/dist/oauth/openai-codex.js +29 -25
  69. package/dist/oauth/refresh-control.d.ts +12 -0
  70. package/dist/oauth/refresh-control.js +42 -0
  71. package/dist/oauth/storage.d.ts +55 -1
  72. package/dist/oauth/storage.js +281 -16
  73. package/dist/permissions/rule.d.ts +6 -3
  74. package/dist/permissions/rule.js +71 -12
  75. package/dist/permissions/settings.d.ts +28 -1
  76. package/dist/permissions/settings.js +93 -3
  77. package/dist/permissions/shell-command.d.ts +36 -0
  78. package/dist/permissions/shell-command.js +226 -0
  79. package/dist/permissions/trust-prompt.d.ts +10 -0
  80. package/dist/permissions/trust-prompt.js +34 -0
  81. package/dist/permissions/trust.d.ts +26 -0
  82. package/dist/permissions/trust.js +113 -0
  83. package/dist/prompt/compose.d.ts +3 -0
  84. package/dist/prompt/compose.js +24 -7
  85. package/dist/prompt/environment.js +2 -2
  86. package/dist/prompt/reminders.js +1 -1
  87. package/dist/prompt/runtime.js +1 -1
  88. package/dist/provider-anthropic.d.ts +6 -2
  89. package/dist/provider-anthropic.js +98 -18
  90. package/dist/provider-error-record.d.ts +9 -0
  91. package/dist/provider-error-record.js +42 -14
  92. package/dist/provider-grok.js +5 -1
  93. package/dist/provider-openai-codex.d.ts +8 -0
  94. package/dist/provider-openai-codex.js +107 -24
  95. package/dist/provider-registry.d.ts +45 -2
  96. package/dist/provider-registry.js +300 -88
  97. package/dist/provider-transform.js +8 -0
  98. package/dist/provider-zen-errors.d.ts +9 -0
  99. package/dist/provider-zen-errors.js +36 -0
  100. package/dist/provider.js +36 -5
  101. package/dist/sdk/index.d.ts +69 -8
  102. package/dist/sdk/index.js +252 -39
  103. package/dist/sdk/replay-event-log.d.ts +7 -0
  104. package/dist/sdk/replay-event-log.js +66 -16
  105. package/dist/session-context-fence.d.ts +16 -0
  106. package/dist/session-context-fence.js +26 -0
  107. package/dist/session-log.d.ts +11 -2
  108. package/dist/session-log.js +187 -39
  109. package/dist/session-types.d.ts +5 -1
  110. package/dist/session.d.ts +39 -7
  111. package/dist/session.js +341 -93
  112. package/dist/skills/format.d.ts +31 -1
  113. package/dist/skills/format.js +45 -24
  114. package/dist/slash-commands/commands.d.ts +3 -0
  115. package/dist/slash-commands/commands.js +29 -14
  116. package/dist/system-prompt.d.ts +3 -1
  117. package/dist/tasks/manager.d.ts +7 -0
  118. package/dist/tasks/manager.js +97 -18
  119. package/dist/tools/agent-lifecycle.js +11 -7
  120. package/dist/tools/child-tools.d.ts +1 -1
  121. package/dist/tools/child-tools.js +4 -4
  122. package/dist/tools/edit-apply.d.ts +0 -3
  123. package/dist/tools/edit-apply.js +45 -249
  124. package/dist/tools/edit.js +3 -3
  125. package/dist/tools/exit-plan-mode.d.ts +5 -0
  126. package/dist/tools/exit-plan-mode.js +3 -2
  127. package/dist/tools/file-state.d.ts +4 -0
  128. package/dist/tools/file-state.js +12 -3
  129. package/dist/tools/grep.js +20 -1
  130. package/dist/tools/index.d.ts +1 -1
  131. package/dist/tools/index.js +3 -3
  132. package/dist/tools/ls.d.ts +3 -0
  133. package/dist/tools/ls.js +85 -0
  134. package/dist/tools/path-utils.d.ts +6 -0
  135. package/dist/tools/path-utils.js +38 -1
  136. package/dist/tools/sensitive-paths.d.ts +8 -0
  137. package/dist/tools/sensitive-paths.js +85 -3
  138. package/dist/tools/skill.d.ts +2 -0
  139. package/dist/tools/skill.js +34 -2
  140. package/dist/tools/task-tools.js +11 -7
  141. package/dist/tui/app.js +22 -12
  142. package/dist/tui/components/approval-dialog.d.ts +5 -5
  143. package/dist/tui/components/approval-dialog.js +16 -51
  144. package/dist/tui/components/skills-panel.d.ts +4 -0
  145. package/dist/tui/components/skills-panel.js +15 -1
  146. package/dist/tui/components/streaming-message.js +7 -2
  147. package/dist/tui/components/tasks-pane.d.ts +38 -0
  148. package/dist/tui/components/tasks-pane.js +182 -32
  149. package/dist/tui/components/transcript.d.ts +11 -4
  150. package/dist/tui/components/transcript.js +158 -71
  151. package/dist/tui/composer-autocomplete.d.ts +17 -2
  152. package/dist/tui/composer-autocomplete.js +79 -2
  153. package/dist/tui/controller/controller.d.ts +9 -4
  154. package/dist/tui/controller/controller.js +87 -95
  155. package/dist/tui/controller/goal-runtime-controller.js +1 -1
  156. package/dist/tui/controller/session-transition.js +1 -1
  157. package/dist/tui/model/subagent-view.d.ts +11 -19
  158. package/dist/tui/model/subagent-view.js +56 -85
  159. package/dist/tui/model/task-lifecycle.d.ts +57 -0
  160. package/dist/tui/model/task-lifecycle.js +124 -0
  161. package/dist/tui/model/trace-groups.js +10 -9
  162. package/dist/tui/trace-groups.js +10 -9
  163. package/dist/types.d.ts +20 -0
  164. package/dist/variant/variant-resolver.js +2 -0
  165. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.d.ts +1 -0
  166. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.d.ts.map +1 -1
  167. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.js +6 -0
  168. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.js.map +1 -1
  169. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.d.ts +2 -0
  170. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.d.ts.map +1 -1
  171. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.js +8 -4
  172. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.js.map +1 -1
  173. package/package.json +11 -3
  174. package/dist/prompt/skills.d.ts +0 -2
  175. package/dist/prompt/skills.js +0 -4
  176. package/dist/tools/glob.d.ts +0 -5
  177. package/dist/tools/glob.js +0 -199
@@ -12,7 +12,7 @@ import { BUILTIN_PROVIDERS as CATALOG_PROVIDERS, clearDynamicModelMetadata, getB
12
12
  import { ModelConfig } from "./model-config.js";
13
13
  import { AuthStorage } from "./oauth/index.js";
14
14
  import { fetchGeminiModels, geminiReasoningLevels } from "./provider-ai-sdk.js";
15
- import { extractChatGptAccountId, fetchOpenAICodexModelCatalog } from "./provider-openai-codex.js";
15
+ import { extractChatGptAccountId, fetchOpenAICodexModelCatalog, getCodexClientVersion } from "./provider-openai-codex.js";
16
16
  import { fetchGrokSubscriptionModels } from "./provider-grok.js";
17
17
  import { refreshOpenAICodex } from "./oauth/openai-codex.js";
18
18
  import { refreshGrok } from "./oauth/grok.js";
@@ -44,7 +44,9 @@ async function fetchOpenAICompatibleModelIds(provider) {
44
44
  if (!response.ok)
45
45
  throw new Error(`HTTP ${response.status}`);
46
46
  const payload = (await response.json());
47
- const entries = payload.data ?? payload.models ?? [];
47
+ const entries = payload?.data ?? payload?.models;
48
+ if (!Array.isArray(entries))
49
+ throw new Error("Model catalog response has no model list.");
48
50
  return entries
49
51
  .filter((entry) => typeof entry?.id === "string" && entry.id.trim().length > 0)
50
52
  .map((entry) => ({
@@ -63,12 +65,17 @@ export function sanitizeProviderHeaders(value) {
63
65
  }
64
66
  return Object.keys(out).length > 0 ? out : undefined;
65
67
  }
68
+ function isCompleteDiscovery(result) {
69
+ return result.source === "remote" && result.authoritative && !result.error;
70
+ }
66
71
  const MODEL_DISCOVERY_SUCCESS_TTL_MS = 60_000;
67
72
  const MODEL_DISCOVERY_FAILURE_TTL_MS = 10_000;
68
73
  /** How long a successful discovery survives on disk before a fresh fetch. */
69
74
  const MODEL_DISCOVERY_DISK_TTL_MS = 24 * 60 * 60 * 1000;
70
75
  /** Discovery must never delay startup or a model picker for long. */
71
76
  const MODEL_DISCOVERY_TIMEOUT_MS = 5_000;
77
+ // Per request: the ChatGPT backend is often reached through a user proxy.
78
+ const OPENAI_CODEX_MODEL_DISCOVERY_TIMEOUT_MS = 15_000;
72
79
  // Included in the cache identity so a pre-single-model OpenRouter disk cache
73
80
  // can never repopulate the picker with the old full remote catalog.
74
81
  const OPENROUTER_CATALOG_SCOPE = "ox-alpha-only-v2";
@@ -123,6 +130,10 @@ export const USER_VISIBLE_PROVIDER_IDS = BUILTIN_PROVIDERS
123
130
  export function isUserVisibleProvider(providerId) {
124
131
  return USER_VISIBLE_PROVIDER_IDS.includes(providerId);
125
132
  }
133
+ /** Judge expiry on the exact credential set the caller is about to send. */
134
+ function credentialsExpired(credentials, graceMs = 5 * 60 * 1000) {
135
+ return Date.now() >= credentials.expiresAt - graceMs;
136
+ }
126
137
  export class ProviderRegistry {
127
138
  config;
128
139
  modelConfig;
@@ -165,10 +176,12 @@ export class ProviderRegistry {
165
176
  const parsed = JSON.parse(readFileSync(this.discoveryDiskCachePath, "utf8"));
166
177
  const now = Date.now();
167
178
  for (const [key, entry] of Object.entries(parsed)) {
168
- if (!entry || typeof entry.expiresAt !== "number" || entry.expiresAt <= now)
179
+ if (!entry || typeof entry.expiresAt !== "number")
169
180
  continue;
170
181
  if (!entry.result || !Array.isArray(entry.result.models))
171
182
  continue;
183
+ if (entry.expiresAt <= now && !(entry.providerId === "openai" && isCompleteDiscovery(entry.result)))
184
+ continue;
172
185
  const provider = { id: entry.providerId ?? "", authType: entry.authType, protocol: entry.protocol };
173
186
  // Capability fields may have been derived by an older Bubble build.
174
187
  // Recompute them with the current code while retaining cached remote
@@ -181,6 +194,7 @@ export class ProviderRegistry {
181
194
  providerId: provider.id,
182
195
  authType: entry.authType,
183
196
  protocol: entry.protocol,
197
+ confirmed: isCompleteDiscovery(result) ? { models: result.models, until: entry.expiresAt } : undefined,
184
198
  });
185
199
  // Rebuild the dynamic overlay so context window / reasoning levels
186
200
  // resolve from the cached catalog at startup, not only on /model open.
@@ -199,17 +213,28 @@ export class ProviderRegistry {
199
213
  return;
200
214
  try {
201
215
  const data = {};
216
+ const now = Date.now();
202
217
  for (const [key, entry] of this.modelDiscoveryCache) {
203
- if (entry.result.error)
204
- continue;
205
- data[key] = {
206
- result: entry.result,
207
- expiresAt: Date.now() + MODEL_DISCOVERY_DISK_TTL_MS,
218
+ const common = {
208
219
  identityKey: entry.identityKey,
209
220
  providerId: entry.providerId,
210
221
  authType: entry.authType,
211
222
  protocol: entry.protocol,
212
223
  };
224
+ if (!entry.result.error) {
225
+ data[key] = { ...common, result: entry.result, expiresAt: now + MODEL_DISCOVERY_DISK_TTL_MS };
226
+ continue;
227
+ }
228
+ // A failed refresh must not erase the identity's last confirmed catalog
229
+ // from disk when an unrelated provider triggers a rewrite; persist the
230
+ // confirmation under its remaining horizon instead of dropping the key.
231
+ if (entry.confirmed && (entry.confirmed.until > now || entry.providerId === "openai")) {
232
+ data[key] = {
233
+ ...common,
234
+ result: { models: entry.confirmed.models, source: "remote", authoritative: true },
235
+ expiresAt: entry.confirmed.until,
236
+ };
237
+ }
213
238
  }
214
239
  mkdirSync(dirname(this.discoveryDiskCachePath), { recursive: true });
215
240
  writeFileSync(this.discoveryDiskCachePath, JSON.stringify(data, null, 2), { mode: 0o600 });
@@ -225,6 +250,10 @@ export class ProviderRegistry {
225
250
  * (docs/model-routing-design.md §1.6).
226
251
  */
227
252
  getRoutingRevision() {
253
+ // A login/logout in another Bubble process only reaches handleAuthMutation
254
+ // when the storage notices the file changed; callers cache against this
255
+ // number without touching credentials, so look now (one stat).
256
+ this.authStorage.sync();
228
257
  return this.routingRevision;
229
258
  }
230
259
  bumpRoutingRevision() {
@@ -259,9 +288,57 @@ export class ProviderRegistry {
259
288
  getAuthStorage() {
260
289
  return this.authStorage;
261
290
  }
291
+ /**
292
+ * Run discovery for an account-scoped (OAuth) provider so the routing
293
+ * snapshot knows the account's real catalog before the routing prompt is
294
+ * composed and before the first subagent spawn. Discovery was previously
295
+ * triggered only by the model picker, which left tier routing on the static
296
+ * builtin list for the whole session unless the user opened /model.
297
+ * Resolves when discovery settles (it never throws: failures resolve to the
298
+ * local fallback); resolves immediately when a fresh cache exists or the
299
+ * provider is not account-scoped. Hosts bound the wait, see waitForModelDiscovery.
300
+ */
301
+ warmModelDiscovery(providerId) {
302
+ const provider = this.getConfigured().find((item) => item.id === providerId);
303
+ if (!provider?.enabled || !provider.apiKey || provider.authType !== "oauth")
304
+ return Promise.resolve();
305
+ if (this.getCachedDiscoverySnapshot(providerId))
306
+ return Promise.resolve();
307
+ return this.discoverModels(provider).then(() => undefined, () => undefined);
308
+ }
309
+ /**
310
+ * Warm discovery but never hold startup hostage to the network: after
311
+ * `maxWaitMs` the host proceeds with whatever the snapshot has, and the
312
+ * still-running discovery updates the live accessor when it lands.
313
+ */
314
+ async waitForModelDiscovery(providerId, maxWaitMs) {
315
+ let timer;
316
+ const deadline = new Promise((resolve) => {
317
+ timer = setTimeout(resolve, maxWaitMs);
318
+ timer.unref?.();
319
+ });
320
+ try {
321
+ await Promise.race([this.warmModelDiscovery(providerId), deadline]);
322
+ }
323
+ finally {
324
+ if (timer)
325
+ clearTimeout(timer);
326
+ }
327
+ }
262
328
  supportsOAuth(providerId) {
263
329
  return !!getBuiltinProvider(providerId)?.supportsOAuth;
264
330
  }
331
+ /**
332
+ * Auth-storage keys currently backing a provider's OAuth login. The visible
333
+ * `openai` provider may still be backed by the legacy `openai-codex` key,
334
+ * so UI state and logout must not check `has(providerId)` directly.
335
+ */
336
+ getOAuthLoginKeys(providerId) {
337
+ const candidates = providerId === "openai" || providerId === "openai-codex"
338
+ ? ["openai", "openai-codex"]
339
+ : [providerId];
340
+ return candidates.filter((key) => this.authStorage.has(key));
341
+ }
265
342
  resolveOAuthAuthKey(providerId) {
266
343
  if (providerId === "openai" || providerId === "openai-codex") {
267
344
  if (this.authStorage.has("openai"))
@@ -280,30 +357,10 @@ export class ProviderRegistry {
280
357
  let refreshPromise;
281
358
  return {
282
359
  getCredentials: readCredentials,
283
- isExpired: (_credentials, graceMs) => this.authStorage.isExpired(this.resolveOAuthAuthKey(providerId), graceMs),
284
- refreshCredentials: async () => {
360
+ isExpired: credentialsExpired,
361
+ refreshCredentials: async (used) => {
285
362
  if (!refreshPromise) {
286
- refreshPromise = (async () => {
287
- const authKey = this.resolveOAuthAuthKey(providerId);
288
- const current = this.authStorage.get(authKey);
289
- if (!current?.refreshToken) {
290
- throw new Error(`OpenAI OAuth credentials for ${providerId} are missing a refresh token.`);
291
- }
292
- const refreshed = await refreshOpenAICodex(current.refreshToken);
293
- const next = {
294
- type: "oauth",
295
- accessToken: refreshed.accessToken,
296
- refreshToken: refreshed.refreshToken,
297
- expiresAt: refreshed.expiresAt,
298
- idToken: refreshed.idToken || current.idToken,
299
- accountId: refreshed.accountId || current.accountId,
300
- };
301
- this.authStorage.set("openai", next);
302
- if (authKey !== "openai") {
303
- this.authStorage.set(authKey, next);
304
- }
305
- return next;
306
- })().finally(() => {
363
+ refreshPromise = this.refreshOpenAICredentials(providerId, used).finally(() => {
307
364
  refreshPromise = undefined;
308
365
  });
309
366
  }
@@ -317,24 +374,10 @@ export class ProviderRegistry {
317
374
  let refreshPromise;
318
375
  return {
319
376
  getCredentials: () => this.authStorage.get("grok"),
320
- isExpired: (_credentials, graceMs) => this.authStorage.isExpired("grok", graceMs),
321
- refreshCredentials: async () => {
377
+ isExpired: credentialsExpired,
378
+ refreshCredentials: async (used) => {
322
379
  if (!refreshPromise) {
323
- refreshPromise = (async () => {
324
- const current = this.authStorage.get("grok");
325
- if (!current?.refreshToken) {
326
- throw new Error("Grok OAuth credentials are missing a refresh token. Run /login grok again.");
327
- }
328
- const refreshed = await refreshGrok(current.refreshToken);
329
- const next = {
330
- type: "oauth",
331
- accessToken: refreshed.accessToken,
332
- refreshToken: refreshed.refreshToken,
333
- expiresAt: refreshed.expiresAt,
334
- };
335
- this.authStorage.set("grok", next);
336
- return next;
337
- })().finally(() => {
380
+ refreshPromise = this.refreshGrokCredentials(used).finally(() => {
338
381
  refreshPromise = undefined;
339
382
  });
340
383
  }
@@ -353,38 +396,138 @@ export class ProviderRegistry {
353
396
  return listBuiltinModels(providerId)[0]?.id;
354
397
  }
355
398
  async prepareProvider(providerId) {
356
- if (providerId === "grok" && this.authStorage.isExpired("grok")) {
357
- const creds = this.authStorage.get("grok");
358
- if (creds?.refreshToken) {
359
- const refreshed = await refreshGrok(creds.refreshToken);
360
- this.authStorage.set("grok", {
399
+ // One snapshot decides both "is it expired" and "what did we hold": with
400
+ // two reads another process could rotate in between, and the fresh set
401
+ // would be taken for the held one and refreshed again for nothing.
402
+ if (providerId === "grok") {
403
+ const held = this.authStorage.get("grok");
404
+ if (held?.refreshToken && credentialsExpired(held))
405
+ await this.refreshGrokCredentials(held);
406
+ return;
407
+ }
408
+ if (providerId !== "openai" && providerId !== "openai-codex")
409
+ return;
410
+ const held = this.authStorage.get(this.resolveOAuthAuthKey(providerId));
411
+ if (held?.refreshToken && credentialsExpired(held))
412
+ await this.refreshOpenAICredentials(providerId, held);
413
+ }
414
+ refreshOpenAICredentials(providerId, used) {
415
+ return this.refreshSharedCredentials({
416
+ // Resolved under the lock: another process may have completed a fresh
417
+ // /login openai meanwhile, and a legacy openai-codex refresh must not be
418
+ // mirrored over it.
419
+ resolveKeys: () => {
420
+ const authKey = this.resolveOAuthAuthKey(providerId);
421
+ // "openai" is the canonical key; a legacy openai-codex login is mirrored.
422
+ return { authKey, writeKeys: authKey === "openai" ? ["openai"] : ["openai", authKey] };
423
+ },
424
+ used,
425
+ label: "OpenAI",
426
+ loginHint: "/login openai",
427
+ refresh: async (current) => {
428
+ const refreshed = await refreshOpenAICodex(current.refreshToken);
429
+ return {
361
430
  type: "oauth",
362
431
  accessToken: refreshed.accessToken,
363
432
  refreshToken: refreshed.refreshToken,
364
433
  expiresAt: refreshed.expiresAt,
365
- });
366
- }
367
- return;
368
- }
369
- const authKey = this.resolveOAuthAuthKey(providerId);
370
- if ((providerId === "openai" || providerId === "openai-codex") && this.authStorage.isExpired(authKey)) {
371
- const creds = this.authStorage.get(authKey);
372
- if (creds?.refreshToken) {
373
- const refreshed = await refreshOpenAICodex(creds.refreshToken);
374
- const next = {
434
+ idToken: refreshed.idToken || current.idToken,
435
+ accountId: refreshed.accountId || current.accountId,
436
+ };
437
+ },
438
+ });
439
+ }
440
+ refreshGrokCredentials(used) {
441
+ return this.refreshSharedCredentials({
442
+ resolveKeys: () => ({ authKey: "grok", writeKeys: ["grok"] }),
443
+ used,
444
+ label: "Grok",
445
+ loginHint: "/login grok",
446
+ refresh: async (current) => {
447
+ const refreshed = await refreshGrok(current.refreshToken);
448
+ return {
375
449
  type: "oauth",
376
450
  accessToken: refreshed.accessToken,
377
451
  refreshToken: refreshed.refreshToken,
378
452
  expiresAt: refreshed.expiresAt,
379
- idToken: refreshed.idToken || creds.idToken,
380
- accountId: refreshed.accountId || creds.accountId,
381
453
  };
382
- this.authStorage.set("openai", next);
383
- if (authKey !== "openai") {
384
- this.authStorage.set(authKey, next);
454
+ },
455
+ });
456
+ }
457
+ /**
458
+ * The one place a refresh token is spent. auth.json is shared by every
459
+ * Bubble process and refresh tokens are single-use, so the token is only
460
+ * presented under the cross-process lock and only after re-reading the file:
461
+ * if another process already rotated it, its result is adopted and no
462
+ * request is made. A rejected token gets the same second look before the
463
+ * error surfaces, because the rotation may have landed mid-request.
464
+ *
465
+ * `used` is the credential set the caller was holding when it asked (the
466
+ * provider passes it, also for a forced refresh after a 401): "rotated" means
467
+ * the file no longer holds that set, not merely that it is unexpired.
468
+ */
469
+ refreshSharedCredentials(options) {
470
+ const { label, loginHint, refresh } = options;
471
+ const held = options.used ?? this.authStorage.get(options.resolveKeys().authKey);
472
+ return this.authStorage.withRefreshLock(async () => {
473
+ this.authStorage.reload();
474
+ const { authKey, writeKeys } = options.resolveKeys();
475
+ const current = this.authStorage.get(authKey);
476
+ if (!current?.refreshToken) {
477
+ throw new Error(`${label} OAuth credentials are missing a refresh token. Run ${loginHint} again.`);
478
+ }
479
+ const rotatedElsewhere = (since) => {
480
+ this.authStorage.reload();
481
+ // Re-resolved every time: a login elsewhere may have created the
482
+ // canonical key since, and it is then the entry to adopt.
483
+ const latest = this.authStorage.get(options.resolveKeys().authKey);
484
+ if (!latest || !since)
485
+ return undefined;
486
+ if (latest.refreshToken === since.refreshToken && latest.accessToken === since.accessToken)
487
+ return undefined;
488
+ return credentialsExpired(latest) ? undefined : latest;
489
+ };
490
+ const adopted = rotatedElsewhere(held);
491
+ if (adopted)
492
+ return adopted;
493
+ let next;
494
+ try {
495
+ next = await refresh(current);
496
+ }
497
+ catch (error) {
498
+ const rescued = rotatedElsewhere(current);
499
+ if (rescued)
500
+ return rescued;
501
+ const message = error instanceof Error ? error.message : String(error);
502
+ // A 400/401 from the token endpoint is a verdict on the refresh token
503
+ // (reused, revoked, expired), not a transient failure: say what to do.
504
+ if (/Token refresh failed: 40[01]\b|refresh_token_reused|invalid_grant/.test(message)) {
505
+ throw new Error(`${label} login is no longer valid. Run ${loginHint} to sign in again.\n${message}`);
385
506
  }
507
+ throw error;
386
508
  }
387
- }
509
+ // A /login or /logout elsewhere may have landed while the request was in
510
+ // flight (they do not take the refresh lock): their outcome wins.
511
+ if (!this.authStorage.replaceIfUnchanged(authKey, next, current.refreshToken)) {
512
+ const replaced = this.authStorage.get(authKey);
513
+ if (replaced)
514
+ return replaced;
515
+ throw new Error(`${label} credentials were removed while refreshing. Run ${loginHint} to sign in again.`);
516
+ }
517
+ for (const key of writeKeys) {
518
+ if (key === authKey)
519
+ continue;
520
+ // Mirror keys were absent when resolved under the lock. If a login
521
+ // created one since, it is now the preferred entry: leave it alone and
522
+ // hand its credentials to the caller instead of the old account's.
523
+ if (!this.authStorage.replaceIfUnchanged(key, next, null)) {
524
+ const preferred = this.authStorage.get(options.resolveKeys().authKey);
525
+ if (preferred && !credentialsExpired(preferred))
526
+ return preferred;
527
+ }
528
+ }
529
+ return next;
530
+ });
388
531
  }
389
532
  getConfigured() {
390
533
  // 1. Try models.json first
@@ -459,6 +602,14 @@ export class ProviderRegistry {
459
602
  });
460
603
  }
461
604
  }
605
+ // OAuth providers can be injected even when models.json owns the model
606
+ // catalog. Honor the separately stored enable preference in either path.
607
+ const enablePreferences = new Map(this.config.getProviders().map((p) => [p.id, p.enabled]));
608
+ for (const provider of providers) {
609
+ if (provider.authType === "oauth" && enablePreferences.has(provider.id)) {
610
+ provider.enabled = enablePreferences.get(provider.id) !== false && !!provider.apiKey;
611
+ }
612
+ }
462
613
  return providers;
463
614
  }
464
615
  getEnabled() {
@@ -515,6 +666,8 @@ export class ProviderRegistry {
515
666
  async discoverModels(provider, options = {}) {
516
667
  const key = this.modelDiscoveryKey(provider);
517
668
  const now = Date.now();
669
+ // A failed refresh must carry the last confirmed catalog forward.
670
+ const retainedConfirmation = this.modelDiscoveryCache.get(key)?.confirmed;
518
671
  if (!options.forceRefresh) {
519
672
  const cached = this.modelDiscoveryCache.get(key);
520
673
  if (cached && cached.expiresAt > now) {
@@ -527,11 +680,13 @@ export class ProviderRegistry {
527
680
  // without waiting up to 24 hours or requiring a manual Ctrl+R refresh.
528
681
  const local = this.localModelsForProvider(provider);
529
682
  const localIds = new Set(local.map((model) => model.id));
530
- const models = cachedResult.source === "static"
531
- ? local
532
- : cachedResult.authoritative
533
- ? cachedResult.models
534
- : [...local, ...cachedResult.models.filter((model) => !localIds.has(model.id))];
683
+ const models = provider.id === "openai"
684
+ ? cachedResult.models
685
+ : cachedResult.source === "static"
686
+ ? local
687
+ : cachedResult.authoritative
688
+ ? cachedResult.models
689
+ : [...local, ...cachedResult.models.filter((model) => !localIds.has(model.id))];
535
690
  const result = { ...cachedResult, models, source: "cache" };
536
691
  const current = this.getConfigured().find((item) => item.id === provider.id);
537
692
  if (!current || this.modelDiscoveryKey(current) === key) {
@@ -544,7 +699,8 @@ export class ProviderRegistry {
544
699
  return inFlight;
545
700
  }
546
701
  else {
547
- this.modelDiscoveryCache.delete(key);
702
+ // Keep the last confirmation readable while the replacement is in flight.
703
+ // Forced discovery bypasses freshness without invalidating a good catalog.
548
704
  // Do not let a superseded request's finally-handler remove this retry.
549
705
  this.modelDiscoveryInFlight.delete(key);
550
706
  }
@@ -552,10 +708,12 @@ export class ProviderRegistry {
552
708
  this.modelDiscoveryGeneration.set(provider.id, generation);
553
709
  let pending;
554
710
  pending = this.performModelDiscovery(provider).then((rawResult) => {
555
- const result = normalizeProviderDiscoveryMetadata(provider, rawResult);
711
+ const result = normalizeProviderDiscoveryMetadata(provider, provider.id === "openai" && rawResult.error
712
+ ? { ...rawResult, models: retainedConfirmation?.models ?? [] }
713
+ : rawResult);
556
714
  if (!this.isCurrentModelDiscovery(provider, key, generation)) {
557
715
  return {
558
- models: this.localModelsForProvider(this.getConfigured().find((item) => item.id === provider.id) ?? provider),
716
+ models: provider.id === "openai" ? [] : this.localModelsForProvider(this.getConfigured().find((item) => item.id === provider.id) ?? provider),
559
717
  source: "fallback",
560
718
  authoritative: false,
561
719
  error: "Model catalog changed while it was refreshing.",
@@ -563,9 +721,15 @@ export class ProviderRegistry {
563
721
  }
564
722
  this.applyDynamicDiscoveryMetadata(provider, result);
565
723
  const previous = this.lastDiscoveryMembership.get(key);
724
+ const stored = { ...result, source: result.source === "cache" ? "remote" : result.source };
566
725
  this.modelDiscoveryCache.set(key, {
567
- result: { ...result, source: result.source === "cache" ? "remote" : result.source },
726
+ result: stored,
568
727
  expiresAt: Date.now() + (result.error ? MODEL_DISCOVERY_FAILURE_TTL_MS : MODEL_DISCOVERY_SUCCESS_TTL_MS),
728
+ // A complete success starts a new confirmation horizon; anything else
729
+ // (failure fallback, partial union) carries the previous one forward.
730
+ confirmed: isCompleteDiscovery(stored)
731
+ ? { models: stored.models, until: Date.now() + MODEL_DISCOVERY_DISK_TTL_MS }
732
+ : this.modelDiscoveryCache.get(key)?.confirmed ?? retainedConfirmation,
569
733
  identityKey: this.discoveryIdentity(provider),
570
734
  providerId: provider.id,
571
735
  authType: provider.authType,
@@ -591,7 +755,7 @@ export class ProviderRegistry {
591
755
  }
592
756
  async performModelDiscovery(provider) {
593
757
  const customModels = filterProviderModels(provider.id, this.modelConfig.getCustomModels(provider.id));
594
- if (customModels.length > 0) {
758
+ if (customModels.length > 0 && provider.id !== "openai") {
595
759
  return { models: customModels, source: "static", authoritative: true };
596
760
  }
597
761
  if (provider.id === "openrouter") {
@@ -659,9 +823,10 @@ export class ProviderRegistry {
659
823
  const catalog = await fetchOpenAICodexModelCatalog({
660
824
  baseURL: currentProvider.baseURL,
661
825
  accessToken: currentProvider.apiKey,
826
+ timeoutMs: OPENAI_CODEX_MODEL_DISCOVERY_TIMEOUT_MS,
662
827
  });
663
828
  if (catalog.status === "unavailable") {
664
- throw new Error("OpenAI Codex model catalog is unavailable.");
829
+ throw new Error(`OpenAI Codex model catalog is unavailable${catalog.reason ? ` (${catalog.reason})` : ""}.`);
665
830
  }
666
831
  const visible = catalog.descriptors.filter((descriptor) => descriptor.visibility !== "hide");
667
832
  return {
@@ -749,10 +914,19 @@ export class ProviderRegistry {
749
914
  // while unlisted), remote-only ids are appended, obvious non-chat
750
915
  // modalities are filtered out. Not authoritative — membership is a union,
751
916
  // so nothing downstream may treat it as a closed allowlist.
752
- if (isOpenAICompatibleProtocol(provider.protocol) && provider.apiKey) {
917
+ if ((isOpenAICompatibleProtocol(provider.protocol) || provider.id === "openai") && provider.apiKey) {
753
918
  try {
754
919
  const remote = await fetchOpenAICompatibleModelIds(provider);
755
920
  const local = this.localModelsForProvider(provider);
921
+ if (provider.id === "openai") {
922
+ const metadata = new Map(local.map(model => [model.id, model]));
923
+ return {
924
+ models: remote.filter(entry => isLikelyChatModelId(entry.id)).map(entry => ({
925
+ ...metadata.get(entry.id), id: entry.id, name: entry.name || entry.id, providerId: provider.id,
926
+ })),
927
+ source: "remote", authoritative: true,
928
+ };
929
+ }
756
930
  const known = new Set(local.map((model) => model.id));
757
931
  const extras = remote
758
932
  .filter((entry) => !known.has(entry.id) && isLikelyChatModelId(entry.id))
@@ -767,21 +941,23 @@ export class ProviderRegistry {
767
941
  }
768
942
  return { models: [...local, ...extras], source: "remote", authoritative: false };
769
943
  }
770
- catch {
944
+ catch (error) {
945
+ if (provider.id === "openai")
946
+ return this.fallbackDiscovery(provider, error);
771
947
  // Vendors that don't implement /models (or gate it) fall back silently:
772
948
  // the curated catalog is the contract, discovery is a bonus.
773
949
  return { models: this.localModelsForProvider(provider), source: "static", authoritative: true };
774
950
  }
775
951
  }
776
952
  return {
777
- models: this.localModelsForProvider(provider),
953
+ models: provider.id === "openai" ? [] : this.localModelsForProvider(provider),
778
954
  source: "static",
779
955
  authoritative: true,
780
956
  };
781
957
  }
782
958
  fallbackDiscovery(provider, error) {
783
959
  return {
784
- models: this.localModelsForProvider(provider),
960
+ models: provider.id === "openai" ? [] : this.localModelsForProvider(provider),
785
961
  source: "fallback",
786
962
  authoritative: false,
787
963
  error: modelDiscoveryError(error),
@@ -825,7 +1001,15 @@ export class ProviderRegistry {
825
1001
  provider.authType ?? "api",
826
1002
  provider.protocol ?? "default",
827
1003
  this.discoveryIdentity(provider),
828
- provider.id === "openrouter" ? OPENROUTER_CATALOG_SCOPE : undefined,
1004
+ provider.id === "openrouter"
1005
+ ? OPENROUTER_CATALOG_SCOPE
1006
+ // The ChatGPT backend filters /codex/models by the client version we
1007
+ // claim, so a catalog fetched under an older pin (possibly by another
1008
+ // still-running Bubble sharing the disk cache) must not satisfy this
1009
+ // build's discovery.
1010
+ : provider.id === "openai"
1011
+ ? `remote-only-v1:${provider.authType === "oauth" ? `codex-client:${getCodexClientVersion()}` : "api"}`
1012
+ : undefined,
829
1013
  ]);
830
1014
  }
831
1015
  /**
@@ -835,18 +1019,46 @@ export class ProviderRegistry {
835
1019
  * derived strictly: only a successful full remote catalog qualifies —
836
1020
  * never static or fallback results, whose internal `authoritative` flag
837
1021
  * this design does not trust.
1022
+ * Desktop catalogs can explicitly retain the last confirmation beyond its
1023
+ * routing horizon; ordinary routing callers keep the default expiry policy.
838
1024
  */
839
- getCachedDiscoverySnapshot(providerId) {
1025
+ getCachedDiscoverySnapshot(providerId, options = {}) {
840
1026
  const provider = this.getConfigured().find((item) => item.id === providerId);
841
1027
  if (!provider)
842
1028
  return undefined;
843
1029
  const cached = this.modelDiscoveryCache.get(this.modelDiscoveryKey(provider));
844
- if (!cached || cached.expiresAt <= Date.now())
1030
+ if (!cached)
1031
+ return undefined;
1032
+ const now = Date.now();
1033
+ const fresh = cached.expiresAt > now;
1034
+ if (fresh && isCompleteDiscovery(cached.result)) {
1035
+ return {
1036
+ models: cached.result.models,
1037
+ source: cached.result.source,
1038
+ complete: true,
1039
+ expiresAt: cached.expiresAt,
1040
+ identityKey: cached.identityKey,
1041
+ };
1042
+ }
1043
+ // The live result is stale or not complete: routing keeps the last
1044
+ // confirmed catalog for this identity until its horizon passes, so a
1045
+ // long session does not lose its tier candidates after the freshness
1046
+ // minute or across one failed refresh.
1047
+ if (cached.confirmed && (cached.confirmed.until > now || options.allowExpiredConfirmation)) {
1048
+ return {
1049
+ models: cached.confirmed.models,
1050
+ source: "cache",
1051
+ complete: true,
1052
+ expiresAt: cached.confirmed.until,
1053
+ identityKey: cached.identityKey,
1054
+ };
1055
+ }
1056
+ if (!fresh)
845
1057
  return undefined;
846
1058
  return {
847
1059
  models: cached.result.models,
848
1060
  source: cached.result.source,
849
- complete: cached.result.source === "remote" && cached.result.authoritative && !cached.result.error,
1061
+ complete: false,
850
1062
  expiresAt: cached.expiresAt,
851
1063
  identityKey: cached.identityKey,
852
1064
  };
@@ -31,6 +31,14 @@ function isFireworksKimi(providerId, modelId) {
31
31
  export function resolveProviderRequestConfig(providerId, modelId, requestedLevel) {
32
32
  const supportedLevels = getAvailableThinkingLevels(providerId, modelId);
33
33
  const effectiveThinkingLevel = normalizeThinkingLevel(requestedLevel, supportedLevels);
34
+ if (providerId === "opencode-zen" && modelId === "space-bunny-free") {
35
+ const level = normalizeInheritedThinkingLevel(providerId, modelId, requestedLevel);
36
+ return {
37
+ effectiveThinkingLevel: level,
38
+ reasoningContentEcho: "tool_calls",
39
+ extraBody: { reasoning_effort: level },
40
+ };
41
+ }
34
42
  if (providerId === "openai-codex") {
35
43
  const model = getBuiltinModel(providerId, modelId);
36
44
  if (!model || model.reasoningLevels.length === 0) {
@@ -0,0 +1,9 @@
1
+ import { ProviderStreamInterruptedError } from "./network/retry.js";
2
+ /** Only Zen's opaque upstream rejection is recoverable, not arbitrary 422s. */
3
+ export declare function isSpaceBunnyUpstreamRejection(providerId: string | undefined, model: string, error: unknown): boolean;
4
+ export declare function recoverableSpaceBunnyError(error: unknown): ProviderStreamInterruptedError;
5
+ /** Counts only: never persist prompts, tool arguments, URLs or API headers. */
6
+ export declare function chatRequestShape(body: {
7
+ messages?: Record<string, unknown>[];
8
+ tools?: unknown[];
9
+ }): Record<string, number>;