@bitkyc08/opencodex 2.35.0 → 2.36.0-preview.20260830

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 (155) hide show
  1. package/gui/dist/assets/index-Cy7Z_pl0.css +1 -0
  2. package/gui/dist/assets/index-DPl4nBMA.js +112 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +2 -1
  5. package/src/AGENTS.md +2 -1
  6. package/src/adapters/agentrouter.ts +50 -0
  7. package/src/adapters/anthropic.ts +1 -51
  8. package/src/adapters/cursor/call-id.ts +76 -8
  9. package/src/adapters/cursor/checkpoint-store.ts +6 -1
  10. package/src/adapters/cursor/cursor-errors.ts +44 -0
  11. package/src/adapters/cursor/native-exec.ts +13 -0
  12. package/src/adapters/cursor/protobuf-request.ts +651 -29
  13. package/src/adapters/cursor/tool-result-normalize.ts +3 -3
  14. package/src/adapters/cursor/transport-retry.ts +5 -1
  15. package/src/adapters/cursor.ts +15 -1
  16. package/src/adapters/empty-tool-output-annotation.ts +43 -0
  17. package/src/adapters/exec-tool-result-normalize.ts +70 -5
  18. package/src/adapters/google.ts +22 -2
  19. package/src/adapters/kiro.ts +26 -2
  20. package/src/adapters/ollama-native-url.ts +111 -0
  21. package/src/adapters/ollama-native.ts +1131 -0
  22. package/src/adapters/openai-chat.ts +30 -7
  23. package/src/adapters/openai-responses.ts +72 -4
  24. package/src/adapters/registry.ts +7 -0
  25. package/src/adapters/xai-web-search.ts +58 -0
  26. package/src/claude/desktop-3p.ts +21 -1
  27. package/src/claude/desktop-policy.ts +149 -0
  28. package/src/cli/account.ts +16 -2
  29. package/src/cli/claude-desktop.ts +13 -3
  30. package/src/cli/combo.ts +8 -5
  31. package/src/cli/doctor.ts +77 -11
  32. package/src/cli/help.ts +1 -1
  33. package/src/cli/index.ts +16 -0
  34. package/src/cli/models.ts +20 -3
  35. package/src/cli/registry.ts +2 -1
  36. package/src/cli/status.ts +140 -2
  37. package/src/cli/storage.ts +10 -1
  38. package/src/codex/account-runtime-state.ts +39 -5
  39. package/src/codex/account-store.ts +393 -13
  40. package/src/codex/account-usability.ts +11 -4
  41. package/src/codex/app-server-processes.ts +46 -5
  42. package/src/codex/auth-context.ts +160 -32
  43. package/src/codex/catalog/bundled.ts +7 -5
  44. package/src/codex/catalog/metadata.ts +1 -1
  45. package/src/codex/catalog/parsing.ts +57 -1
  46. package/src/codex/catalog/provider-fetch.ts +61 -4
  47. package/src/codex/catalog/sync.ts +4 -3
  48. package/src/codex/convergence.ts +3 -2
  49. package/src/codex/data/upstream-models.json +40 -8
  50. package/src/codex/inject-coordination.ts +111 -14
  51. package/src/codex/integration-record.ts +12 -2
  52. package/src/codex/main-account.ts +225 -1
  53. package/src/codex/model-entitlements.ts +339 -27
  54. package/src/codex/prompt-layers.ts +346 -7
  55. package/src/codex/prompt-text-probe.ts +272 -21
  56. package/src/codex/routing.ts +693 -132
  57. package/src/codex/runtime.ts +12 -0
  58. package/src/codex/subagent-model-fallback.ts +62 -24
  59. package/src/codex/user-identity.ts +33 -25
  60. package/src/combos/index.ts +1 -0
  61. package/src/combos/reset-window.ts +46 -0
  62. package/src/combos/resolve.ts +84 -2
  63. package/src/combos/types.ts +5 -2
  64. package/src/config/atomic-write.ts +104 -22
  65. package/src/config/provider-validation.ts +11 -0
  66. package/src/config.ts +75 -3
  67. package/src/generated/compatibility-version.json +207 -131
  68. package/src/generated/model-metadata.ts +1 -1
  69. package/src/grok/catalog.ts +71 -0
  70. package/src/grok/effort.ts +83 -0
  71. package/src/grok/inject.ts +952 -127
  72. package/src/grok/models.ts +56 -0
  73. package/src/grok/status.ts +21 -8
  74. package/src/grok/sync.ts +10 -18
  75. package/src/images/loop.ts +6 -3
  76. package/src/integrations/native/ownership-preflight.ts +4 -1
  77. package/src/lab/fabric/producer-isolate.ts +36 -3
  78. package/src/lib/destination-policy.ts +93 -7
  79. package/src/lib/redact.ts +6 -1
  80. package/src/lib/shadow-call.ts +38 -3
  81. package/src/lib/test-home-guard.ts +18 -3
  82. package/src/lib/upstream-retry.ts +43 -6
  83. package/src/lib/windows-secret-acl.ts +66 -0
  84. package/src/lib/windows-text.ts +28 -2
  85. package/src/lib/windows-user-principal.ts +35 -23
  86. package/src/oauth/account-quota-rank.ts +107 -0
  87. package/src/oauth/anthropic-routing.ts +125 -30
  88. package/src/oauth/chatgpt.ts +5 -1
  89. package/src/oauth/generic-account-failover.ts +114 -7
  90. package/src/oauth/index.ts +15 -8
  91. package/src/oauth/store.ts +16 -0
  92. package/src/providers/account-quota-disk.ts +79 -0
  93. package/src/providers/command-code-efforts.ts +24 -0
  94. package/src/providers/derive.ts +6 -0
  95. package/src/providers/key-failover.ts +33 -1
  96. package/src/providers/kiro-usage.ts +272 -0
  97. package/src/providers/ollama-show.ts +311 -0
  98. package/src/providers/openai-sidecar.ts +5 -0
  99. package/src/providers/quota-routing-cache.ts +32 -0
  100. package/src/providers/quota-types.ts +36 -0
  101. package/src/providers/quota-wire.ts +102 -0
  102. package/src/providers/quota.ts +208 -147
  103. package/src/providers/registry.ts +68 -8
  104. package/src/providers/slug-codec.ts +12 -4
  105. package/src/providers/vercel-gateway-routing.ts +108 -0
  106. package/src/router.ts +22 -12
  107. package/src/server/auth-cors.ts +26 -0
  108. package/src/server/catalog-download.ts +73 -0
  109. package/src/server/chat-native.ts +12 -2
  110. package/src/server/gui-static.ts +4 -1
  111. package/src/server/index.ts +132 -9
  112. package/src/server/management/agent-settings-routes.ts +38 -5
  113. package/src/server/management/codex-prompt-routes.ts +7 -1
  114. package/src/server/management/combo-routes.ts +10 -1
  115. package/src/server/management/config-routes.ts +9 -1
  116. package/src/server/management/context.ts +5 -0
  117. package/src/server/management/model-routes.ts +16 -6
  118. package/src/server/management/native-integration-routes.ts +12 -17
  119. package/src/server/management/oauth-account-routes.ts +13 -0
  120. package/src/server/management/provider-routes.ts +32 -5
  121. package/src/server/management/routing-profile-routes.ts +15 -0
  122. package/src/server/management/shadow-call-validation.ts +29 -0
  123. package/src/server/management-api.ts +7 -3
  124. package/src/server/request-log.ts +3 -5
  125. package/src/server/responses/agent-task-recovery-cache.ts +8 -0
  126. package/src/server/responses/agent-task-recovery.ts +52 -20
  127. package/src/server/responses/codex-auth-error.ts +26 -0
  128. package/src/server/responses/compact.ts +345 -10
  129. package/src/server/responses/core.ts +736 -108
  130. package/src/server/responses/empty-completion-guard.ts +16 -0
  131. package/src/server/responses/fetch-helpers.ts +42 -0
  132. package/src/server/responses/policy-fallback.ts +11 -6
  133. package/src/server/responses-undeclared-tool-guard.ts +16 -3
  134. package/src/server/startup-health-cache.ts +59 -13
  135. package/src/service-manager-probe.ts +115 -9
  136. package/src/service.ts +139 -40
  137. package/src/storage/cleanup.ts +10 -0
  138. package/src/storage/storage-mutation-coordinator.ts +14 -3
  139. package/src/tray/windows-tray.ps1 +10 -4
  140. package/src/tray/windows.ts +30 -2
  141. package/src/types/config.ts +27 -14
  142. package/src/types/provider.ts +54 -0
  143. package/src/types/tools.ts +13 -3
  144. package/src/types.ts +4 -0
  145. package/src/usage/summary.ts +421 -177
  146. package/src/vision/anthropic-describe.ts +3 -3
  147. package/src/vision/describe.ts +5 -3
  148. package/src/web-search/anthropic-executor.ts +9 -2
  149. package/src/web-search/exa-executor.ts +3 -3
  150. package/src/web-search/executor.ts +8 -3
  151. package/src/web-search/gemini-executor.ts +3 -3
  152. package/src/web-search/loop.ts +11 -3
  153. package/src/web-search/xai-executor.ts +3 -3
  154. package/gui/dist/assets/index-DNdRKXK9.js +0 -112
  155. package/gui/dist/assets/index-DQ-Ie18T.css +0 -1
@@ -16,8 +16,8 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-DNdRKXK9.js"></script>
20
- <link rel="stylesheet" crossorigin href="/assets/index-DQ-Ie18T.css">
19
+ <script type="module" crossorigin src="/assets/index-DPl4nBMA.js"></script>
20
+ <link rel="stylesheet" crossorigin href="/assets/index-Cy7Z_pl0.css">
21
21
  </head>
22
22
  <body>
23
23
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.35.0",
3
+ "version": "2.36.0-preview.20260830",
4
4
  "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -39,6 +39,7 @@
39
39
  "dev:gui": "cd gui && bun run dev",
40
40
  "start": "bun run src/cli/index.ts start",
41
41
  "test": "bun scripts/test.ts",
42
+ "test:changed": "bun scripts/test.ts --changed=dev",
42
43
  "typecheck": "bun x tsc --noEmit",
43
44
  "audit:high": "bun audit --audit-level=high && cd gui && bun audit --audit-level=high",
44
45
  "privacy:scan": "bun scripts/privacy-scan.ts",
package/src/AGENTS.md CHANGED
@@ -23,6 +23,7 @@ This file applies to `src/` and inherits the repository-wide rules in `/AGENTS.m
23
23
 
24
24
  - Place focused regression coverage near the existing tests for the affected subsystem.
25
25
  - For focused behavior, run the relevant `bun test tests/<name>.test.ts` and `bun run typecheck`.
26
- - For shared routing, adapters, config, OAuth, or server behavior, also run `bun run test`.
26
+ - If the change set is broader than one file, run `bun run test:changed` instead of the full suite.
27
+ - Run `bun run test` only before marking a PR review-ready, or when the user explicitly asks for the full suite.
27
28
  - For logging, requests, credentials, account data, or fixtures, also run `bun run privacy:scan`.
28
29
  - Update `docs-site/` when the change affects user-visible behavior or configuration.
@@ -0,0 +1,50 @@
1
+ export const AGENTROUTER_LANGUAGE_PREAMBLE =
2
+ "[Instruction: Process the user request below and respond in the appropriate language.]";
3
+
4
+ /** Match only AgentRouter itself or one of its subdomains, never a lookalike hostname. */
5
+ export function isAgentRouterEndpoint(baseUrl: string): boolean {
6
+ try {
7
+ const { hostname } = new URL(baseUrl);
8
+ return hostname === "agentrouter.org" || hostname.endsWith(".agentrouter.org");
9
+ } catch {
10
+ return false;
11
+ }
12
+ }
13
+
14
+ /** Supply AgentRouter's stable Codex admission identity unless the operator set one. */
15
+ export function agentRouterDefaultHeaders(
16
+ baseUrl: string,
17
+ configuredHeaders?: Record<string, string>,
18
+ ): Record<string, string> {
19
+ if (!isAgentRouterEndpoint(baseUrl)) return {};
20
+ const hasOriginator = Object.keys(configuredHeaders ?? {}).some(name => name.toLowerCase() === "originator");
21
+ return hasOriginator ? {} : { originator: "codex_cli_rs" };
22
+ }
23
+
24
+ /** Prepend the compatibility marker as a distinct block on the first user turn. */
25
+ export function applyAgentRouterLanguageFraming(messages: unknown[]): void {
26
+ const firstUser = messages.find(
27
+ (message): message is { role: string; content: unknown } =>
28
+ typeof message === "object" && message !== null && (message as { role?: unknown }).role === "user",
29
+ );
30
+ if (!firstUser) return;
31
+ const preamble = { type: "text", text: AGENTROUTER_LANGUAGE_PREAMBLE };
32
+ if (typeof firstUser.content === "string") {
33
+ firstUser.content = firstUser.content === ""
34
+ ? [preamble]
35
+ : [preamble, { type: "text", text: firstUser.content }];
36
+ return;
37
+ }
38
+ if (!Array.isArray(firstUser.content)) return;
39
+ const [head] = firstUser.content as { type?: unknown; text?: unknown }[];
40
+ if (head?.type === "text" && head.text === AGENTROUTER_LANGUAGE_PREAMBLE) return;
41
+ (firstUser.content as unknown[]).unshift(preamble);
42
+ }
43
+
44
+ /** Frame only an owned copy so translated and passthrough callers remain unchanged. */
45
+ export function frameAgentRouterMessages(baseUrl: string, messages: unknown): unknown {
46
+ if (!isAgentRouterEndpoint(baseUrl) || !Array.isArray(messages)) return messages;
47
+ const copy = structuredClone(messages) as unknown[];
48
+ applyAgentRouterLanguageFraming(copy);
49
+ return copy;
50
+ }
@@ -28,6 +28,7 @@ import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge";
28
28
  import { decodeServerSentEvents } from "../lib/sse-decoder";
29
29
  import { isTranslatorBudgetExceededError, retainTranslatedEventBatch, type TranslatorBudget } from "../lib/translator-budget";
30
30
  import { isReasoningEffortOmitted, modelRecordValue } from "../reasoning-effort";
31
+ import { applyAgentRouterLanguageFraming, isAgentRouterEndpoint } from "./agentrouter";
31
32
 
32
33
  /** Map a user content part to an Anthropic content block (text or image source). */
33
34
  function toAnthropicContentPart(p: OcxContentPart): unknown {
@@ -647,57 +648,6 @@ function orphanToolResultText(msg: OcxToolResultMessage): string {
647
648
  * user content, so an Anthropic `system` string cannot reach it — the framing has to sit in the
648
649
  * first user turn.
649
650
  */
650
- const AGENTROUTER_LANGUAGE_PREAMBLE =
651
- "[Instruction: Process the user request below and respond in the appropriate language.]";
652
-
653
- /**
654
- * Exact host match, not a substring.
655
- *
656
- * A `hostname.includes("agentrouter")` test also matches `notagentrouter.example` and
657
- * `agentrouter.org.attacker.example`, which would let an unrelated destination silently
658
- * receive an injected instruction block. A prompt mutation keyed on a provider's identity
659
- * must be keyed on that identity exactly.
660
- */
661
- function isAgentRouterEndpoint(baseUrl: string): boolean {
662
- try {
663
- const { hostname } = new URL(baseUrl);
664
- return hostname === "agentrouter.org" || hostname.endsWith(".agentrouter.org");
665
- } catch {
666
- return false;
667
- }
668
- }
669
-
670
- /**
671
- * Prepend the framing as its OWN text block instead of splicing it into the user's string.
672
- *
673
- * The distinction matters: rewriting `content` to `${marker}\n\n${original}` edits what the
674
- * user wrote, and every downstream consumer — logs, retries, an upstream that echoes the turn —
675
- * then sees a sentence the user never typed as if they had. A separate leading block carries the
676
- * same signal to the filter while the original text survives byte-for-byte.
677
- *
678
- * Only the first user turn is framed, because only the first is what the gateway rejects.
679
- */
680
- function applyAgentRouterLanguageFraming(messages: unknown[]): void {
681
- const firstUser = messages.find(
682
- (m): m is { role: string; content: unknown } =>
683
- typeof m === "object" && m !== null && (m as { role?: unknown }).role === "user",
684
- );
685
- if (!firstUser) return;
686
- const preamble = { type: "text", text: AGENTROUTER_LANGUAGE_PREAMBLE };
687
- if (typeof firstUser.content === "string") {
688
- firstUser.content = firstUser.content === ""
689
- ? [preamble]
690
- : [preamble, { type: "text", text: firstUser.content }];
691
- return;
692
- }
693
- if (!Array.isArray(firstUser.content)) return;
694
- // Idempotence is keyed on the LEADING block being exactly the marker. A substring test would
695
- // let a user who quotes the marker later in their own prompt suppress the framing entirely.
696
- const [head] = firstUser.content as { type?: unknown; text?: unknown }[];
697
- if (head?.type === "text" && head.text === AGENTROUTER_LANGUAGE_PREAMBLE) return;
698
- (firstUser.content as unknown[]).unshift(preamble);
699
- }
700
-
701
651
  function messagesToAnthropicFormat(
702
652
  parsed: OcxParsedRequest,
703
653
  toolNames: { toWire: (name: string) => string },
@@ -1,3 +1,5 @@
1
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
+
1
3
  /**
2
4
  * Reversible single-line codec for Cursor composite tool-call ids.
3
5
  *
@@ -5,22 +7,73 @@
5
7
  * literal newline ("call-<uuid>-<n>\nfc_<uuid>_<n>"). OpenCodex forwards ids
6
8
  * verbatim, so that newline leaked into Responses-visible `call_id` values,
7
9
  * where line-oriented clients (logging, splitting, validation) break. The codec
8
- * encodes only ids containing CR/LF into a versioned single-line form and
9
- * decodes both that form and legacy raw multi-line ids back to the exact
10
- * upstream bytes before anything is serialized toward Cursor.
10
+ * encodes ids containing CR/LF into a versioned single-line form. It also
11
+ * escapes ids already in that form's reserved namespace so encoding remains
12
+ * injective. Both forms decode back to the exact upstream bytes before
13
+ * anything is serialized toward Cursor. Every encoded value carries a keyed tag
14
+ * generated by this process. Prefix and base64 shape alone are never provenance:
15
+ * an opaque upstream id may equal any stateless encoder output.
16
+ *
17
+ * The process key is intentionally ephemeral. After restart, the decoder leaves
18
+ * values from the prior process unchanged rather than guessing from attacker- or
19
+ * provider-controlled text. This keeps provenance state constant-size instead of
20
+ * retaining an unbounded id map.
11
21
  */
12
22
 
13
23
  const CALL_ID_PREFIX = "ocxc1_";
24
+ /** Escape namespace for ids that already sit in a reserved namespace. */
25
+ const CALL_ID_ESCAPE_PREFIX = "ocxc1e_";
26
+ const CALL_ID_PROVENANCE_DOMAIN = "opencodex:cursor-call-id:v1\0";
27
+ const CALL_ID_PROVENANCE_SEPARATOR = ".";
28
+ const CALL_ID_PROVENANCE_TAG_BYTES = 16;
29
+ let callIdProvenanceKey: Uint8Array = randomBytes(32);
14
30
 
15
31
  /** True when the id needs encoding to survive line-oriented consumers. */
16
32
  function needsEncoding(id: string): boolean {
17
33
  return id.includes("\n") || id.includes("\r");
18
34
  }
19
35
 
36
+ /** True when the id sits in a namespace this codec owns and must be escaped. */
37
+ function isReserved(id: string): boolean {
38
+ return id.startsWith(CALL_ID_PREFIX) || id.startsWith(CALL_ID_ESCAPE_PREFIX);
39
+ }
40
+
41
+ function provenanceTag(prefix: string, payload: string): Buffer {
42
+ return createHmac("sha256", callIdProvenanceKey)
43
+ .update(CALL_ID_PROVENANCE_DOMAIN)
44
+ .update(prefix)
45
+ .update(payload)
46
+ .digest()
47
+ .subarray(0, CALL_ID_PROVENANCE_TAG_BYTES);
48
+ }
49
+
50
+ function encodeWithProvenance(prefix: string, id: string): string {
51
+ const payload = Buffer.from(id, "utf8").toString("base64url");
52
+ const tag = provenanceTag(prefix, payload).toString("base64url");
53
+ return `${prefix}${payload}${CALL_ID_PROVENANCE_SEPARATOR}${tag}`;
54
+ }
55
+
56
+ function hasValidProvenance(prefix: string, payload: string, tag: string): boolean {
57
+ let received: Buffer;
58
+ try {
59
+ received = Buffer.from(tag, "base64url");
60
+ } catch {
61
+ return false;
62
+ }
63
+ if (
64
+ received.byteLength !== CALL_ID_PROVENANCE_TAG_BYTES
65
+ || received.toString("base64url") !== tag
66
+ ) return false;
67
+ return timingSafeEqual(received, provenanceTag(prefix, payload));
68
+ }
69
+
20
70
  /** Encode a Cursor wire call id into a single-line Responses-safe id. */
21
71
  export function encodeCursorCallId(id: string): string {
22
- if (!needsEncoding(id)) return id;
23
- return CALL_ID_PREFIX + Buffer.from(id, "utf8").toString("base64url");
72
+ // CR/LF content is the codec's actual job, so it wins the primary namespace.
73
+ if (needsEncoding(id)) return encodeWithProvenance(CALL_ID_PREFIX, id);
74
+ // A reserved id carries no newline; it only needs to stop looking like our output.
75
+ if (isReserved(id)) return encodeWithProvenance(CALL_ID_ESCAPE_PREFIX, id);
76
+ return id;
24
77
  }
25
78
 
26
79
  /**
@@ -30,15 +83,30 @@ export function encodeCursorCallId(id: string): string {
30
83
  * through rather than corrupting pairing.
31
84
  */
32
85
  export function decodeCursorCallId(id: string): string {
33
- if (!id.startsWith(CALL_ID_PREFIX)) return id;
34
- const payload = id.slice(CALL_ID_PREFIX.length);
35
- if (payload.length === 0) return id;
86
+ const escaped = id.startsWith(CALL_ID_ESCAPE_PREFIX);
87
+ if (!escaped && !id.startsWith(CALL_ID_PREFIX)) return id;
88
+ const prefix = escaped ? CALL_ID_ESCAPE_PREFIX : CALL_ID_PREFIX;
89
+ const encoded = id.slice(prefix.length);
90
+ const separator = encoded.indexOf(CALL_ID_PROVENANCE_SEPARATOR);
91
+ if (separator <= 0 || separator !== encoded.lastIndexOf(CALL_ID_PROVENANCE_SEPARATOR)) return id;
92
+ const payload = encoded.slice(0, separator);
93
+ const tag = encoded.slice(separator + CALL_ID_PROVENANCE_SEPARATOR.length);
94
+ if (!hasValidProvenance(prefix, payload, tag)) return id;
36
95
  try {
37
96
  const decoded = Buffer.from(payload, "base64url").toString("utf8");
38
97
  // Round-trip guard: only trust payloads our encoder could have produced.
39
98
  if (Buffer.from(decoded, "utf8").toString("base64url") !== payload) return id;
99
+ // Each namespace admits exactly what its encoder puts there. An `ocxc1_` payload
100
+ // that decodes to newline-free text is NOT our output — it is an opaque upstream
101
+ // id that merely looks like ours, and unwrapping it would change the id.
102
+ if (escaped ? !isReserved(decoded) : !needsEncoding(decoded)) return id;
40
103
  return decoded;
41
104
  } catch {
42
105
  return id;
43
106
  }
44
107
  }
108
+
109
+ /** Simulates process restart without reloading the module. */
110
+ export function resetCursorCallIdProvenanceForTests(): void {
111
+ callIdProvenanceKey = randomBytes(32);
112
+ }
@@ -23,7 +23,12 @@ export type CursorCheckpointInvalidationReason =
23
23
  | "trailing_tool_result"
24
24
  | "force_fresh"
25
25
  | "upstream_invalid_argument"
26
- | "lineage_mismatch";
26
+ | "lineage_mismatch"
27
+ /**
28
+ * The checkpoint's own roots leave no room for the uncovered suffix inside Cursor's root envelope.
29
+ * Resuming would send history the model cannot see; a full replay prunes coherently instead.
30
+ */
31
+ | "envelope_exhausted";
27
32
 
28
33
  export interface CursorCheckpointSnapshot {
29
34
  ref: string;
@@ -71,6 +71,50 @@ export class CursorUnexpectedCancelError extends Error {
71
71
  }
72
72
  }
73
73
 
74
+ /**
75
+ * The assembled root-blob envelope exceeded what Cursor's external workers accept.
76
+ *
77
+ * Raised locally, BEFORE the request is sent. Cursor rejects an oversized replay set with a late
78
+ * `invalid_argument` only after hydrating every blob, so the upstream failure arrives with no
79
+ * usable measurement — that is why this carries the counts it measured. Never retryable: replaying
80
+ * the same over-envelope request reproduces it exactly.
81
+ *
82
+ * The measurement is taken on the FINAL root set, after checkpoint and suffix assembly. Bounding
83
+ * an intermediate set is what let 192 checkpoint roots plus a two-root suffix emit 194 (#1527).
84
+ */
85
+ export class CursorRootEnvelopeLimitError extends Error {
86
+ public readonly code = "cursor_root_envelope_limit";
87
+ public readonly status = 400;
88
+
89
+ constructor(
90
+ public readonly rootCount: number,
91
+ public readonly rootBytes: number,
92
+ public readonly maxRootCount: number,
93
+ public readonly maxRootBytes: number,
94
+ ) {
95
+ super(
96
+ // No "Cursor invalid request:" prefix here: `safeCursorErrorMessage` adds it for
97
+ // invalid-argument classes, and carrying it in the message produced it twice.
98
+ "the assembled conversation exceeds the replay envelope "
99
+ + `(${rootCount} root blobs / ${rootBytes} bytes against a limit of ${maxRootCount} / ${maxRootBytes}). `
100
+ + "Start a new conversation or reduce the pending tool output.",
101
+ );
102
+ this.name = "CursorRootEnvelopeLimitError";
103
+ }
104
+ }
105
+
106
+ /**
107
+ * The envelope failure is local and deterministic; a retry cannot change the outcome.
108
+ *
109
+ * A companion `CursorRootMeasurementError` was drafted for the case where a root blob's size
110
+ * cannot be read, then deleted: an unmeasurable root is legitimate (a resumed conversation
111
+ * references ids Cursor minted and this process never stored), so the guard counts it instead of
112
+ * failing. There is no reachable second case, and an unreachable error class cannot be tested.
113
+ */
114
+ export function isCursorRootEnvelopeError(value: unknown): boolean {
115
+ return value instanceof CursorRootEnvelopeLimitError;
116
+ }
117
+
74
118
  export function isCursorBenignCancelError(value: unknown): boolean {
75
119
  // An unexpected cancel is never benign, however it is spelled. This class is raised only when
76
120
  // the transport knows WE did not request the cancel, so its provenance outranks the code match
@@ -403,6 +403,19 @@ export function storeCursorBlob(data: Uint8Array, requestScope?: CursorBlobReque
403
403
  return blobId;
404
404
  }
405
405
 
406
+ /**
407
+ * Stored byte length of one blob, or null when it is not in the store.
408
+ *
409
+ * Size only, never content: the envelope guard needs to measure the FINAL root set, which mixes
410
+ * roots minted this turn with roots carried inside a checkpoint. Reading them back through a
411
+ * hydration path would both defeat the request-scope sealing and log served bytes for a request
412
+ * that may never be sent.
413
+ */
414
+ export function cursorBlobByteLength(blobId: Uint8Array): number | null {
415
+ const entry = blobs.get(key(blobId));
416
+ return entry ? entry.data.byteLength : null;
417
+ }
418
+
406
419
  /**
407
420
  * Serve-time integrity for content-addressed blobs (devlog 260826_cursor_responses_gap 080):
408
421
  * a raw 32-byte blob id IS the SHA-256 of its bytes, so served data whose digest mismatches