@bitkyc08/opencodex 2.8.0 → 2.9.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 (175) hide show
  1. package/README.md +24 -0
  2. package/bin/ocx.mjs +32 -4
  3. package/gui/dist/assets/index-CHwf3tTD.css +1 -0
  4. package/gui/dist/assets/index-u5eFOv2y.js +67 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/adapters/anthropic-image-normalize.ts +114 -20
  8. package/src/adapters/anthropic.ts +126 -10
  9. package/src/adapters/azure.ts +3 -3
  10. package/src/adapters/base.ts +7 -3
  11. package/src/adapters/cursor/discovery.ts +14 -4
  12. package/src/adapters/cursor/effort-map.ts +18 -6
  13. package/src/adapters/cursor/framing.ts +102 -27
  14. package/src/adapters/cursor/kv-store.ts +30 -3
  15. package/src/adapters/cursor/live-models.ts +22 -2
  16. package/src/adapters/cursor/live-transport.ts +245 -49
  17. package/src/adapters/cursor/mcp-manager.ts +105 -8
  18. package/src/adapters/cursor/native-exec-mcp.ts +5 -3
  19. package/src/adapters/cursor/native-exec-shell.ts +296 -14
  20. package/src/adapters/cursor/native-exec.ts +381 -33
  21. package/src/adapters/cursor/protobuf-events.ts +28 -1
  22. package/src/adapters/cursor/protobuf-request.ts +71 -39
  23. package/src/adapters/cursor/request-builder.ts +2 -2
  24. package/src/adapters/cursor/transport.ts +2 -0
  25. package/src/adapters/cursor.ts +13 -2
  26. package/src/adapters/google-antigravity-replay.ts +184 -17
  27. package/src/adapters/google.ts +58 -8
  28. package/src/adapters/kiro-thinking.ts +23 -9
  29. package/src/adapters/kiro-tools.ts +49 -18
  30. package/src/adapters/kiro.ts +377 -133
  31. package/src/adapters/mimo-free.ts +36 -4
  32. package/src/adapters/openai-chat.ts +143 -17
  33. package/src/adapters/openai-responses.ts +130 -14
  34. package/src/adapters/run-turn-queue.ts +7 -1
  35. package/src/bridge.ts +466 -69
  36. package/src/chat/outbound.ts +144 -38
  37. package/src/claude/inbound-debug.ts +53 -8
  38. package/src/claude/outbound.ts +224 -38
  39. package/src/cli/agent-driven.ts +34 -1
  40. package/src/cli/catalog-prewarm.ts +5 -2
  41. package/src/cli/claude-desktop.ts +2 -2
  42. package/src/cli/doctor.ts +12 -0
  43. package/src/cli/export-command.ts +187 -0
  44. package/src/cli/help.ts +11 -0
  45. package/src/cli/index.ts +13 -3
  46. package/src/cli/init.ts +129 -102
  47. package/src/cli/opencode.ts +36 -151
  48. package/src/cli/star-prompt.ts +13 -4
  49. package/src/cli/status-oauth.ts +12 -2
  50. package/src/clients/config-export.ts +377 -0
  51. package/src/codex/account-runtime-state.ts +19 -1
  52. package/src/codex/account-store.ts +162 -82
  53. package/src/codex/auth-api.ts +467 -159
  54. package/src/codex/auth-context.ts +15 -2
  55. package/src/codex/catalog/aggregation.ts +15 -0
  56. package/src/codex/catalog/effort.ts +16 -6
  57. package/src/codex/catalog/metadata.ts +6 -0
  58. package/src/codex/catalog/parsing.ts +3 -1
  59. package/src/codex/catalog/provider-fetch.ts +29 -0
  60. package/src/codex/catalog/sync.ts +64 -7
  61. package/src/codex/catalog.ts +2 -2
  62. package/src/codex/inject.ts +5 -5
  63. package/src/codex/main-account-cache.ts +8 -1
  64. package/src/codex/model-cache.ts +81 -2
  65. package/src/codex/pool-rotation.ts +39 -0
  66. package/src/codex/project-config-warnings.ts +12 -1
  67. package/src/codex/quota.ts +35 -3
  68. package/src/codex/routing.ts +46 -1
  69. package/src/codex/shim.ts +10 -4
  70. package/src/codex/subagent-model-fallback.ts +12 -0
  71. package/src/codex/websocket-registry.ts +27 -0
  72. package/src/combos/failover.ts +31 -1
  73. package/src/combos/request.ts +9 -0
  74. package/src/combos/resolve.ts +60 -4
  75. package/src/combos/types.ts +12 -0
  76. package/src/config.ts +510 -55
  77. package/src/github/star-state.ts +13 -1
  78. package/src/images/fulfill.ts +39 -1
  79. package/src/images/loop.ts +52 -12
  80. package/src/lib/admission.ts +83 -0
  81. package/src/lib/app-owned-memory-stores.ts +173 -0
  82. package/src/lib/app-owned-memory.ts +265 -0
  83. package/src/lib/bun-stream-caps.ts +31 -7
  84. package/src/lib/config-ownership.ts +33 -0
  85. package/src/lib/crash-guard.ts +65 -5
  86. package/src/lib/debug-log-buffer.ts +47 -6
  87. package/src/lib/destination-policy.ts +12 -1
  88. package/src/lib/errors.ts +3 -0
  89. package/src/lib/gcp-adc.ts +40 -2
  90. package/src/lib/injection-debug-log.ts +26 -2
  91. package/src/lib/provider-outbound.ts +3 -0
  92. package/src/lib/sidecar-tracker.ts +5 -2
  93. package/src/lib/sse-decoder.ts +257 -37
  94. package/src/lib/state-store-registrations.ts +109 -0
  95. package/src/lib/state-store-sweeper.ts +184 -0
  96. package/src/lib/translator-budget.ts +356 -0
  97. package/src/lib/windows-secret-acl.ts +33 -12
  98. package/src/lib/winsw.ts +14 -1
  99. package/src/oauth/anthropic-routing.ts +31 -7
  100. package/src/oauth/google-antigravity.ts +2 -1
  101. package/src/oauth/health.ts +30 -12
  102. package/src/oauth/index.ts +127 -23
  103. package/src/oauth/kiro-credentials.ts +72 -1
  104. package/src/oauth/kiro.ts +23 -4
  105. package/src/oauth/store.ts +165 -18
  106. package/src/oauth/token-guardian.ts +43 -4
  107. package/src/oauth/types.ts +2 -1
  108. package/src/providers/base-url-choices.ts +10 -0
  109. package/src/providers/derive.ts +12 -0
  110. package/src/providers/free-directory.ts +4 -1
  111. package/src/providers/key-failover.ts +12 -0
  112. package/src/providers/openai-sidecar.ts +4 -1
  113. package/src/providers/quota.ts +68 -7
  114. package/src/providers/registry.ts +279 -3
  115. package/src/responses/parser.ts +5 -1
  116. package/src/responses/spill-store.ts +394 -0
  117. package/src/responses/state.ts +520 -102
  118. package/src/router.ts +18 -1
  119. package/src/server/adapter-resolve.ts +20 -3
  120. package/src/server/auth-cors.ts +121 -28
  121. package/src/server/chat-completions.ts +57 -12
  122. package/src/server/claude-messages.ts +85 -13
  123. package/src/server/index.ts +242 -100
  124. package/src/server/lifecycle.ts +155 -25
  125. package/src/server/management/agent-settings-routes.ts +79 -36
  126. package/src/server/management/api-key-usage.ts +167 -0
  127. package/src/server/management/body.ts +35 -0
  128. package/src/server/management/combo-routes.ts +5 -1
  129. package/src/server/management/config-routes.ts +42 -12
  130. package/src/server/management/logs-usage-routes.ts +41 -21
  131. package/src/server/management/model-routes.ts +188 -54
  132. package/src/server/management/oauth-account-routes.ts +115 -26
  133. package/src/server/management/provider-routes.ts +56 -6
  134. package/src/server/management/shared.ts +16 -3
  135. package/src/server/management/sidebar-routes.ts +50 -1
  136. package/src/server/management/system-restart.ts +13 -6
  137. package/src/server/management/system-routes.ts +15 -3
  138. package/src/server/management/usage-summary-cache.ts +86 -0
  139. package/src/server/management-api.ts +39 -5
  140. package/src/server/management-auth.ts +65 -14
  141. package/src/server/port-reclaim.ts +58 -12
  142. package/src/server/ports.ts +2 -0
  143. package/src/server/proxy-liveness.ts +60 -14
  144. package/src/server/relay-eager.ts +20 -4
  145. package/src/server/relay.ts +548 -154
  146. package/src/server/request-decompress.ts +51 -4
  147. package/src/server/request-log.ts +134 -15
  148. package/src/server/responses/collaboration.ts +15 -4
  149. package/src/server/responses/compact.ts +3 -0
  150. package/src/server/responses/core.ts +241 -66
  151. package/src/server/responses-image-gen-repair.ts +19 -5
  152. package/src/server/responses-item-id-repair.ts +23 -5
  153. package/src/server/sse-payload-rewrite.ts +71 -12
  154. package/src/server/startup-health-cache.ts +14 -1
  155. package/src/server/system-env.ts +8 -1
  156. package/src/server/windows-tcp-drop.ts +15 -5
  157. package/src/server/ws-bridge.ts +25 -0
  158. package/src/service.ts +179 -13
  159. package/src/storage/policy-job.ts +93 -23
  160. package/src/storage/policy-worker.ts +6 -0
  161. package/src/storage/restore-job.ts +62 -16
  162. package/src/storage/restore-worker.ts +6 -0
  163. package/src/storage/storage-mutation-coordinator.ts +36 -6
  164. package/src/storage/worker-lifecycle.ts +181 -47
  165. package/src/tray/windows.ts +97 -25
  166. package/src/types.ts +39 -6
  167. package/src/update/index.ts +24 -5
  168. package/src/update/job.ts +598 -73
  169. package/src/usage/log.ts +115 -17
  170. package/src/usage/summary.ts +67 -2
  171. package/src/vision/index.ts +112 -22
  172. package/src/web-search/loop.ts +38 -6
  173. package/src/web-search/progress-stream.ts +14 -3
  174. package/gui/dist/assets/index-BDjpkcRN.js +0 -67
  175. package/gui/dist/assets/index-BHsKRFh9.css +0 -1
@@ -1,8 +1,10 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { enforceAppOwnedMemoryBudget } from "../../lib/app-owned-memory";
2
3
  import { create } from "@bufbuild/protobuf";
3
4
  import {
4
5
  DiagnosticsErrorSchema,
5
6
  DiagnosticsResultSchema,
7
+ ErrorSchema,
6
8
  GetBlobResultSchema,
7
9
  KvClientMessageSchema,
8
10
  McpErrorSchema,
@@ -60,6 +62,8 @@ export type CursorNativeExecDeps = CursorNativeNetworkDeps & CursorNativeToolDep
60
62
  * never told any MCP tools exist, so it never sends `mcpArgs`.
61
63
  */
62
64
  export interface CursorNativeExecContext extends CursorNativeExecDeps {
65
+ /** Stable owner for background shells created by this transport session. */
66
+ sessionId?: string;
63
67
  mcpToolDefs?: McpToolDefinition[];
64
68
  clientToolDefs?: McpToolDefinition[];
65
69
  /** Unsafe opt-in escape hatch for Cursor server-driven local fs/shell/fetch execution. */
@@ -80,42 +84,245 @@ export function cursorUnsafeNativeLocalExecEnabled(input: Pick<CursorNativeExecC
80
84
  * their blobs on every turn (`rootPromptMessages` → `storeCursorBlob`), so TTL + cap eviction is
81
85
  * safe for live sessions: only genuinely abandoned entries age out.
82
86
  */
83
- const BLOB_TTL_MS = 15 * 60 * 1000;
84
- const BLOB_MAX_ENTRIES = 4096;
85
- const blobs = new Map<string, { data: Uint8Array; storedAt: number }>();
86
-
87
- function evictStaleBlobs(now: number): void {
88
- if (blobs.size <= BLOB_MAX_ENTRIES) {
89
- // TTL sweep only when the map has any chance of stale entries; Map iterates insertion order.
90
- for (const [k, entry] of blobs) {
91
- if (now - entry.storedAt <= BLOB_TTL_MS) break;
92
- blobs.delete(k);
87
+ const BLOB_TTL_MS = 15 * 60_000;
88
+ const BLOB_MAX_ENTRIES = 4_096;
89
+ const BLOB_MAX_ENTRY_BYTES = 16 * 1024 * 1024;
90
+ export const CURSOR_BLOB_MAX_TOTAL_BYTES = 64 * 1024 * 1024;
91
+
92
+ type CursorBlobProvenance = "local-regenerated" | "remote-setBlobArgs";
93
+ export type CursorBlobRequestScopeToken = symbol;
94
+
95
+ interface CursorBlobEntry {
96
+ data: Uint8Array;
97
+ storedAt: number;
98
+ sizeBytes: number;
99
+ provenance: CursorBlobProvenance;
100
+ requestPins: Set<CursorBlobRequestScopeToken>;
101
+ }
102
+
103
+ type CursorBlobRejectionReason = "entry_too_large" | "pinned_saturation" | "request_pinned_conflict";
104
+ type CursorBlobAdmission =
105
+ | { admitted: true; replaced: boolean }
106
+ | { admitted: false; reason: CursorBlobRejectionReason };
107
+
108
+ interface CursorBlobLimits {
109
+ ttlMs: number;
110
+ maxEntries: number;
111
+ maxEntryBytes: number;
112
+ maxTotalBytes: number;
113
+ }
114
+
115
+ interface CursorBlobRequestScopeState {
116
+ keys: Set<string>;
117
+ sealed: boolean;
118
+ }
119
+
120
+ const DEFAULT_BLOB_LIMITS: CursorBlobLimits = {
121
+ ttlMs: BLOB_TTL_MS,
122
+ maxEntries: BLOB_MAX_ENTRIES,
123
+ maxEntryBytes: BLOB_MAX_ENTRY_BYTES,
124
+ maxTotalBytes: CURSOR_BLOB_MAX_TOTAL_BYTES,
125
+ };
126
+
127
+ const blobs = new Map<string, CursorBlobEntry>();
128
+ const blobRequestScopes = new Map<CursorBlobRequestScopeToken, CursorBlobRequestScopeState>();
129
+ let blobLimits = { ...DEFAULT_BLOB_LIMITS };
130
+ let blobBytes = 0;
131
+ let blobLocalBytes = 0;
132
+ let blobPinnedBytes = 0;
133
+ let blobEvictableBytes = 0;
134
+ let blobOldestEvictableAt: number | null = null;
135
+ let rejectedEntryTooLarge = 0;
136
+ let rejectedPinnedSaturation = 0;
137
+ let blobExpiryAccountingTimer: ReturnType<typeof setTimeout> | undefined;
138
+
139
+ function isExpired(entry: CursorBlobEntry, now: number): boolean {
140
+ return now - entry.storedAt >= blobLimits.ttlMs;
141
+ }
142
+
143
+ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
144
+ if (a.byteLength !== b.byteLength) return false;
145
+ for (let i = 0; i < a.byteLength; i++) if (a[i] !== b[i]) return false;
146
+ return true;
147
+ }
148
+
149
+ function recomputeBlobClassAccounting(): void {
150
+ const now = Date.now();
151
+ let localBytes = 0;
152
+ let pinnedBytes = 0;
153
+ let evictableBytes = 0;
154
+ let oldestAt: number | null = null;
155
+ for (const entry of blobs.values()) {
156
+ const requestPinned = entry.requestPins.size > 0;
157
+ const provenancePinned = entry.provenance === "remote-setBlobArgs" && !isExpired(entry, now);
158
+ if (entry.provenance === "local-regenerated") localBytes += entry.sizeBytes;
159
+ if (requestPinned || provenancePinned) pinnedBytes += entry.sizeBytes;
160
+ if (!requestPinned && (entry.provenance === "local-regenerated" || isExpired(entry, now))) {
161
+ evictableBytes += entry.sizeBytes;
162
+ oldestAt = oldestAt === null ? entry.storedAt : Math.min(oldestAt, entry.storedAt);
93
163
  }
94
- return;
95
164
  }
96
- // Over cap: drop oldest entries first (insertion order approximates recency because re-stores
97
- // delete+set to refresh their position).
98
- const excess = blobs.size - BLOB_MAX_ENTRIES;
99
- let dropped = 0;
100
- for (const k of blobs.keys()) {
101
- if (dropped >= excess) break;
102
- blobs.delete(k);
103
- dropped++;
165
+ blobLocalBytes = localBytes;
166
+ blobPinnedBytes = pinnedBytes;
167
+ blobEvictableBytes = evictableBytes;
168
+ blobOldestEvictableAt = oldestAt;
169
+ scheduleBlobExpiryAccounting(now);
170
+ }
171
+
172
+ function reconcileBlobClassAccountingAndEnforce(): void {
173
+ recomputeBlobClassAccounting();
174
+ enforceAppOwnedMemoryBudget();
175
+ }
176
+
177
+ function scheduleBlobExpiryAccounting(now: number): void {
178
+ if (blobExpiryAccountingTimer) clearTimeout(blobExpiryAccountingTimer);
179
+ blobExpiryAccountingTimer = undefined;
180
+ let nextExpiry = Number.POSITIVE_INFINITY;
181
+ for (const entry of blobs.values()) {
182
+ if (entry.provenance !== "remote-setBlobArgs" || entry.requestPins.size > 0) continue;
183
+ const expiresAt = entry.storedAt + blobLimits.ttlMs;
184
+ if (expiresAt > now) nextExpiry = Math.min(nextExpiry, expiresAt);
185
+ }
186
+ if (!Number.isFinite(nextExpiry)) return;
187
+ blobExpiryAccountingTimer = setTimeout(() => {
188
+ blobExpiryAccountingTimer = undefined;
189
+ reconcileBlobClassAccountingAndEnforce();
190
+ }, Math.max(0, nextExpiry - now));
191
+ blobExpiryAccountingTimer.unref?.();
192
+ }
193
+
194
+ function deleteBlob(k: string, recompute = true): number {
195
+ const entry = blobs.get(k);
196
+ if (!entry) return 0;
197
+ blobs.delete(k);
198
+ blobBytes -= entry.sizeBytes;
199
+ for (const scope of entry.requestPins) blobRequestScopes.get(scope)?.keys.delete(k);
200
+ if (recompute) recomputeBlobClassAccounting();
201
+ return entry.sizeBytes;
202
+ }
203
+
204
+ function releaseHydratedBlob(k: string, requestScope?: CursorBlobRequestScopeToken): void {
205
+ const entry = blobs.get(k);
206
+ if (!entry) return;
207
+ const scopes = requestScope ? [requestScope] : [...entry.requestPins];
208
+ let changed = false;
209
+ for (const scope of scopes) {
210
+ if (!entry.requestPins.delete(scope)) continue;
211
+ changed = true;
212
+ const state = blobRequestScopes.get(scope);
213
+ state?.keys.delete(k);
214
+ if (state?.sealed && state.keys.size === 0) blobRequestScopes.delete(scope);
104
215
  }
216
+ if (changed) reconcileBlobClassAccountingAndEnforce();
105
217
  }
106
218
 
107
- function setBlob(k: string, data: Uint8Array): void {
219
+ function setBlob(
220
+ k: string,
221
+ data: Uint8Array,
222
+ provenance: CursorBlobProvenance,
223
+ requestScope?: CursorBlobRequestScopeToken,
224
+ ): CursorBlobAdmission {
225
+ if (data.byteLength > blobLimits.maxEntryBytes) {
226
+ rejectedEntryTooLarge++;
227
+ return { admitted: false, reason: "entry_too_large" };
228
+ }
229
+
108
230
  const now = Date.now();
109
- blobs.delete(k); // refresh insertion order so live sessions stay newest
110
- blobs.set(k, { data, storedAt: now });
111
- evictStaleBlobs(now);
231
+ const existing = blobs.get(k);
232
+ const sameData = existing ? bytesEqual(existing.data, data) : false;
233
+ if (existing && !sameData && existing.requestPins.size > 0) {
234
+ return { admitted: false, reason: "request_pinned_conflict" };
235
+ }
236
+
237
+ const removals = new Set<string>();
238
+ for (const [candidateKey, entry] of blobs) {
239
+ if (candidateKey === k && sameData) continue;
240
+ if (entry.requestPins.size === 0 && isExpired(entry, now)) removals.add(candidateKey);
241
+ }
242
+
243
+ const existingRemovedByTtl = existing !== undefined && removals.has(k);
244
+ const reuseExisting = existing !== undefined && sameData && !existingRemovedByTtl;
245
+ const mergedProvenance: CursorBlobProvenance = reuseExisting && existing.provenance === "remote-setBlobArgs"
246
+ ? "remote-setBlobArgs"
247
+ : provenance;
248
+ const storedAt = reuseExisting && existing.provenance === "remote-setBlobArgs" && provenance === "local-regenerated"
249
+ ? existing.storedAt
250
+ : now;
251
+
252
+ let projectedBytes = blobBytes;
253
+ let projectedCount = blobs.size;
254
+ for (const candidateKey of removals) {
255
+ const entry = blobs.get(candidateKey);
256
+ if (!entry) continue;
257
+ projectedBytes -= entry.sizeBytes;
258
+ projectedCount--;
259
+ }
260
+ if (existing && !existingRemovedByTtl) {
261
+ projectedBytes -= existing.sizeBytes;
262
+ projectedCount--;
263
+ }
264
+ projectedBytes += data.byteLength;
265
+ projectedCount++;
266
+
267
+ if (projectedBytes > blobLimits.maxTotalBytes || projectedCount > blobLimits.maxEntries) {
268
+ const localVictims = [...blobs.entries()].sort((a, b) => a[1].storedAt - b[1].storedAt);
269
+ for (const [candidateKey, entry] of localVictims) {
270
+ if (
271
+ candidateKey === k
272
+ || removals.has(candidateKey)
273
+ || entry.requestPins.size > 0
274
+ || entry.provenance !== "local-regenerated"
275
+ ) continue;
276
+ removals.add(candidateKey);
277
+ projectedBytes -= entry.sizeBytes;
278
+ projectedCount--;
279
+ if (projectedBytes <= blobLimits.maxTotalBytes && projectedCount <= blobLimits.maxEntries) break;
280
+ }
281
+ }
282
+
283
+ if (projectedBytes > blobLimits.maxTotalBytes || projectedCount > blobLimits.maxEntries) {
284
+ rejectedPinnedSaturation++;
285
+ return { admitted: false, reason: "pinned_saturation" };
286
+ }
287
+
288
+ // Carry forward only pins whose scope still has live state; a dead scope's
289
+ // token can never be released again (review C1-1).
290
+ const requestPins = new Set<CursorBlobRequestScopeToken>();
291
+ if (reuseExisting) {
292
+ for (const scope of existing.requestPins) {
293
+ if (blobRequestScopes.has(scope)) requestPins.add(scope);
294
+ }
295
+ }
296
+ // A pin is only attached when the scope still has live UNSEALED state:
297
+ // sealing fixes the advertised key set (locked contract), so a remote
298
+ // setBlobArgs arriving after seal must not extend the pin set — and a
299
+ // sealed scope whose advertised keys have all hydrated is already deleted,
300
+ // so attaching its stale token would create a permanent untracked pin the
301
+ // terminal release could never clear (reviews C1-1/C2-1).
302
+ const scopeState = requestScope ? blobRequestScopes.get(requestScope) : undefined;
303
+ if (requestScope && scopeState && !scopeState.sealed) requestPins.add(requestScope);
304
+ const entry: CursorBlobEntry = {
305
+ data: reuseExisting ? existing.data : data.slice(),
306
+ storedAt,
307
+ sizeBytes: data.byteLength,
308
+ provenance: mergedProvenance,
309
+ requestPins,
310
+ };
311
+
312
+ for (const candidateKey of removals) deleteBlob(candidateKey, false);
313
+ if (blobs.has(k)) deleteBlob(k, false);
314
+ blobs.set(k, entry);
315
+ blobBytes += entry.sizeBytes;
316
+ for (const scope of entry.requestPins) blobRequestScopes.get(scope)?.keys.add(k);
317
+ reconcileBlobClassAccountingAndEnforce();
318
+ return { admitted: true, replaced: existing !== undefined };
112
319
  }
113
320
 
114
321
  function getBlob(k: string): Uint8Array | undefined {
115
322
  const entry = blobs.get(k);
116
323
  if (!entry) return undefined;
117
- if (Date.now() - entry.storedAt > BLOB_TTL_MS) {
118
- blobs.delete(k);
324
+ if (entry.requestPins.size === 0 && isExpired(entry, Date.now())) {
325
+ deleteBlob(k);
119
326
  return undefined;
120
327
  }
121
328
  return entry.data;
@@ -130,12 +337,136 @@ function key(bytes: Uint8Array): string {
130
337
  * blob id. Cursor's `rootPromptMessagesJson`/turn entries are blob IDS, not inline content — the
131
338
  * server fetches the bytes back via `getBlobArgs`. Mirrors jawcode `createBlobId`/`storeCursorBlob`.
132
339
  */
133
- export function storeCursorBlob(data: Uint8Array): Uint8Array {
340
+ export class CursorBlobAdmissionError extends Error {
341
+ readonly code = "cursor_blob_capacity";
342
+
343
+ constructor(readonly reason: CursorBlobRejectionReason) {
344
+ super("Cursor blob capacity is exhausted; retry with a smaller request.");
345
+ this.name = "CursorBlobAdmissionError";
346
+ }
347
+ }
348
+
349
+ let blobScopeSequence = 0;
350
+
351
+ export function createCursorBlobRequestScope(): CursorBlobRequestScopeToken {
352
+ // Unique description per token so debug snapshots expose exact pin
353
+ // IDENTITY, not just pin counts (review C2-2: identical descriptions made
354
+ // scope-swap bugs invisible to deep comparison).
355
+ const scope = Symbol(`cursor-blob-request-${++blobScopeSequence}`);
356
+ blobRequestScopes.set(scope, { keys: new Set(), sealed: false });
357
+ return scope;
358
+ }
359
+
360
+ export function sealCursorBlobRequestScope(scope: CursorBlobRequestScopeToken): void {
361
+ const state = blobRequestScopes.get(scope);
362
+ if (!state) return;
363
+ state.sealed = true;
364
+ if (state.keys.size === 0) blobRequestScopes.delete(scope);
365
+ }
366
+
367
+ export function releaseCursorBlobRequestScope(scope: CursorBlobRequestScopeToken): void {
368
+ const state = blobRequestScopes.get(scope);
369
+ if (!state) return;
370
+ for (const k of state.keys) blobs.get(k)?.requestPins.delete(scope);
371
+ blobRequestScopes.delete(scope);
372
+ reconcileBlobClassAccountingAndEnforce();
373
+ }
374
+
375
+ export function storeCursorBlob(data: Uint8Array, requestScope?: CursorBlobRequestScopeToken): Uint8Array {
134
376
  const blobId = new Uint8Array(createHash("sha256").update(data).digest());
135
- setBlob(key(blobId), data);
377
+ const admission = setBlob(key(blobId), data, "local-regenerated", requestScope);
378
+ if (!admission.admitted) throw new CursorBlobAdmissionError(admission.reason);
136
379
  return blobId;
137
380
  }
138
381
 
382
+ export interface CursorBlobMetrics {
383
+ count: number;
384
+ totalBytes: number;
385
+ localBytes: number;
386
+ pinnedBytes: number;
387
+ rejectedEntryTooLarge: number;
388
+ rejectedPinnedSaturation: number;
389
+ oldestAt: number | null;
390
+ }
391
+
392
+ export function cursorBlobMetrics(): CursorBlobMetrics {
393
+ return {
394
+ count: blobs.size,
395
+ totalBytes: blobBytes,
396
+ localBytes: blobLocalBytes,
397
+ pinnedBytes: blobPinnedBytes,
398
+ rejectedEntryTooLarge,
399
+ rejectedPinnedSaturation,
400
+ oldestAt: blobOldestEvictableAt,
401
+ };
402
+ }
403
+
404
+ export function cursorBlobRetainedStoreSnapshot(): {
405
+ count: number;
406
+ bytes: number;
407
+ evictableBytes: number;
408
+ pinnedBytes: number;
409
+ oldestAt: number | null;
410
+ } {
411
+ return {
412
+ count: blobs.size,
413
+ bytes: blobBytes,
414
+ evictableBytes: blobEvictableBytes,
415
+ pinnedBytes: blobPinnedBytes,
416
+ oldestAt: blobOldestEvictableAt,
417
+ };
418
+ }
419
+
420
+ export function evictOldestCursorBlobForBudget(): number {
421
+ const now = Date.now();
422
+ let oldest: [string, CursorBlobEntry] | undefined;
423
+ for (const candidate of blobs) {
424
+ const entry = candidate[1];
425
+ if (entry.requestPins.size > 0) continue;
426
+ if (entry.provenance !== "local-regenerated" && !isExpired(entry, now)) continue;
427
+ if (!oldest || entry.storedAt < oldest[1].storedAt) oldest = candidate;
428
+ }
429
+ return oldest ? deleteBlob(oldest[0]) : 0;
430
+ }
431
+
432
+ export function setCursorBlobLimitsForTests(limits?: Partial<CursorBlobLimits>): void {
433
+ resetCursorBlobStateForTests();
434
+ blobLimits = limits ? { ...DEFAULT_BLOB_LIMITS, ...limits } : { ...DEFAULT_BLOB_LIMITS };
435
+ }
436
+
437
+ export function resetCursorBlobStateForTests(): void {
438
+ if (blobExpiryAccountingTimer) clearTimeout(blobExpiryAccountingTimer);
439
+ blobExpiryAccountingTimer = undefined;
440
+ blobs.clear();
441
+ blobRequestScopes.clear();
442
+ blobBytes = 0;
443
+ rejectedEntryTooLarge = 0;
444
+ rejectedPinnedSaturation = 0;
445
+ recomputeBlobClassAccounting();
446
+ }
447
+
448
+ export function cursorBlobStoreDebugSnapshotForTests(): Array<{
449
+ key: string;
450
+ sizeBytes: number;
451
+ storedAt: number;
452
+ provenance: CursorBlobProvenance;
453
+ requestPins: number;
454
+ dataDigest: string;
455
+ pinTokens: string[];
456
+ }> {
457
+ return [...blobs].map(([blobKey, entry]) => ({
458
+ key: blobKey,
459
+ sizeBytes: entry.sizeBytes,
460
+ storedAt: entry.storedAt,
461
+ provenance: entry.provenance,
462
+ requestPins: entry.requestPins.size,
463
+ // Byte-for-byte content identity + exact pin-set membership so rollback
464
+ // tests can deep-compare complete state (review C1-2).
465
+ dataDigest: createHash("sha256").update(entry.data).digest("hex"),
466
+ pinTokens: [...entry.requestPins].map(scope => String(scope.description ?? "scope")).sort(),
467
+ }));
468
+ }
469
+
139
470
  export async function handleCursorNativeExec(execMsg: ExecServerMessage, deps: CursorNativeExecContext = {}): Promise<Uint8Array[]> {
140
471
  const execCase = execMsg.message.case;
141
472
  if (execCase === "requestContextArgs") {
@@ -163,8 +494,8 @@ export async function handleCursorNativeExec(execMsg: ExecServerMessage, deps: C
163
494
  if (execCase === "grepArgs") return [grepExec(execMsg)];
164
495
  if (execCase === "shellArgs") return [shellExec(execMsg)];
165
496
  if (execCase === "shellStreamArgs") return shellStreamExec(execMsg);
166
- if (execCase === "backgroundShellSpawnArgs") return [backgroundShellSpawnExec(execMsg)];
167
- if (execCase === "writeShellStdinArgs") return [writeShellStdinExec(execMsg)];
497
+ if (execCase === "backgroundShellSpawnArgs") return [backgroundShellSpawnExec(execMsg, deps.sessionId ?? "")];
498
+ if (execCase === "writeShellStdinArgs") return [writeShellStdinExec(execMsg, deps.sessionId ?? "")];
168
499
  if (execCase === "fetchArgs") return [await fetchExec(execMsg, deps)];
169
500
  if (execCase === "mcpArgs" && execMsg.message.value.providerIdentifier === OCX_RESPONSES_TOOL_PROVIDER) {
170
501
  return [execBytes(execMsg, "mcpResult", create(McpResultSchema, {
@@ -199,9 +530,14 @@ export async function handleCursorNativeExec(execMsg: ExecServerMessage, deps: C
199
530
  }
200
531
 
201
532
 
202
- export function handleCursorNativeKv(kvMsg: KvServerMessage): Uint8Array {
533
+ export function handleCursorNativeKv(
534
+ kvMsg: KvServerMessage,
535
+ requestScope?: CursorBlobRequestScopeToken,
536
+ ): Uint8Array {
203
537
  if (kvMsg.message.case === "getBlobArgs") {
204
- const blobData = getBlob(key(kvMsg.message.value.blobId));
538
+ const blobKey = key(kvMsg.message.value.blobId);
539
+ const blobData = getBlob(blobKey);
540
+ if (blobData) releaseHydratedBlob(blobKey, requestScope);
205
541
  return clientBytes({
206
542
  message: {
207
543
  case: "kvClientMessage",
@@ -213,13 +549,25 @@ export function handleCursorNativeKv(kvMsg: KvServerMessage): Uint8Array {
213
549
  });
214
550
  }
215
551
  if (kvMsg.message.case === "setBlobArgs") {
216
- setBlob(key(kvMsg.message.value.blobId), kvMsg.message.value.blobData);
552
+ const admission = setBlob(
553
+ key(kvMsg.message.value.blobId),
554
+ kvMsg.message.value.blobData,
555
+ "remote-setBlobArgs",
556
+ requestScope,
557
+ );
217
558
  return clientBytes({
218
559
  message: {
219
560
  case: "kvClientMessage",
220
561
  value: create(KvClientMessageSchema, {
221
562
  id: kvMsg.id,
222
- message: { case: "setBlobResult", value: create(SetBlobResultSchema, {}) },
563
+ message: {
564
+ case: "setBlobResult",
565
+ value: create(SetBlobResultSchema, admission.admitted ? {} : {
566
+ error: create(ErrorSchema, {
567
+ message: "Cursor blob capacity is exhausted; the blob was not stored.",
568
+ }),
569
+ }),
570
+ },
223
571
  }),
224
572
  },
225
573
  });
@@ -13,6 +13,7 @@ import {
13
13
  responsesToolNameFromCursorWire,
14
14
  } from "./tool-definitions";
15
15
  import type { CursorServerMessage } from "./types";
16
+ import type { TranslatorBudget } from "../../lib/translator-budget";
16
17
 
17
18
  const DEFAULT_CONTEXT_USAGE_MAX_ENTRIES = 200;
18
19
  const DEFAULT_CONTEXT_USAGE_TTL_MS = 60 * 60 * 1_000;
@@ -160,6 +161,7 @@ export interface CursorProtobufEventState {
160
161
  toolSchemas?: Map<string, unknown>;
161
162
  /** Cursor wire-name → original Responses/Codex tool name for this request. */
162
163
  cursorToolNameMap?: Map<string, string>;
164
+ translatorBudget?: TranslatorBudget;
163
165
  }
164
166
 
165
167
  export function createCursorProtobufEventState(options: {
@@ -174,6 +176,7 @@ export function createCursorProtobufEventState(options: {
174
176
  * the checkpoint tracker (#373).
175
177
  */
176
178
  estimatedInputTokens?: number;
179
+ translatorBudget?: TranslatorBudget;
177
180
  } = {}): CursorProtobufEventState {
178
181
  return {
179
182
  // Cursor provides no authoritative usage frame; token counts are heuristic estimates from
@@ -186,6 +189,7 @@ export function createCursorProtobufEventState(options: {
186
189
  startedClientToolCalls: 0,
187
190
  ...(options.toolSchemas ? { toolSchemas: options.toolSchemas } : {}),
188
191
  ...(options.cursorToolNameMap ? { cursorToolNameMap: options.cursorToolNameMap } : {}),
192
+ ...(options.translatorBudget ? { translatorBudget: options.translatorBudget } : {}),
189
193
  ...(options.contextUsage?.carryForwardTokens !== undefined ? { contextCarryForwardTokens: options.contextUsage.carryForwardTokens } : {}),
190
194
  ...(options.contextUsage?.recordContextTokens ? { recordContextTokens: options.contextUsage.recordContextTokens } : {}),
191
195
  ...(typeof options.estimatedInputTokens === "number"
@@ -363,6 +367,7 @@ function recordToolCall(state: CursorProtobufEventState, callId: string, cursorW
363
367
  // land on the tool Codex actually exposed this turn (#399).
364
368
  const mapKey = advertisedName ?? normalizeCursorWireName(cursorWireName);
365
369
  state.openToolCalls.set(callId, { name: responsesToolNameFromCursorWire(mapKey, state.cursorToolNameMap), args: "" });
370
+ state.translatorBudget?.openCall(callId);
366
371
  state.startedClientToolCalls++;
367
372
  return [];
368
373
  }
@@ -375,6 +380,7 @@ function recordToolCall(state: CursorProtobufEventState, callId: string, cursorW
375
380
  */
376
381
  function dropShellBridgeCall(state: CursorProtobufEventState, callId: string, toolName: string): CursorServerMessage[] {
377
382
  state.openToolCalls.delete(callId);
383
+ state.translatorBudget?.closeCall(callId);
378
384
  state.completedToolCalls.add(callId);
379
385
  return [{ type: "error", message: cursorShellBridgeDropError(toolName) }];
380
386
  }
@@ -386,6 +392,16 @@ function commitToolCall(state: CursorProtobufEventState, callId: string, finalAr
386
392
  if (!cursorShellBridgeArgsValid(finalArgs, open.name, schema)) {
387
393
  if (isCodexShellBridgeToolName(open.name)) return dropShellBridgeCall(state, callId, open.name);
388
394
  }
395
+ if (finalArgs !== open.args) {
396
+ const previousBytes = Buffer.byteLength(open.args);
397
+ const reservation = state.translatorBudget?.reserveTransient(
398
+ Buffer.byteLength(finalArgs),
399
+ { kind: "tool_args", callId },
400
+ );
401
+ open.args = finalArgs;
402
+ reservation?.commitRetained();
403
+ state.translatorBudget?.releaseRetained(previousBytes, { kind: "tool_args", callId });
404
+ }
389
405
  const out: CursorServerMessage[] = [{ type: "tool_call_start", id: callId, name: open.name }];
390
406
  if (finalArgs.length > 0) out.push({ type: "tool_call_delta", arguments: finalArgs });
391
407
  out.push(...endToolCall(state, callId));
@@ -401,12 +417,23 @@ function commitToolCall(state: CursorProtobufEventState, callId: string, finalAr
401
417
  function bufferToolArgs(state: CursorProtobufEventState, callId: string, cumulative: string): void {
402
418
  const open = state.openToolCalls.get(callId);
403
419
  if (!open) return;
404
- if (cumulative.length >= open.args.length) open.args = cumulative;
420
+ if (cumulative.length >= open.args.length) {
421
+ const encoder = new TextEncoder();
422
+ const previousBytes = encoder.encode(open.args).byteLength;
423
+ const reservation = state.translatorBudget?.reserveTransient(
424
+ encoder.encode(cumulative).byteLength,
425
+ { kind: "tool_args", callId },
426
+ );
427
+ open.args = cumulative;
428
+ reservation?.commitRetained();
429
+ state.translatorBudget?.releaseRetained(previousBytes, { kind: "tool_args", callId });
430
+ }
405
431
  }
406
432
 
407
433
  function endToolCall(state: CursorProtobufEventState, callId: string): CursorServerMessage[] {
408
434
  if (!state.openToolCalls.has(callId)) return [];
409
435
  state.openToolCalls.delete(callId);
436
+ state.translatorBudget?.closeCall(callId);
410
437
  state.completedToolCalls.add(callId);
411
438
  return [{ type: "tool_call_end", id: callId }];
412
439
  }