@bitkyc08/opencodex 2.64.0 → 2.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/README.md +28 -25
  2. package/bin/ocx.mjs +16 -2
  3. package/gui/dist/assets/App-8NMiZxT0.css +1 -0
  4. package/gui/dist/assets/App-DGLte6IR.js +50 -0
  5. package/gui/dist/assets/Tray-D4bA1vff.js +1 -0
  6. package/gui/dist/assets/{index-DiBRuK-d.css → index--EWgGQvZ.css} +1 -1
  7. package/gui/dist/assets/{index-SggB6t3z.js → index-Bi1K37Wl.js} +12 -12
  8. package/gui/dist/assets/tray-data-BZXhB3qz.js +1 -0
  9. package/gui/dist/index.html +2 -2
  10. package/package.json +1 -4
  11. package/src/adapters/anthropic.ts +30 -1
  12. package/src/adapters/claude-cli/adapter.ts +218 -0
  13. package/src/adapters/claude-cli/profiles.ts +38 -0
  14. package/src/adapters/codebuddy/profiles.ts +2 -0
  15. package/src/adapters/coding-agent/profile.ts +12 -4
  16. package/src/adapters/coding-agent/turn.ts +16 -6
  17. package/src/adapters/command-code-tool-text.ts +133 -23
  18. package/src/adapters/cursor/catalog.ts +4 -8
  19. package/src/adapters/cursor/discovery.ts +10 -5
  20. package/src/adapters/cursor/effort-map.ts +2 -6
  21. package/src/adapters/cursor/envelope-echo.ts +10 -5
  22. package/src/adapters/cursor/request-builder.ts +9 -4
  23. package/src/adapters/cursor/thread-continuity.ts +53 -0
  24. package/src/adapters/cursor.ts +29 -13
  25. package/src/adapters/devin/cloud-direct/chat.ts +3 -1
  26. package/src/adapters/google-tool-schema.ts +26 -3
  27. package/src/adapters/identity.ts +214 -2
  28. package/src/adapters/openai-chat/passthrough.ts +1 -0
  29. package/src/adapters/openai-chat/serialized-tool-call-content.ts +569 -0
  30. package/src/adapters/openai-chat.ts +43 -15
  31. package/src/adapters/openai-responses/canonical-forward.ts +17 -10
  32. package/src/adapters/openai-responses/passthrough.ts +19 -4
  33. package/src/adapters/openai-responses/request-strips.ts +25 -0
  34. package/src/adapters/openai-responses/web-search.ts +22 -4
  35. package/src/adapters/qoder/profiles.ts +2 -0
  36. package/src/adapters/registry.ts +8 -0
  37. package/src/adapters/run-turn-queue.ts +7 -0
  38. package/src/bridge/response-json.ts +6 -4
  39. package/src/bridge/sse.ts +7 -5
  40. package/src/claude/alias.ts +73 -26
  41. package/src/claude/context-windows.ts +15 -6
  42. package/src/claude/desktop-3p-library.ts +3 -2
  43. package/src/claude/desktop-3p.ts +1 -1
  44. package/src/claude/desktop-first-party.ts +63 -22
  45. package/src/claude/desktop-picker-profile.ts +302 -0
  46. package/src/claude/desktop-picker.ts +365 -0
  47. package/src/claude/desktop-risk.ts +20 -0
  48. package/src/claude/gateway-cache.ts +13 -4
  49. package/src/claude/inbound.ts +5 -2
  50. package/src/claude/intercept/connect-proxy.ts +106 -29
  51. package/src/claude/intercept/local-ca.ts +76 -18
  52. package/src/claude/intercept/model-bindings.ts +145 -0
  53. package/src/claude/intercept/picker-bootstrap.ts +141 -0
  54. package/src/claude/intercept/picker-ca.ts +126 -0
  55. package/src/claude/intercept/picker-listener.ts +213 -0
  56. package/src/claude/intercept/picker-models.ts +101 -0
  57. package/src/claude/intercept/picker-runtime.ts +330 -0
  58. package/src/claude/intercept/picker-trust.ts +125 -0
  59. package/src/claude/intercept/runtime.ts +149 -2
  60. package/src/claude/model-info.ts +18 -4
  61. package/src/cli/agent.ts +16 -2
  62. package/src/cli/capabilities.ts +71 -2
  63. package/src/cli/claude-desktop.ts +319 -33
  64. package/src/cli/claude.ts +15 -7
  65. package/src/cli/codex-shim-autorestore.ts +1 -0
  66. package/src/cli/dispatch.ts +29 -23
  67. package/src/cli/effort.ts +16 -6
  68. package/src/cli/ensure-desired-integrations.ts +29 -4
  69. package/src/cli/ready.ts +1 -1
  70. package/src/cli/registry.ts +8 -0
  71. package/src/cli/restart-scope.ts +9 -1
  72. package/src/cli/runtime-api.ts +17 -0
  73. package/src/cli/system-command.ts +11 -12
  74. package/src/client/connect.ts +12 -2
  75. package/src/client/state.ts +39 -1
  76. package/src/clients/config-export.ts +57 -9
  77. package/src/codex/auth-context.ts +51 -11
  78. package/src/codex/catalog/derive-entry.ts +5 -5
  79. package/src/codex/catalog/gather-capture.ts +25 -6
  80. package/src/codex/catalog/metadata.ts +3 -3
  81. package/src/codex/catalog/parsing.ts +39 -1
  82. package/src/codex/catalog/provider-models.ts +8 -6
  83. package/src/codex/catalog/retained-sync.ts +17 -7
  84. package/src/codex/catalog/sync.ts +2 -2
  85. package/src/codex/desktop-app-restart.ts +8 -0
  86. package/src/codex/desktop-switches.ts +9 -1
  87. package/src/codex/home.ts +43 -4
  88. package/src/codex/inject/config-toml.ts +137 -0
  89. package/src/codex/inject/plan.ts +23 -0
  90. package/src/codex/inject/remove.ts +21 -1
  91. package/src/codex/inject.ts +63 -8
  92. package/src/codex/journal.ts +36 -0
  93. package/src/codex/main-account-hard-lock.ts +14 -1
  94. package/src/codex/main-account-policy-wait.ts +109 -0
  95. package/src/codex/native-profile-startup.ts +24 -6
  96. package/src/codex/native-residue.ts +28 -10
  97. package/src/codex/quota-types.ts +8 -1
  98. package/src/codex/quota.ts +49 -7
  99. package/src/codex/runtime.ts +31 -10
  100. package/src/combos/failover.ts +7 -6
  101. package/src/combos/resolve.ts +91 -14
  102. package/src/combos/types.ts +18 -1
  103. package/src/config/load-degrade.ts +2 -0
  104. package/src/config/schema/config-schema.ts +21 -2
  105. package/src/config/schema/leaf-validators.ts +6 -3
  106. package/src/config/subagent-models.ts +3 -1
  107. package/src/generated/compatibility-version.json +275 -163
  108. package/src/images/artifacts.ts +14 -0
  109. package/src/integrations/owned-refresh.ts +1 -1
  110. package/src/integrations/ownership-policy.ts +32 -1
  111. package/src/integrations/state.ts +3 -1
  112. package/src/integrations/writer.ts +9 -0
  113. package/src/lib/config-ownership.ts +3 -0
  114. package/src/lib/errors.ts +87 -0
  115. package/src/lib/package-tree-integrity.ts +1 -1
  116. package/src/lib/request-execution-budget.ts +42 -14
  117. package/src/lib/request-resend-gate.ts +4 -3
  118. package/src/lib/retry-delay.ts +7 -1
  119. package/src/lib/tool-envelope-echo-filter.ts +236 -0
  120. package/src/lib/upstream-retry.ts +20 -12
  121. package/src/oauth/index.ts +1 -0
  122. package/src/oauth/kiro-credentials.ts +14 -1
  123. package/src/oauth/login-cli.ts +1 -0
  124. package/src/providers/derive.ts +4 -0
  125. package/src/providers/fast-opt-in.ts +31 -0
  126. package/src/providers/model-rename-fields.ts +2 -0
  127. package/src/providers/provider-id-rewrite.ts +2 -0
  128. package/src/providers/quota/vendor-probes-key.ts +8 -2
  129. package/src/providers/registry/entries-core.ts +45 -2
  130. package/src/providers/registry/entries-extended.ts +99 -12
  131. package/src/providers/registry/model-ids.ts +2 -0
  132. package/src/providers/registry/types.ts +7 -1
  133. package/src/providers/resolved-model-policy.ts +11 -4
  134. package/src/providers/service-tier.ts +10 -3
  135. package/src/responses/code-mode-helper-compat.ts +3 -0
  136. package/src/responses/parser.ts +45 -3
  137. package/src/router.ts +6 -0
  138. package/src/server/auth-cors.ts +8 -0
  139. package/src/server/chat-completions.ts +3 -1
  140. package/src/server/claude-messages.ts +69 -14
  141. package/src/server/grok-upstream-envelope-echo.ts +120 -0
  142. package/src/server/index/claude-intercept-lifecycle.ts +24 -0
  143. package/src/server/index/serve-options.ts +2 -2
  144. package/src/server/index.ts +7 -0
  145. package/src/server/management/agent-settings-routes.ts +204 -112
  146. package/src/server/management/claude-desktop-picker-routes.ts +128 -0
  147. package/src/server/management/combo-routes.ts +59 -2
  148. package/src/server/management/config-routes.ts +81 -39
  149. package/src/server/management/context.ts +3 -0
  150. package/src/server/management/native-integration-routes.ts +169 -93
  151. package/src/server/management/provider-routes.ts +46 -4
  152. package/src/server/management/route-registry.ts +10 -0
  153. package/src/server/management/routing-profile-routes.ts +9 -0
  154. package/src/server/management/sidebar-routes.ts +52 -0
  155. package/src/server/management-api.ts +2 -0
  156. package/src/server/proxy-liveness.ts +38 -3
  157. package/src/server/request-log-account-rotation.ts +53 -0
  158. package/src/server/request-log.ts +3 -16
  159. package/src/server/responses/adapter-dispatch.ts +17 -1
  160. package/src/server/responses/agent-task-recovery.ts +49 -26
  161. package/src/server/responses/codex-ws-exchange.ts +18 -7
  162. package/src/server/responses/codex-ws-wire.ts +27 -4
  163. package/src/server/responses/core-combo.ts +21 -3
  164. package/src/server/responses/core-normalize.ts +7 -0
  165. package/src/server/responses/core-opaque-recovery.ts +3 -2
  166. package/src/server/responses/encrypted-payload.ts +15 -14
  167. package/src/server/responses/fetch-helpers.ts +1 -1
  168. package/src/server/responses/input-admission.ts +10 -6
  169. package/src/server/responses/native-response-control.ts +2 -2
  170. package/src/server/responses/passthrough-delivery.ts +182 -12
  171. package/src/server/responses/passthrough-dispatch.ts +102 -44
  172. package/src/server/responses/policy-fallback.ts +3 -0
  173. package/src/server/responses/policy-refusal.ts +67 -0
  174. package/src/server/responses/request-send-budget.ts +4 -0
  175. package/src/server/responses/run-turn-execution.ts +42 -11
  176. package/src/server/responses/ws-upstream.ts +2 -1
  177. package/src/server/system-env-shell.ts +0 -1
  178. package/src/server/system-env.ts +25 -4
  179. package/src/service/cli.ts +34 -3
  180. package/src/tray/assets/opencodex-tray-offline-update.ico +0 -0
  181. package/src/tray/assets/opencodex-tray-online-update.ico +0 -0
  182. package/src/tray/assets/opencodex-tray-warning-update.ico +0 -0
  183. package/src/tray/windows-tray.ps1 +188 -7
  184. package/src/tray/windows.ts +23 -2
  185. package/src/types/config.ts +52 -12
  186. package/src/types/provider.ts +22 -5
  187. package/src/types/tools.ts +11 -5
  188. package/src/types.ts +1 -0
  189. package/src/update/async-check.ts +109 -0
  190. package/src/update/badge.ts +11 -17
  191. package/src/update/check-types.ts +9 -0
  192. package/src/update/desktop-badge.ts +102 -0
  193. package/src/update/index.ts +48 -8
  194. package/src/update/install-detection.d.mts +29 -2
  195. package/src/update/install-detection.mjs +213 -7
  196. package/src/update/job.ts +17 -12
  197. package/src/update/notify.ts +16 -11
  198. package/src/update/pnpm-owner-worker.ts +12 -0
  199. package/src/update/refresh-scheduler.ts +141 -0
  200. package/src/usage/cost.ts +31 -4
  201. package/src/usage/expected-prices.ts +57 -0
  202. package/assets/download-linux.svg +0 -10
  203. package/assets/download-macos.svg +0 -10
  204. package/assets/download-windows.svg +0 -10
  205. package/gui/dist/assets/App-CpuDF3ci.js +0 -50
  206. package/gui/dist/assets/App-I5AnaSLh.css +0 -1
  207. package/gui/dist/assets/Tray-B4uEIa1O.js +0 -1
  208. package/gui/dist/assets/usage-companion-chart-DLbKOJml.js +0 -1
@@ -14,6 +14,13 @@ const MAX_DECODED_BYTES_PER_RESPONSE = 100 * 1024 * 1024;
14
14
  export const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024; // 50 MiB
15
15
  /** Idle timeout for pinned HTTPS connect/headers/body when no AbortSignal is provided. */
16
16
  export const DOWNLOAD_IDLE_TIMEOUT_MS = 60_000;
17
+ /**
18
+ * Connect deadline for pinned HTTPS downloads: a TCP/TLS setup that never
19
+ * completes fails with connect_timeout after 10 s instead of holding the
20
+ * download until the 60 s first-byte timer fires. Callers can still override
21
+ * per call.
22
+ */
23
+ export const DOWNLOAD_CONNECT_TIMEOUT_MS = 10_000;
17
24
 
18
25
  /**
19
26
  * Upper bound on the raw base64 string length before it is decoded. Base64
@@ -271,6 +278,7 @@ export function pinnedHttpsGet(
271
278
  options?: {
272
279
  maxBytes?: number;
273
280
  idleTimeoutMs?: number;
281
+ connectTimeoutMs?: number;
274
282
  rejectUnauthorized?: boolean;
275
283
  },
276
284
  ): Promise<Response> {
@@ -280,9 +288,11 @@ export function pinnedHttpsGet(
280
288
  }
281
289
  const maxBytes = options?.maxBytes ?? MAX_DOWNLOAD_BYTES;
282
290
  const idleTimeoutMs = options?.idleTimeoutMs ?? DOWNLOAD_IDLE_TIMEOUT_MS;
291
+ const connectTimeoutMs = options?.connectTimeoutMs ?? DOWNLOAD_CONNECT_TIMEOUT_MS;
283
292
  return pinnedHttpGet(url, pinned, signal, {
284
293
  maxBytes,
285
294
  idleTimeoutMs,
295
+ connectTimeoutMs,
286
296
  rejectUnauthorized: options?.rejectUnauthorized,
287
297
  context: "image download",
288
298
  }).then(response => {
@@ -325,6 +335,10 @@ async function connectPublicHttps(
325
335
  // cap entirely instead of inheriting a default. Keep the 50 MiB ceiling when a
326
336
  // caller omits a limit, and honour an explicit tighter one.
327
337
  maxBytes: options.maxBytes ?? MAX_DOWNLOAD_BYTES,
338
+ // Bound the TCP/TLS setup phase on its own: without this, a peer that never
339
+ // completes the handshake holds the download until the 60 s first-byte timer
340
+ // fires. Covers image and video downloads (both go through here).
341
+ connectTimeoutMs: DOWNLOAD_CONNECT_TIMEOUT_MS,
328
342
  context: `${options.context} download`,
329
343
  }));
330
344
  return download(url, pinned, options.signal);
@@ -77,7 +77,7 @@ export async function refreshOwnedIntegration(
77
77
  client: input.clientId,
78
78
  ok: true,
79
79
  changed: result.changed,
80
- ...(result.state === "absent" ? { reason: result.message } : {}),
80
+ ...(result.state !== "current" ? { reason: result.message } : {}),
81
81
  }
82
82
  : { client: input.clientId, ok: false, reason: result.message, refusalReason: result.reason, state: result.state,
83
83
  ...(result.snapshotPath ? { snapshotPath: result.snapshotPath } : {}),
@@ -12,7 +12,8 @@ import {
12
12
  type ManagedContribution,
13
13
  type ManagedFragment,
14
14
  } from "../clients/config-export";
15
- import { canonicalContribution, fingerprint, semanticContribution } from "./ownership";
15
+ import { canonicalContribution, fingerprint, semanticContribution, type OwnershipRecord } from "./ownership";
16
+ import { readPath } from "./merge";
16
17
 
17
18
  type JsonObject = Record<string, unknown>;
18
19
 
@@ -20,6 +21,36 @@ function isObject(value: unknown): value is JsonObject {
20
21
  return typeof value === "object" && value !== null && !Array.isArray(value);
21
22
  }
22
23
 
24
+ /**
25
+ * One-way Hermes upgrade: the old owned block is unchanged, or has only gained
26
+ * the supported dynamic affinity setting. Once applied, that field is protected
27
+ * like every other field; this is not a permanent refreshable-path exemption.
28
+ * Callers must first establish the record's client and config-path ownership.
29
+ */
30
+ export function isHermesAffinityUpgrade(
31
+ doc: unknown,
32
+ record: OwnershipRecord,
33
+ desired: ManagedContribution,
34
+ ): boolean {
35
+ if (record.clientId !== "hermes" || desired.clientId !== "hermes") return false;
36
+ const path = ["providers", OPENCODE_PROVIDER_ID];
37
+ const matchesPath = (candidate: readonly string[]) => (
38
+ candidate.length === path.length && candidate.every((key, index) => key === path[index])
39
+ );
40
+ if (record.fragmentPaths.length !== 1 || !matchesPath(record.fragmentPaths[0]!)) return false;
41
+ const fragment = desired.fragments.find(item => matchesPath(item.path));
42
+ if (!isObject(fragment?.value) || fragment.value.session_affinity_header !== "session-id") return false;
43
+ const observed = readPath(doc, path);
44
+ if (!isObject(observed)) return false;
45
+ if (Object.hasOwn(observed, "session_affinity_header") && observed.session_affinity_header !== "session-id") return false;
46
+ const value = { ...observed };
47
+ delete value.session_affinity_header;
48
+ const predecessor: ManagedContribution = { clientId: "hermes", fragments: [{ path, value }] };
49
+ return fingerprint(canonicalContribution(predecessor)) === record.blockFingerprint
50
+ || (typeof record.semanticBlockFingerprint === "string"
51
+ && fingerprint(semanticContribution(predecessor)) === record.semanticBlockFingerprint);
52
+ }
53
+
23
54
  function pathStartsWith(path: readonly string[], prefix: readonly string[]): boolean {
24
55
  return prefix.length <= path.length && prefix.every((part, index) => path[index] === part);
25
56
  }
@@ -25,6 +25,7 @@ import {
25
25
  } from "./merge";
26
26
  import { canonicalContribution, fingerprint, semanticContribution, type OwnershipRecord } from "./ownership";
27
27
  import {
28
+ isHermesAffinityUpgrade,
28
29
  protectedContributionFingerprint,
29
30
  refreshablePathsOf,
30
31
  semanticProtectedContributionFingerprint,
@@ -385,7 +386,8 @@ export function classifyIntegration(input: {
385
386
  * conflict no matter what the rest of the file looks like, so the sibling-
386
387
  * edit exemption below can never mask it.
387
388
  */
388
- if (!recordedBlockIsOwned(input.parsed, input.record, input.contribution)) {
389
+ if (!recordedBlockIsOwned(input.parsed, input.record, input.contribution)
390
+ && !isHermesAffinityUpgrade(input.parsed, input.record, input.contribution)) {
389
391
  return { state: "conflict", reason: "foreign-edit" };
390
392
  }
391
393
  if (!INTEGRATION_CLIENTS[clientId].sourcePreservingYaml
@@ -27,6 +27,7 @@ import {
27
27
  type OwnershipRecord,
28
28
  } from "./ownership";
29
29
  import {
30
+ isHermesAffinityUpgrade,
30
31
  protectedContributionFingerprint,
31
32
  refreshablePathsOf,
32
33
  semanticProtectedContributionFingerprint,
@@ -341,6 +342,14 @@ function applyOrRefreshIntegration(
341
342
  if (classified.state === "current") {
342
343
  return { ok: true, changed: false, state: "current", clientId, message: "already applied" };
343
344
  }
345
+ // Catalog refresh must not opt an existing Hermes integration into affinity.
346
+ // Explicit Apply (or Replace) records the new contract before refresh resumes.
347
+ if (!allowAbsent && record && isHermesAffinityUpgrade(parsed, record, contribution)) {
348
+ return {
349
+ ok: true, changed: false, state: "stale", clientId,
350
+ message: "Hermes session affinity requires Apply in Integrations; refresh left the configuration unchanged",
351
+ };
352
+ }
344
353
 
345
354
  // A stale refresh drops what the PREVIOUS record owned before merging: a
346
355
  // model that left the catalog would otherwise stay behind as an orphan the
@@ -61,8 +61,11 @@ const INITIAL_OWNED_PATHS = [
61
61
  "opencodex-service-task.xml",
62
62
  "opencodex-service.cmd",
63
63
  "opencodex-tray-offline.ico",
64
+ "opencodex-tray-offline-update.ico",
64
65
  "opencodex-tray-online.ico",
66
+ "opencodex-tray-online-update.ico",
65
67
  "opencodex-tray-warning.ico",
68
+ "opencodex-tray-warning-update.ico",
66
69
  "opencodex-tray.ps1",
67
70
  "responses-state.json",
68
71
  "runtime-port.json",
package/src/lib/errors.ts CHANGED
@@ -156,6 +156,93 @@ function isSubscriptionGateMessage(text: string): boolean {
156
156
  );
157
157
  }
158
158
 
159
+ /**
160
+ * xAI (and similar Chat Completions gateways) sometimes refuse a turn with HTTP 403
161
+ * and a model-refusal sentence instead of 200 + finish_reason=content_filter.
162
+ * Codex treats that 403 as a transport failure, so the user message is never
163
+ * recorded as a completed turn and retries loop. Keep this allowlist narrow:
164
+ * entitlement / plan / model-access 403s must stay errors.
165
+ */
166
+ const POLICY_REFUSAL_PHRASES = [
167
+ "i can't help with that request",
168
+ "i cannot help with that request",
169
+ "i'm unable to help with that request",
170
+ "i am unable to help with that request",
171
+ ] as const;
172
+
173
+ function hasModelAccessCue(text: string): boolean {
174
+ return (
175
+ text.includes("not allowed to use this model")
176
+ || text.includes("not allowed to use this operation")
177
+ );
178
+ }
179
+
180
+ /**
181
+ * xAI plan and credit 403 wording. Checked only by the refusal matcher below: adding these to
182
+ * the global subscription classifier would also change status and error-code inference for
183
+ * every message-only error that happens to mention credits.
184
+ */
185
+ function hasEntitlementCue(text: string): boolean {
186
+ return (
187
+ isSubscriptionGateMessage(text)
188
+ || text.includes("need a grok subscription")
189
+ || text.includes("run out of credits")
190
+ );
191
+ }
192
+
193
+ /** Lowercase, collapse whitespace, and strip trailing .!? so an exact phrase match is stable. */
194
+ function normalizePolicyRefusalSentence(text: string): string {
195
+ return text
196
+ .trim()
197
+ .toLowerCase()
198
+ .replace(/\s+/g, " ")
199
+ .replace(/[.!?]+$/g, "")
200
+ .trim();
201
+ }
202
+
203
+ /**
204
+ * True only when the extracted error sentence is exactly a known model-refusal
205
+ * phrase. JSON / "Provider error 403:" wrappers are unwrapped first. Extra
206
+ * plan, credit, entitlement, or model-access wording keeps the error path.
207
+ */
208
+ export function isUpstreamPolicyRefusalMessage(text: string): boolean {
209
+ const extracted = extractPolicyRefusalText(text);
210
+ const originalLower = text.toLowerCase();
211
+ const extractedLower = extracted.toLowerCase();
212
+ if (hasEntitlementCue(originalLower) || hasEntitlementCue(extractedLower)) return false;
213
+ if (hasModelAccessCue(originalLower) || hasModelAccessCue(extractedLower)) return false;
214
+ const normalized = normalizePolicyRefusalSentence(extracted);
215
+ return (POLICY_REFUSAL_PHRASES as readonly string[]).includes(normalized);
216
+ }
217
+
218
+ /** HTTP 403 plus {@link isUpstreamPolicyRefusalMessage}; other statuses never rewrite. */
219
+ export function isUpstreamPolicyRefusal(status: number, text: string): boolean {
220
+ return status === 403 && isUpstreamPolicyRefusalMessage(text);
221
+ }
222
+
223
+ /** Pull the human-readable refusal sentence out of a JSON or prefixed error body. */
224
+ export function extractPolicyRefusalText(raw: string): string {
225
+ const trimmed = raw.trim();
226
+ try {
227
+ const parsed = JSON.parse(trimmed) as { error?: unknown; message?: unknown };
228
+ const nested = parsed.error;
229
+ if (typeof nested === "string" && nested.trim()) return nested.trim();
230
+ if (nested && typeof nested === "object") {
231
+ const msg = (nested as { message?: unknown; error?: unknown }).message
232
+ ?? (nested as { error?: unknown }).error;
233
+ if (typeof msg === "string" && msg.trim()) return msg.trim();
234
+ }
235
+ if (typeof parsed.message === "string" && parsed.message.trim()) return parsed.message.trim();
236
+ } catch {
237
+ /* not JSON */
238
+ }
239
+ // The proxy's own error text wraps the upstream JSON (`Provider error 403: {"error": ...}`),
240
+ // so the remainder after the prefix gets the same unwrapping.
241
+ const prefixed = trimmed.match(/^Provider error 403:\s*([\s\S]+)$/i);
242
+ if (prefixed?.[1]?.trim()) return extractPolicyRefusalText(prefixed[1]);
243
+ return trimmed;
244
+ }
245
+
159
246
  function isLocalAclHardeningMessage(text: string): boolean {
160
247
  const secretPathHardening = text.includes("secret path") && (
161
248
  text.includes("acl") ||
@@ -57,7 +57,7 @@ export interface PackageTreeIntegrityOptions {
57
57
  }
58
58
 
59
59
  export type ObservePackageTree = () => PackageTreeObservation | null;
60
- export type PackageTreeRuntimeInstall = "bun" | "npm" | "pnpm" | "source";
60
+ export type PackageTreeRuntimeInstall = "bun" | "mise" | "npm" | "pnpm" | "source";
61
61
 
62
62
  const packageManifestUrl = new URL("../../package.json", import.meta.url);
63
63
 
@@ -187,6 +187,8 @@ export interface RequestExecutionBudget extends TransientSendBudget {
187
187
  * that cannot reach it has no operator override, which is the fail-closed answer.
188
188
  */
189
189
  claimAmbiguousResend?(limit: number): boolean;
190
+ /** True once any scope has claimed a replacement for this logical request. */
191
+ readonly ambiguousResendSpent?: boolean;
190
192
  }
191
193
 
192
194
  const RESERVE_FUNDED_CLASSES: ReadonlySet<SendClass> = new Set<SendClass>([
@@ -224,6 +226,7 @@ interface SharedSendLedger {
224
226
  * it in -- it has to ask whoever holds the request's grant.
225
227
  */
226
228
  claimAmbiguousResend(limit: number): boolean;
229
+ readonly ambiguousResendSpent: boolean;
227
230
  readonly observer?: RequestSendObserver;
228
231
  }
229
232
 
@@ -240,20 +243,23 @@ const sharedSendLedgers = new WeakMap<RequestExecutionBudget, SharedSendLedger>(
240
243
  * already spent the one replacement a strict row granted buy another as soon as a more
241
244
  * permissive row asked, which is a second duplicate inference of one turn.
242
245
  */
243
- function createAmbiguousResendGrant(): (limit: number) => boolean {
246
+ function createAmbiguousResendGrant(): Pick<SharedSendLedger, "claimAmbiguousResend" | "ambiguousResendSpent"> {
244
247
  let claimed = 0;
245
248
  let ceiling: number | undefined;
246
- return (limit: number): boolean => {
247
- const presented = Number.isFinite(limit) ? Math.trunc(limit) : 0;
248
- // A zero or nonsense ceiling refuses on its own and leaves the request's alone. It is a
249
- // caller that cannot state a grant, not an operator narrowing this request: a leg with no
250
- // policy is refused before it ever claims, so binding the request to a malformed number
251
- // would only let such a caller cancel a grant an opted-in row really made.
252
- if (presented <= 0) return false;
253
- ceiling = ceiling === undefined ? presented : Math.min(ceiling, presented);
254
- if (claimed >= ceiling) return false;
255
- claimed += 1;
256
- return true;
249
+ return {
250
+ get ambiguousResendSpent(): boolean { return claimed > 0; },
251
+ claimAmbiguousResend(limit: number): boolean {
252
+ const presented = Number.isFinite(limit) ? Math.trunc(limit) : 0;
253
+ // A zero or nonsense ceiling refuses on its own and leaves the request's alone. It is a
254
+ // caller that cannot state a grant, not an operator narrowing this request: a leg with no
255
+ // policy is refused before it ever claims, so binding the request to a malformed number
256
+ // would only let such a caller cancel a grant an opted-in row really made.
257
+ if (presented <= 0) return false;
258
+ ceiling = ceiling === undefined ? presented : Math.min(ceiling, presented);
259
+ if (claimed >= ceiling) return false;
260
+ claimed += 1;
261
+ return true;
262
+ },
257
263
  };
258
264
  }
259
265
 
@@ -302,6 +308,7 @@ function createRequestExecutionBudgetWithLedger(
302
308
  claimAmbiguousResend(limit: number): boolean {
303
309
  return counter.claimAmbiguousResend(limit);
304
310
  },
311
+ get ambiguousResendSpent(): boolean { return counter.ambiguousResendSpent; },
305
312
  reserveDispatch(intent: DispatchIntent): DispatchDecision {
306
313
  if (intent.replaySafe === false) return { allowed: false, reason: "not-replay-safe" };
307
314
  if (counter.spent >= policy.maxTotalModelSends) return { allowed: false, reason: "total-exhausted" };
@@ -396,10 +403,12 @@ export function createRequestExecutionBudget(
396
403
  logicalRequestId?: string,
397
404
  observer?: RequestSendObserver,
398
405
  ): RequestExecutionBudget {
406
+ const grant = createAmbiguousResendGrant();
399
407
  return createRequestExecutionBudgetWithLedger(policy, logicalRequestId, {
400
408
  spent: 0,
401
409
  pendingExternalSends: 0,
402
- claimAmbiguousResend: createAmbiguousResendGrant(),
410
+ claimAmbiguousResend: grant.claimAmbiguousResend,
411
+ get ambiguousResendSpent(): boolean { return grant.ambiguousResendSpent; },
403
412
  ...(observer ? { observer } : {}),
404
413
  });
405
414
  }
@@ -430,10 +439,24 @@ export function deriveRequestExecutionBudget(
430
439
  * share pending external bookings and a durable-spend observer, which are private by
431
440
  * construction; a bridged scope keeps the parent's spend accurate and books nothing of its own.
432
441
  */
442
+ /**
443
+ * Grant claims made THROUGH a bridge, keyed by the bridged parent so every scope derived from it
444
+ * sees them. A parent that predates `ambiguousResendSpent` can still grant through
445
+ * `claimAmbiguousResend`; reading only its missing flag would report "not spent" after a derived
446
+ * scope spent the grant, and a combo would then hop on a zero-output 200 from the replacement.
447
+ */
448
+ const bridgedGrantClaims = new WeakMap<RequestExecutionBudget, { claimed: boolean }>();
449
+
433
450
  function ledgerFor(parent: RequestExecutionBudget): SharedSendLedger {
434
451
  const existing = sharedSendLedgers.get(parent);
435
452
  if (existing) return existing;
436
453
  let pendingExternalSends = 0;
454
+ let bridged = bridgedGrantClaims.get(parent);
455
+ if (!bridged) {
456
+ bridged = { claimed: false };
457
+ bridgedGrantClaims.set(parent, bridged);
458
+ }
459
+ const claims = bridged;
437
460
  return {
438
461
  get spent(): number { return parent.used; },
439
462
  set spent(next: number) { parent.used = next; },
@@ -446,7 +469,12 @@ function ledgerFor(parent: RequestExecutionBudget): SharedSendLedger {
446
469
  // but the grant can -- `claimAmbiguousResend` is public on the parent. A parent that does
447
470
  // not implement it grants nothing, which is the fail-closed answer for a send whose
448
471
  // upstream state is unknown.
449
- claimAmbiguousResend: (limit: number): boolean => parent.claimAmbiguousResend?.(limit) === true,
472
+ claimAmbiguousResend: (limit: number): boolean => {
473
+ const granted = parent.claimAmbiguousResend?.(limit) === true;
474
+ if (granted) claims.claimed = true;
475
+ return granted;
476
+ },
477
+ get ambiguousResendSpent(): boolean { return claims.claimed || parent.ambiguousResendSpent === true; },
450
478
  };
451
479
  }
452
480
 
@@ -5,9 +5,10 @@
5
5
  * asked it for a connection that died before any head; #4989 asked it for an SSE body that
6
6
  * died after the head while carrying only control events. Both are the same row of the stage
7
7
  * table: a stage the caller observed nothing at, with a cause that cannot prove the origin did
8
- * not run the turn. `resendPermission` answers `refused-ambiguous` for both, and
9
- * request-failure-model.ts already names the only thing that may override that answer -- a
10
- * narrowly scoped recovery a maintainer opted into and bounded.
8
+ * not run the turn. A Codex WebSocket that dies under its create frame before any Responses
9
+ * event (#4191) is that row a third time. `resendPermission` answers `refused-ambiguous` for all
10
+ * of them, and request-failure-model.ts already names the only thing that may override that
11
+ * answer -- a narrowly scoped recovery a maintainer opted into and bounded.
11
12
  *
12
13
  * One override, not two. The reason this module exists rather than a boolean in each caller is
13
14
  * that a request which resets before the head and again after it would otherwise buy a
@@ -19,6 +19,7 @@ const MAX_COMPONENTS = 16;
19
19
 
20
20
  function durationSeconds(tail: string, allowBareSeconds: boolean): number | undefined {
21
21
  let rest = tail.trimStart();
22
+ if (allowBareSeconds && rest.startsWith("~")) rest = rest.slice(1).trimStart();
22
23
  let seconds = 0;
23
24
  let components = 0;
24
25
  while (true) {
@@ -39,6 +40,10 @@ function durationSeconds(tail: string, allowBareSeconds: boolean): number | unde
39
40
  rest = rest.slice(component[0].length);
40
41
  const separator = SEPARATOR.exec(rest)![0];
41
42
  const next = rest.slice(separator.length);
43
+ // The approximation marker belongs to the whole hint, once, before the
44
+ // first component. A second one ("~1 minute ~30 seconds") is malformed and
45
+ // must reject the hint rather than shorten it to the first component.
46
+ if (next.startsWith("~")) return undefined;
42
47
  if (!/^[+-]?(?:\d|\.\d)/.test(next)) break;
43
48
  // A numeric continuation is part of this duration; a malformed second
44
49
  // component must reject the hint, not silently shorten it to the first.
@@ -50,7 +55,8 @@ function durationSeconds(tail: string, allowBareSeconds: boolean): number | unde
50
55
 
51
56
  /**
52
57
  * Supports reset(s) in, try again in and Retry-After/retry after hints; accepts
53
- * compound durations and rounds UP once after summing all components.
58
+ * compound durations, the generated Retry-After approximation marker, and
59
+ * rounds UP once after summing all components.
54
60
  * A bare number is permitted only for header-style Retry-After hints, never
55
61
  * for "reset in 2026". When a message declares several usable lower bounds,
56
62
  * honour the longest one rather than re-entering a still-live quota window.
@@ -0,0 +1,236 @@
1
+ /** Line-aware filter for echoed tool envelopes in incremental assistant text. */
2
+ const MARKERS = ["[Tool Result]", "[Tool Error]", "[tool_result]", "[Tool Call]", "[Tool call:"] as const;
3
+ const UNTERMINATED_MARKERS = ["[Tool Result", "[Tool Error", "[tool_result", "[Tool Call"] as const;
4
+ /** Lines that are an echoed envelope marker on their own (after trimming trailing whitespace). */
5
+ const WHOLE_LINE_MARKERS: readonly string[] = [...MARKERS, ...UNTERMINATED_MARKERS];
6
+ /**
7
+ * The coding-agent call line "[Tool call: name (call_id: ...) with args: ...]" can wrap across lines
8
+ * when its arguments do, so it is recognised by its prefix, as before; prose rarely opens that way.
9
+ */
10
+ const TOOL_CALL_LINE = "[Tool call:";
11
+ const MAX_INDENT = 128;
12
+ // Markdown fenced code (CommonMark): an opener is a run of at least three backticks or tildes
13
+ // indented at most three spaces; only a run of the same character, at least as long and followed
14
+ // by nothing but whitespace, closes it. A backtick opener's info string may not contain a backtick.
15
+ const MAX_FENCE_INDENT = 3;
16
+ const MAX_FENCE_LINE = 1024;
17
+ const FENCE_LINE = /^(\x60{3,}|~{3,})(.*)$/s;
18
+ const FENCE_PREFIX = /^(\x60{1,2}|~{1,2})$/;
19
+ const FENCE_RUN = /^(\x60{3,}|~{3,})/;
20
+
21
+ /**
22
+ * The envelope OpenCodex replays is a marker alone on its line ("[Tool Result]\n<payload>"). Only a
23
+ * line that is exactly such a marker is an echo; prose that merely starts with one ("[Tool Result]
24
+ * shows the build passed.") is an answer. The call line keeps its prefix rule (TOOL_CALL_LINE).
25
+ */
26
+ export function isWholeLineEchoMarker(line: string): boolean {
27
+ const trimmed = line.replace(/^[ \t]*/, "").trimEnd();
28
+ return WHOLE_LINE_MARKERS.includes(trimmed) || trimmed.startsWith(TOOL_CALL_LINE);
29
+ }
30
+
31
+ interface FenceLine {
32
+ char: string;
33
+ length: number;
34
+ rest: string;
35
+ }
36
+
37
+ /** A complete line (no newline) parsed as a CommonMark fence line, or null. */
38
+ function parseFenceLine(line: string): FenceLine | null {
39
+ const probe = line.replace(/^[ \t]*/, "");
40
+ if (line.length - probe.length > MAX_FENCE_INDENT) return null;
41
+ const match = FENCE_LINE.exec(probe.replace(/\r?\n?$/, ""));
42
+ if (!match) return null;
43
+ return { char: match[1]![0]!, length: match[1]!.length, rest: match[2] ?? "" };
44
+ }
45
+
46
+ function opensFence(line: FenceLine): boolean {
47
+ return !(line.char === "\x60" && line.rest.includes("\x60"));
48
+ }
49
+
50
+ function closesFence(line: FenceLine, open: { char: string; length: number }): boolean {
51
+ return line.char === open.char && line.length >= open.length && line.rest.trim() === "";
52
+ }
53
+
54
+ /**
55
+ * For complete text (assistant history): true for each line that is a fence line of, or sits
56
+ * inside, a fenced block that is closed later in the same text. A block that never closes shields
57
+ * nothing, matching the live filter, which drops a held tail when the turn ends inside a fence.
58
+ */
59
+ export function closedFenceLines(lines: readonly string[]): boolean[] {
60
+ const shielded = lines.map(() => false);
61
+ let open: { char: string; length: number; start: number } | null = null;
62
+ lines.forEach((text, index) => {
63
+ const fence = parseFenceLine(text);
64
+ if (!open) {
65
+ if (fence && opensFence(fence)) open = { char: fence.char, length: fence.length, start: index };
66
+ return;
67
+ }
68
+ if (fence && closesFence(fence, open)) {
69
+ for (let line = open.start; line <= index; line++) shielded[line] = true;
70
+ open = null;
71
+ }
72
+ });
73
+ return shielded;
74
+ }
75
+
76
+ // A marker line inside a fence may be a quoted example or an echo pasted into a block that never
77
+ // closes. Output from that line is held: a matching closer releases it as code, the end of the turn
78
+ // drops it as an echo. The hold is bounded, so a long block cannot stall the stream.
79
+ const MAX_HELD_CHARS = 64 * 1024;
80
+
81
+ interface Fence {
82
+ char: string;
83
+ length: number;
84
+ holdDisabled: boolean;
85
+ /** Its hold overflowed, so its marker is unverified until the block closes. */
86
+ overflowed?: boolean;
87
+ }
88
+
89
+ export class ToolEnvelopeEchoFilter {
90
+ private pending = "";
91
+ private safeLine = false;
92
+ private fence: Fence | null = null;
93
+ private held: string | null = null;
94
+ matched = false;
95
+ /** A fenced marker whose hold overflowed was released without proof that the block closes. */
96
+ unverifiedMarker = false;
97
+
98
+ feed(delta: string): string {
99
+ if (this.matched) return "";
100
+ let output = "";
101
+ for (const char of delta) {
102
+ if (this.matched) break;
103
+ if (this.safeLine) {
104
+ output += this.emit(char);
105
+ if (char === "\n") this.safeLine = false;
106
+ continue;
107
+ }
108
+ this.pending += char;
109
+ if (char === "\n") {
110
+ output += this.completeLine();
111
+ continue;
112
+ }
113
+ const probe = this.pending.replace(/^[ \t]*/, "");
114
+ const indent = this.pending.length - probe.length;
115
+ if (indent <= MAX_INDENT && probe === TOOL_CALL_LINE) {
116
+ if (!this.fence) {
117
+ this.pending = "";
118
+ this.matched = true;
119
+ break;
120
+ }
121
+ this.startHold();
122
+ this.flushLineStart();
123
+ output += this.emit(this.takePending());
124
+ continue;
125
+ }
126
+ // A marker is decided when its line completes (completeLine): until then a line that is a
127
+ // marker so far or a marker plus trailing whitespace stays pending. Anything else on the
128
+ // line makes it prose and releases it.
129
+ const fenceCandidate = indent <= MAX_FENCE_INDENT
130
+ && this.pending.length <= MAX_FENCE_LINE
131
+ && (FENCE_PREFIX.test(probe) || FENCE_RUN.test(probe));
132
+ const markerCandidate = indent <= MAX_INDENT
133
+ && (probe === ""
134
+ || MARKERS.some(marker => marker.startsWith(probe))
135
+ || WHOLE_LINE_MARKERS.includes(probe.trimEnd()));
136
+ if (fenceCandidate || markerCandidate) continue;
137
+ this.flushLineStart();
138
+ output += this.emit(this.takePending());
139
+ }
140
+ return output;
141
+ }
142
+
143
+ /**
144
+ * At normal end, a held fence tail, a last line that is a whole marker (or a bare unterminated
145
+ * marker such as "[Tool Result") or a "[Tool call:" line is an echo; other text, including a
146
+ * line that merely starts with a result or error marker, is prose.
147
+ */
148
+ finish(): string {
149
+ if (this.matched) return "";
150
+ // A closing fence may end the stream without a trailing newline; settle it before the hold.
151
+ const settled = this.pending !== "" && parseFenceLine(this.pending) ? this.completeLine() : "";
152
+ const pending = this.takePending();
153
+ if (this.held !== null) {
154
+ this.held = null;
155
+ this.matched = true;
156
+ return "";
157
+ }
158
+ const probe = pending.replace(/^[ \t]*/, "");
159
+ if (isWholeLineEchoMarker(probe)) {
160
+ this.matched = true;
161
+ return settled;
162
+ }
163
+ return settled + pending;
164
+ }
165
+
166
+ private completeLine(): string {
167
+ const raw = this.takePending();
168
+ const probe = raw.replace(/^[ \t]*/, "");
169
+ const indent = raw.length - probe.length;
170
+ const line = probe.replace(/\r?\n$/, "");
171
+ const fenceLine = parseFenceLine(raw);
172
+ if (fenceLine) {
173
+ if (!this.fence) {
174
+ if (opensFence(fenceLine)) {
175
+ this.fence = { char: fenceLine.char, length: fenceLine.length, holdDisabled: false };
176
+ }
177
+ return this.emit(raw);
178
+ }
179
+ if (closesFence(fenceLine, this.fence)) {
180
+ const out = this.emit(raw);
181
+ // Only one block is open at a time, so closing the overflowed one settles the doubt. This
182
+ // runs after the closer is emitted, because the closer itself can be what overflows the hold.
183
+ if (this.fence.overflowed) this.unverifiedMarker = false;
184
+ const released = this.held ?? "";
185
+ this.held = null;
186
+ this.fence = null;
187
+ return out + released;
188
+ }
189
+ return this.emit(raw);
190
+ }
191
+ const trimmed = line.trimEnd();
192
+ const markerLine = indent <= MAX_INDENT && isWholeLineEchoMarker(trimmed);
193
+ if (markerLine) {
194
+ if (!this.fence) {
195
+ this.matched = true;
196
+ return "";
197
+ }
198
+ this.startHold();
199
+ }
200
+ return this.emit(raw);
201
+ }
202
+
203
+ private startHold(): void {
204
+ if (this.fence && !this.fence.holdDisabled && this.held === null) this.held = "";
205
+ }
206
+
207
+ private flushLineStart(): void {
208
+ this.safeLine = true;
209
+ }
210
+
211
+ private takePending(): string {
212
+ const pending = this.pending;
213
+ this.pending = "";
214
+ return pending;
215
+ }
216
+
217
+ /** Route text to the client, or into the fence hold while one is open. */
218
+ private emit(text: string): string {
219
+ if (this.held === null) return text;
220
+ this.held += text;
221
+ if (this.held.length <= MAX_HELD_CHARS) return "";
222
+ const released = this.held;
223
+ this.held = null;
224
+ this.unverifiedMarker = true;
225
+ if (this.fence) {
226
+ this.fence.holdDisabled = true;
227
+ this.fence.overflowed = true;
228
+ }
229
+ return released;
230
+ }
231
+ }
232
+
233
+ export function stripToolEnvelopeEcho(text: string): string {
234
+ const filter = new ToolEnvelopeEchoFilter();
235
+ return filter.feed(text) + filter.finish();
236
+ }