@bitkyc08/opencodex 2.7.36 → 2.7.37

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 (66) hide show
  1. package/README.ja.md +8 -1
  2. package/README.ko.md +7 -1
  3. package/README.md +7 -1
  4. package/README.ru.md +7 -1
  5. package/README.zh-CN.md +7 -1
  6. package/gui/dist/assets/index-BhUTxmCy.js +52 -0
  7. package/gui/dist/assets/index-oOZcqVmj.css +1 -0
  8. package/gui/dist/index.html +2 -2
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic.ts +22 -2
  11. package/src/adapters/cursor/live-transport.ts +7 -0
  12. package/src/adapters/cursor/message-mapper.ts +3 -0
  13. package/src/adapters/cursor/protobuf-request.ts +223 -27
  14. package/src/adapters/cursor/request-builder.ts +41 -15
  15. package/src/adapters/cursor/thread-continuity.ts +67 -0
  16. package/src/adapters/cursor/types.ts +3 -1
  17. package/src/adapters/cursor.ts +44 -9
  18. package/src/adapters/google.ts +115 -62
  19. package/src/adapters/kiro.ts +3 -17
  20. package/src/adapters/openai-chat.ts +16 -5
  21. package/src/adapters/openai-responses.ts +56 -1
  22. package/src/adapters/run-turn-queue.ts +11 -1
  23. package/src/bridge.ts +139 -69
  24. package/src/chat/outbound.ts +135 -73
  25. package/src/cli/codex-shim-autorestore.ts +45 -0
  26. package/src/cli/doctor.ts +197 -2
  27. package/src/cli/index.ts +17 -3
  28. package/src/cli/status.ts +80 -0
  29. package/src/cli/v2.ts +14 -2
  30. package/src/codex/auth-context.ts +18 -2
  31. package/src/codex/catalog/bundled.ts +83 -27
  32. package/src/codex/catalog/effort.ts +95 -3
  33. package/src/codex/catalog/parsing.ts +17 -0
  34. package/src/codex/catalog/provider-fetch.ts +31 -8
  35. package/src/codex/exec-invocation.ts +22 -0
  36. package/src/codex/model-cache.ts +44 -0
  37. package/src/codex/runtime.ts +529 -0
  38. package/src/codex/shim.ts +608 -10
  39. package/src/combos/resolve.ts +7 -2
  40. package/src/config.ts +32 -1
  41. package/src/lib/bun-stream-caps.ts +88 -0
  42. package/src/lib/crash-guard.ts +3 -1
  43. package/src/lib/sse-decoder.ts +25 -6
  44. package/src/responses/parser.ts +2 -1
  45. package/src/responses/state.ts +10 -2
  46. package/src/server/auth-cors.ts +4 -1
  47. package/src/server/index.ts +191 -1
  48. package/src/server/live.ts +491 -0
  49. package/src/server/management/config-routes.ts +79 -3
  50. package/src/server/management/provider-routes.ts +2 -0
  51. package/src/server/management/shared.ts +6 -6
  52. package/src/server/management/system-routes.ts +65 -0
  53. package/src/server/management-api.ts +3 -1
  54. package/src/server/memory-watchdog.ts +112 -0
  55. package/src/server/relay-eager.ts +199 -0
  56. package/src/server/relay.ts +131 -81
  57. package/src/server/responses/collaboration.ts +20 -3
  58. package/src/server/responses/core.ts +236 -21
  59. package/src/server/responses/encrypted-payload.ts +118 -41
  60. package/src/server/ws-bridge.ts +7 -0
  61. package/src/types.ts +25 -0
  62. package/src/usage/cost.ts +0 -0
  63. package/src/usage/expected-prices.ts +19 -0
  64. package/src/usage/summary.ts +11 -8
  65. package/gui/dist/assets/index-BpX-hoSd.css +0 -1
  66. package/gui/dist/assets/index-ZmFopEYw.js +0 -52
@@ -46,9 +46,17 @@ import {
46
46
  } from "./tool-definitions";
47
47
 
48
48
  const encoder = new TextEncoder();
49
+ const decoder = new TextDecoder();
49
50
 
50
51
  /** Parameter id advertised by Cursor's `default` model for its Cost/Balance/Intelligence control. */
51
52
  export const CURSOR_ROUTING_LEVEL_PARAMETER_ID = "optimization";
53
+ // Cursor external workers reject oversized root replay sets with a late invalid_argument after
54
+ // hydrating every blob (observed at 208 roots with usedTokens=0). Keep headroom below that boundary,
55
+ // retaining all system prompts and the newest model-visible history. Cursor IDE similarly bounds /
56
+ // compacts long conversations rather than replaying an unbounded message list.
57
+ export const CURSOR_EXTERNAL_ROOT_BLOB_LIMIT = 192;
58
+ /** Approximate prompt-size guard; tool schemas and protocol framing consume context separately. */
59
+ export const CURSOR_EXTERNAL_ROOT_BYTE_LIMIT = 512 * 1024;
52
60
 
53
61
  /** Runtime timezone for protobuf RequestContextEnv (dynamic, never hardcoded). */
54
62
  function runtimeTimeZone(): string {
@@ -72,7 +80,59 @@ function jsonBlob(value: unknown): Uint8Array {
72
80
  return encoder.encode(JSON.stringify(value));
73
81
  }
74
82
 
75
- function systemPromptBlobs(request: CursorRunRequest): Uint8Array[] {
83
+ type StoredRootBlob = {
84
+ id: Uint8Array;
85
+ byteLength: number;
86
+ role: "system" | "user" | "assistant" | "toolResult";
87
+ messageIndex?: number;
88
+ /** Original JSON text payload used when an active tool result must be truncated to fit. */
89
+ text?: string;
90
+ };
91
+
92
+ function storedRootBlob(
93
+ value: unknown,
94
+ role: StoredRootBlob["role"],
95
+ opts?: { messageIndex?: number; text?: string },
96
+ ): StoredRootBlob {
97
+ const data = jsonBlob(value);
98
+ return {
99
+ id: storeCursorBlob(data),
100
+ byteLength: data.byteLength,
101
+ role,
102
+ ...(opts?.messageIndex !== undefined ? { messageIndex: opts.messageIndex } : {}),
103
+ ...(opts?.text !== undefined ? { text: opts.text } : {}),
104
+ };
105
+ }
106
+
107
+ function truncateToolResultBlob(entry: StoredRootBlob, maxBytes: number): StoredRootBlob | null {
108
+ if (entry.byteLength <= maxBytes) return entry;
109
+ if (entry.role !== "toolResult" || entry.text === undefined) return null;
110
+ const marker = "\n…[truncated for Cursor external replay budget]";
111
+ const encoded = encoder.encode(entry.text);
112
+ // Leave headroom for JSON envelope (`role`/`content` wrapper) around the truncated text.
113
+ let keepBytes = Math.min(encoded.byteLength, Math.max(0, maxBytes - encoder.encode(marker).byteLength - 96));
114
+ for (let attempt = 0; attempt < 8; attempt++) {
115
+ let end = keepBytes;
116
+ while (end > 0 && end < encoded.byteLength && (encoded[end]! & 0xc0) === 0x80) end -= 1;
117
+ const truncated = `${decoder.decode(encoded.subarray(0, end))}${marker}`;
118
+ const result = storedRootBlob(
119
+ { role: "user", content: [{ type: "text", text: truncated }] },
120
+ "toolResult",
121
+ { messageIndex: entry.messageIndex, text: truncated },
122
+ );
123
+ if (result.byteLength <= maxBytes) return result;
124
+ if (end === 0) break;
125
+ keepBytes = Math.max(0, end - (result.byteLength - maxBytes) - 16);
126
+ }
127
+ const markerOnly = storedRootBlob(
128
+ { role: "user", content: [{ type: "text", text: marker.trimStart() }] },
129
+ "toolResult",
130
+ { messageIndex: entry.messageIndex, text: marker.trimStart() },
131
+ );
132
+ return markerOnly.byteLength <= maxBytes ? markerOnly : null;
133
+ }
134
+
135
+ function systemPromptBlobs(request: CursorRunRequest): StoredRootBlob[] {
76
136
  const prompts = request.system.length > 0 ? [...request.system] : ["You are a helpful assistant."];
77
137
  if (cursorRequestHasShellAlias(request.tools)) prompts.push(CURSOR_SHELL_ALIAS_SYSTEM_NOTE);
78
138
  const cursorToolGuidance = buildCursorToolGuidanceSystemNote(
@@ -80,13 +140,16 @@ function systemPromptBlobs(request: CursorRunRequest): Uint8Array[] {
80
140
  request.toolChoice,
81
141
  );
82
142
  if (cursorToolGuidance) prompts.push(cursorToolGuidance);
83
- return prompts.map(content => storeCursorBlob(jsonBlob({ role: "system", content })));
143
+ return prompts.map(content => storedRootBlob({ role: "system", content }, "system"));
84
144
  }
85
145
 
86
- function assistantRootText(message: Extract<OcxMessage, { role: "assistant" }>): string {
146
+ function assistantRootText(
147
+ message: Extract<OcxMessage, { role: "assistant" }>,
148
+ includeThinking: boolean,
149
+ ): string {
87
150
  if (typeof message.content === "string") return message.content;
88
151
  return message.content
89
- .map(part => (part.type === "text" ? part.text : part.type === "thinking" ? part.thinking : undefined))
152
+ .map(part => (part.type === "text" ? part.text : includeThinking && part.type === "thinking" ? part.thinking : undefined))
90
153
  .filter((value): value is string => typeof value === "string" && value.length > 0)
91
154
  .join("\n");
92
155
  }
@@ -97,11 +160,23 @@ function assistantRootText(message: Extract<OcxMessage, { role: "assistant" }>):
97
160
  // because it travels in the action. Tool results are rendered as user-role text with a marker, and
98
161
  // each entry is a SHA-256 blob ID (Cursor fetches the bytes back via getBlobArgs). Mirrors the
99
162
  // danger-pi reference buildRootPromptMessagesJson.
100
- function rootPromptMessages(request: CursorRunRequest): Uint8Array[] {
163
+ function rootPromptMessages(request: CursorRunRequest): {
164
+ ids: Uint8Array[];
165
+ byteLength: number;
166
+ historyMessageStart: number;
167
+ } {
101
168
  const entries = systemPromptBlobs(request);
169
+ const systemEntryCount = entries.length;
102
170
  const messages = request.rawMessages;
103
- if (!messages?.length) return entries;
171
+ if (!messages?.length) {
172
+ return {
173
+ ids: entries.map(entry => entry.id),
174
+ byteLength: entries.reduce((sum, entry) => sum + entry.byteLength, 0),
175
+ historyMessageStart: 0,
176
+ };
177
+ }
104
178
 
179
+ const externalModel = isCursorExternalWireModel(request.modelId);
105
180
  const lastRawIsToolResult = messages.at(-1)?.role === "toolResult";
106
181
  const activeUserIndex = lastRawIsToolResult ? -1 : lastActionIndex(messages);
107
182
 
@@ -111,24 +186,110 @@ function rootPromptMessages(request: CursorRunRequest): Uint8Array[] {
111
186
  if (!message) continue;
112
187
  if (message.role === "user" || message.role === "developer") {
113
188
  const text = contentText(message).trim();
114
- if (text.length > 0) entries.push(storeCursorBlob(jsonBlob({ role: "user", content: text })));
189
+ // Cursor root replay expects OpenAI-style content parts for historical user messages.
190
+ // A bare string survives blob hydration but external workers reject the completed replay
191
+ // before tokenization (`usedTokens: 0`, then invalid_argument).
192
+ if (text.length > 0) {
193
+ entries.push(storedRootBlob({
194
+ role: "user",
195
+ content: [{ type: "text", text }],
196
+ }, "user", { messageIndex: i }));
197
+ }
115
198
  } else if (message.role === "assistant") {
116
- const text = assistantRootText(message).trim();
117
- if (text.length > 0) entries.push(storeCursorBlob(jsonBlob({ role: "assistant", content: [{ type: "text", text }] })));
199
+ // External Cursor clients do not replay hidden reasoning as assistant-visible prompt text.
200
+ // Native Composer state can preserve it through ThinkingMessage/history structures.
201
+ const text = assistantRootText(message, !externalModel).trim();
202
+ if (text.length > 0) {
203
+ entries.push(storedRootBlob(
204
+ { role: "assistant", content: [{ type: "text", text }] },
205
+ "assistant",
206
+ { messageIndex: i },
207
+ ));
208
+ }
118
209
  // Assistant tool CALLS are intentionally NOT replayed as visible "[Tool Call]" text here.
119
- // rootPromptMessagesJson is the model-visible prompt, so a synthetic "[Tool Call]" marker in an
120
- // assistant turn gets few-shot-mimicked: the model then emits later (esp. parallel/mixed) tool
121
- // calls as inert text instead of real tool frames, halting multi-tool continuations. The paired
122
- // tool result below ([Tool Result]/[Tool Error]) carries the call id/name/output Cursor needs to
123
- // continue, and conversationTurns replays the native mcpToolCall step. Mirrors request-builder.ts
124
- // contentPartToText() which returns undefined for toolCall for the same reason.
125
210
  } else if (message.role === "toolResult") {
126
211
  const prefix = message.isError ? "[Tool Error]" : "[Tool Result]";
127
212
  const text = `${prefix}\n${toolResultToText(message)}`;
128
- entries.push(storeCursorBlob(jsonBlob({ role: "user", content: [{ type: "text", text }] })));
213
+ entries.push(storedRootBlob(
214
+ { role: "user", content: [{ type: "text", text }] },
215
+ "toolResult",
216
+ { messageIndex: i, text },
217
+ ));
218
+ }
219
+ }
220
+
221
+ let selected = entries;
222
+ let historyMessageStart = 0;
223
+ if (externalModel) {
224
+ const systemEntries = entries.slice(0, systemEntryCount);
225
+ const history = entries.slice(systemEntryCount);
226
+ const systemBytes = systemEntries.reduce((sum, entry) => sum + entry.byteLength, 0);
227
+ const historyLimit = Math.max(0, CURSOR_EXTERNAL_ROOT_BLOB_LIMIT - systemEntryCount);
228
+ const historyBudget = Math.max(0, CURSOR_EXTERNAL_ROOT_BYTE_LIMIT - systemBytes);
229
+
230
+ // Retain the active trailing tool-result block when it fits (may truncate text).
231
+ // If even a truncation marker cannot fit the remaining budget, omit it rather than
232
+ // emitting an oversized root blob.
233
+ let activeStart = history.length;
234
+ while (activeStart > 0 && history[activeStart - 1]?.role === "toolResult") activeStart -= 1;
235
+ const active = history
236
+ .slice(activeStart)
237
+ .map(entry => truncateToolResultBlob(entry, historyBudget))
238
+ .filter((entry): entry is StoredRootBlob => entry !== null);
239
+ let activeBytes = active.reduce((sum, entry) => sum + entry.byteLength, 0);
240
+ while (active.length > 1 && activeBytes > historyBudget) {
241
+ const dropped = active.shift();
242
+ activeBytes -= dropped?.byteLength ?? 0;
243
+ }
244
+ if (active.length === 1 && active[0] && activeBytes > historyBudget) {
245
+ const truncated = truncateToolResultBlob(active[0], historyBudget);
246
+ if (truncated) {
247
+ active[0] = truncated;
248
+ activeBytes = truncated.byteLength;
249
+ } else {
250
+ active.length = 0;
251
+ activeBytes = 0;
252
+ }
253
+ }
254
+
255
+ const prior = history.slice(0, activeStart);
256
+ const keptPrior: StoredRootBlob[] = [];
257
+ let priorBytes = 0;
258
+ // Take complete turns from the end: a turn starts at a user/developer root entry.
259
+ let i = prior.length - 1;
260
+ while (i >= 0 && keptPrior.length + active.length < historyLimit) {
261
+ let turnStart = i;
262
+ while (turnStart > 0 && prior[turnStart]?.role !== "user") turnStart -= 1;
263
+ const turn = prior.slice(turnStart, i + 1);
264
+ const turnBytes = turn.reduce((sum, entry) => sum + entry.byteLength, 0);
265
+ if (
266
+ keptPrior.length + active.length + turn.length > historyLimit
267
+ || priorBytes + activeBytes + turnBytes > historyBudget
268
+ ) {
269
+ break;
270
+ }
271
+ keptPrior.unshift(...turn);
272
+ priorBytes += turnBytes;
273
+ i = turnStart - 1;
129
274
  }
275
+
276
+ const historyEntries = [...keptPrior, ...active];
277
+ // Guard against orphan assistant / toolResult at the start of the retained suffix.
278
+ while (historyEntries[0]?.role === "assistant" || historyEntries[0]?.role === "toolResult") {
279
+ // Never drop the sole active tool-result block.
280
+ if (historyEntries.length <= active.length) break;
281
+ historyEntries.shift();
282
+ }
283
+ selected = [...systemEntries, ...historyEntries];
284
+ const firstKept = historyEntries.find(entry => entry.messageIndex !== undefined);
285
+ historyMessageStart = firstKept?.messageIndex ?? (messages.length);
130
286
  }
131
- return entries;
287
+
288
+ return {
289
+ ids: selected.map(entry => entry.id),
290
+ byteLength: selected.reduce((sum, entry) => sum + entry.byteLength, 0),
291
+ historyMessageStart,
292
+ };
132
293
  }
133
294
 
134
295
  function contentText(message: OcxMessage): string {
@@ -240,11 +401,13 @@ function lastActionIndex(messages: readonly OcxMessage[] | undefined): number {
240
401
  return -1;
241
402
  }
242
403
 
243
- function conversationTurns(request: CursorRunRequest): Uint8Array[] {
404
+ function conversationTurns(request: CursorRunRequest, historyMessageStart = 0): Uint8Array[] {
244
405
  const messages = request.rawMessages;
245
406
  if (!messages?.length) return [];
246
407
  const end = lastActionIndex(messages);
408
+ const externalModel = isCursorExternalWireModel(request.modelId);
247
409
  const historyEnd = messages.at(-1)?.role === "toolResult" ? messages.length : Math.max(0, end);
410
+ const start = externalModel ? Math.max(0, historyMessageStart) : 0;
248
411
  const turns: Uint8Array[] = [];
249
412
  let current: { userMessage: Uint8Array; steps: Uint8Array[] } | undefined;
250
413
  const pendingToolCalls = new Map<string, Extract<OcxAssistantContentPart, { type: "toolCall" }>>();
@@ -261,10 +424,24 @@ function conversationTurns(request: CursorRunRequest): Uint8Array[] {
261
424
  pendingToolCalls.clear();
262
425
  };
263
426
 
264
- for (const message of messages.slice(0, historyEnd)) {
427
+ for (const message of messages.slice(start, historyEnd)) {
265
428
  if (message.role === "assistant") {
266
429
  if (!current) continue;
267
430
  for (const part of message.content) {
431
+ if (externalModel) {
432
+ // Working external-model clients replay only assistant text. Native mcpToolCall and
433
+ // ThinkingMessage structures are Composer state and cause external workers to hydrate
434
+ // the blobs, reach stepCompleted, then reject the turn with invalid_argument.
435
+ if (part.type === "text" && part.text.length > 0) {
436
+ current.steps.push(storeCursorBlob(toBinary(ConversationStepSchema, create(ConversationStepSchema, {
437
+ message: {
438
+ case: "assistantMessage",
439
+ value: create(AssistantMessageSchema, { text: part.text }),
440
+ },
441
+ }))));
442
+ }
443
+ continue;
444
+ }
268
445
  if (part.type === "toolCall") {
269
446
  pendingToolCalls.set(part.id, part);
270
447
  continue;
@@ -276,6 +453,16 @@ function conversationTurns(request: CursorRunRequest): Uint8Array[] {
276
453
  }
277
454
  if (message.role === "toolResult") {
278
455
  if (!current) continue;
456
+ if (externalModel) {
457
+ const prefix = message.isError ? "[Tool Error]" : "[Tool Result]";
458
+ current.steps.push(storeCursorBlob(toBinary(ConversationStepSchema, create(ConversationStepSchema, {
459
+ message: {
460
+ case: "assistantMessage",
461
+ value: create(AssistantMessageSchema, { text: `${prefix}\n${contentToText(message.content)}` }),
462
+ },
463
+ }))));
464
+ continue;
465
+ }
279
466
  const priorCall = pendingToolCalls.get(message.toolCallId);
280
467
  if (priorCall) {
281
468
  current.steps.push(toolCallStep(priorCall, message));
@@ -322,13 +509,11 @@ export function encodeCursorRunRequest(request: CursorRunRequest): Uint8Array {
322
509
  const text = lastRole === "user" || lastRole === "developer"
323
510
  ? appendCursorShellAliasHint(request.tools, appendCursorGenericToolUseHint(request.tools, rawText))
324
511
  : rawText;
325
- // A tool-result-only turn (the last raw message is a toolResult) continues the SAME Cursor
326
- // conversation with the tool result carried as structured conversation history (mcpToolCall.result
327
- // in conversationTurns). It must NOT inject the tool result text as a new UserMessageAction — that
328
- // would pollute the model input and double-deliver the result. Use ResumeAction so Cursor picks up
329
- // from the history we provided.
512
+ // Tool-result-only turns resume the remembered Cursor conversation with results in history.
330
513
  const lastRawIsToolResult = request.rawMessages?.at(-1)?.role === "toolResult";
331
- const actionCase = !lastRawIsToolResult && text.trim().length > 0 ? "userMessageAction" : "resumeAction";
514
+ const actionCase = !lastRawIsToolResult && text.trim().length > 0
515
+ ? "userMessageAction"
516
+ : "resumeAction";
332
517
  const action = create(ConversationActionSchema, {
333
518
  action: actionCase === "userMessageAction"
334
519
  ? {
@@ -348,19 +533,27 @@ export function encodeCursorRunRequest(request: CursorRunRequest): Uint8Array {
348
533
  }),
349
534
  },
350
535
  });
536
+ const rootPromptMessagesState = rootPromptMessages(request);
537
+ const rootPromptMessageIds = rootPromptMessagesState.ids;
538
+ const turnIds = conversationTurns(request, rootPromptMessagesState.historyMessageStart);
351
539
  debugProviderDiagnostic("cursor", "run-request", {
352
540
  wireModel: request.modelId,
353
541
  action: actionCase,
354
542
  conversationId: request.conversationId,
355
543
  turnType: lastRawIsToolResult ? "tool-continuation" : "initial",
356
544
  externalModel: isCursorExternalWireModel(request.modelId),
545
+ rawMessages: request.rawMessages?.length ?? 0,
546
+ rootBlobs: rootPromptMessageIds.length,
547
+ rootBytes: rootPromptMessagesState.byteLength,
548
+ turnBlobs: turnIds.length,
549
+ tools: request.tools?.length ?? 0,
357
550
  });
358
551
 
359
552
  const runRequest = create(AgentRunRequestSchema, {
360
553
  conversationId: request.conversationId,
361
554
  conversationState: create(ConversationStateStructureSchema, {
362
- rootPromptMessagesJson: rootPromptMessages(request),
363
- turns: conversationTurns(request),
555
+ rootPromptMessagesJson: rootPromptMessageIds,
556
+ turns: turnIds,
364
557
  todos: [],
365
558
  pendingToolCalls: [],
366
559
  previousWorkspaceUris: [],
@@ -379,6 +572,9 @@ export function encodeCursorRunRequest(request: CursorRunRequest): Uint8Array {
379
572
  displayNameShort: request.modelId,
380
573
  aliases: [],
381
574
  }),
575
+ // requested_model is currently a Cursor Router-only surface. External model clients still
576
+ // send model_details alone; sending both makes external workers reach stepCompleted and then
577
+ // reject the turn with invalid_argument.
382
578
  ...(request.routingLevel ? {
383
579
  requestedModel: create(RequestedModelSchema, {
384
580
  modelId: request.modelId,
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import type {
2
3
  OcxAssistantContentPart,
3
4
  OcxContentPart,
@@ -8,7 +9,7 @@ import type {
8
9
  } from "../../types";
9
10
  import { isAllowedToolChoice, namespacedToolName, toolChoiceAliases, type OcxTool, type OcxToolChoice } from "../../types";
10
11
  import type { CursorRequestMessage, CursorRunRequest } from "./types";
11
- import { cursorWireModelSelection, isCursorExternalWireModel, type CursorRoutingLevel } from "./discovery";
12
+ import { cursorWireModelSelection, type CursorRoutingLevel } from "./discovery";
12
13
  import { cursorEffortSuffix } from "./effort-map";
13
14
  import {
14
15
  cursorMcpToolEncodedSize,
@@ -17,6 +18,7 @@ import {
17
18
  cursorToolWireName,
18
19
  cursorToolsForActivePrompt,
19
20
  } from "./tool-definitions";
21
+ import { lookupCursorThreadConversation } from "./thread-continuity";
20
22
 
21
23
  /** Probe-verified Cursor Connect boundaries, with byte headroom for the enclosing field. */
22
24
  export const CURSOR_TOOL_COUNT_LIMIT = 330;
@@ -159,6 +161,43 @@ export function generatedCursorConversationId(): string {
159
161
  return `cursor_${crypto.randomUUID().replace(/-/g, "")}`;
160
162
  }
161
163
 
164
+ /** Derive an opaque provider-scoped Cursor id from the upstream client's conversation identity. */
165
+ export function cursorConversationIdFromClientThread(threadId: string, identityScope?: string): string {
166
+ const digest = createHash("sha256")
167
+ .update("ocx:cursor:thread:")
168
+ .update(identityScope?.trim() || "local")
169
+ .update("\0")
170
+ .update(threadId)
171
+ .digest("hex")
172
+ .slice(0, 32);
173
+ return `cursor_${digest}`;
174
+ }
175
+
176
+ /**
177
+ * Resolve the Cursor conversation id for this turn.
178
+ * Priority: force-fresh → isolate helper → remembered → thread override → client thread → random.
179
+ * Never use OpenAI Responses `previous_response_id` (resp_*) or shared `prompt_cache_key`
180
+ * (cache-cohort fingerprint, not conversation ownership).
181
+ */
182
+ export function resolveCursorConversationId(
183
+ parsed: OcxParsedRequest,
184
+ _wireModelId: string,
185
+ options: CreateCursorRequestOptions = {},
186
+ ): string {
187
+ if (options.forceFreshConversation === true) return generatedCursorConversationId();
188
+ // Helper/shadow/compaction turns must not append into the parent's Cursor conversation,
189
+ // even when previous_response_id restored the parent's remembered id.
190
+ if (parsed._cursorIsolateConversation === true) return generatedCursorConversationId();
191
+ if (parsed._cursorConversationId) return parsed._cursorConversationId;
192
+ const threadId = parsed._clientThreadId?.trim();
193
+ if (threadId) {
194
+ const recovered = lookupCursorThreadConversation(threadId, parsed._cursorIdentityScope);
195
+ if (recovered) return recovered;
196
+ return cursorConversationIdFromClientThread(threadId, parsed._cursorIdentityScope);
197
+ }
198
+ return generatedCursorConversationId();
199
+ }
200
+
162
201
  export interface CreateCursorRequestOptions {
163
202
  /** Force a brand-new Cursor conversation id even when remembered state exists. */
164
203
  forceFreshConversation?: boolean;
@@ -176,23 +215,10 @@ export function createCursorRequest(
176
215
  const budget = applyCursorToolBudget(visibleTools, parsed.options.toolChoice);
177
216
  const limitNote = catalogLimitNote(budget.tools, budget.omitted);
178
217
  const model = normalizeCursorModelId(parsed.modelId, parsed.options.reasoning);
179
- const lastRaw = parsed.context.messages.at(-1);
180
- // External Cursor models (e.g. gpt-5.6-sol) can corrupt server-side conversation state across
181
- // tool-result continuations when ResumeAction reuses the same conversationId. Force a fresh id
182
- // so the full history is replayed without depending on that state.
183
- const forceFreshConversation =
184
- options.forceFreshConversation === true
185
- || (lastRaw?.role === "toolResult" && isCursorExternalWireModel(model.modelId));
186
218
  return {
187
219
  modelId: model.modelId,
188
220
  ...(model.routingLevel ? { routingLevel: model.routingLevel } : {}),
189
- // The Cursor conversation id comes ONLY from remembered state (_cursorConversationId). Do NOT fall
190
- // back to the OpenAI Responses previous_response_id (resp_*): that is a Responses-chain id in a
191
- // different namespace and would start an unrelated Cursor conversation, breaking tool-result
192
- // continuation. If we have no remembered Cursor conversation, start a fresh one.
193
- conversationId: forceFreshConversation
194
- ? generatedCursorConversationId()
195
- : (parsed._cursorConversationId ?? generatedCursorConversationId()),
221
+ conversationId: resolveCursorConversationId(parsed, model.modelId, options),
196
222
  system: [...(parsed.context.systemPrompt ?? []), ...(limitNote ? [limitNote] : [])],
197
223
  messages,
198
224
  rawMessages: parsed.context.messages,
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Bounded in-memory overrides for Cursor conversation continuity.
3
+ *
4
+ * When an invalid_argument recovery mints a fresh conversation id for a store:false
5
+ * thread-identified client, later turns without previous_response_id must reuse that
6
+ * recovered id instead of recomputing the stale deterministic thread hash.
7
+ */
8
+
9
+ const OVERRIDE_TTL_MS = 60 * 60 * 1000;
10
+ const OVERRIDE_MAX_ENTRIES = 2048;
11
+
12
+ const overrides = new Map<string, { conversationId: string; updatedAt: number }>();
13
+
14
+ function now(): number {
15
+ return Date.now();
16
+ }
17
+
18
+ function prune(at: number): void {
19
+ for (const [key, entry] of overrides) {
20
+ if (at - entry.updatedAt > OVERRIDE_TTL_MS) overrides.delete(key);
21
+ else break; // Map iterates insertion order; refreshed entries are moved to the end
22
+ }
23
+ while (overrides.size > OVERRIDE_MAX_ENTRIES) {
24
+ const oldest = overrides.keys().next().value;
25
+ if (oldest === undefined) break;
26
+ overrides.delete(oldest);
27
+ }
28
+ }
29
+
30
+ /** Scope key for a client thread, optionally namespaced by authenticated tenant/operator identity. */
31
+ export function cursorThreadScopeKey(threadId: string, identityScope?: string): string {
32
+ const scope = identityScope?.trim() || "local";
33
+ return `${scope}\0${threadId}`;
34
+ }
35
+
36
+ export function rememberCursorThreadConversation(
37
+ threadId: string,
38
+ conversationId: string,
39
+ identityScope?: string,
40
+ ): void {
41
+ const key = cursorThreadScopeKey(threadId, identityScope);
42
+ const at = now();
43
+ overrides.delete(key);
44
+ overrides.set(key, { conversationId, updatedAt: at });
45
+ prune(at);
46
+ }
47
+
48
+ export function lookupCursorThreadConversation(
49
+ threadId: string,
50
+ identityScope?: string,
51
+ ): string | undefined {
52
+ const key = cursorThreadScopeKey(threadId, identityScope);
53
+ const entry = overrides.get(key);
54
+ if (!entry) return undefined;
55
+ const at = now();
56
+ if (at - entry.updatedAt > OVERRIDE_TTL_MS) {
57
+ overrides.delete(key);
58
+ return undefined;
59
+ }
60
+ overrides.delete(key);
61
+ overrides.set(key, { conversationId: entry.conversationId, updatedAt: at });
62
+ return entry.conversationId;
63
+ }
64
+
65
+ export function clearCursorThreadContinuityForTests(): void {
66
+ overrides.clear();
67
+ }
@@ -42,7 +42,9 @@ export type CursorServerMessage =
42
42
  | { type: "heartbeat" }
43
43
  | { type: "kv_get"; key: string }
44
44
  | { type: "kv_set"; key: string; value: Uint8Array }
45
- | { type: "exec"; execCase: string; requestId: string };
45
+ | { type: "exec"; execCase: string; requestId: string }
46
+ /** A native exec/MCP action ran locally; retrying this turn could duplicate its side effects. */
47
+ | { type: "local_side_effect" };
46
48
 
47
49
  export type CursorClientMessage =
48
50
  | { type: "kv_value"; key: string; value?: Uint8Array }
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import type { AdapterEvent, OcxProviderConfig } from "../types";
2
3
  import type { ProviderAdapter } from "./base";
3
4
  import { cursorExecDeniedMessage, cursorRequestDeclaresFullAccess } from "./cursor/exec-policy";
@@ -5,12 +6,14 @@ import { isCursorBenignCancelError, isCursorInvalidArgumentError, safeCursorErro
5
6
  import { isCursorExternalWireModel } from "./cursor/discovery";
6
7
  import { createCursorKvStore, type CursorKvStore } from "./cursor/kv-store";
7
8
  import { mapCursorServerMessage } from "./cursor/message-mapper";
8
- import { createCursorRequest, generatedCursorConversationId } from "./cursor/request-builder";
9
+ import { createCursorRequest } from "./cursor/request-builder";
9
10
  import {
10
11
  createLiveCursorTransport,
11
12
  CursorMissingCredentialError,
12
13
  rekeyCursorContextUsage,
14
+ resolveCursorToken,
13
15
  } from "./cursor/live-transport";
16
+ import { rememberCursorThreadConversation } from "./cursor/thread-continuity";
14
17
  import { runCursorTurnWithRetry } from "./cursor/transport-retry";
15
18
  import {
16
19
  createDisabledCursorTransport,
@@ -77,16 +80,36 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
77
80
  const makeTransport = deps.createTransport ?? createLiveCursorTransport;
78
81
  const kv = deps.kv ?? createCursorKvStore();
79
82
  const rekeyContextUsage = deps.rekeyContextUsage ?? rekeyCursorContextUsage;
80
- _parsed._cursorConversationId ??= generatedCursorConversationId();
83
+ // Namespace thread→conversation derivation by the authenticated Cursor credential so
84
+ // shared-proxy tenants with different Cursor accounts cannot collide on a parent thread id.
85
+ // Prefer an already-set auth scope (e.g. Codex pool account) when present.
86
+ if (!_parsed._cursorIdentityScope) {
87
+ try {
88
+ const token = resolveCursorToken(provider, incoming.headers);
89
+ _parsed._cursorIdentityScope = createHash("sha256")
90
+ .update("ocx:cursor:acct:")
91
+ .update(token)
92
+ .digest("hex")
93
+ .slice(0, 16);
94
+ } catch {
95
+ /* Missing credential is handled by the live transport path below. */
96
+ }
97
+ }
81
98
  const previousConversationId = _parsed._cursorConversationId;
82
99
  let request = createCursorRequest(_parsed);
83
- // Keep remembered conversation id in sync when the request builder mints a fresh id
84
- // for external-model tool-result continuations (stateless replay).
85
- if (request.conversationId !== previousConversationId) {
100
+ // The builder may derive a stable provider id from the client thread when Responses state
101
+ // is unavailable. Rekey only existing state; there is nothing to migrate on a fresh turn,
102
+ // and isolated helper/compaction turns must never inherit or donate the parent's usage state.
103
+ if (
104
+ previousConversationId
105
+ && request.conversationId !== previousConversationId
106
+ && _parsed._cursorIsolateConversation !== true
107
+ ) {
86
108
  rekeyContextUsage(previousConversationId, request.conversationId);
87
109
  }
88
110
  _parsed._cursorConversationId = request.conversationId;
89
111
  let emittedOutput = false;
112
+ let replayUnsafe = false;
90
113
  const lastRawIsToolResult = _parsed.context.messages.at(-1)?.role === "toolResult";
91
114
 
92
115
  const runOnce = async (activeRequest: ReturnType<typeof createCursorRequest>) => {
@@ -104,6 +127,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
104
127
  emit({ type: "error", message: "Cursor turn was aborted." });
105
128
  return;
106
129
  }
130
+ if (message.type === "local_side_effect") replayUnsafe = true;
107
131
  const events = mapCursorServerMessage(message, {
108
132
  kv,
109
133
  writeClient: clientMessage => {
@@ -121,14 +145,15 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
121
145
  try {
122
146
  await runOnce(request);
123
147
  } catch (err) {
124
- // One-shot fallback: only for external-model tool-result continuations that fail
125
- // with Connect invalid_argument before any non-heartbeat output was forwarded.
126
- // Replaying after text/tool events would duplicate output.
148
+ // One-shot fallback for external-model Connect invalid_argument before any
149
+ // non-heartbeat output. Retries apply only to safe plain-user turns; tool-result
150
+ // resumes, local exec/MCP side effects, and already-emitted output fail closed.
127
151
  if (
128
152
  !isCursorInvalidArgumentError(err)
129
153
  || !isCursorExternalWireModel(request.modelId)
130
- || !lastRawIsToolResult
154
+ || lastRawIsToolResult
131
155
  || emittedOutput
156
+ || replayUnsafe
132
157
  || incoming.abortSignal?.aborted
133
158
  ) {
134
159
  throw err;
@@ -138,6 +163,16 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
138
163
  request = createCursorRequest(_parsed, { forceFreshConversation: true });
139
164
  rekeyContextUsage(failedConversationId, request.conversationId);
140
165
  _parsed._cursorConversationId = request.conversationId;
166
+ // Persist recovery for store:false clients that only send a parent thread id, so the
167
+ // next turn does not recompute the stale deterministic thread hash. Isolated helper /
168
+ // compaction turns must not park their throwaway id under the parent thread key.
169
+ if (_parsed._clientThreadId && _parsed._cursorIsolateConversation !== true) {
170
+ rememberCursorThreadConversation(
171
+ _parsed._clientThreadId,
172
+ request.conversationId,
173
+ _parsed._cursorIdentityScope,
174
+ );
175
+ }
141
176
  await runOnce(request);
142
177
  }
143
178
  } catch (err) {