@bitkyc08/opencodex 2.28.0 → 2.29.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 (90) hide show
  1. package/README.md +9 -1
  2. package/gui/dist/assets/index-BNESwCzn.js +102 -0
  3. package/gui/dist/assets/index-CH7ncHCC.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/base.ts +3 -1
  7. package/src/adapters/cursor/checkpoint-store.ts +303 -0
  8. package/src/adapters/cursor/discovery.ts +25 -0
  9. package/src/adapters/cursor/live-transport.ts +19 -2
  10. package/src/adapters/cursor/native-exec.ts +47 -2
  11. package/src/adapters/cursor/protobuf-request.ts +80 -23
  12. package/src/adapters/cursor/request-builder.ts +119 -5
  13. package/src/adapters/cursor/transport.ts +5 -0
  14. package/src/adapters/cursor/types.ts +13 -0
  15. package/src/adapters/cursor.ts +109 -3
  16. package/src/adapters/google-antigravity-replay.ts +31 -4
  17. package/src/adapters/google.ts +323 -38
  18. package/src/adapters/openai-chat.ts +19 -0
  19. package/src/adapters/openai-responses.ts +218 -37
  20. package/src/bridge.ts +38 -20
  21. package/src/claude/desktop-3p.ts +15 -6
  22. package/src/cli/agent.ts +44 -1
  23. package/src/cli/claude-agent-startup-sync.ts +73 -0
  24. package/src/cli/dispatch.ts +24 -1
  25. package/src/cli/ensure-desired-integrations.ts +152 -0
  26. package/src/cli/help.ts +1 -1
  27. package/src/cli/index.ts +40 -38
  28. package/src/cli/integrations.ts +1 -1
  29. package/src/cli/registry.ts +2 -2
  30. package/src/clients/config-export.ts +119 -20
  31. package/src/codex/affinity-debug.ts +162 -0
  32. package/src/codex/inject.ts +46 -14
  33. package/src/codex/journal.ts +22 -8
  34. package/src/config.ts +1 -0
  35. package/src/generated/compatibility-version.json +134 -66
  36. package/src/integrations/mutation-flight.ts +71 -0
  37. package/src/integrations/owned-refresh.ts +74 -0
  38. package/src/integrations/registry.ts +25 -2
  39. package/src/integrations/writer.ts +32 -1
  40. package/src/lab/public/signature.ts +25 -1
  41. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  42. package/src/lib/redact.ts +2 -2
  43. package/src/oauth/log.ts +3 -1
  44. package/src/providers/derive.ts +9 -0
  45. package/src/providers/fastwire.ts +24 -18
  46. package/src/providers/openai-tiers.ts +60 -1
  47. package/src/providers/registry.ts +14 -7
  48. package/src/providers/xai-responses-opt-in.ts +15 -0
  49. package/src/responses/compaction.ts +18 -0
  50. package/src/responses/custom-tool-compat.ts +70 -5
  51. package/src/responses/namespace-tool-compat.ts +356 -0
  52. package/src/responses/parser.ts +2 -2
  53. package/src/responses/provider-continuation.ts +98 -0
  54. package/src/responses/reasoning-replay-cache.ts +125 -7
  55. package/src/responses/spill-store.ts +6 -1
  56. package/src/responses/state.ts +11 -0
  57. package/src/router.ts +14 -0
  58. package/src/routing/compatibility/behavior.ts +1 -0
  59. package/src/server/auth-cors.ts +4 -0
  60. package/src/server/management/agent-settings-routes.ts +57 -9
  61. package/src/server/management/config-routes.ts +134 -15
  62. package/src/server/management/integration-routes.ts +8 -55
  63. package/src/server/management/model-routes.ts +23 -1
  64. package/src/server/management/provider-routes.ts +22 -0
  65. package/src/server/management/vision-sidecar-options.ts +18 -11
  66. package/src/server/management/web-search-sidecar-options.ts +120 -0
  67. package/src/server/responses/core.ts +588 -78
  68. package/src/server/responses/responses-field-backfill.ts +7 -11
  69. package/src/server/responses/terminal-guard.ts +22 -11
  70. package/src/server/responses-custom-tool-repair.ts +3 -1
  71. package/src/server/responses-reasoning-summary-rewrite.ts +7 -0
  72. package/src/server/responses-tool-search-repair.ts +64 -14
  73. package/src/sidecar/auth.ts +92 -0
  74. package/src/sidecar/candidates.ts +83 -0
  75. package/src/types/config.ts +26 -5
  76. package/src/types/provider.ts +18 -0
  77. package/src/types/request.ts +26 -0
  78. package/src/types.ts +1 -0
  79. package/src/usage/log.ts +2 -0
  80. package/src/vision/index.ts +8 -9
  81. package/src/web-search/backends.ts +108 -0
  82. package/src/web-search/exa-executor.ts +88 -0
  83. package/src/web-search/gemini-executor.ts +141 -0
  84. package/src/web-search/index.ts +139 -12
  85. package/src/web-search/loop.ts +45 -5
  86. package/src/web-search/parse.ts +34 -23
  87. package/src/web-search/sources.ts +60 -0
  88. package/src/web-search/xai-executor.ts +219 -0
  89. package/gui/dist/assets/index-D2sP-biU.js +0 -102
  90. package/gui/dist/assets/index-DQsMZzI5.css +0 -1
@@ -0,0 +1,303 @@
1
+ import { createHash } from "node:crypto";
2
+ import { fromBinary } from "@bufbuild/protobuf";
3
+ import { ConversationStateStructureSchema, type ConversationStateStructure } from "./gen/agent_pb";
4
+ import {
5
+ createCursorBlobCheckpointLease,
6
+ pinCursorBlobIdsForCheckpoint,
7
+ releaseCursorBlobRequestScope,
8
+ type CursorBlobRequestScopeToken,
9
+ } from "./native-exec";
10
+
11
+ export const CURSOR_CHECKPOINT_TTL_MS = 15 * 60_000;
12
+ export const CURSOR_CHECKPOINT_MAX_ENTRIES = 64;
13
+ export const CURSOR_CHECKPOINT_MAX_TOTAL_BYTES = 16 * 1024 * 1024;
14
+
15
+ export type CursorCheckpointInvalidationReason =
16
+ | "missing_ref"
17
+ | "expired"
18
+ | "decode_failed"
19
+ | "conversation_changed"
20
+ | "identity_changed"
21
+ | "model_changed"
22
+ | "compaction"
23
+ | "trailing_tool_result"
24
+ | "force_fresh"
25
+ | "upstream_invalid_argument"
26
+ | "lineage_mismatch";
27
+
28
+ export interface CursorCheckpointSnapshot {
29
+ ref: string;
30
+ conversationId: string;
31
+ identityScope: string;
32
+ modelId: string;
33
+ checkpointBytes: Uint8Array;
34
+ createdAt: number;
35
+ lastAccessAt: number;
36
+ blobLease?: CursorBlobRequestScopeToken;
37
+ coveredMessageCount?: number;
38
+ prefixDigest?: string;
39
+ systemDigest?: string;
40
+ }
41
+
42
+ interface CursorCheckpointStore {
43
+ snapshots: Map<string, CursorCheckpointSnapshot>;
44
+ prefixIndex: Map<string, Set<string>>;
45
+ totalBytes: number;
46
+ }
47
+
48
+ const store: CursorCheckpointStore = {
49
+ snapshots: new Map(),
50
+ prefixIndex: new Map(),
51
+ totalBytes: 0,
52
+ };
53
+
54
+ let nowFn = (): number => Date.now();
55
+ let scheduleFn = (fn: () => void, ms: number): ReturnType<typeof setTimeout> => {
56
+ const timer = setTimeout(fn, ms);
57
+ timer.unref?.();
58
+ return timer;
59
+ };
60
+ let clearScheduleFn = (timer: ReturnType<typeof setTimeout>): void => {
61
+ clearTimeout(timer);
62
+ };
63
+ let pruneTimer: ReturnType<typeof setTimeout> | undefined;
64
+
65
+ function now(): number {
66
+ return nowFn();
67
+ }
68
+
69
+ export function installCursorCheckpointClockForTests(input: {
70
+ now?: () => number;
71
+ schedule?: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>;
72
+ clear?: (timer: ReturnType<typeof setTimeout>) => void;
73
+ }): void {
74
+ if (input.now) nowFn = input.now;
75
+ if (input.schedule) scheduleFn = input.schedule;
76
+ if (input.clear) clearScheduleFn = input.clear;
77
+ }
78
+
79
+ export function resetCursorCheckpointClockForTests(): void {
80
+ nowFn = () => Date.now();
81
+ scheduleFn = (fn, ms) => {
82
+ const timer = setTimeout(fn, ms);
83
+ timer.unref?.();
84
+ return timer;
85
+ };
86
+ clearScheduleFn = timer => {
87
+ clearTimeout(timer);
88
+ };
89
+ stopPruneTimer();
90
+ }
91
+
92
+ function stopPruneTimer(): void {
93
+ if (pruneTimer !== undefined) clearScheduleFn(pruneTimer);
94
+ pruneTimer = undefined;
95
+ }
96
+
97
+ function schedulePrune(at = now()): void {
98
+ stopPruneTimer();
99
+ let nextExpiry = Number.POSITIVE_INFINITY;
100
+ for (const snapshot of store.snapshots.values()) {
101
+ nextExpiry = Math.min(nextExpiry, snapshot.lastAccessAt + CURSOR_CHECKPOINT_TTL_MS);
102
+ }
103
+ if (!Number.isFinite(nextExpiry)) return;
104
+ pruneTimer = scheduleFn(() => {
105
+ pruneTimer = undefined;
106
+ prune();
107
+ schedulePrune();
108
+ }, Math.max(0, nextExpiry - at));
109
+ }
110
+
111
+ function prune(at = now()): void {
112
+ for (const [ref, snapshot] of store.snapshots) {
113
+ if (at - snapshot.lastAccessAt > CURSOR_CHECKPOINT_TTL_MS) deleteSnapshot(ref);
114
+ }
115
+ while (store.snapshots.size > CURSOR_CHECKPOINT_MAX_ENTRIES || store.totalBytes > CURSOR_CHECKPOINT_MAX_TOTAL_BYTES) {
116
+ const oldest = store.snapshots.keys().next().value;
117
+ if (oldest === undefined) break;
118
+ deleteSnapshot(oldest);
119
+ }
120
+ schedulePrune(at);
121
+ }
122
+
123
+ function indexPrefix(digest: string | undefined, ref: string): void {
124
+ if (!digest) return;
125
+ const refs = store.prefixIndex.get(digest) ?? new Set<string>();
126
+ refs.add(ref);
127
+ store.prefixIndex.set(digest, refs);
128
+ }
129
+
130
+ function unindexPrefix(digest: string | undefined, ref: string): void {
131
+ if (!digest) return;
132
+ const refs = store.prefixIndex.get(digest);
133
+ if (!refs) return;
134
+ refs.delete(ref);
135
+ if (refs.size === 0) store.prefixIndex.delete(digest);
136
+ }
137
+
138
+ function deleteSnapshot(ref: string): void {
139
+ const existing = store.snapshots.get(ref);
140
+ if (!existing) return;
141
+ if (existing.blobLease) releaseCursorBlobRequestScope(existing.blobLease);
142
+ unindexPrefix(existing.prefixDigest, ref);
143
+ store.snapshots.delete(ref);
144
+ store.totalBytes = Math.max(0, store.totalBytes - existing.checkpointBytes.byteLength);
145
+ }
146
+
147
+ function collectStateBlobIds(state: ConversationStateStructure, ids: Uint8Array[]): void {
148
+ ids.push(
149
+ ...state.rootPromptMessagesJson,
150
+ ...state.turns,
151
+ ...state.turnsOld,
152
+ ...state.todos,
153
+ ...state.summaryArchives,
154
+ );
155
+ if (state.summary) ids.push(state.summary);
156
+ if (state.summaryArchive) ids.push(state.summaryArchive);
157
+ if (state.plan) ids.push(state.plan);
158
+ for (const value of Object.values(state.fileStates)) ids.push(value);
159
+ for (const value of Object.values(state.fileStatesV2)) {
160
+ if (value.content) ids.push(value.content);
161
+ if (value.initialContent) ids.push(value.initialContent);
162
+ }
163
+ for (const nested of Object.values(state.subagentStates)) {
164
+ if (nested.conversationState) collectStateBlobIds(nested.conversationState, ids);
165
+ }
166
+ }
167
+
168
+ function collectCheckpointBlobIds(checkpointBytes: Uint8Array): Uint8Array[] | undefined {
169
+ try {
170
+ const ids: Uint8Array[] = [];
171
+ collectStateBlobIds(fromBinary(ConversationStateStructureSchema, checkpointBytes), ids);
172
+ return ids.filter(id => id.byteLength > 0);
173
+ } catch {
174
+ return undefined;
175
+ }
176
+ }
177
+
178
+ export function cursorCheckpointRefHash(ref: string): string {
179
+ return createHash("sha256").update("ocx:cursor:ckpt-ref:").update(ref).digest("hex").slice(0, 16);
180
+ }
181
+
182
+ export function commitCursorCheckpoint(input: {
183
+ conversationId: string;
184
+ identityScope?: string;
185
+ modelId: string;
186
+ checkpointBytes: Uint8Array;
187
+ coveredMessageCount?: number;
188
+ prefixDigest?: string;
189
+ systemDigest?: string;
190
+ }): string | undefined {
191
+ if (!input.conversationId || !input.modelId || input.checkpointBytes.byteLength === 0) return undefined;
192
+ if (input.checkpointBytes.byteLength > CURSOR_CHECKPOINT_MAX_TOTAL_BYTES) return undefined;
193
+ prune();
194
+ const createdAt = now();
195
+ const ref = createHash("sha256")
196
+ .update("ocx:cursor:ckpt:")
197
+ .update(input.conversationId)
198
+ .update("|")
199
+ .update(input.identityScope?.trim() || "local")
200
+ .update("|")
201
+ .update(input.modelId)
202
+ .update("|")
203
+ .update(String(createdAt))
204
+ .update("|")
205
+ .update(input.checkpointBytes)
206
+ .digest("hex")
207
+ .slice(0, 32);
208
+ const snapshot: CursorCheckpointSnapshot = {
209
+ ref,
210
+ conversationId: input.conversationId,
211
+ identityScope: input.identityScope?.trim() || "local",
212
+ modelId: input.modelId,
213
+ checkpointBytes: input.checkpointBytes.slice(),
214
+ createdAt,
215
+ lastAccessAt: createdAt,
216
+ ...(input.coveredMessageCount !== undefined ? { coveredMessageCount: input.coveredMessageCount } : {}),
217
+ ...(input.prefixDigest ? { prefixDigest: input.prefixDigest } : {}),
218
+ ...(input.systemDigest ? { systemDigest: input.systemDigest } : {}),
219
+ };
220
+ const blobIds = collectCheckpointBlobIds(input.checkpointBytes);
221
+ if (blobIds === undefined) return undefined;
222
+ if (blobIds.length > 0) {
223
+ const lease = createCursorBlobCheckpointLease(ref);
224
+ if (!pinCursorBlobIdsForCheckpoint(blobIds, lease)) {
225
+ releaseCursorBlobRequestScope(lease);
226
+ return undefined;
227
+ }
228
+ snapshot.blobLease = lease;
229
+ }
230
+ deleteSnapshot(ref);
231
+ store.snapshots.set(ref, snapshot);
232
+ store.totalBytes += snapshot.checkpointBytes.byteLength;
233
+ indexPrefix(snapshot.prefixDigest, ref);
234
+ prune(createdAt);
235
+ return store.snapshots.has(ref) ? ref : undefined;
236
+ }
237
+
238
+ export function getCursorCheckpointForPrefix(input: {
239
+ prefixDigest: string;
240
+ systemDigest: string;
241
+ coveredMessageCount: number;
242
+ identityScope?: string;
243
+ modelId: string;
244
+ }): CursorCheckpointSnapshot | undefined {
245
+ prune();
246
+ const refs = store.prefixIndex.get(input.prefixDigest);
247
+ if (!refs || refs.size !== 1) return undefined;
248
+ const [ref] = refs;
249
+ if (!ref) return undefined;
250
+ const snapshot = getCursorCheckpoint(ref);
251
+ if (!snapshot) return undefined;
252
+ const identityScope = input.identityScope?.trim() || "local";
253
+ if (snapshot.systemDigest !== input.systemDigest) return undefined;
254
+ if (snapshot.coveredMessageCount !== input.coveredMessageCount) return undefined;
255
+ if (snapshot.identityScope !== identityScope) return undefined;
256
+ if (snapshot.modelId !== input.modelId) return undefined;
257
+ return snapshot;
258
+ }
259
+
260
+ export function getLatestCursorCheckpoint(
261
+ match: (snapshot: CursorCheckpointSnapshot) => boolean,
262
+ ): CursorCheckpointSnapshot | undefined {
263
+ prune();
264
+ let found: CursorCheckpointSnapshot | undefined;
265
+ for (const snapshot of store.snapshots.values()) {
266
+ if (match(snapshot)) found = snapshot;
267
+ }
268
+ return found ? getCursorCheckpoint(found.ref) : undefined;
269
+ }
270
+
271
+ export function getCursorCheckpoint(ref: string | undefined): CursorCheckpointSnapshot | undefined {
272
+ if (!ref) return undefined;
273
+ prune();
274
+ const snapshot = store.snapshots.get(ref);
275
+ if (!snapshot) return undefined;
276
+ const at = now();
277
+ if (at - snapshot.lastAccessAt > CURSOR_CHECKPOINT_TTL_MS) {
278
+ deleteSnapshot(ref);
279
+ return undefined;
280
+ }
281
+ snapshot.lastAccessAt = at;
282
+ store.snapshots.delete(ref);
283
+ store.snapshots.set(ref, snapshot);
284
+ return snapshot;
285
+ }
286
+
287
+ export function invalidateCursorCheckpoint(ref: string | undefined): void {
288
+ if (!ref) return;
289
+ deleteSnapshot(ref);
290
+ }
291
+
292
+ export function clearCursorCheckpointsForTests(): void {
293
+ stopPruneTimer();
294
+ for (const ref of [...store.snapshots.keys()]) deleteSnapshot(ref);
295
+ store.snapshots.clear();
296
+ store.prefixIndex.clear();
297
+ store.totalBytes = 0;
298
+ resetCursorCheckpointClockForTests();
299
+ }
300
+
301
+ export function cursorCheckpointStoreMetricsForTests(): { count: number; totalBytes: number } {
302
+ return { count: store.snapshots.size, totalBytes: store.totalBytes };
303
+ }
@@ -146,6 +146,24 @@ export function isCursorExternalWireModel(modelId: string): boolean {
146
146
  return !isCursorNativeWireModel(modelId);
147
147
  }
148
148
 
149
+ /**
150
+ * Native composer models whose tool-result continuation must still be sent as a
151
+ * userMessageAction with the plain "Continue:" text instead of a bare resumeAction.
152
+ *
153
+ * Observed on live Cursor Connect traffic (2026-08-20): `composer-2.5` (the
154
+ * standard, non-fast build) resumes a tool-result turn with server-side native
155
+ * tool calls (read/grep/exec) instead of answering, or completes with zero text
156
+ * (empty `content` + `stop`). `composer-2.5-fast` answers correctly on the same
157
+ * resumeAction path, so only the affected id is listed here. Sending the same
158
+ * continuation as an explicit user message (external path) makes the model
159
+ * answer reliably.
160
+ */
161
+ export function cursorNeedsExternalToolContinuation(modelId: string): boolean {
162
+ if (isCursorExternalWireModel(modelId)) return true;
163
+ const wire = cursorCodexToWireModelId(modelId).trim().toLowerCase();
164
+ return wire === "composer-2.5";
165
+ }
166
+
149
167
  function stripCursorEffortSuffix(wireModelId: string): string {
150
168
  const suffixes = [...CANONICAL_EFFORT_SUFFIXES].sort((a, b) => b.length - a.length);
151
169
  for (const suffix of suffixes) {
@@ -155,6 +173,13 @@ function stripCursorEffortSuffix(wireModelId: string): string {
155
173
  return wireModelId;
156
174
  }
157
175
 
176
+ /** Compare Cursor wire models without effort suffix or the grok cursor- request prefix. */
177
+ export function cursorCheckpointModelAffinityId(modelId: string): string {
178
+ const wire = cursorCodexToWireModelId(modelId).trim().toLowerCase();
179
+ const withoutPrefix = wire.startsWith("cursor-") ? wire.slice("cursor-".length) : wire;
180
+ return stripCursorEffortSuffix(withoutPrefix);
181
+ }
182
+
158
183
  export function isCursorRouterModelId(modelId: string): boolean {
159
184
  return (CURSOR_ROUTER_MODEL_IDS as readonly string[]).includes(modelId);
160
185
  }
@@ -33,6 +33,7 @@ import {
33
33
  CreatePlanRequestResponseSchema,
34
34
  CreatePlanResultSchema,
35
35
  CreatePlanSuccessSchema,
36
+ ConversationStateStructureSchema,
36
37
  ExaFetchRequestResponseSchema,
37
38
  ExaFetchRequestResponse_ApprovedSchema,
38
39
  ExaSearchRequestResponseSchema,
@@ -439,11 +440,12 @@ class LiveCursorTransport implements CursorTransport {
439
440
  private framesReceived = 0;
440
441
  private sawAssistantText = false;
441
442
  private firstFrameAt?: number;
442
- private firstFrameLogged = false;
443
+ private firstFrameLogged = false;
443
444
  /** Stable session identifier sent as x-session-id; mirrors IDE session semantics. */
444
445
  private readonly sessionId: string;
445
446
  /** Per-transport owner for native-exec / background shells. Must not share conversationId. */
446
447
  private readonly shellOwnerId = crypto.randomUUID();
448
+ private capturedCheckpointBytes?: Uint8Array;
447
449
 
448
450
  constructor(private readonly input: CursorTransportFactoryInput) {
449
451
  this.sessionId = input.sessionId?.trim() || crypto.randomUUID();
@@ -1129,7 +1131,7 @@ class LiveCursorTransport implements CursorTransport {
1129
1131
  }
1130
1132
  releaseBacklogLease();
1131
1133
  settler.settleFinish();
1132
- }, (err) => {
1134
+ }).catch((err) => {
1133
1135
  failAndClear(err instanceof Error ? err : new Error(String(err)));
1134
1136
  });
1135
1137
  };
@@ -1187,6 +1189,10 @@ class LiveCursorTransport implements CursorTransport {
1187
1189
  }, HEARTBEAT_MS);
1188
1190
  }
1189
1191
 
1192
+ capturedConversationCheckpoint(): Uint8Array | undefined {
1193
+ return this.capturedCheckpointBytes;
1194
+ }
1195
+
1190
1196
  private async handleServerMessage(
1191
1197
  message: AgentServerMessage,
1192
1198
  state: ReturnType<typeof createCursorProtobufEventState>,
@@ -1194,6 +1200,13 @@ class LiveCursorTransport implements CursorTransport {
1194
1200
  ): Promise<void> {
1195
1201
  if (!this.stream && !this.http1Connection) return;
1196
1202
  debugProviderDiagnostic("cursor", "frame", describeCursorServerFrame(message));
1203
+ if (message.message.case === "conversationCheckpointUpdate") {
1204
+ try {
1205
+ this.capturedCheckpointBytes = toBinary(ConversationStateStructureSchema, message.message.value);
1206
+ } catch {
1207
+ this.capturedCheckpointBytes = undefined;
1208
+ }
1209
+ }
1197
1210
  if (message.message.case === "kvServerMessage") {
1198
1211
  this.writeConnectFrame(encodeConnectFrame(handleCursorNativeKv(message.message.value, this.blobRequestScope)));
1199
1212
  return;
@@ -1403,3 +1416,7 @@ function cursorConnectErrorCode(payload: Uint8Array): string | undefined {
1403
1416
  export function createLiveCursorTransport(input: CursorTransportFactoryInput): CursorTransport {
1404
1417
  return new LiveCursorTransport(input);
1405
1418
  }
1419
+
1420
+ export function capturedCursorCheckpointBytes(transport: CursorTransport): Uint8Array | undefined {
1421
+ return transport.capturedConversationCheckpoint?.();
1422
+ }
@@ -117,6 +117,7 @@ interface CursorBlobLimits {
117
117
  interface CursorBlobRequestScopeState {
118
118
  keys: Set<string>;
119
119
  sealed: boolean;
120
+ kind: "request" | "checkpoint";
120
121
  }
121
122
 
122
123
  const DEFAULT_BLOB_LIMITS: CursorBlobLimits = {
@@ -376,7 +377,7 @@ export function createCursorBlobRequestScope(): CursorBlobRequestScopeToken {
376
377
  // IDENTITY, not just pin counts (review C2-2: identical descriptions made
377
378
  // scope-swap bugs invisible to deep comparison).
378
379
  const scope = Symbol(`cursor-blob-request-${++blobScopeSequence}`);
379
- blobRequestScopes.set(scope, { keys: new Set(), sealed: false });
380
+ blobRequestScopes.set(scope, { keys: new Set(), sealed: false, kind: "request" });
380
381
  return scope;
381
382
  }
382
383
 
@@ -402,6 +403,48 @@ export function storeCursorBlob(data: Uint8Array, requestScope?: CursorBlobReque
402
403
  return blobId;
403
404
  }
404
405
 
406
+ /**
407
+ * Long-lived pin for blobs referenced by an active Cursor conversation checkpoint.
408
+ * Unlike a request scope, this lease is not sealed and is not released by getBlob hydration.
409
+ */
410
+ export function createCursorBlobCheckpointLease(label: string): CursorBlobRequestScopeToken {
411
+ const scope = Symbol("cursor-blob-checkpoint-" + (++blobScopeSequence) + "-" + label.slice(0, 16));
412
+ blobRequestScopes.set(scope, { keys: new Set(), sealed: false, kind: "checkpoint" });
413
+ return scope;
414
+ }
415
+
416
+ export function pinCursorBlobIdsForCheckpoint(
417
+ blobIds: readonly Uint8Array[],
418
+ lease: CursorBlobRequestScopeToken,
419
+ ): boolean {
420
+ const state = blobRequestScopes.get(lease);
421
+ if (!state || state.sealed) return false;
422
+ const added: Array<{ entry: { requestPins: Set<CursorBlobRequestScopeToken> }; key: string }> = [];
423
+ for (const blobId of blobIds) {
424
+ if (blobId.byteLength === 0) continue;
425
+ const k = key(blobId);
426
+ const entry = blobs.get(k);
427
+ if (!entry || (isExpired(entry, Date.now()) && entry.requestPins.size === 0 && entry.provenance !== "remote-setBlobArgs")) {
428
+ for (const pinned of added) {
429
+ pinned.entry.requestPins.delete(lease);
430
+ state.keys.delete(pinned.key);
431
+ }
432
+ return false;
433
+ }
434
+ if (!entry.requestPins.has(lease)) {
435
+ entry.requestPins.add(lease);
436
+ state.keys.add(k);
437
+ added.push({ entry, key: k });
438
+ }
439
+ }
440
+ reconcileBlobClassAccountingAndEnforce();
441
+ return true;
442
+ }
443
+
444
+ export function hasCursorBlob(blobId: Uint8Array): boolean {
445
+ return getBlob(key(blobId)) !== undefined;
446
+ }
447
+
405
448
  export interface CursorBlobMetrics {
406
449
  count: number;
407
450
  totalBytes: number;
@@ -574,7 +617,9 @@ export function handleCursorNativeKv(
574
617
  if (kvMsg.message.case === "getBlobArgs") {
575
618
  const blobKey = key(kvMsg.message.value.blobId);
576
619
  const blobData = getBlob(blobKey);
577
- if (blobData) releaseHydratedBlob(blobKey, requestScope);
620
+ if (blobData && requestScope && blobRequestScopes.get(requestScope)?.kind === "request") {
621
+ releaseHydratedBlob(blobKey, requestScope);
622
+ }
578
623
  return clientBytes({
579
624
  message: {
580
625
  case: "kvClientMessage",
@@ -4,7 +4,7 @@ import { ValueSchema } from "@bufbuild/protobuf/wkt";
4
4
  import type { OcxAssistantContentPart, OcxMessage, OcxToolResultMessage } from "../../types";
5
5
  import { namespacedToolName } from "../../types";
6
6
  import type { CursorRunRequest } from "./types";
7
- import { isCursorExternalWireModel } from "./discovery";
7
+ import { cursorNeedsExternalToolContinuation, isCursorExternalWireModel } from "./discovery";
8
8
  import { normalizeCursorToolResultText } from "./tool-result-normalize";
9
9
  import { debugProviderDiagnostic } from "../../lib/debug";
10
10
  import {
@@ -25,6 +25,7 @@ import {
25
25
  ConversationActionSchema,
26
26
  ConversationStepSchema,
27
27
  ConversationStateStructureSchema,
28
+ type ConversationStateStructure,
28
29
  ConversationTurnStructureSchema,
29
30
  McpArgsSchema,
30
31
  McpSuccessSchema,
@@ -793,8 +794,11 @@ function buildPreparedCursorRunRequest(
793
794
  const lastRawIsToolResult = request.rawMessages?.at(-1)?.role === "toolResult";
794
795
  // Native models resume the remembered Cursor conversation. External wire
795
796
  // models continue as userMessageAction so history-blob tool results stay
796
- // visible without a ResumeAction.
797
- const externalToolContinuation = lastRawIsToolResult && isCursorExternalWireModel(request.modelId);
797
+ // visible without a ResumeAction. Some native composer ids are also routed
798
+ // through the external continuation path (cursorNeedsExternalToolContinuation)
799
+ // because a bare resumeAction makes them continue exploring with native tools
800
+ // instead of answering (observed on composer-2.5; see discovery.ts).
801
+ const externalToolContinuation = lastRawIsToolResult && cursorNeedsExternalToolContinuation(request.modelId);
798
802
  const actionCase = (externalToolContinuation || (!lastRawIsToolResult && text.trim().length > 0))
799
803
  ? "userMessageAction"
800
804
  : "resumeAction";
@@ -820,9 +824,70 @@ function buildPreparedCursorRunRequest(
820
824
  }),
821
825
  },
822
826
  });
823
- const rootPromptMessagesState = rootPromptMessages(request, requestScope);
824
- const rootPromptMessageIds = rootPromptMessagesState.ids;
825
- const turnIds = conversationTurns(request, requestScope, rootPromptMessagesState.historyMessageStart);
827
+ let continuationMode: "full-replay" | "checkpoint" = "full-replay";
828
+ let checkpointInvalidationReason = request.checkpointInvalidationReason;
829
+ let conversationState: ConversationStateStructure | undefined;
830
+ let rootPromptMessagesState: ReturnType<typeof rootPromptMessages> | undefined;
831
+ if (request.checkpointBytes && request.checkpointBytes.byteLength > 0) {
832
+ try {
833
+ conversationState = fromBinary(ConversationStateStructureSchema, request.checkpointBytes);
834
+ continuationMode = "checkpoint";
835
+ const suffixStart = request.checkpointSuffixStart;
836
+ if (
837
+ typeof suffixStart === "number"
838
+ && Number.isSafeInteger(suffixStart)
839
+ && suffixStart >= 0
840
+ && request.rawMessages
841
+ && suffixStart < request.rawMessages.length
842
+ ) {
843
+ const suffixRequest: CursorRunRequest = {
844
+ ...request,
845
+ system: [],
846
+ rawMessages: request.rawMessages.slice(suffixStart),
847
+ };
848
+ const suffixRoots = rootPromptMessages(suffixRequest, requestScope);
849
+ const suffixTurns = conversationTurns(suffixRequest, requestScope, suffixRoots.historyMessageStart);
850
+ const suffixSystemCount = systemPromptBlobs(suffixRequest).length;
851
+ const suffixHistoryIds = suffixRoots.ids.slice(suffixSystemCount);
852
+ const suffixHistorySerialized = suffixRoots.serialized.slice(suffixSystemCount);
853
+ conversationState = create(ConversationStateStructureSchema, {
854
+ ...conversationState,
855
+ rootPromptMessagesJson: [
856
+ ...conversationState.rootPromptMessagesJson,
857
+ ...suffixHistoryIds,
858
+ ],
859
+ turns: [
860
+ ...conversationState.turns,
861
+ ...suffixTurns,
862
+ ],
863
+ });
864
+ rootPromptMessagesState = {
865
+ ids: suffixHistoryIds,
866
+ byteLength: suffixRoots.byteLength,
867
+ historyMessageStart: suffixRoots.historyMessageStart,
868
+ serialized: suffixHistorySerialized,
869
+ };
870
+ }
871
+ } catch {
872
+ checkpointInvalidationReason = "decode_failed";
873
+ }
874
+ }
875
+ if (!conversationState) {
876
+ rootPromptMessagesState = rootPromptMessages(request, requestScope);
877
+ conversationState = create(ConversationStateStructureSchema, {
878
+ rootPromptMessagesJson: rootPromptMessagesState.ids,
879
+ turns: conversationTurns(request, requestScope, rootPromptMessagesState.historyMessageStart),
880
+ todos: [],
881
+ pendingToolCalls: [],
882
+ previousWorkspaceUris: [],
883
+ fileStates: {},
884
+ fileStatesV2: {},
885
+ summaryArchives: [],
886
+ turnTimings: [],
887
+ subagentStates: {},
888
+ readPaths: [],
889
+ });
890
+ }
826
891
  // Hoisted out of the mcp_tools spread below so the estimate can read the same
827
892
  // filtered definitions the wire carries. Both helpers are pure.
828
893
  const visibleTools = cursorToolsForActivePrompt(request.tools, rawText, request.toolChoice);
@@ -834,9 +899,13 @@ function buildPreparedCursorRunRequest(
834
899
  turnType: lastRawIsToolResult ? "tool-continuation" : "initial",
835
900
  externalModel: isCursorExternalWireModel(request.modelId),
836
901
  rawMessages: request.rawMessages?.length ?? 0,
837
- rootBlobs: rootPromptMessageIds.length,
838
- rootBytes: rootPromptMessagesState.byteLength,
839
- turnBlobs: turnIds.length,
902
+ continuationMode,
903
+ checkpointPresent: continuationMode === "checkpoint",
904
+ checkpointBytes: continuationMode === "checkpoint" ? request.checkpointBytes?.byteLength : undefined,
905
+ checkpointInvalidationReason,
906
+ rootBlobs: conversationState.rootPromptMessagesJson.length,
907
+ rootBytes: rootPromptMessagesState?.byteLength ?? 0,
908
+ turnBlobs: conversationState.turns.length,
840
909
  tools: request.tools?.length ?? 0,
841
910
  });
842
911
 
@@ -847,19 +916,7 @@ function buildPreparedCursorRunRequest(
847
916
  const hasExplicitModelParameters = (request.requestedModelParameters?.length ?? 0) > 0;
848
917
  const runRequest = create(AgentRunRequestSchema, {
849
918
  conversationId: request.conversationId,
850
- conversationState: create(ConversationStateStructureSchema, {
851
- rootPromptMessagesJson: rootPromptMessageIds,
852
- turns: turnIds,
853
- todos: [],
854
- pendingToolCalls: [],
855
- previousWorkspaceUris: [],
856
- fileStates: {},
857
- fileStatesV2: {},
858
- summaryArchives: [],
859
- turnTimings: [],
860
- subagentStates: {},
861
- readPaths: [],
862
- }),
919
+ conversationState,
863
920
  action,
864
921
  // Explicit model-picker parameters follow current Cursor clients and use requested_model alone.
865
922
  // Keep legacy model_details for flat model ids and the already-live Router path; sending both for
@@ -906,7 +963,7 @@ function buildPreparedCursorRunRequest(
906
963
  // Same instances that produced `bytes`, so the estimate cannot count history or
907
964
  // tools the payload dropped — the defect that blocked PR #376.
908
965
  const modelVisibleParts = [
909
- ...rootPromptMessagesState.serialized,
966
+ ...(rootPromptMessagesState?.serialized ?? []),
910
967
  ...(actionCase === "userMessageAction" ? [actionText] : []),
911
968
  ...mcpToolDefs.map(modelVisibleToolText),
912
969
  ];