@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
@@ -1,6 +1,10 @@
1
1
  import { getCurrentVersion } from "./update/index.js";
2
2
  const MAX_FIELD_CHARS = 160;
3
3
  const RUNTIME_STARTED_AT = Date.now();
4
+ const TRANSPORT_ERROR_CODES = new Set([
5
+ "UND_ERR_SOCKET", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT",
6
+ "ECONNRESET", "ECONNREFUSED", "EPIPE", "ETIMEDOUT", "ENOTFOUND", "EAI_AGAIN",
7
+ ]);
4
8
  const SAFE_ERROR_CODES = new Map([
5
9
  ["authentication_error", "authentication_error"],
6
10
  ["bad_request", "bad_request"],
@@ -8,6 +12,7 @@ const SAFE_ERROR_CODES = new Map([
8
12
  ["failed_precondition", "failed_precondition"],
9
13
  ["internal_error", "internal_error"],
10
14
  ["invalid_argument", "invalid_argument"],
15
+ ["invalid_request_error", "invalid_request_error"],
11
16
  ["invalidparameter", "invalid_parameter"],
12
17
  ["invalid_parameter", "invalid_parameter"],
13
18
  ["location_not_supported", "location_not_supported"],
@@ -20,6 +25,7 @@ const SAFE_ERROR_CODES = new Map([
20
25
  ["unauthenticated", "unauthenticated"],
21
26
  ["unsupported_parameter", "unsupported_parameter"],
22
27
  ["unsupported_value", "unsupported_value"],
28
+ ...Array.from(TRANSPORT_ERROR_CODES, (code) => [code.toLowerCase(), code]),
23
29
  ]);
24
30
  const SAFE_PARAMETER_NAMES = new Set([
25
31
  "contents",
@@ -59,7 +65,8 @@ export function createSanitizedProviderError(error, context) {
59
65
  const rawMessage = firstString(sources, ["message"])
60
66
  || (typeof error === "string" ? error : "Provider request failed.");
61
67
  const httpStatus = firstHttpStatus(sources);
62
- const code = sanitizeErrorCode(firstScalarString(sources, ["code", "errorCode", "type"]));
68
+ // A wrapper's unknown code must not hide an allowlisted nested transport code.
69
+ const code = sources.flatMap((source) => ["code", "errorCode", "type"].map((key) => typeof source[key] === "string" ? sanitizeErrorCode(source[key]) : undefined)).find((value) => value !== undefined);
63
70
  const parameter = sanitizeParameterName(firstScalarString(sources, ["param", "parameter"]));
64
71
  return {
65
72
  providerId: sanitizeControlledIdentifier(context.providerId),
@@ -67,17 +74,37 @@ export function createSanitizedProviderError(error, context) {
67
74
  ...(context.model ? { model: sanitizeControlledIdentifier(context.model) } : {}),
68
75
  thinkingLevel: context.thinkingLevel,
69
76
  name: "ProviderError",
70
- message: sanitizeProviderErrorText(rawMessage),
77
+ message: code && TRANSPORT_ERROR_CODES.has(code)
78
+ ? code.includes("TIMEOUT") || code === "ETIMEDOUT" ? "Provider request timed out." : "Provider connection failed."
79
+ : sanitizeProviderErrorText(rawMessage),
71
80
  ...(httpStatus !== undefined ? { httpStatus } : {}),
72
81
  ...(code ? { code } : {}),
73
82
  ...(parameter ? { parameter } : {}),
74
83
  messageCount: Math.max(0, Math.trunc(context.messageCount)),
75
84
  toolCount: Math.max(0, Math.trunc(context.toolCount)),
85
+ ...sanitizedRequestShape(sources),
76
86
  bubbleVersion: getCurrentVersion(),
77
87
  pid: process.pid,
78
88
  runtimeStartedAt: RUNTIME_STARTED_AT,
89
+ ...(context.retry ? { retry: {
90
+ attempt: context.retry.attempt,
91
+ maxAttempts: context.retry.maxAttempts,
92
+ } } : {}),
79
93
  };
80
94
  }
95
+ function sanitizedRequestShape(sources) {
96
+ const raw = sources.find(source => asRecord(source.requestShape))?.requestShape;
97
+ const shape = asRecord(raw);
98
+ if (!shape)
99
+ return {};
100
+ const safe = {};
101
+ for (const key of ["messages", "tools", "assistant", "emptyAssistant", "reasoning", "toolCalls", "toolResults", "contentChars"]) {
102
+ const value = shape[key];
103
+ if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0)
104
+ safe[key] = value;
105
+ }
106
+ return { requestShape: safe };
107
+ }
81
108
  export function sanitizeProviderErrorText(value) {
82
109
  const text = value.replace(/[\r\n\t]+/g, " ").replace(/\s{2,}/g, " ").trim();
83
110
  // Fail closed: provider/SDK messages can embed request headers, bodies,
@@ -99,6 +126,9 @@ export function sanitizeProviderErrorText(value) {
99
126
  if (/forbidden|permission denied|access denied|\b403\b/i.test(text)) {
100
127
  return "Provider denied the request.";
101
128
  }
129
+ if (/unprocessable entity|\b422\b/i.test(text)) {
130
+ return "Provider rejected the request (unprocessable entity).";
131
+ }
102
132
  if (/invalid[^.]{0,60}(?:parameter|argument)|unsupported[^.]{0,60}(?:parameter|argument)|bad request|\b400\b/i.test(text)) {
103
133
  return "Provider rejected a request parameter.";
104
134
  }
@@ -115,18 +145,16 @@ export function sanitizeProviderErrorText(value) {
115
145
  }
116
146
  function errorSources(error) {
117
147
  const sources = [];
118
- const root = asRecord(error);
119
- if (root)
120
- sources.push(root);
121
- const nested = asRecord(root?.error);
122
- if (nested)
123
- sources.push(nested);
124
- const cause = asRecord(root?.cause);
125
- if (cause)
126
- sources.push(cause);
127
- const causeError = asRecord(cause?.error);
128
- if (causeError)
129
- sources.push(causeError);
148
+ const pending = [error];
149
+ const seen = new Set();
150
+ while (pending.length && sources.length < 12) {
151
+ const source = asRecord(pending.shift());
152
+ if (!source || seen.has(source))
153
+ continue;
154
+ seen.add(source);
155
+ sources.push(source);
156
+ pending.push(source.error, source.cause);
157
+ }
130
158
  return sources;
131
159
  }
132
160
  function asRecord(value) {
@@ -7,6 +7,7 @@
7
7
  * identity headers the proxy gates on, and a fetch wrapper that keeps the
8
8
  * OAuth bearer fresh across long sessions.
9
9
  */
10
+ import { waitForOAuth } from "./oauth/refresh-control.js";
10
11
  import { getChatGptFetch } from "./network/chatgpt-transport.js";
11
12
  import { THINKING_LEVELS } from "./types.js";
12
13
  export const GROK_SUBSCRIPTION_BASE_URL = "https://cli-chat-proxy.grok.com/v1";
@@ -49,7 +50,10 @@ export function createGrokSubscriptionFetch(auth, baseFetch = getChatGptFetch())
49
50
  return credentials.accessToken || undefined;
50
51
  }
51
52
  return async (input, init) => {
52
- const token = await freshAccessToken();
53
+ const signal = init?.signal ?? (input instanceof Request ? input.signal : undefined);
54
+ signal?.throwIfAborted();
55
+ const token = await waitForOAuth(freshAccessToken(), signal);
56
+ signal?.throwIfAborted();
53
57
  if (!token) {
54
58
  return baseFetch(input, init);
55
59
  }
@@ -14,6 +14,8 @@ export interface CodexModelDescriptor {
14
14
  /** Server-declared per-tool-output token cap (truncation_policy.limit when mode=tokens). */
15
15
  toolOutputTokenLimit?: number;
16
16
  }
17
+ /** The Codex client version Bubble claims; the account catalog the server returns depends on it. */
18
+ export declare function getCodexClientVersion(): string;
17
19
  export declare function isOpenAICodexBaseUrl(baseURL: string): boolean;
18
20
  export declare function getOpenAICodexFallbackModels(): string[];
19
21
  export interface OpenAICodexAuthAdapter {
@@ -45,6 +47,8 @@ export declare function fetchOpenAICodexModels(options: {
45
47
  export interface OpenAICodexModelCatalogResult {
46
48
  descriptors: CodexModelDescriptor[];
47
49
  status: "success" | "unavailable";
50
+ /** Why each discovery path failed, when status is "unavailable". */
51
+ reason?: string;
48
52
  }
49
53
  /**
50
54
  * Fetches the account-scoped Codex catalog while preserving the distinction
@@ -54,5 +58,9 @@ export declare function fetchOpenAICodexModelCatalog(options: {
54
58
  baseURL: string;
55
59
  accessToken: string;
56
60
  fetch?: ChatGptFetch;
61
+ /** Bounds both catalog GETs; a stalled warm-up must not pin the in-flight slot. */
62
+ signal?: AbortSignal;
63
+ /** Per-request timeout, so a slow first path still leaves the fallback a full window. */
64
+ timeoutMs?: number;
57
65
  }): Promise<OpenAICodexModelCatalogResult>;
58
66
  export declare function sortCodexModelDescriptors(descriptors: CodexModelDescriptor[]): CodexModelDescriptor[];
@@ -1,8 +1,10 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { THINKING_LEVELS } from "./types.js";
3
+ import { waitForOAuth } from "./oauth/refresh-control.js";
3
4
  import { getBuiltinModel, listBuiltinModels } from "./model-catalog.js";
4
5
  import { resolveProviderRequestConfig } from "./provider-transform.js";
5
6
  import { chatGptFetch } from "./network/chatgpt-transport.js";
7
+ import { logProviderTransportFailure } from "./network/provider-transport-log.js";
6
8
  import { computeRetryDelayMs, getProviderMaxRetries, isRetryableHttpStatus, ProviderStreamInterruptedError, sleepBeforeRetry, } from "./network/retry.js";
7
9
  const DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api";
8
10
  const OPENAI_BETA_RESPONSES = "responses=experimental";
@@ -10,7 +12,13 @@ const TOKEN_REFRESH_GRACE_MS = 5 * 60 * 1000;
10
12
  // OpenAI gates new codex models server-side by client_version (each model carries a
11
13
  // `minimal_client_version`). Track a recent real Codex CLI release; override via env
12
14
  // when OpenAI lifts the gate again before we cut a new release.
13
- const CODEX_CLIENT_VERSION = process.env.BUBBLE_CODEX_CLIENT_VERSION?.trim() || "0.150.0";
15
+ // 2026-09-17: gpt-6-astra requires 0.153.0.
16
+ // 2026-10-06: gpt-6.1-sol requires 0.159.0, the version the Codex app sends.
17
+ const CODEX_CLIENT_VERSION = process.env.BUBBLE_CODEX_CLIENT_VERSION?.trim() || "0.159.0";
18
+ /** The Codex client version Bubble claims; the account catalog the server returns depends on it. */
19
+ export function getCodexClientVersion() {
20
+ return CODEX_CLIENT_VERSION;
21
+ }
14
22
  const MODEL_DISCOVERY_PATHS = [
15
23
  `/codex/models?client_version=${CODEX_CLIENT_VERSION}`,
16
24
  "/models",
@@ -45,13 +53,20 @@ export function createOpenAICodexProvider(options) {
45
53
  const sessionId = globalThis.crypto?.randomUUID?.() ?? `bubble_${Date.now()}`;
46
54
  const fetchImpl = options.fetch ?? chatGptFetch;
47
55
  let refreshPromise;
48
- async function resolveRequestAuth(forceRefresh = false) {
56
+ // `rejectedAccessToken`: the token a 401 was returned for. If storage already
57
+ // holds a different one, another process rotated it while the request was in
58
+ // flight — use that instead of spending its refresh token on a second rotation.
59
+ async function resolveRequestAuth(forceRefresh = false, rejectedAccessToken) {
49
60
  let credentials = await options.auth?.getCredentials();
50
61
  if (credentials && options.auth) {
51
62
  const expired = options.auth.isExpired
52
63
  ? options.auth.isExpired(credentials, TOKEN_REFRESH_GRACE_MS)
53
64
  : Date.now() >= credentials.expiresAt - TOKEN_REFRESH_GRACE_MS;
54
- if ((forceRefresh || !credentials.accessToken || expired) && credentials.refreshToken) {
65
+ const alreadyRotated = rejectedAccessToken !== undefined
66
+ && !!credentials.accessToken
67
+ && credentials.accessToken !== rejectedAccessToken;
68
+ const mustRefresh = (forceRefresh && !alreadyRotated) || !credentials.accessToken || expired;
69
+ if (mustRefresh && credentials.refreshToken) {
55
70
  if (!refreshPromise) {
56
71
  refreshPromise = options.auth.refreshCredentials(credentials).finally(() => {
57
72
  refreshPromise = undefined;
@@ -80,8 +95,12 @@ export function createOpenAICodexProvider(options) {
80
95
  promptCacheKey: options.promptCacheKey,
81
96
  useResponsesLite,
82
97
  }));
98
+ let sentAccessToken;
83
99
  const sendRequest = async (forceRefresh = false) => {
84
- const { accessToken, accountId } = await resolveRequestAuth(forceRefresh);
100
+ chatOptions.abortSignal?.throwIfAborted();
101
+ const { accessToken, accountId } = await waitForOAuth(resolveRequestAuth(forceRefresh, forceRefresh ? sentAccessToken : undefined), chatOptions.abortSignal);
102
+ chatOptions.abortSignal?.throwIfAborted();
103
+ sentAccessToken = accessToken;
85
104
  return fetchImpl(resolveCodexUrl(options.baseURL), buildCodexRequestInit({
86
105
  accessToken,
87
106
  accountId,
@@ -92,14 +111,25 @@ export function createOpenAICodexProvider(options) {
92
111
  }));
93
112
  };
94
113
  for (let attempt = 0;; attempt++) {
114
+ const requestId = globalThis.crypto.randomUUID();
115
+ const startedAt = Date.now();
116
+ let responseStatus;
117
+ let receivedEvents = 0;
118
+ let lastEventAt;
95
119
  let sawParsedSseEvent = false;
96
120
  let currentToolCall;
121
+ // OpenAI streams one reasoning summary per part; each part is a
122
+ // self-contained markdown block. Separate parts so Thinking does not
123
+ // read as one run-on line.
124
+ let summaryPartsSeen = 0;
97
125
  try {
98
126
  let response = await sendRequest();
127
+ responseStatus = response.status;
99
128
  if (!response.ok) {
100
129
  const errorText = await response.text().catch(() => "");
101
130
  if (response.status === 401 && options.auth && isTokenExpiredError(errorText)) {
102
131
  response = await sendRequest(true);
132
+ responseStatus = response.status;
103
133
  }
104
134
  else {
105
135
  throw new Error(`${response.status} status code${errorText ? `: ${errorText}` : " (no body)"}`);
@@ -111,6 +141,8 @@ export function createOpenAICodexProvider(options) {
111
141
  }
112
142
  for await (const event of parseSse(response)) {
113
143
  sawParsedSseEvent = true;
144
+ receivedEvents += 1;
145
+ lastEventAt = Date.now();
114
146
  const type = typeof event.type === "string" ? event.type : undefined;
115
147
  if (!type)
116
148
  continue;
@@ -151,6 +183,13 @@ export function createOpenAICodexProvider(options) {
151
183
  }
152
184
  continue;
153
185
  }
186
+ if (type === "response.reasoning_summary_part.added") {
187
+ summaryPartsSeen += 1;
188
+ if (summaryPartsSeen > 1) {
189
+ yield { type: "reasoning_delta", content: "\n\n" };
190
+ }
191
+ continue;
192
+ }
154
193
  if (type === "response.reasoning_summary_text.delta") {
155
194
  const delta = typeof event.delta === "string" ? event.delta : "";
156
195
  if (delta) {
@@ -224,19 +263,30 @@ export function createOpenAICodexProvider(options) {
224
263
  return;
225
264
  }
226
265
  catch (error) {
227
- if (sawParsedSseEvent
266
+ const delegateRetry = sawParsedSseEvent
228
267
  && !chatOptions.abortSignal?.aborted
229
- && isTransientCodexTransportError(error)) {
268
+ && isTransientCodexTransportError(error);
269
+ const retry = shouldRetryCodexTransportError({
270
+ error, attempt, sawParsedSseEvent, signal: chatOptions.abortSignal,
271
+ });
272
+ logProviderTransportFailure(error, {
273
+ providerId: options.providerId ?? "openai-codex",
274
+ modelId: chatOptions.model,
275
+ thinkingLevel: chatOptions.thinkingLevel ?? options.thinkingLevel ?? "off",
276
+ messageCount: messages.length,
277
+ toolCount: chatOptions.tools?.length ?? 0,
278
+ sessionId, requestId, attempt: attempt + 1, responseStatus, receivedEvents,
279
+ elapsedMs: Date.now() - startedAt,
280
+ lastEventAgeMs: lastEventAt === undefined ? undefined : Date.now() - lastEventAt,
281
+ decision: chatOptions.abortSignal?.aborted ? "cancelled"
282
+ : delegateRetry ? "delegate_retry" : retry ? "retry" : "fail",
283
+ });
284
+ if (delegateRetry) {
230
285
  // Partial content already surfaced — the agent loop discards the
231
286
  // half-built assistant message and re-issues the whole request.
232
- throw new ProviderStreamInterruptedError(error instanceof Error ? error.message : String(error), { cause: error });
287
+ throw new ProviderStreamInterruptedError("ChatGPT connection interrupted while receiving a response.", { cause: error });
233
288
  }
234
- if (!shouldRetryCodexTransportError({
235
- error,
236
- attempt,
237
- sawParsedSseEvent,
238
- signal: chatOptions.abortSignal,
239
- })) {
289
+ if (!retry) {
240
290
  throw error;
241
291
  }
242
292
  await sleepBeforeRetry(computeRetryDelayMs(attempt + 1), chatOptions.abortSignal);
@@ -297,27 +347,50 @@ export async function fetchOpenAICodexModels(options) {
297
347
  export async function fetchOpenAICodexModelCatalog(options) {
298
348
  const accountId = extractChatGptAccountId(options.accessToken);
299
349
  if (!accountId) {
300
- return { descriptors: [], status: "unavailable" };
350
+ return { descriptors: [], status: "unavailable", reason: "access token has no ChatGPT account id" };
301
351
  }
302
352
  const fetchImpl = options.fetch ?? chatGptFetch;
353
+ const failures = [];
303
354
  for (const path of MODEL_DISCOVERY_PATHS) {
355
+ const signal = options.timeoutMs === undefined
356
+ ? options.signal
357
+ : options.signal
358
+ ? AbortSignal.any([options.signal, AbortSignal.timeout(options.timeoutMs)])
359
+ : AbortSignal.timeout(options.timeoutMs);
360
+ const startedAt = Date.now();
304
361
  const response = await fetchImpl(resolveRelativeUrl(options.baseURL, path), {
305
362
  method: "GET",
306
363
  headers: buildBaseHeaders(options.accessToken, accountId, globalThis.crypto?.randomUUID?.() ?? `bubble_${Date.now()}`, { accept: "application/json" }),
307
- }).catch(() => undefined);
308
- if (!response?.ok)
364
+ signal,
365
+ }).catch((error) => {
366
+ failures.push(`${path}: ${error instanceof Error ? error.message : String(error)} after ${Date.now() - startedAt}ms`);
367
+ return undefined;
368
+ });
369
+ if (!response)
370
+ continue;
371
+ if (!response.ok) {
372
+ failures.push(`${path}: HTTP ${response.status} after ${Date.now() - startedAt}ms`);
309
373
  continue;
374
+ }
310
375
  const parsed = await response.json()
311
376
  .then((payload) => ({ ok: true, payload }))
312
377
  .catch(() => ({ ok: false }));
313
- if (!parsed.ok)
378
+ if (!parsed.ok) {
379
+ failures.push(`${path}: invalid JSON`);
314
380
  continue;
381
+ }
382
+ const payload = parsed.payload;
383
+ if (!Array.isArray(payload) && !(payload && typeof payload === "object"
384
+ && (Array.isArray(payload.models) || Array.isArray(payload.data)))) {
385
+ failures.push(`${path}: unexpected payload`);
386
+ continue;
387
+ }
315
388
  return {
316
- descriptors: sortCodexModelDescriptors(extractCodexModelDescriptors(parsed.payload)),
389
+ descriptors: sortCodexModelDescriptors(extractCodexModelDescriptors(payload)),
317
390
  status: "success",
318
391
  };
319
392
  }
320
- return { descriptors: [], status: "unavailable" };
393
+ return { descriptors: [], status: "unavailable", reason: failures.join("; ") };
321
394
  }
322
395
  function buildRequestBody(messages, options) {
323
396
  const instructions = messages
@@ -379,6 +452,11 @@ function buildRequestBody(messages, options) {
379
452
  const wireEffort = options.reasoningEffort === "ultra" ? "max" : options.reasoningEffort;
380
453
  body.reasoning = {
381
454
  effort: wireEffort,
455
+ // The Codex backend's default_reasoning_summary is "none" for every
456
+ // current model, so without an explicit summary request no
457
+ // response.reasoning_summary_text.delta events arrive and the TUI never
458
+ // shows Thinking. Ask for the server-chosen summary like Codex CLI does.
459
+ summary: "auto",
382
460
  ...(options.useResponsesLite ? { context: "all_turns" } : {}),
383
461
  };
384
462
  }
@@ -515,6 +593,7 @@ function isTransientCodexTransportError(error) {
515
593
  /\bConnectionClosed\b/i,
516
594
  /\bECONNRESET\b/i,
517
595
  /\bUND_ERR_SOCKET\b/i,
596
+ /\bUND_ERR_(?:CONNECT|HEADERS|BODY)_TIMEOUT\b/i,
518
597
  /\bEPIPE\b/i,
519
598
  /socket hang up/i,
520
599
  /fetch failed/i,
@@ -533,6 +612,9 @@ function errorMessageChain(error) {
533
612
  for (let depth = 0; current && depth < 6; depth++) {
534
613
  if (current instanceof Error) {
535
614
  messages.push(current.name, current.message);
615
+ const code = current.code;
616
+ if (typeof code === "string")
617
+ messages.push(code);
536
618
  current = current.cause;
537
619
  continue;
538
620
  }
@@ -669,14 +751,15 @@ function extractCodexModelDescriptors(payload) {
669
751
  visit(payload);
670
752
  return out;
671
753
  }
672
- // Extracts the family version from a codex slug (e.g. "gpt-5.5-codex" → 5005).
673
- // Used so models from a newer family float to the top even before the static
674
- // catalog knows about them.
754
+ // Extracts the family version from a codex slug (e.g. "gpt-5.5-codex" → 5005,
755
+ // "gpt-6-astra" → 6000). Used so models from a newer family float to the top
756
+ // even before the static catalog knows about them. Integer-major slugs must
757
+ // rank above every older major.minor family, not fall to zero.
675
758
  function parseCodexFamilyRank(id) {
676
- const match = id.match(/(\d+)\.(\d+)/);
759
+ const match = id.match(/(\d+)(?:\.(\d+))?/);
677
760
  if (!match)
678
761
  return 0;
679
- return parseInt(match[1], 10) * 1000 + parseInt(match[2], 10);
762
+ return parseInt(match[1], 10) * 1000 + (match[2] ? parseInt(match[2], 10) : 0);
680
763
  }
681
764
  export function sortCodexModelDescriptors(descriptors) {
682
765
  const preferred = new Map(getOpenAICodexFallbackModels().map((id, index) => [id, index]));
@@ -64,7 +64,8 @@ export interface ModelDiscoveryResult {
64
64
  */
65
65
  export interface CachedDiscoverySnapshot {
66
66
  models: ModelInfo[];
67
- source: Exclude<ModelDiscoverySource, "cache">;
67
+ /** "cache" marks a confirmed catalog retained past the live result's freshness. */
68
+ source: ModelDiscoverySource;
68
69
  complete: boolean;
69
70
  expiresAt: number;
70
71
  identityKey: string;
@@ -107,12 +108,50 @@ export declare class ProviderRegistry {
107
108
  private handleAuthMutation;
108
109
  getModelConfig(): ModelConfig;
109
110
  getAuthStorage(): AuthStorage;
111
+ /**
112
+ * Run discovery for an account-scoped (OAuth) provider so the routing
113
+ * snapshot knows the account's real catalog before the routing prompt is
114
+ * composed and before the first subagent spawn. Discovery was previously
115
+ * triggered only by the model picker, which left tier routing on the static
116
+ * builtin list for the whole session unless the user opened /model.
117
+ * Resolves when discovery settles (it never throws: failures resolve to the
118
+ * local fallback); resolves immediately when a fresh cache exists or the
119
+ * provider is not account-scoped. Hosts bound the wait, see waitForModelDiscovery.
120
+ */
121
+ warmModelDiscovery(providerId: string): Promise<void>;
122
+ /**
123
+ * Warm discovery but never hold startup hostage to the network: after
124
+ * `maxWaitMs` the host proceeds with whatever the snapshot has, and the
125
+ * still-running discovery updates the live accessor when it lands.
126
+ */
127
+ waitForModelDiscovery(providerId: string, maxWaitMs: number): Promise<void>;
110
128
  supportsOAuth(providerId: string): boolean;
129
+ /**
130
+ * Auth-storage keys currently backing a provider's OAuth login. The visible
131
+ * `openai` provider may still be backed by the legacy `openai-codex` key,
132
+ * so UI state and logout must not check `has(providerId)` directly.
133
+ */
134
+ getOAuthLoginKeys(providerId: string): string[];
111
135
  private resolveOAuthAuthKey;
112
136
  createOpenAICodexAuthAdapter(providerId: string): OpenAICodexAuthAdapter | undefined;
113
137
  createGrokAuthAdapter(providerId: string): GrokAuthAdapter | undefined;
114
138
  getDefaultModel(providerId: string, authType?: ProviderProfile["authType"]): string | undefined;
115
139
  prepareProvider(providerId: string): Promise<void>;
140
+ private refreshOpenAICredentials;
141
+ private refreshGrokCredentials;
142
+ /**
143
+ * The one place a refresh token is spent. auth.json is shared by every
144
+ * Bubble process and refresh tokens are single-use, so the token is only
145
+ * presented under the cross-process lock and only after re-reading the file:
146
+ * if another process already rotated it, its result is adopted and no
147
+ * request is made. A rejected token gets the same second look before the
148
+ * error surfaces, because the rotation may have landed mid-request.
149
+ *
150
+ * `used` is the credential set the caller was holding when it asked (the
151
+ * provider passes it, also for a forced refresh after a 401): "rotated" means
152
+ * the file no longer holds that set, not merely that it is unexpired.
153
+ */
154
+ private refreshSharedCredentials;
116
155
  getConfigured(): ProviderProfile[];
117
156
  getEnabled(): ProviderProfile[];
118
157
  getDefault(): ProviderProfile | undefined;
@@ -136,8 +175,12 @@ export declare class ProviderRegistry {
136
175
  * derived strictly: only a successful full remote catalog qualifies —
137
176
  * never static or fallback results, whose internal `authoritative` flag
138
177
  * this design does not trust.
178
+ * Desktop catalogs can explicitly retain the last confirmation beyond its
179
+ * routing horizon; ordinary routing callers keep the default expiry policy.
139
180
  */
140
- getCachedDiscoverySnapshot(providerId: string): CachedDiscoverySnapshot | undefined;
181
+ getCachedDiscoverySnapshot(providerId: string, options?: {
182
+ allowExpiredConfirmation?: boolean;
183
+ }): CachedDiscoverySnapshot | undefined;
141
184
  private isCurrentModelDiscovery;
142
185
  private applyDynamicDiscoveryMetadata;
143
186
  }