@bitkyc08/opencodex 2.58.0 → 2.59.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 (188) hide show
  1. package/README.md +28 -10
  2. package/gui/dist/assets/index-C5IebErG.js +136 -0
  3. package/gui/dist/assets/{index-C5-RdDmD.css → index-OESInAjC.css} +1 -1
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/crusoe.svg +1 -0
  6. package/gui/dist/provider-icons/opper.svg +3 -0
  7. package/package.json +1 -1
  8. package/src/adapters/base.ts +11 -1
  9. package/src/adapters/cursor/catalog.ts +11 -0
  10. package/src/adapters/cursor/effort-map.ts +16 -2
  11. package/src/adapters/cursor/envelope-echo.ts +55 -2
  12. package/src/adapters/cursor/message-mapper.ts +3 -2
  13. package/src/adapters/cursor/protobuf-request.ts +8 -5
  14. package/src/adapters/cursor/request-builder.ts +14 -3
  15. package/src/adapters/cursor/thread-continuity.ts +105 -31
  16. package/src/adapters/cursor/tool-guidance.ts +5 -4
  17. package/src/adapters/cursor.ts +42 -1
  18. package/src/adapters/devin/cloud-direct/chat.ts +11 -2
  19. package/src/adapters/devin/cloud-direct/index.ts +7 -0
  20. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +103 -0
  21. package/src/adapters/devin.ts +75 -13
  22. package/src/adapters/google-antigravity-wire.ts +29 -2
  23. package/src/adapters/google-http.ts +8 -1
  24. package/src/adapters/google.ts +23 -4
  25. package/src/adapters/openai-chat/response-events.ts +61 -0
  26. package/src/adapters/openai-chat.ts +5 -10
  27. package/src/adapters/openai-responses/passthrough.ts +10 -1
  28. package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
  29. package/src/adapters/openai-responses/tool-schema.ts +19 -7
  30. package/src/adapters/responses-tool-schema.ts +76 -46
  31. package/src/adapters/run-turn-queue.ts +17 -4
  32. package/src/bridge/response-json.ts +1 -1
  33. package/src/bridge/sse.ts +165 -24
  34. package/src/claude/context-windows.ts +22 -0
  35. package/src/claude/outbound.ts +35 -4
  36. package/src/cli/account-api.ts +4 -3
  37. package/src/cli/account-extended.ts +22 -2
  38. package/src/cli/account-orca-import.ts +63 -0
  39. package/src/cli/account.ts +32 -4
  40. package/src/cli/capabilities.ts +40 -0
  41. package/src/cli/claude.ts +29 -1
  42. package/src/cli/codex-cli-update.ts +97 -2
  43. package/src/cli/dispatch.ts +54 -0
  44. package/src/cli/doctor.ts +197 -2
  45. package/src/cli/help.ts +4 -1
  46. package/src/cli/index.ts +88 -20
  47. package/src/cli/models-runtime.ts +33 -4
  48. package/src/cli/registry.ts +11 -1
  49. package/src/cli/runtime-api.ts +44 -0
  50. package/src/cli/start-args.ts +94 -0
  51. package/src/cli/system-command.ts +2 -0
  52. package/src/client/machine-api.ts +4 -3
  53. package/src/client/machine-listener.ts +14 -1
  54. package/src/clients/config-export/constants.ts +2 -3
  55. package/src/clients/config-export.ts +5 -5
  56. package/src/codex/account-store.ts +81 -5
  57. package/src/codex/auth-api/pool-quota-probe.ts +14 -3
  58. package/src/codex/auth-api/routes.ts +17 -2
  59. package/src/codex/auth-context.ts +16 -12
  60. package/src/codex/catalog/build-entries.ts +25 -4
  61. package/src/codex/catalog/derive-entry.ts +8 -1
  62. package/src/codex/catalog/effort.ts +10 -6
  63. package/src/codex/catalog/gather-capture.ts +1 -0
  64. package/src/codex/catalog/model-hints.ts +37 -5
  65. package/src/codex/catalog/parsing.ts +83 -5
  66. package/src/codex/catalog/reserve-warn.ts +96 -0
  67. package/src/codex/catalog/retained-sync.ts +19 -0
  68. package/src/codex/catalog/routed-gather.ts +42 -3
  69. package/src/codex/cli-installation-identity.ts +210 -0
  70. package/src/codex/cli-installation-targets.ts +158 -0
  71. package/src/codex/convergence.ts +5 -0
  72. package/src/codex/history-provider.ts +4 -1
  73. package/src/codex/history-state-open.ts +105 -0
  74. package/src/codex/inject/config-toml.ts +44 -2
  75. package/src/codex/inject.ts +3 -2
  76. package/src/codex/lineage.ts +83 -32
  77. package/src/codex/loopback-target.ts +31 -0
  78. package/src/codex/main-account-hard-lock.ts +2 -1
  79. package/src/codex/main-account.ts +10 -3
  80. package/src/codex/main-device-reauth.ts +17 -9
  81. package/src/codex/model-entitlements.ts +60 -1
  82. package/src/codex/observed-model-denials.ts +137 -0
  83. package/src/codex/orca-auth-source.ts +94 -0
  84. package/src/codex/orca-import.ts +219 -0
  85. package/src/codex/prompt-text-probe.ts +282 -12
  86. package/src/codex/quota-401-recovery.ts +12 -0
  87. package/src/codex/quota-types.ts +65 -0
  88. package/src/codex/quota.ts +24 -19
  89. package/src/codex/routing/cooldown-math.ts +8 -47
  90. package/src/codex/routing/pin-drain.ts +57 -0
  91. package/src/codex/routing.ts +13 -15
  92. package/src/codex/subagent-model-fallback.ts +94 -0
  93. package/src/codex/windows-installation-files.ts +224 -0
  94. package/src/combos/failover.ts +122 -5
  95. package/src/config/diagnostics.ts +21 -0
  96. package/src/config/load-degrade.ts +15 -0
  97. package/src/config/pending-teardown.ts +8 -0
  98. package/src/config/process-state.ts +36 -3
  99. package/src/config/provider-relative-send-path.ts +16 -0
  100. package/src/config/proxy-env.ts +23 -5
  101. package/src/config/schema/config-schema.ts +21 -0
  102. package/src/config/schema/leaf-validators.ts +64 -17
  103. package/src/generated/compatibility-version.json +235 -163
  104. package/src/generated/model-metadata.ts +1 -1
  105. package/src/lib/bounded-body.ts +4 -2
  106. package/src/lib/destination-policy.ts +48 -6
  107. package/src/lib/errors.ts +3 -15
  108. package/src/lib/local-destinations.ts +32 -5
  109. package/src/lib/provider-outbound.ts +3 -3
  110. package/src/lib/proxy-env.ts +70 -3
  111. package/src/lib/request-execution-budget.ts +11 -3
  112. package/src/lib/response-body-inactivity.ts +193 -0
  113. package/src/lib/retry-delay.ts +69 -0
  114. package/src/lib/socks5-fetch.ts +631 -0
  115. package/src/lib/spend-reservation-ledger.ts +115 -9
  116. package/src/lib/workflow-budget.ts +145 -8
  117. package/src/oauth/account-quota-rank.ts +72 -15
  118. package/src/oauth/generic-account-failover.ts +40 -27
  119. package/src/oauth/orcarouter.ts +15 -2
  120. package/src/oauth/store.ts +8 -0
  121. package/src/providers/codex-capacity.ts +9 -0
  122. package/src/providers/devin-provider-merge-migration.ts +33 -12
  123. package/src/providers/free-directory.ts +20 -2
  124. package/src/providers/key-failover.ts +261 -7
  125. package/src/providers/model-rename-migration.ts +1 -0
  126. package/src/providers/openai-sidecar.ts +4 -0
  127. package/src/providers/opencode-go-transport.ts +14 -5
  128. package/src/providers/quota/report-cache.ts +3 -0
  129. package/src/providers/registry/entries-extended.ts +96 -0
  130. package/src/providers/registry/model-seeds.ts +78 -21
  131. package/src/responses/apply-patch-envelope.ts +44 -11
  132. package/src/responses/bridge-search-replay-cache.ts +152 -0
  133. package/src/responses/code-mode-helper-compat.ts +26 -16
  134. package/src/responses/custom-tool-compat.ts +1 -1
  135. package/src/responses/hosted-tool-policy.ts +85 -2
  136. package/src/responses/schema.ts +9 -2
  137. package/src/server/auth-cors.ts +26 -0
  138. package/src/server/chat-completions.ts +9 -4
  139. package/src/server/chat-native-sse.ts +26 -9
  140. package/src/server/chat-native.ts +10 -4
  141. package/src/server/claude-messages.ts +24 -2
  142. package/src/server/gui-static.ts +36 -2
  143. package/src/server/inbound-body-admission.ts +187 -0
  144. package/src/server/index.ts +15 -19
  145. package/src/server/management/api-access.ts +3 -4
  146. package/src/server/management/config-routes.ts +31 -6
  147. package/src/server/management/provider-capability-config.ts +35 -7
  148. package/src/server/management/provider-routes.ts +70 -18
  149. package/src/server/proxy-liveness.ts +97 -2
  150. package/src/server/relay.ts +17 -24
  151. package/src/server/request-log.ts +25 -1
  152. package/src/server/responses/adapter-continuation.ts +71 -27
  153. package/src/server/responses/adapter-delivery.ts +39 -8
  154. package/src/server/responses/adapter-dispatch.ts +52 -24
  155. package/src/server/responses/compact.ts +60 -11
  156. package/src/server/responses/core-codex-account.ts +83 -22
  157. package/src/server/responses/core-normalize.ts +12 -5
  158. package/src/server/responses/fetch-helpers.ts +68 -2
  159. package/src/server/responses/passthrough-delivery.ts +10 -1
  160. package/src/server/responses/passthrough-dispatch.ts +113 -48
  161. package/src/server/responses/passthrough-execution.ts +11 -1
  162. package/src/server/responses/request-prepare.ts +29 -0
  163. package/src/server/responses/request-send-budget.ts +84 -7
  164. package/src/server/responses/request-sidecar-auth.ts +16 -8
  165. package/src/server/responses/request-spend.ts +38 -9
  166. package/src/server/responses/request-transport.ts +13 -10
  167. package/src/server/responses/run-turn-execution.ts +20 -5
  168. package/src/server/responses/sidecar-execution.ts +2 -0
  169. package/src/server/responses/ws-upstream.ts +2 -1
  170. package/src/server/responses-custom-tool-repair.ts +2 -2
  171. package/src/server/sse-frame-buffer.ts +12 -10
  172. package/src/server/sse-payload-rewrite.ts +36 -9
  173. package/src/server/system-env-shell.ts +5 -1
  174. package/src/server/system-env.ts +7 -1
  175. package/src/server/workflow-refusal.ts +56 -2
  176. package/src/service/cli.ts +16 -6
  177. package/src/service/guards.ts +10 -0
  178. package/src/service/health.ts +43 -0
  179. package/src/service/state.ts +7 -2
  180. package/src/types/accounts.ts +4 -0
  181. package/src/types/config.ts +100 -3
  182. package/src/types/provider.ts +19 -0
  183. package/src/types/request.ts +7 -1
  184. package/src/types/wire.ts +9 -1
  185. package/src/usage/expected-prices.ts +28 -0
  186. package/src/usage/log.ts +87 -4
  187. package/src/web-search/passthrough-bridge.ts +39 -5
  188. package/gui/dist/assets/index-BbrHOIY0.js +0 -128
@@ -16,8 +16,8 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-BbrHOIY0.js"></script>
20
- <link rel="stylesheet" crossorigin href="/assets/index-C5-RdDmD.css">
19
+ <script type="module" crossorigin src="/assets/index-C5IebErG.js"></script>
20
+ <link rel="stylesheet" crossorigin href="/assets/index-OESInAjC.css">
21
21
  </head>
22
22
  <body>
23
23
  <div id="root"></div>
@@ -0,0 +1 @@
1
+ <svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Crusoe</title><path d="M12 0L4.583 6.583c-3.23 2.869-3.23 7.965 0 10.834L12 24l7.417-6.583c3.23-2.869 3.23-7.965 0-10.834L12 0z" fill="url(#lobe-icons-crusoe-_R_0_)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-crusoe-_R_0_" x1="18.919" x2="4.853" y1="5.595" y2="18.301"><stop stop-color="#F4BF45"></stop><stop offset=".35" stop-color="#E48047"></stop><stop offset=".69" stop-color="#C73361"></stop><stop offset="1" stop-color="#A42F5F"></stop></linearGradient></defs></svg>
@@ -0,0 +1,3 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 315 315" fill="#000000">
2
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M159.78 315C71.53 315 0 244.49 0 157.5C0 -18.9499 159.78 0.650075 159.78 0.650075C159.78 87.2201 88.36 157.4 0.2 157.5C149.8 157.64 159.78 315 159.78 315ZM160.52 217.98C160.52 217.98 156.94 161.65 105.04 157.52C120.6 157.34 160.52 151.54 160.52 96.5601C160.52 151.54 200.44 157.34 216 157.52C164.1 161.63 160.52 217.98 160.52 217.98Z"/>
3
+ </svg>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.58.0",
3
+ "version": "2.59.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",
@@ -1,7 +1,7 @@
1
1
  import type { AdapterEvent, OcxParsedRequest } from "../types";
2
2
  import type { TranslatorBudget } from "../lib/translator-budget";
3
3
  import type { RequestExecutionBudget } from "../lib/request-execution-budget";
4
- import type { AttemptRecoveryKind } from "../usage/log";
4
+ import type { AttemptRecoveryKind, AttemptRecoveryWithheld } from "../usage/log";
5
5
  import type { AdapterTierMetadata } from "../providers/fastwire";
6
6
 
7
7
  /** Metadata about the caller's incoming request, for auth-forwarding adapters. */
@@ -168,6 +168,16 @@ export interface AdapterFetchContext {
168
168
  * to `sendCount` and no regression could assert a count for them (#4546).
169
169
  */
170
170
  onPhysicalSend?: (send: { ordinal: number; recovery?: AttemptRecoveryKind }) => void;
171
+ /**
172
+ * Observes a recovery this adapter was ready to make and did not, because the send budget
173
+ * refused the dispatch.
174
+ *
175
+ * Separate from `onPhysicalSend` because nothing was sent: folding it in would inflate
176
+ * `sendCount`, the one number that means "requests this proxy actually made". Without it a
177
+ * log with one send cannot distinguish "no recovery was eligible" from "one was and the
178
+ * budget withheld it", and those need opposite follow-ups (#5044).
179
+ */
180
+ onRecoveryWithheld?: (withheld: { reason: AttemptRecoveryWithheld }) => void;
171
181
  }
172
182
 
173
183
  /**
@@ -60,6 +60,8 @@ const CONTEXT_500K = 500 * K;
60
60
  const CONTEXT_1M = 1_000 * K;
61
61
  /** Gemini publishes the exact power-of-two window, not a rounded 1M. */
62
62
  const CONTEXT_GEMINI = 1_048_576;
63
+ /** Meta publishes 1,048,576 for both Muse Spark 1.3 tiers (dev.meta.ai/docs/models). */
64
+ const CONTEXT_MUSE = 1_048_576;
63
65
 
64
66
  const FULL = ["low", "medium", "high", "xhigh", "max"] as const;
65
67
  const T = "thinking-then-effort" as const;
@@ -211,6 +213,15 @@ export const CURSOR_CAPABILITIES: Record<string, CursorCapability> = {
211
213
  defaultVariant: "regular",
212
214
  variants: { regular: { levels: ["low", "medium", "high"] } },
213
215
  },
216
+ // Seeded from the live GetUsableModels roster attached to #4820, which advertises six
217
+ // muse-spark-1.3 effort variants. The ladder stops at xhigh on purpose: see the matching
218
+ // effort-map entry for why Cursor advertising `-max` is not evidence that it runs.
219
+ "muse-spark-1.3": {
220
+ displayName: "Muse Spark 1.3",
221
+ window: CONTEXT_MUSE,
222
+ defaultVariant: "regular",
223
+ variants: { regular: { levels: ["minimal", "low", "medium", "high", "xhigh"] } },
224
+ },
214
225
  "kimi-k3": {
215
226
  displayName: "Kimi K3",
216
227
  window: CONTEXT_1M,
@@ -38,8 +38,10 @@ const CURSOR_MODEL_EFFORT_TIERS: Record<string, readonly string[]> = {
38
38
  "claude-opus-5-fast": ["low", "medium", "high"],
39
39
  "claude-sonnet-5": ["low", "medium", "high", "xhigh", "max"],
40
40
  "glm-5.2": ["high", "max"],
41
- // 260825 live GetUsableModels. gemini-3.6-flash is the only Cursor model exposing `minimal`;
42
- // listing it here is also what admits the suffix into CANONICAL_EFFORT_SUFFIXES below.
41
+ // 260825 live GetUsableModels. gemini-3.6-flash was the first Cursor model exposing
42
+ // `minimal`; listing a rung here is also what admits the suffix into
43
+ // CANONICAL_EFFORT_SUFFIXES below. muse-spark-1.3 now carries it too, so `minimal` no
44
+ // longer depends on this single row.
43
45
  "gemini-3.6-flash": ["minimal", "low", "medium", "high"],
44
46
  "gemini-3.7-flash": ["low", "medium", "high"],
45
47
  // 260903 preemptive: gemini-3.8-flash seeded ahead of Cursor's lineup update, the same way
@@ -91,6 +93,18 @@ const CURSOR_MODEL_EFFORT_TIERS: Record<string, readonly string[]> = {
91
93
  "gpt-5.6-sol": ["low", "medium", "high", "xhigh", "max"],
92
94
  "gpt-5.6-terra": ["low", "medium", "high", "xhigh", "max"],
93
95
  "gpt-5.6-luna": ["low", "medium", "high", "xhigh", "max"],
96
+ // 260916 live GetUsableModels (#4820) advertises muse-spark-1.3 at minimal, low, medium,
97
+ // high, xhigh AND max. The seed stops at xhigh deliberately.
98
+ //
99
+ // Meta publishes minimal..xhigh for Muse Spark and lists no `max` at all
100
+ // (dev.meta.ai/docs/reasoning), and an independent OpenCode Zen probe of
101
+ // muse-spark-1.3-contributor-free rejected max with `unknown variant` — both already
102
+ // recorded on META_MUSE_REASONING_EFFORTS in src/providers/registry/model-seeds.ts.
103
+ // Cursor advertising a wire id is not evidence that Run accepts it; that is exactly the
104
+ // advertised-but-not-callable shape CURSOR_KNOWN_UNCALLABLE_MODEL_IDS was created for.
105
+ // Publishing the rung anyway would invent a capability on two sources' contrary evidence.
106
+ // Add `max` here once a Cursor Run at max is observed to succeed.
107
+ "muse-spark-1.3": ["minimal", "low", "medium", "high", "xhigh"],
94
108
  };
95
109
 
96
110
  /** All effort suffixes accepted when matching live Cursor model ids to configured base ids. */
@@ -13,6 +13,58 @@
13
13
  */
14
14
 
15
15
  const ECHO_MARKERS = ["[Tool Result]", "[Tool Error]", "[tool_result]"] as const;
16
+
17
+ function isEchoMarkerLine(line: string): boolean {
18
+ return (ECHO_MARKERS as readonly string[]).includes(line.replace(/^[ \t]+/, ""));
19
+ }
20
+
21
+ /**
22
+ * Drop echoed tool-result envelopes from assistant history before Cursor root replay.
23
+ *
24
+ * The prefix sniffer catches an echo that STARTS a turn, but grok-4.6 routinely writes a real
25
+ * sentence first and pastes the envelope after it. That text has already reached the client and
26
+ * is stored as assistant output, so replaying it verbatim re-primes the next turn with the very
27
+ * envelope the model is copying.
28
+ *
29
+ * Scope starts AT the marker line and runs to the next blank line, rather than to the end of
30
+ * the message. The echoed envelope has no terminator we can recognise — we build it as a marker
31
+ * line plus arbitrary result text (protobuf-request.ts), and the observed copies are not
32
+ * byte-exact, so matching against the replayed envelope is not available either. Truncating to
33
+ * the end of the message was the alternative, and it discards a genuine answer whenever the
34
+ * model resumes after the echo. A blank line is the one boundary the model reliably writes when
35
+ * it goes back to prose.
36
+ *
37
+ * The tradeoff is explicit: an echoed envelope whose pasted result itself contains a blank line
38
+ * leaves its remainder in replay. That is the safer direction to be wrong in — conversation
39
+ * remint, not this filter, is the primary defence against a poisoned conversation, and this only
40
+ * stops the transcript from feeding itself.
41
+ *
42
+ * Only whole-line markers count, so prose such as "the string [Tool Result] appeared" survives.
43
+ */
44
+ export function stripAssistantEchoedToolEnvelope(text: string): string {
45
+ if (!text || !ECHO_MARKERS.some(marker => text.includes(marker))) return text;
46
+ const newline = text.includes("\r\n") ? "\r\n" : "\n";
47
+ const lines = text.split(/\r?\n/);
48
+ const kept: string[] = [];
49
+ let dropped = false;
50
+ let index = 0;
51
+ while (index < lines.length) {
52
+ const line = lines[index] ?? "";
53
+ if (!isEchoMarkerLine(line)) {
54
+ kept.push(line);
55
+ index += 1;
56
+ continue;
57
+ }
58
+ dropped = true;
59
+ index += 1;
60
+ // The envelope body is the contiguous non-blank run after the marker. The blank line that
61
+ // ends it is left in place, so surviving prose on either side stays separated.
62
+ while (index < lines.length && (lines[index] ?? "").trim() !== "") index += 1;
63
+ }
64
+ if (!dropped) return text;
65
+ return kept.join(newline).trimEnd();
66
+ }
67
+
16
68
  const MAX_SNIFF_BYTES = 40;
17
69
  /** Mid-stream observer: max leading whitespace on a line before matching disarms. */
18
70
  const MAX_MIDSTREAM_LINE_INDENT = 128;
@@ -66,8 +118,9 @@ export interface MidstreamEchoFinding {
66
118
  * MIDDLE of an agent message — after legitimate leading text — one of them
67
119
  * carrying a whitespace-spliced call-id ("fc_x mar-y" instead of "fc_x-y").
68
120
  * Deltas at that point have already reached the client, so this observer
69
- * never throws and never withholds output: it records findings so the
70
- * adapter can emit a structured diagnostic at turn end. Only fixed marker
121
+ * never throws and never withholds output. It records findings so the adapter
122
+ * can emit a structured diagnostic and remint the conversation for the next
123
+ * turn at turn end. Only fixed marker
71
124
  * enums, numeric offsets, and corruption booleans are retained — never
72
125
  * content bytes.
73
126
  */
@@ -46,7 +46,8 @@ export function mapCursorServerMessage(
46
46
  state.writeClient(cursorExecResult(message.requestId, message.execCase));
47
47
  return [];
48
48
  case "local_side_effect":
49
- // Internal retry-safety signal only; keep the bridge alive without producing protocol output.
50
- return [{ type: "heartbeat" }];
49
+ // Internal retry-safety signal only; keep the bridge alive without producing protocol output,
50
+ // while preventing server-level failover from replaying the completed local operation.
51
+ return [{ type: "heartbeat", replayUnsafe: true }];
51
52
  }
52
53
  }
@@ -6,6 +6,7 @@ import { namespacedToolName } from "../../types";
6
6
  import type { CursorRunRequest } from "./types";
7
7
  import { decodeCursorCallId } from "./call-id";
8
8
  import { cursorNeedsExternalToolContinuation, isCursorExternalWireModel } from "./discovery";
9
+ import { stripAssistantEchoedToolEnvelope } from "./envelope-echo";
9
10
  import { normalizeCursorToolResultText } from "./tool-result-normalize";
10
11
  import { debugProviderDiagnostic } from "../../lib/debug";
11
12
  import {
@@ -208,11 +209,13 @@ function assistantRootText(
208
209
  message: Extract<OcxMessage, { role: "assistant" }>,
209
210
  includeThinking: boolean,
210
211
  ): string {
211
- if (typeof message.content === "string") return message.content;
212
- return message.content
213
- .map(part => (part.type === "text" ? part.text : includeThinking && part.type === "thinking" ? part.thinking : undefined))
214
- .filter((value): value is string => typeof value === "string" && value.length > 0)
215
- .join("\n");
212
+ const raw = typeof message.content === "string"
213
+ ? message.content
214
+ : message.content
215
+ .map(part => (part.type === "text" ? part.text : includeThinking && part.type === "thinking" ? part.thinking : undefined))
216
+ .filter((value): value is string => typeof value === "string" && value.length > 0)
217
+ .join("\n");
218
+ return stripAssistantEchoedToolEnvelope(raw);
216
219
  }
217
220
 
218
221
  // Cursor builds the actual model prompt from rootPromptMessagesJson (turns[] is UI/display metadata),
@@ -340,7 +340,13 @@ export function cursorConversationIdFromClientThread(threadId: string, identityS
340
340
 
341
341
  /**
342
342
  * Resolve the Cursor conversation id for this turn.
343
- * Priority: force-fresh → isolate helper → remembered → client thread owner → random.
343
+ * Priority: force-fresh → isolate helper → thread remint override → stored conversation id
344
+ * → client thread hash → random.
345
+ *
346
+ * The remint override must beat a stored `_cursorConversationId`. Only the remint path writes
347
+ * the thread store (cursor.ts), so a stored id that disagrees with it is the pre-remint value,
348
+ * and preferring it let a second Responses chain in the same Codex thread keep resuming the
349
+ * conversation the previous turn just rotated away from.
344
350
  * Never use OpenAI Responses `previous_response_id` (resp_*) or shared `prompt_cache_key`
345
351
  * (cache-cohort fingerprint, not conversation ownership).
346
352
  */
@@ -351,11 +357,16 @@ export function resolveCursorConversationId(
351
357
  ): string {
352
358
  if (options.forceFreshConversation === true) return generatedCursorConversationId();
353
359
  if (parsed._cursorIsolateConversation === true) return generatedCursorConversationId();
354
- if (parsed._cursorConversationId) return parsed._cursorConversationId;
355
360
  const threadId = cursorClientThreadOwner(parsed);
356
- if (threadId) {
361
+ // A compaction turn carries its own conversation id and must not be pulled onto the parent's
362
+ // thread override. It is isolated in effect without ever setting the isolate flag, which is why
363
+ // the override check has to exclude it explicitly rather than rely on that flag.
364
+ if (threadId && parsed._compactionRequest !== true) {
357
365
  const recovered = lookupCursorThreadConversation(threadId, parsed._cursorIdentityScope);
358
366
  if (recovered) return recovered;
367
+ }
368
+ if (parsed._cursorConversationId) return parsed._cursorConversationId;
369
+ if (threadId) {
359
370
  return cursorConversationIdFromClientThread(`thread:${threadId}`, parsed._cursorIdentityScope);
360
371
  }
361
372
  return generatedCursorConversationId();
@@ -169,21 +169,65 @@ type IncompleteToolRemintState = {
169
169
  updatedAt: number;
170
170
  };
171
171
 
172
- const incompleteToolRemintByScope = new Map<string, IncompleteToolRemintState>();
172
+ /**
173
+ * One bounded next-turn remint allowance, keyed by retained thread scope.
174
+ *
175
+ * Each recovery reason owns its own instance. Sharing one budget would let a cheap, frequent
176
+ * failure spend the allowance that a rarer, more expensive recovery depends on.
177
+ */
178
+ function createCursorRemintBudget(max: number, ttlMs: number, maxEntries: number) {
179
+ const byScope = new Map<string, IncompleteToolRemintState>();
173
180
 
174
- function pruneIncompleteToolRemints(at: number): void {
175
- for (const [scopeKey, entry] of incompleteToolRemintByScope) {
176
- if (at - entry.updatedAt > CURSOR_INCOMPLETE_TOOL_REMINT_TTL_MS) {
177
- incompleteToolRemintByScope.delete(scopeKey);
181
+ const prune = (at: number): void => {
182
+ for (const [scopeKey, entry] of byScope) {
183
+ if (at - entry.updatedAt > ttlMs) byScope.delete(scopeKey);
178
184
  }
179
- }
180
- while (incompleteToolRemintByScope.size > CURSOR_INCOMPLETE_TOOL_REMINT_MAX_ENTRIES) {
181
- const oldest = incompleteToolRemintByScope.keys().next().value;
182
- if (oldest === undefined) break;
183
- incompleteToolRemintByScope.delete(oldest);
184
- }
185
+ while (byScope.size > maxEntries) {
186
+ const oldest = byScope.keys().next().value;
187
+ if (oldest === undefined) break;
188
+ byScope.delete(oldest);
189
+ }
190
+ };
191
+
192
+ return {
193
+ /** Record one remint; returns false when this budget is exhausted. */
194
+ record(scopeKey: string): boolean {
195
+ const at = now();
196
+ prune(at);
197
+ const existing = byScope.get(scopeKey);
198
+ if (existing && existing.remintCount >= max) {
199
+ existing.updatedAt = at;
200
+ byScope.delete(scopeKey);
201
+ byScope.set(scopeKey, existing);
202
+ return false;
203
+ }
204
+ const entry = existing ?? { remintCount: 0, updatedAt: at };
205
+ entry.remintCount += 1;
206
+ entry.updatedAt = at;
207
+ byScope.delete(scopeKey);
208
+ byScope.set(scopeKey, entry);
209
+ prune(at);
210
+ return true;
211
+ },
212
+ clear(scopeKey: string): void {
213
+ byScope.delete(scopeKey);
214
+ },
215
+ clearForTests(): void {
216
+ byScope.clear();
217
+ },
218
+ countForTests(): number {
219
+ prune(now());
220
+ return byScope.size;
221
+ },
222
+ };
185
223
  }
186
224
 
225
+ const incompleteToolRemintBudget = createCursorRemintBudget(
226
+ CURSOR_INCOMPLETE_TOOL_REMINT_MAX,
227
+ CURSOR_INCOMPLETE_TOOL_REMINT_TTL_MS,
228
+ CURSOR_INCOMPLETE_TOOL_REMINT_MAX_ENTRIES,
229
+ );
230
+
187
231
  /** Incomplete-tool and overflow recovery share ownership scope, but keep independent budgets. */
188
232
  export function cursorIncompleteToolRemintScopeKey(
189
233
  threadOwner: string | undefined,
@@ -194,34 +238,64 @@ export function cursorIncompleteToolRemintScopeKey(
194
238
 
195
239
  /** Record one incomplete-tool remint; returns false when the independent cap is exhausted. */
196
240
  export function recordCursorIncompleteToolRemint(scopeKey: string): boolean {
197
- const at = now();
198
- pruneIncompleteToolRemints(at);
199
- const existing = incompleteToolRemintByScope.get(scopeKey);
200
- if (existing && existing.remintCount >= CURSOR_INCOMPLETE_TOOL_REMINT_MAX) {
201
- existing.updatedAt = at;
202
- incompleteToolRemintByScope.delete(scopeKey);
203
- incompleteToolRemintByScope.set(scopeKey, existing);
204
- return false;
205
- }
206
- const entry = existing ?? { remintCount: 0, updatedAt: at };
207
- entry.remintCount += 1;
208
- entry.updatedAt = at;
209
- incompleteToolRemintByScope.delete(scopeKey);
210
- incompleteToolRemintByScope.set(scopeKey, entry);
211
- pruneIncompleteToolRemints(at);
212
- return true;
241
+ return incompleteToolRemintBudget.record(scopeKey);
213
242
  }
214
243
 
215
244
  /** A clean turn replenishes this recovery without changing the overflow retry budget. */
216
245
  export function clearCursorIncompleteToolRemint(scopeKey: string): void {
217
- incompleteToolRemintByScope.delete(scopeKey);
246
+ incompleteToolRemintBudget.clear(scopeKey);
218
247
  }
219
248
 
220
249
  export function clearCursorIncompleteToolRemintForTests(): void {
221
- incompleteToolRemintByScope.clear();
250
+ incompleteToolRemintBudget.clearForTests();
222
251
  }
223
252
 
224
253
  export function cursorIncompleteToolRemintCountForTests(): number {
225
- pruneIncompleteToolRemints(now());
226
- return incompleteToolRemintByScope.size;
254
+ return incompleteToolRemintBudget.countForTests();
255
+ }
256
+
257
+ /**
258
+ * Max next-turn rotations after a MID-STREAM envelope echo, per retained scope.
259
+ *
260
+ * Deliberately a separate budget from the incomplete-tool allowance. A mid-stream echo is a
261
+ * cheap, repeatable formatting failure, while an incomplete client-tool stream is a rarer
262
+ * structural one; on a shared counter a model that echoes every turn would spend the budget
263
+ * that incomplete-tool recovery depends on. Bounding it at all is the point: the echo has
264
+ * already reached the client and cannot be quarantined, so without a cap a persistently
265
+ * echoing model would remint the conversation on every single turn, forever.
266
+ */
267
+ export const CURSOR_ENVELOPE_ECHO_REMINT_MAX = 3;
268
+ export const CURSOR_ENVELOPE_ECHO_REMINT_TTL_MS = CURSOR_OVERFLOW_REMINT_TTL_MS;
269
+ export const CURSOR_ENVELOPE_ECHO_REMINT_MAX_ENTRIES = CURSOR_OVERFLOW_REMINT_MAX_ENTRIES;
270
+
271
+ const envelopeEchoRemintBudget = createCursorRemintBudget(
272
+ CURSOR_ENVELOPE_ECHO_REMINT_MAX,
273
+ CURSOR_ENVELOPE_ECHO_REMINT_TTL_MS,
274
+ CURSOR_ENVELOPE_ECHO_REMINT_MAX_ENTRIES,
275
+ );
276
+
277
+ /** Echo recovery shares ownership scope with overflow and incomplete-tool, budget apart. */
278
+ export function cursorEnvelopeEchoRemintScopeKey(
279
+ threadOwner: string | undefined,
280
+ identityScope?: string,
281
+ ): string | null {
282
+ return cursorOverflowRemintScopeKey(threadOwner, identityScope);
283
+ }
284
+
285
+ /** Record one envelope-echo remint; returns false when the independent cap is exhausted. */
286
+ export function recordCursorEnvelopeEchoRemint(scopeKey: string): boolean {
287
+ return envelopeEchoRemintBudget.record(scopeKey);
288
+ }
289
+
290
+ /** A turn that completed without an echo replenishes only this budget. */
291
+ export function clearCursorEnvelopeEchoRemint(scopeKey: string): void {
292
+ envelopeEchoRemintBudget.clear(scopeKey);
293
+ }
294
+
295
+ export function clearCursorEnvelopeEchoRemintForTests(): void {
296
+ envelopeEchoRemintBudget.clearForTests();
297
+ }
298
+
299
+ export function cursorEnvelopeEchoRemintCountForTests(): number {
300
+ return envelopeEchoRemintBudget.countForTests();
227
301
  }
@@ -4,13 +4,14 @@ import { CODEX_SHELL_BRIDGE_TOOL_NAMES, CODEX_TOOL_SEARCH_TOOL, CODEX_UNIFIED_EX
4
4
 
5
5
  export const CURSOR_SHELL_ALIAS_SYSTEM_NOTE =
6
6
  'Shell commands use the Codex shell bridge tool shown in this turn\'s catalog (`shell_command` or `exec_command`) with JSON arguments like {"cmd":"..."}. The long `mcp_opencodex-responses_*` display name is the same tool. Prefer it over Cursor-native Shell.';
7
- const NEIGHBOR_AGENT_TOOL_NAMES = ["Read", "Grep", "Glob", "Bash", "LS"] as const;
7
+ const NEIGHBOR_AGENT_TOOL_NAMES = ["Read", "Grep", "Glob", "Bash", "LS", "Write"] as const;
8
8
  const NEIGHBOR_AGENT_TOOL_ALIASES: Record<(typeof NEIGHBOR_AGENT_TOOL_NAMES)[number], readonly string[]> = {
9
9
  Read: ["read", "read_file"],
10
10
  Grep: ["grep"],
11
11
  Glob: ["glob", "find"],
12
12
  Bash: ["bash", "shell"],
13
13
  LS: ["ls"],
14
+ Write: ["write", "write_file"],
14
15
  };
15
16
 
16
17
  export const CURSOR_GENERIC_TOOL_USE_USER_HINT = [
@@ -22,7 +23,7 @@ export const CURSOR_GENERIC_TOOL_USE_USER_HINT = [
22
23
  "The Cursor bridge may suspend after the first returned bridge tool call, so emit sibling calls together before any result is needed.",
23
24
  "If parallel emission is unavailable, continue with separate shell-bridge calls until the requested count has returned.",
24
25
  "Do not use `tool_search`, external MCP, or resource discovery just to pad the count unless explicitly asked.",
25
- "Do not suggest or switch to neighboring-agent tools such as `Grep`, `Read`, `Glob`, `Bash`, or `LS` unless this turn's catalog lists those exact names or an equivalent listed client tool.",
26
+ "Do not suggest or switch to neighboring-agent tools such as `Grep`, `Read`, `Glob`, `Bash`, `LS`, or `Write` unless this turn's catalog lists those exact names or an equivalent listed client tool.",
26
27
  ].join(" ");
27
28
 
28
29
 
@@ -190,7 +191,7 @@ export function buildCursorToolGuidanceSystemNote(
190
191
  ? CODE_MODE_RESULT_ECHO_SENTENCE + " There is no `require`, no `module`, and no filesystem or network globals; reach the host only through the nested helpers. " + CODE_MODE_HOST_CONTRACT_SENTENCE
191
192
  : undefined,
192
193
  codeMode
193
- ? "NEVER attempt Cursor-native Shell, Read, Grep, List, or any tool absent from the catalog — they are not executed in this environment and every probe wastes a turn. The exec code cell (with its nested helpers) is the ONLY execution surface; go to it directly on the FIRST attempt and do not narrate switching surfaces."
194
+ ? "NEVER attempt Cursor-native Shell, Read, Grep, List, Write, or any tool absent from the catalog — they are not executed in this environment and every probe wastes a turn. The exec code cell (with its nested helpers) is the ONLY execution surface; go to it directly on the FIRST attempt and do not narrate switching surfaces."
194
195
  : undefined,
195
196
  hasBareExec
196
197
  ? `${shellBridgeLabel} is the Codex Responses shell bridge for this turn, exposed through Cursor's tool protocol; it is not an external MCP server tool. \`shell_command\` and \`exec_command\` are aliases of the same bridge.`
@@ -199,7 +200,7 @@ export function buildCursorToolGuidanceSystemNote(
199
200
  ? "Your tool list may display it under a longer `mcp_opencodex-responses_shell_command` / `mcp_opencodex-responses_exec_command` name; those are the SAME tool — call whichever your list shows, and do not comment on the naming difference to the user."
200
201
  : undefined,
201
202
  hasBareExec
202
- ? `NEVER attempt Cursor-native Shell, Read, Grep, List, or any tool not in the catalog above — they are not executed locally in this environment and every attempt wastes a turn and can stall the session. ${shellBridgeLabel} is the ONLY shell surface; go to it directly on the FIRST attempt, never as a fallback after probing a native tool. Do not narrate switching surfaces ("native is blocked, using the bridge instead") — there is exactly one surface.`
203
+ ? `NEVER attempt Cursor-native Shell, Read, Grep, List, Write, or any tool not in the catalog above — they are not executed locally in this environment and every attempt wastes a turn and can stall the session. ${shellBridgeLabel} is the ONLY shell surface; go to it directly on the FIRST attempt, never as a fallback after probing a native tool. Do not narrate switching surfaces ("native is blocked, using the bridge instead") — there is exactly one surface.`
203
204
  : undefined,
204
205
  hasBareExec
205
206
  ? "Tool-selection commentary is forbidden: for any shell, read, grep, list, or file operation, your FIRST visible action is the bridge call itself — never a sentence about which tool you will use, which tool was redirected, or switching surfaces. Words like 차단/전환/blocked/switching must not appear in your output for tool-routing reasons."
@@ -34,9 +34,12 @@ import { estimateTokens } from "../lib/token-estimate";
34
34
  import {
35
35
  clearCursorIncompleteToolRemint,
36
36
  cursorIncompleteToolRemintScopeKey,
37
+ clearCursorEnvelopeEchoRemint,
38
+ cursorEnvelopeEchoRemintScopeKey,
37
39
  cursorOverflowRemintScopeKey,
38
40
  markCursorOverflowSurfaced,
39
41
  recordCursorIncompleteToolRemint,
42
+ recordCursorEnvelopeEchoRemint,
40
43
  recordCursorOverflowRemint,
41
44
  rememberCursorThreadConversation,
42
45
  shouldSkipCursorOverflowRemint,
@@ -206,6 +209,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
206
209
  let lastTransport: { captured?: Uint8Array } | undefined;
207
210
  let emittedClientTool = false;
208
211
  let sawIncompleteToolCall = false;
212
+ let sawMidstreamEnvelopeEcho = false;
209
213
  // Ordering proof for tool-suspended checkpoints: true only when the newest captured
210
214
  // checkpoint bytes arrived AFTER the turn emitted a client tool call, i.e. upstream
211
215
  // serialized its suspended-on-tool-call state. Only that snapshot can safely resume
@@ -390,7 +394,9 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
390
394
  }
391
395
  if (event.type !== "heartbeat") emittedOutput = true;
392
396
  if (event.type === "done") {
393
- for (const finding of midstreamObserver?.findings() ?? []) {
397
+ const midstreamFindings = midstreamObserver?.findings() ?? [];
398
+ if (midstreamFindings.length > 0) sawMidstreamEnvelopeEcho = true;
399
+ for (const finding of midstreamFindings) {
394
400
  debugProviderDiagnostic("cursor", "midstream-envelope-echo", {
395
401
  wireModel: activeRequest.modelId,
396
402
  conversationHash: activeRequest.conversationId.slice(0, 16),
@@ -564,6 +570,41 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
564
570
  } else if (!sawIncompleteToolCall && completedNormally && incompleteToolRemintScopeKey) {
565
571
  clearCursorIncompleteToolRemint(incompleteToolRemintScopeKey);
566
572
  }
573
+ // A mid-stream envelope echo has ALREADY reached the client — the prefix sniffer only
574
+ // watches the first bytes of a turn, and grok-4.6 writes a real sentence before pasting
575
+ // the envelope. It cannot be quarantined, so the recovery is the same as the
576
+ // incomplete-tool case: leave this turn alone and rotate the next turn's id, otherwise
577
+ // the stored echo is replayed and primes the model to echo again.
578
+ //
579
+ // Its own budget, not the incomplete-tool one: echoing is cheap and repeatable while an
580
+ // incomplete client-tool stream is rare and structural, so a shared counter would let a
581
+ // persistently echoing model spend the allowance the other recovery needs. Skipped when
582
+ // the incomplete-tool arm already reminted this turn — one rotation is enough.
583
+ const envelopeEchoRemintScopeKey =
584
+ _parsed._cursorIsolateConversation !== true
585
+ && request.contextUsageStoreCheckpoints !== false
586
+ ? cursorEnvelopeEchoRemintScopeKey(
587
+ cursorClientThreadOwner(_parsed),
588
+ _parsed._cursorIdentityScope,
589
+ )
590
+ : null;
591
+ if (sawMidstreamEnvelopeEcho && !sawIncompleteToolCall && envelopeEchoRemintScopeKey) {
592
+ if (recordCursorEnvelopeEchoRemint(envelopeEchoRemintScopeKey)) {
593
+ if (inheritedCheckpointRef) invalidateCursorCheckpoint(inheritedCheckpointRef);
594
+ debugProviderDiagnostic("cursor", "midstream-envelope-echo-remint", {
595
+ wireModel: request.modelId,
596
+ conversationHash: request.conversationId.slice(0, 16),
597
+ });
598
+ remintConversationId(request.conversationId);
599
+ } else {
600
+ debugProviderDiagnostic("cursor", "midstream-envelope-echo-remint-exhausted", {
601
+ wireModel: request.modelId,
602
+ conversationHash: request.conversationId.slice(0, 16),
603
+ });
604
+ }
605
+ } else if (!sawMidstreamEnvelopeEcho && completedNormally && envelopeEchoRemintScopeKey) {
606
+ clearCursorEnvelopeEchoRemint(envelopeEchoRemintScopeKey);
607
+ }
567
608
  if (
568
609
  request.checkpointInvalidationReason
569
610
  && request.checkpointInvalidationReason !== "missing_ref"
@@ -35,7 +35,7 @@ import {
35
35
  } from './wire.js';
36
36
  import { buildMetadata } from './metadata.js';
37
37
  import { getCachedUserJwt } from './auth.js';
38
- import { getCachedCatalog, ModelNotAvailableError } from './catalog.js';
38
+ import { getCachedCatalog, ModelNotAvailableError, type CacheEntry } from './catalog.js';
39
39
  import { anySignal, cancelBodyOnAbort } from '../../../lib/abort.js';
40
40
  import { resolveDevinApiBaseUrl } from '../../../oauth/devin/api-base.js';
41
41
 
@@ -1046,6 +1046,13 @@ export interface CloudChatRequest {
1046
1046
  completionOpts?: BuildArgs['completionOpts'];
1047
1047
  /** Override request_type (default = 5, CASCADE). */
1048
1048
  requestType?: number;
1049
+ /**
1050
+ * Catalog the caller already resolved this turn. An explicit `null`
1051
+ * records a failed lookup: the pre-flight below then skips its own fetch
1052
+ * instead of paying a second catalog timeout on the same turn. Omit the
1053
+ * field to let the pre-flight perform its own cached lookup.
1054
+ */
1055
+ catalog?: CacheEntry | null;
1049
1056
  /** Abort signal — closes the fetch stream. */
1050
1057
  signal?: AbortSignal;
1051
1058
  }
@@ -1140,7 +1147,9 @@ export async function* streamChatEvents(req: CloudChatRequest): AsyncGenerator<C
1140
1147
  // error and the trailer-error path below enriches the message in-place.
1141
1148
  // Treat an empty catalog (schema drift / unexpected response) as "no catalog"
1142
1149
  // so chat passes through instead of failing every request.
1143
- const catalog = await getCachedCatalog(req.apiKey, host, req.signal).catch(() => null);
1150
+ const catalog = req.catalog !== undefined
1151
+ ? req.catalog
1152
+ : await getCachedCatalog(req.apiKey, host, req.signal).catch(() => null);
1144
1153
  if (catalog && catalog.byUid.size > 0) {
1145
1154
  const entry = catalog.byUid.get(req.modelUid);
1146
1155
  if (!entry) {
@@ -49,6 +49,13 @@ export {
49
49
  type ToolDef,
50
50
  } from './chat.js';
51
51
 
52
+ export {
53
+ streamChatEventsWithResetRetry,
54
+ STATED_RESET_MAX_REPLAYS,
55
+ STATED_RESET_MAX_WAIT_MS,
56
+ type StatedResetRetryOptions,
57
+ } from './stated-reset-retry.js';
58
+
52
59
  export {
53
60
  mintUserJwt,
54
61
  getCachedUserJwt,