@bitkyc08/opencodex 2.52.0-preview.20260911 → 2.52.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 (166) hide show
  1. package/gui/dist/assets/index-CWXut3rG.js +115 -0
  2. package/gui/dist/assets/index-EdoPnm9_.css +1 -0
  3. package/gui/dist/index.html +2 -2
  4. package/gui/dist/provider-icons/devin.svg +49 -0
  5. package/gui/dist/provider-icons/omo.svg +42 -0
  6. package/package.json +3 -1
  7. package/src/AGENTS.md +1 -1
  8. package/src/adapters/cline-pass-deepseek-v4-tool-replay.ts +0 -1
  9. package/src/adapters/command-code.ts +0 -1
  10. package/src/adapters/cursor/checkpoint-store.ts +37 -0
  11. package/src/adapters/cursor/live-transport.ts +74 -2
  12. package/src/adapters/cursor.ts +6 -0
  13. package/src/adapters/devin/cloud-direct/auth.ts +264 -0
  14. package/src/adapters/devin/cloud-direct/catalog.ts +306 -0
  15. package/src/adapters/devin/cloud-direct/chat.ts +1274 -0
  16. package/src/adapters/devin/cloud-direct/index.ts +65 -0
  17. package/src/adapters/devin/cloud-direct/metadata.ts +134 -0
  18. package/src/adapters/devin/cloud-direct/wire.ts +206 -0
  19. package/src/adapters/devin/live-models.ts +133 -0
  20. package/src/adapters/devin-cli/acp.ts +204 -0
  21. package/src/adapters/devin-cli/adapter.ts +345 -0
  22. package/src/adapters/devin-cli/binary.ts +69 -0
  23. package/src/adapters/devin-cli/models.ts +57 -0
  24. package/src/adapters/devin.ts +326 -0
  25. package/src/adapters/google.ts +1 -1
  26. package/src/adapters/openai-chat.ts +31 -10
  27. package/src/adapters/openai-responses.ts +16 -1
  28. package/src/adapters/registry.ts +25 -1
  29. package/src/bridge.ts +8 -2
  30. package/src/claude/inbound-cache-stabilize.ts +130 -0
  31. package/src/claude/inbound.ts +45 -5
  32. package/src/cli/account-auth.ts +60 -1
  33. package/src/cli/account-extended.ts +23 -30
  34. package/src/cli/account.ts +2 -1
  35. package/src/cli/capabilities.ts +50 -8
  36. package/src/cli/config-command.ts +2 -2
  37. package/src/cli/dispatch.ts +14 -2
  38. package/src/cli/export-command.ts +11 -25
  39. package/src/cli/help.ts +2 -2
  40. package/src/cli/opencode.ts +5 -0
  41. package/src/cli/registry.ts +15 -4
  42. package/src/clients/config-export/cline.ts +71 -0
  43. package/src/clients/config-export/contracts.ts +10 -1
  44. package/src/clients/config-export/model-metadata.ts +33 -0
  45. package/src/clients/config-export/zcode.ts +23 -12
  46. package/src/clients/config-export.ts +156 -4
  47. package/src/codex/account-store.ts +7 -2
  48. package/src/codex/auth-context.ts +1 -1
  49. package/src/codex/catalog/effort.ts +4 -3
  50. package/src/codex/catalog/metadata.ts +8 -4
  51. package/src/codex/catalog/native-models.ts +24 -4
  52. package/src/codex/catalog/parsing.ts +19 -1
  53. package/src/codex/catalog/provider-fetch.ts +57 -0
  54. package/src/codex/catalog/sync.ts +1 -1
  55. package/src/codex/context-compat.ts +97 -0
  56. package/src/codex/context-owner.ts +201 -0
  57. package/src/codex/history-provider.ts +161 -14
  58. package/src/codex/inject-coordination.ts +3 -2
  59. package/src/codex/inject.ts +128 -9
  60. package/src/codex/pool-rotation.ts +8 -292
  61. package/src/codex/quota.ts +41 -12
  62. package/src/codex/retired-model-migration.ts +41 -0
  63. package/src/codex/routing.ts +327 -22
  64. package/src/codex/warmup.ts +2 -2
  65. package/src/combos/failover.ts +5 -0
  66. package/src/combos/resolve.ts +11 -15
  67. package/src/config.ts +46 -14
  68. package/src/generated/compatibility-version.json +293 -113
  69. package/src/grok/grpc-web.ts +120 -0
  70. package/src/grok/reset-coupon-ledger.ts +139 -0
  71. package/src/grok/reset-coupons.ts +278 -0
  72. package/src/integrations/catalog-refresh.ts +1 -1
  73. package/src/integrations/cline-document.ts +73 -0
  74. package/src/integrations/cline-io.ts +149 -0
  75. package/src/integrations/cline-transaction.ts +42 -0
  76. package/src/integrations/config-io.ts +21 -2
  77. package/src/integrations/journal.ts +43 -1
  78. package/src/integrations/omp-yaml-source.ts +123 -4
  79. package/src/integrations/ownership-policy.ts +7 -0
  80. package/src/integrations/ownership.ts +36 -0
  81. package/src/integrations/registry.ts +27 -0
  82. package/src/integrations/state.ts +5 -2
  83. package/src/integrations/store.ts +6 -0
  84. package/src/integrations/writer.ts +53 -9
  85. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  86. package/src/lib/abort.ts +36 -0
  87. package/src/lib/local-destinations.ts +1 -1
  88. package/src/lib/upstream-retry.ts +36 -1
  89. package/src/oauth/account-quota-rank.ts +11 -0
  90. package/src/oauth/callback-server.ts +10 -5
  91. package/src/oauth/devin/api-base.ts +63 -0
  92. package/src/oauth/devin/login.ts +1 -0
  93. package/src/oauth/devin/register-user.ts +186 -0
  94. package/src/oauth/devin/types.ts +71 -0
  95. package/src/oauth/devin-cli.ts +149 -0
  96. package/src/oauth/devin.ts +170 -0
  97. package/src/oauth/generic-account-failover.ts +169 -1
  98. package/src/oauth/index.ts +20 -1
  99. package/src/oauth/login-cli.ts +19 -5
  100. package/src/oauth/pool-kernel.ts +321 -0
  101. package/src/oauth/pool-settings-capability.ts +127 -9
  102. package/src/oauth/store.ts +7 -3
  103. package/src/oauth/token-guardian.ts +1 -1
  104. package/src/providers/codebuddy-models.ts +0 -3
  105. package/src/providers/command-code-efforts.ts +23 -4
  106. package/src/providers/default-aliases.ts +4 -0
  107. package/src/providers/derive.ts +8 -0
  108. package/src/providers/devin-cli-authmode-migration.ts +57 -0
  109. package/src/providers/key-failover.ts +175 -0
  110. package/src/providers/model-rename-startup.ts +21 -1
  111. package/src/providers/qoder-models.ts +0 -1
  112. package/src/providers/quota-key-accounts.ts +50 -0
  113. package/src/providers/quota-routing-cache.ts +65 -6
  114. package/src/providers/quota-types.ts +7 -0
  115. package/src/providers/quota.ts +113 -30
  116. package/src/providers/registry.ts +237 -77
  117. package/src/providers/stale-context-window-migration.ts +92 -0
  118. package/src/providers/zai-responses-migration.ts +45 -0
  119. package/src/quota/reset-observer.ts +2 -1
  120. package/src/quota/reset-seen-store.ts +9 -1
  121. package/src/remote-control/crypto.ts +442 -0
  122. package/src/remote-control/host.ts +175 -0
  123. package/src/remote-control/index.ts +100 -0
  124. package/src/remote-control/protocol.ts +200 -0
  125. package/src/remote-control/relay.ts +162 -0
  126. package/src/remote-control/workspace-agent-protocol.ts +246 -0
  127. package/src/remote-control/workspace-rpc-framing.ts +128 -0
  128. package/src/remote-control/workspace-tools.ts +237 -0
  129. package/src/remote-control/workspace-utf8.ts +24 -0
  130. package/src/responses/custom-tool-compat.ts +23 -0
  131. package/src/router.ts +6 -1
  132. package/src/routing/compatibility/behavior.ts +3 -0
  133. package/src/server/adapter-resolve.ts +6 -2
  134. package/src/server/auth-cors.ts +71 -6
  135. package/src/server/chat-completions.ts +5 -3
  136. package/src/server/chat-native.ts +9 -0
  137. package/src/server/claude-messages.ts +25 -30
  138. package/src/server/context-history.ts +207 -0
  139. package/src/server/images.ts +28 -1
  140. package/src/server/index.ts +42 -22
  141. package/src/server/live.ts +2 -1
  142. package/src/server/management/agent-settings-routes.ts +7 -1
  143. package/src/server/management/config-routes.ts +3 -3
  144. package/src/server/management/grok-coupon-routes.ts +287 -0
  145. package/src/server/management/integration-routes.ts +6 -1
  146. package/src/server/management/oauth-account-routes.ts +124 -7
  147. package/src/server/management/provider-routes.ts +77 -2
  148. package/src/server/management/route-registry.ts +6 -1
  149. package/src/server/management-api.ts +7 -0
  150. package/src/server/request-log.ts +4 -0
  151. package/src/server/responses/codex-ws-exchange.ts +90 -14
  152. package/src/server/responses/codex-ws-wire.ts +51 -1
  153. package/src/server/responses/compact.ts +8 -1
  154. package/src/server/responses/core.ts +188 -22
  155. package/src/server/responses/ws-upstream.ts +1 -1
  156. package/src/server/responses-undeclared-tool-guard.ts +261 -12
  157. package/src/server/zai-responses-startup.ts +21 -0
  158. package/src/types/config.ts +33 -3
  159. package/src/types/provider.ts +54 -4
  160. package/src/types/request.ts +1 -1
  161. package/src/types/tools.ts +36 -6
  162. package/src/update/job.ts +33 -11
  163. package/src/vision/plan.ts +40 -37
  164. package/src/web-search/index.ts +1 -0
  165. package/gui/dist/assets/index-BoBRSehJ.css +0 -1
  166. package/gui/dist/assets/index-Dx0xv2EA.js +0 -115
@@ -0,0 +1,42 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
2
+ <path fill="#F4F4F4" d="M512.00 100.50 C591.13 100.50 697.66 102.37 749.39 106.27 C801.11 110.17 802.35 115.84 822.35 123.90 C842.34 131.96 856.40 141.68 869.36 154.64 C882.32 167.60 892.04 181.66 900.10 201.65 C908.16 221.65 913.83 222.89 917.73 274.61 C921.63 326.34 923.50 432.87 923.50 512.00 C923.50 591.13 921.63 697.66 917.73 749.39 C913.83 801.11 908.16 802.35 900.10 822.35 C892.04 842.34 882.32 856.40 869.36 869.36 C856.40 882.32 842.34 892.04 822.35 900.10 C802.35 908.16 801.11 913.83 749.39 917.73 C697.66 921.63 591.13 923.50 512.00 923.50 C432.87 923.50 326.34 921.63 274.61 917.73 C222.89 913.83 221.65 908.16 201.65 900.10 C181.66 892.04 167.60 882.32 154.64 869.36 C141.68 856.40 131.96 842.34 123.90 822.35 C115.84 802.35 110.17 801.11 106.27 749.39 C102.37 697.66 100.50 591.13 100.50 512.00 C100.50 432.87 102.37 326.34 106.27 274.61 C110.17 222.89 115.84 221.65 123.90 201.65 C131.96 181.66 141.68 167.60 154.64 154.64 C167.60 141.68 181.66 131.96 201.65 123.90 C221.65 115.84 222.89 110.17 274.61 106.27 C326.34 102.37 432.87 100.50 512.00 100.50Z"/>
3
+ <g transform="scale(0.5)">
4
+ <g transform="translate(0.000000,2048.000000) scale(0.050000,-0.050000)">
5
+ <path fill="#041617" fill-rule="evenodd" d="M9270 32167 c-309 -64 -456 -152 -621 -373 -562 -748 -776 -2689
6
+ -547 -4984 118 -1187 325 -2600 570 -3890 143 -753 152 -653 -128 -1380 -403
7
+ -1045 -643 -1873 -812 -2800 -853 -4689 1979 -7986 7858 -9148 1140 -225 2514
8
+ -397 3202 -400 l361 -2 79 55 c264 182 210 642 -111 942 -387 362 -950 532
9
+ -2801 845 -3004 507 -4249 965 -5520 2030 -1358 1138 -2012 2878 -1809 4810
10
+ 545 5195 6617 8726 13529 7866 5506 -684 9480 -4255 9480 -8518 0 -2607 -1462
11
+ -4522 -4157 -5448 -927 -318 -1534 -459 -3203 -740 -1851 -313 -2414 -483
12
+ -2801 -845 -321 -300 -375 -760 -111 -942 l79 -55 361 2 c688 3 2062 175 3202
13
+ 400 5879 1162 8711 4459 7858 9148 -169 927 -409 1755 -812 2800 -280 727
14
+ -271 627 -128 1380 245 1290 452 2703 570 3890 271 2720 -77 4861 -855 5259
15
+ -758 388 -2310 -238 -5021 -2024 -868 -571 -764 -557 -1489 -211 -3271 1558
16
+ -6755 1558 -10026 0 -725 -346 -621 -360 -1489 211 -2371 1562 -3959 2278
17
+ -4708 2122z m4132 -3345 c175 -175 124 -268 -409 -739 -1028 -910 -1995 -2053
18
+ -2713 -3209 -97 -156 -200 -318 -228 -359 -221 -321 -646 -76 -469 270 153
19
+ 301 883 1334 1388 1965 578 723 1466 1614 2029 2035 191 142 288 151 402 37z
20
+ m14507 -1 c496 -341 1482 -1323 2080 -2071 505 -631 1235 -1664 1388 -1965
21
+ 177 -346 -248 -591 -469 -270 -28 41 -131 203 -228 359 -718 1156 -1685 2299
22
+ -2713 3209 -535 473 -585 564 -407 742 100 100 199 99 349 -4z M19910 25308 c-4039 -205 -7212 -1666 -9086 -4184 -1840 -2473 -1753
23
+ -5794 196 -7540 1161 -1040 2447 -1518 5420 -2015 1374 -229 1998 -380 2456
24
+ -591 754 -348 1163 -1000 1033 -1650 -10 -49 -1 -72 47 -124 l60 -64 444 0
25
+ 444 0 60 64 c48 52 57 75 47 124 -130 650 279 1302 1033 1650 458 211 1082
26
+ 362 2456 591 2973 497 4259 975 5420 2015 2084 1866 2026 5374 -131 7956
27
+ -2063 2469 -5995 3966 -9899 3768z m-4644 -4374 c1356 -360 2272 -1909 1998
28
+ -3378 -426 -2278 -2856 -3144 -4424 -1576 -1497 1496 -924 4218 1031 4899 393
29
+ 137 996 161 1395 55z m11480 35 c1491 -261 2504 -1921 2154 -3532 -487 -2247
30
+ -3108 -3024 -4525 -1341 -1719 2042 -129 5310 2371 4873z m-6948 -2980 c172
31
+ -40 341 -133 495 -273 95 -86 143 -116 187 -116 44 0 92 30 187 116 718 650
32
+ 1716 188 1854 -860 35 -267 18 -968 -25 -1048 -119 -219 -350 -251 -491 -68
33
+ -68 89 -73 124 -85 670 -13 623 -25 675 -200 838 -212 198 -516 199 -735 2
34
+ -180 -161 -192 -212 -205 -840 -12 -546 -17 -581 -85 -670 -122 -159 -308
35
+ -159 -430 0 -68 89 -73 124 -85 670 -13 628 -25 679 -205 840 -219 197 -523
36
+ 196 -735 -2 -175 -163 -187 -215 -200 -838 -12 -546 -17 -581 -85 -670 -141
37
+ -183 -372 -151 -491 68 -74 136 -54 1039 30 1332 170 594 758 978 1304 849z M14340 20290 c-1858 -372 -2230 -3228 -546 -4191 1280 -732 2786 338
38
+ 2786 1981 0 1391 -1068 2445 -2240 2210z M25990 20291 c-1733 -343 -2208 -2981 -732 -4069 1287 -949 2981 108
39
+ 2982 1860 0 1392 -1070 2443 -2250 2209z"/>
40
+ </g>
41
+ </g>
42
+ </svg>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.52.0-preview.20260911",
3
+ "version": "2.52.0",
4
4
  "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -48,6 +48,8 @@
48
48
  "privacy:scan": "bun scripts/privacy-scan.ts",
49
49
  "skill:surface": "bun scripts/generate-ocx-skill-surface.ts",
50
50
  "skill:surface:check": "bun scripts/generate-ocx-skill-surface.ts --check",
51
+ "structure:index": "bun scripts/structure-ssot.ts --fix",
52
+ "structure:check": "bun scripts/structure-ssot.ts",
51
53
  "generate:model-metadata": "bun scripts/generate-model-metadata.ts",
52
54
  "build:gui": "cd gui && bun install --frozen-lockfile && bun run build && cd .. && bun run prepare:package",
53
55
  "prepare:package": "bun scripts/prepare-package.ts",
package/src/AGENTS.md CHANGED
@@ -8,7 +8,7 @@ This file applies to `src/` and inherits the repository-wide rules in `/AGENTS.m
8
8
  - Do not assume a separate server compilation step.
9
9
  - Prefer Bun and Web-platform APIs. Introduce a Node-only runtime dependency only when the task explicitly requires compatibility code and the owning module already has that role.
10
10
  - Preserve existing public exports and configuration compatibility unless the task explicitly changes them.
11
- - Read the applicable documents in `structure/` before changing shared routing, adapters, transports, sidecars, authentication, configuration, or server architecture.
11
+ - Read the applicable documents in `structure/` before changing shared routing, adapters, transports, sidecars, authentication, configuration, or server architecture. [`structure/INDEX.md`](../structure/INDEX.md) maps each source area to the docs that describe it — usually more than one, because those docs are organised by topic while `src/` is organised by module — and every doc listed for an area is updated in the same change that changes the area.
12
12
 
13
13
  ## Implementation rules
14
14
 
@@ -2,7 +2,6 @@ import type { ProviderAdapter } from "./base";
2
2
 
3
3
  const CLINE_PASS_DEEPSEEK_V4_MODELS = new Set([
4
4
  "cline-pass/deepseek-v4-flash",
5
- "cline-pass/deepseek-v4-pro",
6
5
  ]);
7
6
 
8
7
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -495,7 +495,6 @@ function supportedCommandCodeEffort(provider: OcxProviderConfig, modelId: string
495
495
  let wire = requested;
496
496
  const lower = canonicalId.toLowerCase();
497
497
  const needsAlias =
498
- lower === "deepseek/deepseek-v4-pro" ||
499
498
  lower === "deepseek/deepseek-v4-flash" ||
500
499
  lower === "zai-org/glm-5.2";
501
500
  if (requested === "xhigh" && !supported.includes("xhigh") && supported.includes("max")) {
@@ -184,6 +184,43 @@ export function cursorCheckpointRefHash(ref: string): string {
184
184
  return createHash("sha256").update("ocx:cursor:ckpt-ref:").update(ref).digest("hex").slice(0, 16);
185
185
  }
186
186
 
187
+ /**
188
+ * Counts only — never content. Diagnostics about a checkpoint have so far reported its size in
189
+ * bytes, which says nothing about what is in it, and that gap is exactly what left #4245's native
190
+ * half undecidable: `capturedAfterClientTool` proves a snapshot ARRIVED after the tool call, and
191
+ * only `pendingToolCalls` says whether the snapshot actually knows about one.
192
+ *
193
+ * `pendingToolCalls` is documented upstream as raw JSON tool-call parts awaiting execution, so a
194
+ * non-zero count on a suspended turn is the coverage evidence. The strings themselves are request
195
+ * content and are never read here.
196
+ *
197
+ * If you extend this, keep it counts-only. `ConversationStateStructure` also carries
198
+ * `readPaths`, `previousWorkspaceUris`, and the `fileStates`/`fileStatesV2` keys — all of which
199
+ * are user paths or workspace identity, and all of which would turn a diagnostic into a privacy
200
+ * leak the moment someone returns them as values instead of lengths.
201
+ */
202
+ export function cursorCheckpointShape(checkpointBytes: Uint8Array | undefined): {
203
+ turns: number;
204
+ turnsOld: number;
205
+ rootPromptMessages: number;
206
+ todos: number;
207
+ pendingToolCalls: number;
208
+ } | undefined {
209
+ if (!checkpointBytes || checkpointBytes.byteLength === 0) return undefined;
210
+ try {
211
+ const state = fromBinary(ConversationStateStructureSchema, checkpointBytes);
212
+ return {
213
+ turns: state.turns.length,
214
+ turnsOld: state.turnsOld.length,
215
+ rootPromptMessages: state.rootPromptMessagesJson.length,
216
+ todos: state.todos.length,
217
+ pendingToolCalls: state.pendingToolCalls.length,
218
+ };
219
+ } catch {
220
+ return undefined;
221
+ }
222
+ }
223
+
187
224
  export function commitCursorCheckpoint(input: {
188
225
  conversationId: string;
189
226
  identityScope?: string;
@@ -115,6 +115,15 @@ const CLIENT_TOOL_FINALIZE_GRACE_MS = 50;
115
115
  const GENERIC_TOOL_COUNT_MIN_FINALIZE_GRACE_MS = 750;
116
116
  const GENERIC_TOOL_COUNT_MAX_FINALIZE_GRACE_MS = 1_800;
117
117
  const GENERIC_TOOL_COUNT_PER_TOOL_GRACE_MS = 125;
118
+ /**
119
+ * A client-tool turn suspends before `turnEnded`, and upstream sends that turn's conversation
120
+ * checkpoint right after `toolCallStarted` — after the 50 ms drain grace, so it used to be
121
+ * cancelled away and every such turn full-replayed with `cached_tokens: 0`. Measured on a live
122
+ * account with the tool catalog held constant: 50 ms captures nothing, 1500 ms captures 3036
123
+ * bytes (#4245, devlog 260911_cursor_checkpoint_capture). This window is paid only by a turn
124
+ * that never sends one; `handleServerMessage` finalizes early as soon as the frame lands.
125
+ */
126
+ const CHECKPOINT_CAPTURE_GRACE_MS = 1_500;
118
127
  const cursorContextUsageTracker = createCursorContextUsageTracker();
119
128
 
120
129
  /**
@@ -413,6 +422,28 @@ export function finalizeAfterDrain(state: ReturnType<typeof createCursorProtobuf
413
422
  return finalizeTurnEvents(state);
414
423
  }
415
424
 
425
+ /**
426
+ * Whether a drained client-tool turn should wait one more bounded window for the conversation
427
+ * checkpoint instead of cancelling now.
428
+ *
429
+ * This mirrors `finalizeAfterDrain`'s two guards rather than calling it, and that is load-bearing:
430
+ * `finalizeAfterDrain` reaches `finalizeTurnEvents`, which sets `state.terminated`, and then returns
431
+ * `[]` for a terminated state. Draining first and re-arming would make the retry return early at the
432
+ * length check and leave the stream uncancelled. Pure for unit testing.
433
+ */
434
+ export function shouldExtendForCheckpointCapture(input: {
435
+ /** Optional on the event state, so accept its real shape rather than forcing a cast at the call site. */
436
+ terminated: boolean | undefined;
437
+ openToolCallCount: number;
438
+ wantsCheckpointCapture: boolean;
439
+ hasCapturedCheckpoint: boolean;
440
+ alreadyExtended: boolean;
441
+ }): boolean {
442
+ if (input.terminated || input.openToolCallCount > 0) return false;
443
+ if (!input.wantsCheckpointCapture || input.hasCapturedCheckpoint) return false;
444
+ return !input.alreadyExtended;
445
+ }
446
+
416
447
  export function clientToolFinalizeGraceMsForRequest(request: CursorRunRequest, baseGraceMs = CLIENT_TOOL_FINALIZE_GRACE_MS): number {
417
448
  if (request.rawMessages?.at(-1)?.role === "toolResult") return baseGraceMs;
418
449
  const text = activePromptText(request);
@@ -468,6 +499,10 @@ class LiveCursorTransport implements CursorTransport {
468
499
  */
469
500
  private emittedTerminal = false;
470
501
  private pendingFinalize?: ReturnType<typeof setTimeout>;
502
+ /** The armed finalize body, so the checkpoint frame can run it early instead of waiting out the window. */
503
+ private pendingFinalizeRun?: () => void;
504
+ private checkpointGraceExtended = false;
505
+ private wantsCheckpointCapture = false;
471
506
  private readonly clientToolFinalizeGraceMs: number;
472
507
  private activeClientToolFinalizeGraceMs: number;
473
508
  private readonly token: string;
@@ -641,6 +676,7 @@ class LiveCursorTransport implements CursorTransport {
641
676
  };
642
677
  const activeText = activePromptText(activeRequest);
643
678
  this.activeClientToolFinalizeGraceMs = clientToolFinalizeGraceMsForRequest(activeRequest, this.clientToolFinalizeGraceMs);
679
+ this.wantsCheckpointCapture = activeRequest.contextUsageStoreCheckpoints !== false;
644
680
  const cursorVisibleTools = cursorToolsForActivePrompt(activeRequest.tools, activeText, activeRequest.toolChoice);
645
681
  const clientToolDefs = buildCursorToolDefinitions(cursorVisibleTools, activeRequest.toolChoice);
646
682
  // `request.tools` is the catalog already filtered and budgeted by request-builder. Derive
@@ -981,6 +1017,7 @@ class LiveCursorTransport implements CursorTransport {
981
1017
  clearTimeout(this.pendingFinalize);
982
1018
  this.pendingFinalize = undefined;
983
1019
  }
1020
+ this.pendingFinalizeRun = undefined;
984
1021
  }
985
1022
 
986
1023
  /**
@@ -1001,11 +1038,26 @@ class LiveCursorTransport implements CursorTransport {
1001
1038
  private scheduleClientToolFinalize(
1002
1039
  state: ReturnType<typeof createCursorProtobufEventState>,
1003
1040
  push: (message: CursorServerMessage) => void,
1041
+ graceMsOverride?: number,
1004
1042
  ): void {
1005
1043
  this.clearPendingFinalize();
1006
- this.pendingFinalize = setTimeout(() => {
1044
+ const run = (): void => {
1007
1045
  this.pendingFinalize = undefined;
1046
+ this.pendingFinalizeRun = undefined;
1008
1047
  if (this.expectedClose) return;
1048
+ // The checkpoint for THIS turn is the one we most want and the one we used to throw
1049
+ // away. Extend once, bounded, before draining (#4245).
1050
+ if (shouldExtendForCheckpointCapture({
1051
+ terminated: state.terminated,
1052
+ openToolCallCount: state.openToolCalls.size,
1053
+ wantsCheckpointCapture: this.wantsCheckpointCapture,
1054
+ hasCapturedCheckpoint: this.capturedCheckpointBytes !== undefined,
1055
+ alreadyExtended: this.checkpointGraceExtended,
1056
+ })) {
1057
+ this.checkpointGraceExtended = true;
1058
+ this.scheduleClientToolFinalize(state, push, CHECKPOINT_CAPTURE_GRACE_MS);
1059
+ return;
1060
+ }
1009
1061
  const terminal = finalizeAfterDrain(state);
1010
1062
  if (terminal.length === 0) return;
1011
1063
  for (const event of terminal) push(event);
@@ -1013,9 +1065,13 @@ class LiveCursorTransport implements CursorTransport {
1013
1065
  reason: "Responses bridge owns client tools; ending turn without fake mcpResult",
1014
1066
  framesReceived: this.framesReceived,
1015
1067
  elapsedMs: Date.now() - this.turnStartedAt,
1068
+ graceMs: graceMsOverride ?? this.activeClientToolFinalizeGraceMs,
1069
+ checkpointGraceExtended: this.checkpointGraceExtended,
1016
1070
  });
1017
1071
  this.cancelCursorRun();
1018
- }, this.activeClientToolFinalizeGraceMs);
1072
+ };
1073
+ this.pendingFinalizeRun = run;
1074
+ this.pendingFinalize = setTimeout(run, graceMsOverride ?? this.activeClientToolFinalizeGraceMs);
1019
1075
  }
1020
1076
 
1021
1077
  private open(
@@ -1032,6 +1088,10 @@ class LiveCursorTransport implements CursorTransport {
1032
1088
  }
1033
1089
  this.turnStartedAt = Date.now();
1034
1090
  this.framesReceived = 0;
1091
+ this.checkpointGraceExtended = false;
1092
+ // A turn must not inherit the previous one's snapshot: a stale capture would make
1093
+ // shouldExtendForCheckpointCapture skip the wait this turn actually needs.
1094
+ this.capturedCheckpointBytes = undefined;
1035
1095
  this.sawAssistantText = false;
1036
1096
  this.emittedTerminal = false;
1037
1097
  this.firstFrameAt = undefined;
@@ -1450,6 +1510,18 @@ class LiveCursorTransport implements CursorTransport {
1450
1510
  } catch {
1451
1511
  this.capturedCheckpointBytes = undefined;
1452
1512
  }
1513
+ // We are only still open because the grace was extended waiting for exactly this frame.
1514
+ // Stop waiting, so a turn that does send a checkpoint pays arrival latency rather than the
1515
+ // whole window. Deferred one tick so this frame finishes being mapped and pushed first;
1516
+ // finalizing inline would emit the terminal events ahead of it.
1517
+ if (this.checkpointGraceExtended && this.pendingFinalizeRun && this.capturedCheckpointBytes) {
1518
+ const run = this.pendingFinalizeRun;
1519
+ this.clearPendingFinalize();
1520
+ this.pendingFinalize = setTimeout(run, 0);
1521
+ // Keep the pair in step: clearPendingFinalize() drops the callback, and every other
1522
+ // owner of pendingFinalize expects pendingFinalizeRun to describe it.
1523
+ this.pendingFinalizeRun = run;
1524
+ }
1453
1525
  }
1454
1526
  if (message.message.case === "kvServerMessage") {
1455
1527
  this.writeConnectFrame(encodeConnectFrame(handleCursorNativeKv(message.message.value, this.blobRequestScope)));
@@ -24,9 +24,11 @@ import {
24
24
  import {
25
25
  commitCursorCheckpoint,
26
26
  cursorCheckpointRefHash,
27
+ cursorCheckpointShape,
27
28
  invalidateCursorCheckpoint,
28
29
  } from "./cursor/checkpoint-store";
29
30
  import { debugProviderDiagnostic } from "../lib/debug";
31
+ import { isDebugEnabled } from "../lib/debug-settings";
30
32
  import { createAdapterTierMetadata } from "../providers/fastwire";
31
33
  import { estimateTokens } from "../lib/token-estimate";
32
34
  import { rememberCursorThreadConversation } from "./cursor/thread-continuity";
@@ -208,6 +210,10 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
208
210
  externalModel: isCursorExternalWireModel(activeRequest.modelId),
209
211
  storeCheckpoints: activeRequest.contextUsageStoreCheckpoints !== false,
210
212
  capturedBytes: lastTransport?.captured?.byteLength ?? 0,
213
+ // Byte length says nothing about coverage. `pendingToolCalls` does: it is what
214
+ // distinguishes a snapshot that knows about the suspended call from one that merely
215
+ // arrived after it (#4245). Counts only; the decode is skipped unless debug is on.
216
+ capturedShape: isDebugEnabled() ? cursorCheckpointShape(lastTransport?.captured) : undefined,
211
217
  });
212
218
  return;
213
219
  }
@@ -0,0 +1,264 @@
1
+ /*
2
+ * Derived from rsvedant/opencode-windsurf-auth (src/cloud-direct/), MIT licensed,
3
+ * Copyright (c) 2026 Vedant. The full notice is in ./index.ts.
4
+ */
5
+ /**
6
+ * Mint the short-lived `user_jwt` that accompanies the persistent OAuth-issued
7
+ * `api_key`. The catalog RPC uses it. The hosted chat path does not need it and
8
+ * only sends it when an operator opts in, so a mint failure here cannot take
9
+ * down a turn.
10
+ *
11
+ * POST https://server.codeium.com/exa.auth_pb.AuthService/GetUserJwt
12
+ * Content-Type: application/proto ← unary, NOT streaming
13
+ * Body: GetUserJwtRequest { metadata: Metadata }
14
+ * Response: GetUserJwtResponse { user_jwt: string } (field 1)
15
+ *
16
+ * The returned JWT has a payload like:
17
+ * {
18
+ * "api_key": "devin-synthetic-apikey$account-…$user-…",
19
+ * "auth_uid": "devin-auth-uid$…",
20
+ * "email": "user@example.com",
21
+ * "exp": <unix-seconds>, ← ~24 minute TTL
22
+ * "pro": true,
23
+ * "teams_tier": "TEAMS_TIER_DEVIN_PRO",
24
+ * ...
25
+ * }
26
+ *
27
+ * The JWT is signed HS256 by the server — can't be forged client-side. We
28
+ * cache it and refresh shortly before `exp`.
29
+ */
30
+
31
+ import * as crypto from 'crypto';
32
+ import { encodeMessage, iterFields } from './wire.js';
33
+ import { buildMetadata } from './metadata.js';
34
+ import { anySignal } from '../../../lib/abort.js';
35
+ import { validateDevinApiBaseUrl } from '../../../oauth/devin/api-base.js';
36
+
37
+ const DEFAULT_HOST = 'https://server.codeium.com';
38
+
39
+ export interface MintedUserJwt {
40
+ jwt: string;
41
+ /** Unix epoch seconds when the JWT expires. */
42
+ expiresAt: number;
43
+ }
44
+
45
+ export class CloudAuthError extends Error {
46
+ constructor(message: string, public readonly status?: number) {
47
+ super(message);
48
+ this.name = 'CloudAuthError';
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Default mint timeout — 30s is generous (the endpoint responds in ~200ms
54
+ * in steady state) but enough headroom for slow networks. Callers can pass
55
+ * a tighter `signal` to override.
56
+ */
57
+ const MINT_TIMEOUT_MS = 30_000;
58
+
59
+ /**
60
+ * Mint a fresh user_jwt by calling exa.auth_pb.AuthService/GetUserJwt.
61
+ * `host` defaults to https://server.codeium.com — pass your tenant URL if your
62
+ * RegisterUser response gave a different host.
63
+ *
64
+ * Always applies an internal 30s timeout so a network stall here can't
65
+ * deadlock every concurrent chat request. If the caller passes a `signal`,
66
+ * we honor whichever fires first via AbortSignal.any.
67
+ */
68
+ export async function mintUserJwt(
69
+ apiKey: string,
70
+ host: string = DEFAULT_HOST,
71
+ signal?: AbortSignal,
72
+ ): Promise<MintedUserJwt> {
73
+ const metadata = buildMetadata({
74
+ apiKey,
75
+ sessionId: crypto.randomUUID(),
76
+ requestId: BigInt(Date.now()),
77
+ triggerId: crypto.randomUUID(),
78
+ });
79
+ // GetUserJwtRequest { metadata: Metadata } — Metadata is field 1
80
+ const req = encodeMessage(1, metadata);
81
+
82
+ // Compose caller signal with our internal timeout via `anySignal` — a
83
+ // small polyfill of `AbortSignal.any` for runtimes (Node 18 / older
84
+ // Bun) that lack the built-in. The previous fallback silently dropped
85
+ // the CALLER's signal on those runtimes, so a chat-cancel during a
86
+ // GetUserJwt mint would keep the network request alive for up to the
87
+ // full 30s timeout.
88
+ const timeoutSignal = AbortSignal.timeout(MINT_TIMEOUT_MS);
89
+ const composed = signal ? anySignal([signal, timeoutSignal]) : undefined;
90
+ const combinedSignal: AbortSignal = composed?.signal ?? timeoutSignal;
91
+
92
+ // The host arrives from RegisterUser via the credential store. It is checked
93
+ // again here because this request carries the long-lived api_key inside the
94
+ // protobuf body, and a host that slipped past persistence would exfiltrate it.
95
+ const base = validateDevinApiBaseUrl(host);
96
+ if (!base) {
97
+ throw new CloudAuthError(`Refusing to mint a user_jwt against a non-Cognition host.`);
98
+ }
99
+ let resp: Response;
100
+ try {
101
+ resp = await fetch(`${base}/exa.auth_pb.AuthService/GetUserJwt`, {
102
+ method: 'POST',
103
+ headers: {
104
+ 'Content-Type': 'application/proto',
105
+ 'Connect-Protocol-Version': '1',
106
+ },
107
+ body: new Uint8Array(req),
108
+ // A redirect would replay this POST - whose body holds the api_key - at
109
+ // whatever host Location names.
110
+ redirect: 'error',
111
+ signal: combinedSignal,
112
+ });
113
+ } finally {
114
+ // The caller's signal belongs to a whole turn; do not keep a listener on it.
115
+ composed?.cleanup();
116
+ }
117
+ const buf = Buffer.from(await resp.arrayBuffer());
118
+
119
+ if (!resp.ok) {
120
+ // The body is not echoed. A Connect error here can quote the request, and
121
+ // the request contains the api_key; this message reaches CLI output, the
122
+ // adapter's error event, and /api/logs.
123
+ throw new CloudAuthError(`GetUserJwt failed (HTTP ${resp.status})`, resp.status);
124
+ }
125
+
126
+ // Response is GetUserJwtResponse { user_jwt: string } where user_jwt is
127
+ // field 1, length-delimited. Decode the field properly instead of
128
+ // regex-scanning the whole buffer — the previous regex would pick up
129
+ // any JWT-shaped substring in the response (trace IDs, signature
130
+ // headers, any cached token inadvertently logged) and could even land
131
+ // on a non-user_jwt if Cognition ever embeds another JWT in a sibling
132
+ // field.
133
+ let jwt: string | null = null;
134
+ for (const f of iterFields(buf)) {
135
+ if (f.num === 1 && f.wire === 2 && Buffer.isBuffer(f.value)) {
136
+ const s = (f.value as Buffer).toString('utf8');
137
+ // Sanity-check the shape — defensive: if the cloud ever moves user_jwt
138
+ // out from field 1 we want a clean error, not silently wrong creds.
139
+ // base64url with OPTIONAL `=` padding on each segment. Most modern
140
+ // JWTs omit the `=`, but the spec allows it and a future server-side
141
+ // change could re-introduce it; either way it's still a valid token.
142
+ if (/^eyJ[A-Za-z0-9_-]{10,}={0,2}\.[A-Za-z0-9_-]+={0,2}\.[A-Za-z0-9_-]+={0,2}$/.test(s)) {
143
+ jwt = s;
144
+ break;
145
+ }
146
+ }
147
+ }
148
+ if (!jwt) {
149
+ throw new CloudAuthError(
150
+ // Same reason: a 200 whose field-1 value failed the shape check may still
151
+ // be a live token, so only the size is reported.
152
+ `GetUserJwt returned 200 without a usable field-1 JWT (${buf.length} bytes)`,
153
+ );
154
+ }
155
+
156
+ // Decode the payload to get the expiry.
157
+ let expiresAt = Math.floor(Date.now() / 1000) + 600; // fallback: 10 min
158
+ try {
159
+ const parts = jwt.split('.');
160
+ const pad = (s: string) => s + '='.repeat((4 - (s.length % 4)) % 4);
161
+ const payload = JSON.parse(
162
+ Buffer.from(pad(parts[1]).replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'),
163
+ );
164
+ if (typeof payload.exp === 'number') expiresAt = payload.exp;
165
+ } catch { /* fall back to default */ }
166
+
167
+ return { jwt, expiresAt };
168
+ }
169
+
170
+ // ----------------------------------------------------------------------------
171
+ // In-memory cache — refresh ~60s before expiry
172
+ // ----------------------------------------------------------------------------
173
+
174
+ interface CacheEntry {
175
+ jwt: string;
176
+ expiresAt: number;
177
+ apiKey: string;
178
+ host: string;
179
+ }
180
+
181
+ /**
182
+ * Cache is keyed by (apiKey, host). A single shared `cache` slot only holds
183
+ * the MOST RECENTLY USED entry — common case is one account at a time, so
184
+ * a single slot is enough. inFlight is a per-key map so a JWT mint for
185
+ * account A doesn't get returned to a concurrent request for account B.
186
+ *
187
+ * Previously `inFlight` was a singleton — if account A's mint was in flight
188
+ * and a request for account B arrived, B got A's JWT. That's the M1
189
+ * "concurrent requests after account switch get wrong JWT" bug.
190
+ */
191
+ let cache: CacheEntry | null = null;
192
+ const inFlight = new Map<string, Promise<MintedUserJwt>>();
193
+ /**
194
+ * Monotonic epoch counter. Incremented on every `clearCachedUserJwt()`
195
+ * call so an in-flight mint that started BEFORE the clear can't
196
+ * repopulate the cache after-the-fact. Without this, a logout that
197
+ * happened concurrently with a mint would silently get its just-
198
+ * invalidated JWT cached and served for the next ~24 minutes.
199
+ */
200
+ let cacheEpoch = 0;
201
+
202
+ function flightKey(apiKey: string, host: string): string {
203
+ return `${host}\x1f${apiKey}`;
204
+ }
205
+
206
+ /**
207
+ * Get a cached user_jwt or mint a new one. Refreshes when the cached JWT is
208
+ * within 60s of expiry. Multiple concurrent callers for the SAME (apiKey, host)
209
+ * share the same in-flight mint; concurrent callers for DIFFERENT keys each
210
+ * get their own mint.
211
+ */
212
+ export async function getCachedUserJwt(apiKey: string, host: string = DEFAULT_HOST, signal?: AbortSignal): Promise<string> {
213
+ const now = Math.floor(Date.now() / 1000);
214
+ if (cache && cache.apiKey === apiKey && cache.host === host && cache.expiresAt > now + 60) {
215
+ return cache.jwt;
216
+ }
217
+ // Race the caller's signal against the shared promise so one caller's
218
+ // cancellation doesn't propagate to unrelated callers sharing the mint.
219
+ // mintUserJwt has its own MINT_TIMEOUT_MS guard for the shared lifetime.
220
+ const raceSignal = <T>(p: Promise<T>): Promise<T> =>
221
+ signal
222
+ ? Promise.race([
223
+ p,
224
+ new Promise<T>((_, reject) => {
225
+ if (signal.aborted) reject(signal.reason);
226
+ else signal.addEventListener('abort', () => reject(signal.reason), { once: true });
227
+ }),
228
+ ])
229
+ : p;
230
+ const key = flightKey(apiKey, host);
231
+ const existing = inFlight.get(key);
232
+ if (existing) {
233
+ const minted = await raceSignal(existing);
234
+ return minted.jwt;
235
+ }
236
+ const promise = mintUserJwt(apiKey, host);
237
+ inFlight.set(key, promise);
238
+ // Snapshot the epoch BEFORE awaiting the mint. If clearCachedUserJwt()
239
+ // fires while we're awaiting (logout-during-mint), the epoch changes
240
+ // and we won't repopulate the cache with the just-invalidated JWT.
241
+ const epochAtStart = cacheEpoch;
242
+ try {
243
+ const minted = await raceSignal(promise);
244
+ if (cacheEpoch === epochAtStart) {
245
+ cache = { jwt: minted.jwt, expiresAt: minted.expiresAt, apiKey, host };
246
+ }
247
+ return minted.jwt;
248
+ } finally {
249
+ inFlight.delete(key);
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Drop the in-memory JWT cache. Call after credential changes (logout,
255
+ * account switch) so long-running opencode processes don't keep using a
256
+ * JWT minted from a now-invalid api_key. Also bumps the cache epoch so
257
+ * any in-flight mint racing with this clear can't repopulate cache
258
+ * with the stale JWT after-the-fact.
259
+ */
260
+ export function clearCachedUserJwt(): void {
261
+ cache = null;
262
+ inFlight.clear();
263
+ cacheEpoch++;
264
+ }