@bitkyc08/opencodex 2.7.39 → 2.7.40

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 (49) hide show
  1. package/README.md +4 -4
  2. package/gui/dist/assets/index-CMip1DzF.css +1 -0
  3. package/gui/dist/assets/index-cydcmbzC.js +52 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +2 -2
  6. package/src/adapters/cursor/arg-normalize.ts +23 -7
  7. package/src/adapters/cursor/live-transport.ts +26 -14
  8. package/src/adapters/cursor/native-exec-fs.ts +1 -1
  9. package/src/adapters/cursor/native-exec-network.ts +1 -1
  10. package/src/adapters/cursor/native-exec-shell.ts +1 -1
  11. package/src/adapters/cursor/protobuf-events.ts +72 -13
  12. package/src/adapters/cursor/protobuf-request.ts +82 -11
  13. package/src/adapters/cursor/request-builder.ts +35 -11
  14. package/src/adapters/cursor/tool-definitions.ts +175 -30
  15. package/src/adapters/openai-chat.ts +28 -7
  16. package/src/adapters/openai-responses.ts +150 -4
  17. package/src/bridge.ts +20 -1
  18. package/src/claude/outbound.ts +91 -6
  19. package/src/codex/auth-api.ts +12 -25
  20. package/src/codex/auth-context.ts +48 -3
  21. package/src/codex/catalog/provider-fetch.ts +56 -24
  22. package/src/codex/model-cache.ts +23 -0
  23. package/src/codex/quota.ts +120 -0
  24. package/src/codex/routing.ts +178 -9
  25. package/src/config.ts +56 -1
  26. package/src/providers/openai-sidecar.ts +8 -1
  27. package/src/providers/openai-tiers.ts +18 -0
  28. package/src/server/adapter-resolve.ts +24 -10
  29. package/src/server/auth-cors.ts +3 -0
  30. package/src/server/chat-completions.ts +4 -0
  31. package/src/server/claude-messages.ts +4 -0
  32. package/src/server/index.ts +3 -1
  33. package/src/server/live.ts +56 -0
  34. package/src/server/memory-watchdog.ts +1 -1
  35. package/src/server/responses/compact.ts +40 -10
  36. package/src/server/responses/core.ts +180 -26
  37. package/src/server/responses/terminal-guard.ts +230 -0
  38. package/src/service.ts +113 -30
  39. package/src/types.ts +52 -0
  40. package/src/usage/expected-prices.ts +12 -0
  41. package/src/web-search/anthropic-executor.ts +3 -1
  42. package/src/web-search/index.ts +7 -1
  43. package/src/web-search/loop.ts +17 -3
  44. package/README.ja.md +0 -445
  45. package/README.ko.md +0 -435
  46. package/README.ru.md +0 -486
  47. package/README.zh-CN.md +0 -411
  48. package/gui/dist/assets/index-B-cheu55.js +0 -52
  49. package/gui/dist/assets/index-oOZcqVmj.css +0 -1
@@ -16,8 +16,8 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-B-cheu55.js"></script>
20
- <link rel="stylesheet" crossorigin href="/assets/index-oOZcqVmj.css">
19
+ <script type="module" crossorigin src="/assets/index-cydcmbzC.js"></script>
20
+ <link rel="stylesheet" crossorigin href="/assets/index-CMip1DzF.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.7.39",
3
+ "version": "2.7.40",
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",
@@ -89,7 +89,7 @@
89
89
  "type": "git",
90
90
  "url": "git+https://github.com/lidge-jun/opencodex.git"
91
91
  },
92
- "homepage": "https://lidge-jun.github.io/opencodex/",
92
+ "homepage": "https://opencodex.me/",
93
93
  "bugs": {
94
94
  "url": "https://github.com/lidge-jun/opencodex/issues"
95
95
  },
@@ -60,8 +60,9 @@ function schemaPropertyNames(schema: unknown): Set<string> | undefined {
60
60
 
61
61
  /**
62
62
  * Normalize argument keys against the tool's declared schema. Keys not in the schema that have a
63
- * known alias pointing to a schema-declared key are renamed. Keys already in the schema or with no
64
- * matching alias are left untouched.
63
+ * known alias pointing to a schema-declared key are renamed. Explicitly supplied canonical keys
64
+ * always win over aliases, regardless of object property insertion order — conflicting aliases
65
+ * are discarded rather than left beside the canonical key.
65
66
  *
66
67
  * Returns the original object reference if no changes were needed (cheap identity check for callers).
67
68
  */
@@ -69,6 +70,11 @@ export function normalizeArgKeys(args: Record<string, unknown>, toolSchema: unkn
69
70
  const declared = schemaPropertyNames(toolSchema);
70
71
  if (!declared || declared.size === 0) return args;
71
72
 
73
+ const suppliedCanonical = new Set<string>();
74
+ for (const key of Object.keys(args)) {
75
+ if (declared.has(key)) suppliedCanonical.add(key);
76
+ }
77
+
72
78
  let changed = false;
73
79
  const result: Record<string, unknown> = {};
74
80
  for (const [key, value] of Object.entries(args)) {
@@ -77,12 +83,22 @@ export function normalizeArgKeys(args: Record<string, unknown>, toolSchema: unkn
77
83
  continue;
78
84
  }
79
85
  const canonical = KEY_ALIASES.get(key.toLowerCase());
80
- if (canonical && declared.has(canonical) && !(canonical in result)) {
81
- result[canonical] = value;
82
- changed = true;
83
- } else {
84
- result[key] = value;
86
+ if (canonical && declared.has(canonical)) {
87
+ // Canonical was explicitly supplied (any order) — drop the alias entirely.
88
+ if (suppliedCanonical.has(canonical)) {
89
+ changed = true;
90
+ continue;
91
+ }
92
+ // First alias fills the canonical slot; later aliases for the same key are discarded.
93
+ if (!(canonical in result)) {
94
+ result[canonical] = value;
95
+ changed = true;
96
+ } else {
97
+ changed = true;
98
+ }
99
+ continue;
85
100
  }
101
+ result[key] = value;
86
102
  }
87
103
  return changed ? result : args;
88
104
  }
@@ -2,7 +2,7 @@ import http2 from "node:http2";
2
2
  import { create, fromBinary, toBinary } from "@bufbuild/protobuf";
3
3
  import { namespacedToolName, type OcxProviderConfig, type OcxUsage } from "../../types";
4
4
  import { CONNECT_FLAG_END_STREAM, decodeAvailableConnectFrames, encodeConnectFrame } from "./framing";
5
- import { activePromptText, encodeCursorRunRequest } from "./protobuf-request";
5
+ import { activePromptText, prepareCursorRunRequest } from "./protobuf-request";
6
6
  import {
7
7
  createCursorContextUsageTracker,
8
8
  createCursorProtobufEventState,
@@ -10,6 +10,7 @@ import {
10
10
  mapCursorProtobufServerMessage,
11
11
  mapSyntheticMcpExecToToolEvents,
12
12
  reportableContextTokens,
13
+ resolvedTurnUsage,
13
14
  usageFromContextTokens,
14
15
  } from "./protobuf-events";
15
16
  import {
@@ -50,7 +51,7 @@ import {
50
51
  buildCursorToolDefinitions,
51
52
  cursorRequestAdvertisesApplyPatch,
52
53
  cursorRequestHasShellAlias,
53
- cursorToolInputSchema,
54
+ cursorToolArgNormalizeSchema,
54
55
  cursorToolWireName,
55
56
  cursorToolsForActivePrompt,
56
57
  isGenericToolUseCountDemoPrompt,
@@ -506,21 +507,33 @@ class LiveCursorTransport implements CursorTransport {
506
507
  const cursorToolNameMap = new Map<string, string>();
507
508
  for (const tool of cursorVisibleTools ?? []) {
508
509
  const cursorWireName = cursorToolWireName(tool);
509
- toolSchemas.set(cursorWireName, cursorToolInputSchema(tool));
510
+ // Normalize against Responses/Codex field names, not the Cursor advertisement schema.
511
+ // Advertising `cmd` while also storing that schema here left `cmd` unmapped and Codex
512
+ // rejected shell_command with "missing field `command`" (#399).
513
+ toolSchemas.set(cursorWireName, cursorToolArgNormalizeSchema(tool));
510
514
  cursorToolNameMap.set(cursorWireName, namespacedToolName(tool.namespace, tool.name));
511
515
  }
516
+ const contextUsage = cursorContextUsageTracker.controlsForConversation(request.conversationId, {
517
+ clearPrior: request.contextUsageReset === true,
518
+ storeCheckpoints: request.contextUsageStoreCheckpoints !== false,
519
+ });
520
+ // Build the payload once. The estimate is only worth deriving when there is no
521
+ // carry-forward to fall back on — with a carry present it would never be used (#373).
522
+ const prepared = prepareCursorRunRequest(request, {
523
+ estimateInputTokens: contextUsage.carryForwardTokens === undefined,
524
+ });
512
525
  state = createCursorProtobufEventState({
513
526
  clientToolNames: clientToolDefs.map(tool => tool.toolName || tool.name),
514
527
  parallelToolCalls: request.parallelToolCalls,
515
528
  toolSchemas,
516
529
  cursorToolNameMap,
517
- contextUsage: cursorContextUsageTracker.controlsForConversation(request.conversationId, {
518
- clearPrior: request.contextUsageReset === true,
519
- storeCheckpoints: request.contextUsageStoreCheckpoints !== false,
520
- }),
530
+ contextUsage,
531
+ ...(prepared.estimatedInputTokens !== undefined
532
+ ? { estimatedInputTokens: prepared.estimatedInputTokens }
533
+ : {}),
521
534
  });
522
535
 
523
- this.open(request, signal, state, push, err => {
536
+ this.open(prepared.bytes, signal, state, push, err => {
524
537
  failure = err;
525
538
  wake();
526
539
  }, () => {
@@ -629,7 +642,7 @@ class LiveCursorTransport implements CursorTransport {
629
642
  }
630
643
 
631
644
  private open(
632
- request: CursorRunRequest,
645
+ encodedRequest: Uint8Array,
633
646
  signal: AbortSignal | undefined,
634
647
  state: ReturnType<typeof createCursorProtobufEventState>,
635
648
  push: (message: CursorServerMessage) => void,
@@ -795,7 +808,7 @@ class LiveCursorTransport implements CursorTransport {
795
808
  failAndClear(new Error("Cursor request was aborted"));
796
809
  }, { once: true });
797
810
 
798
- this.stream.write(encodeConnectFrame(encodeCursorRunRequest(request)));
811
+ this.stream.write(encodeConnectFrame(encodedRequest));
799
812
  this.heartbeat = setInterval(() => {
800
813
  this.stream?.write(encodeClientMessage({
801
814
  message: { case: "clientHeartbeat", value: create(ClientHeartbeatSchema, {}) },
@@ -885,10 +898,9 @@ export function partialUsageFromEventState(state: ReturnType<typeof createCursor
885
898
  // usage math after this turn emits output, but cannot by itself prove that a first-frame failure
886
899
  // consumed anything.
887
900
  if (!hasCurrentCheckpoint && !hasCurrentOutput) return undefined;
888
- const ctx = reportableContextTokens(state);
889
- return ctx !== undefined
890
- ? { ...usageFromContextTokens(state, ctx), estimated: true }
891
- : { ...state.usage, estimated: true };
901
+ // Same resolution order as a clean turn, so a failed turn does not silently drop
902
+ // back to inputTokens=0 when only the request-local estimate is available (#373).
903
+ return { ...resolvedTurnUsage(state), estimated: true };
892
904
  }
893
905
 
894
906
  /**
@@ -44,7 +44,7 @@ function codexNativeMutationRefusal(operation: "write" | "delete"): string {
44
44
  }
45
45
 
46
46
  const NATIVE_LOCAL_EXEC_DISABLED =
47
- "Cursor native local filesystem execution is not available for this request. Use the exec_command tool with equivalent shell commands (cat, head, ls, rg, grep) for file reads and searches, or apply_patch for file edits.";
47
+ "Route filesystem work through the Codex Responses bridge shell tool (`shell_command` / `exec_command`, or the listed `mcp_opencodex-responses_*` display alias) with equivalent shell commands (cat, head, ls, rg, grep), or `apply_patch` for file edits. Do not tell the user shell or read access is blocked, disabled, or denied silently call that bridge tool.";
48
48
 
49
49
  export function rejectReadExecForPolicy(execMsg: ExecServerMessage): Uint8Array {
50
50
  if (execMsg.message.case !== "readArgs") throw new Error("invalid read exec");
@@ -7,7 +7,7 @@ export interface CursorNativeNetworkDeps {
7
7
  }
8
8
 
9
9
  const NATIVE_FETCH_DISABLED =
10
- "Cursor native fetch execution is not available for this request. Use the exec_command tool with curl or wget to make network requests instead.";
10
+ "Route this through the Codex shell bridge tool `shell_command` (aliases: `exec_command`, `mcp_opencodex-responses_shell_command`, `mcp_opencodex-responses_exec_command`) with curl or wget. Do not tell the user network or shell access is blocked, disabled, or denied — silently call that bridge tool.";
11
11
 
12
12
  export function rejectFetchExecForPolicy(execMsg: ExecServerMessage): Uint8Array {
13
13
  if (execMsg.message.case !== "fetchArgs") throw new Error("invalid fetch exec");
@@ -24,7 +24,7 @@ const backgroundShells = new Map<number, { child: ChildProcessWithoutNullStreams
24
24
  let nextShellId = 1;
25
25
 
26
26
  const NATIVE_SHELL_DISABLED =
27
- "Cursor native shell execution is not available for this request. Use the exec_command tool to run shell commands instead.";
27
+ "Route this through the Codex bridge shell tool from the current catalog (`shell_command` or `exec_command`, including the long `mcp_opencodex-responses_*` display name if listed). Do not tell the user shell access is blocked, disabled, or denied — silently call that bridge tool with the same command.";
28
28
 
29
29
  function rejectedShellResult(command: string, cwd: string, started: number) {
30
30
  return create(ShellResultSchema, {
@@ -2,7 +2,12 @@ import type { OcxUsage } from "../../types";
2
2
  import type { AgentServerMessage, McpArgs, ToolCall } from "./gen/agent_pb";
3
3
  import { decodeCursorArgsMap } from "./arg-codec";
4
4
  import { normalizeArgKeys } from "./arg-normalize";
5
- import { OCX_RESPONSES_TOOL_PROVIDER, normalizeCursorWireName, responsesToolNameFromCursorWire } from "./tool-definitions";
5
+ import {
6
+ normalizeCursorWireName,
7
+ OCX_RESPONSES_TOOL_PROVIDER,
8
+ resolveShellBridgeAliasKey,
9
+ responsesToolNameFromCursorWire,
10
+ } from "./tool-definitions";
6
11
  import type { CursorServerMessage } from "./types";
7
12
 
8
13
  const DEFAULT_CONTEXT_USAGE_MAX_ENTRIES = 200;
@@ -125,6 +130,13 @@ export interface CursorProtobufEventState {
125
130
  * share one field, or Codex double-counts (e.g. 10000 then 10300 surfacing as 20300).
126
131
  */
127
132
  contextTokens?: number;
133
+ /**
134
+ * Request-local input estimate derived from the payload actually sent to Cursor.
135
+ * Used only when neither a checkpoint nor a carry-forward is available — a restart
136
+ * clears the tracker, and reporting inputTokens=0 makes Codex see an almost-empty
137
+ * context (#373). Never written back into the tracker: only real checkpoints are.
138
+ */
139
+ estimatedInputTokens?: number;
128
140
  /**
129
141
  * Session-level last-known absolute context size for this Cursor conversation. This is a fallback
130
142
  * for no-checkpoint client-tool finalize turns only; any checkpoint observed during the current
@@ -152,6 +164,12 @@ export function createCursorProtobufEventState(options: {
152
164
  toolSchemas?: Map<string, unknown>;
153
165
  cursorToolNameMap?: Map<string, string>;
154
166
  contextUsage?: CursorContextUsageControls;
167
+ /**
168
+ * Request-local input estimate derived from the payload actually sent. Used only
169
+ * when neither a checkpoint nor a carry-forward is available; never recorded into
170
+ * the checkpoint tracker (#373).
171
+ */
172
+ estimatedInputTokens?: number;
155
173
  } = {}): CursorProtobufEventState {
156
174
  return {
157
175
  // Cursor provides no authoritative usage frame; token counts are heuristic estimates from
@@ -166,6 +184,11 @@ export function createCursorProtobufEventState(options: {
166
184
  ...(options.cursorToolNameMap ? { cursorToolNameMap: options.cursorToolNameMap } : {}),
167
185
  ...(options.contextUsage?.carryForwardTokens !== undefined ? { contextCarryForwardTokens: options.contextUsage.carryForwardTokens } : {}),
168
186
  ...(options.contextUsage?.recordContextTokens ? { recordContextTokens: options.contextUsage.recordContextTokens } : {}),
187
+ ...(typeof options.estimatedInputTokens === "number"
188
+ && Number.isFinite(options.estimatedInputTokens)
189
+ && options.estimatedInputTokens > 0
190
+ ? { estimatedInputTokens: options.estimatedInputTokens }
191
+ : {}),
169
192
  };
170
193
  }
171
194
 
@@ -212,12 +235,26 @@ function decodeMcpArgs(args: McpArgs | undefined): string {
212
235
  return JSON.stringify(decodeCursorArgsMap(args?.args));
213
236
  }
214
237
 
238
+ /** Resolve an advertised client-tool wire name, including shell_command/exec_command aliases (#399). */
239
+ function resolveAdvertisedClientToolName(
240
+ state: CursorProtobufEventState,
241
+ cursorWireName: string,
242
+ ): string | undefined {
243
+ const normalized = normalizeCursorWireName(cursorWireName);
244
+ if (!state.clientToolNames) return normalized;
245
+ return resolveShellBridgeAliasKey(normalized, alias => (state.clientToolNames!.has(alias) ? alias : undefined));
246
+ }
247
+
248
+ function toolSchemaForWireName(state: CursorProtobufEventState, toolName: string | undefined): unknown | undefined {
249
+ if (!toolName || !state.toolSchemas) return undefined;
250
+ return resolveShellBridgeAliasKey(toolName, alias => state.toolSchemas!.get(alias));
251
+ }
252
+
215
253
  function decodeMcpArgsNormalized(args: McpArgs | undefined, state: CursorProtobufEventState): string {
216
254
  const decoded = decodeCursorArgsMap(args?.args);
217
255
  const toolName = mcpWireNameFromArgs(args);
218
- if (toolName && state.toolSchemas?.has(toolName)) {
219
- return JSON.stringify(normalizeArgKeys(decoded, state.toolSchemas.get(toolName)));
220
- }
256
+ const schema = toolSchemaForWireName(state, toolName);
257
+ if (schema) return JSON.stringify(normalizeArgKeys(decoded, schema));
221
258
  return JSON.stringify(decoded);
222
259
  }
223
260
 
@@ -237,11 +274,12 @@ function isCompleteJson(text: string): boolean {
237
274
 
238
275
  /** Schema-normalize a JSON-text argument blob for a named tool, if a schema is known. */
239
276
  function normalizeJsonText(text: string, toolName: string | undefined, state: CursorProtobufEventState): string {
240
- if (!toolName || !state.toolSchemas?.has(toolName)) return text;
277
+ const schema = toolSchemaForWireName(state, toolName);
278
+ if (!schema) return text;
241
279
  try {
242
280
  const parsed = JSON.parse(text);
243
281
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
244
- return JSON.stringify(normalizeArgKeys(parsed as Record<string, unknown>, state.toolSchemas.get(toolName)));
282
+ return JSON.stringify(normalizeArgKeys(parsed as Record<string, unknown>, schema));
245
283
  }
246
284
  } catch {
247
285
  // Not parseable as an object: leave as-is.
@@ -305,10 +343,14 @@ export function mapSyntheticMcpExecToToolEvents(
305
343
  function recordToolCall(state: CursorProtobufEventState, callId: string, cursorWireName: string): CursorServerMessage[] {
306
344
  if (state.completedToolCalls.has(callId)) return [];
307
345
  if (state.openToolCalls.has(callId)) return [];
308
- if (state.clientToolNames && !state.clientToolNames.has(cursorWireName)) {
346
+ const advertisedName = resolveAdvertisedClientToolName(state, cursorWireName);
347
+ if (state.clientToolNames && !advertisedName) {
309
348
  return [{ type: "error", message: `Cursor requested unknown Responses tool: ${cursorWireName}` }];
310
349
  }
311
- state.openToolCalls.set(callId, { name: responsesToolNameFromCursorWire(cursorWireName, state.cursorToolNameMap), args: "" });
350
+ // Prefer the advertised catalog name for Responses mapping so shell_command/exec_command aliases
351
+ // land on the tool Codex actually exposed this turn (#399).
352
+ const mapKey = advertisedName ?? normalizeCursorWireName(cursorWireName);
353
+ state.openToolCalls.set(callId, { name: responsesToolNameFromCursorWire(mapKey, state.cursorToolNameMap), args: "" });
312
354
  state.startedClientToolCalls++;
313
355
  return [];
314
356
  }
@@ -428,6 +470,27 @@ export function mapCursorProtobufServerMessage(
428
470
  }
429
471
  }
430
472
 
473
+ /**
474
+ * Resolve the usage to report for a turn, in order of trustworthiness: a checkpoint
475
+ * observed this turn, then the session carry-forward, then the request-local estimate,
476
+ * then the raw per-turn counters. Shared with the partial-usage path in live-transport
477
+ * so a failed turn reports the same input side as a clean one (#373).
478
+ */
479
+ export function resolvedTurnUsage(state: CursorProtobufEventState): OcxUsage {
480
+ const contextTokens = reportableContextTokens(state);
481
+ if (contextTokens !== undefined) return usageFromContextTokens(state, contextTokens);
482
+ const estimate = state.estimatedInputTokens;
483
+ if (estimate !== undefined) {
484
+ return {
485
+ ...state.usage,
486
+ inputTokens: estimate,
487
+ totalTokens: estimate + state.usage.outputTokens,
488
+ estimated: true,
489
+ };
490
+ }
491
+ return { ...state.usage };
492
+ }
493
+
431
494
  /**
432
495
  * Finalize a Cursor turn. If any client tool call is still open (started but never completed),
433
496
  * the stream was truncated and the partial tool call must not reach Codex as a completed call
@@ -447,9 +510,5 @@ export function finalizeTurnEvents(state: CursorProtobufEventState): CursorServe
447
510
  // render the additive pair instead of total_tokens, so leaving inputTokens at 0 makes a 16k-context
448
511
  // first turn display as "9 used". Keep outputTokens as the per-turn delta and clamp the inferred
449
512
  // input to 0 in case Cursor reports a checkpoint smaller than the streamed output delta.
450
- const contextTokens = reportableContextTokens(state);
451
- const usage: OcxUsage = contextTokens !== undefined
452
- ? usageFromContextTokens(state, contextTokens)
453
- : { ...state.usage };
454
- return [{ type: "done", usage }];
513
+ return [{ type: "done", usage: resolvedTurnUsage(state) }];
455
514
  }
@@ -1,4 +1,4 @@
1
- import { create, toBinary } from "@bufbuild/protobuf";
1
+ import { create, fromBinary, toBinary, toJson } from "@bufbuild/protobuf";
2
2
  import { fromJson, type JsonValue } from "@bufbuild/protobuf";
3
3
  import { ValueSchema } from "@bufbuild/protobuf/wkt";
4
4
  import type { OcxAssistantContentPart, OcxMessage, OcxToolResultMessage } from "../../types";
@@ -7,6 +7,7 @@ import type { CursorRunRequest } from "./types";
7
7
  import { isCursorExternalWireModel } from "./discovery";
8
8
  import { debugProviderDiagnostic } from "../../lib/debug";
9
9
  import { storeCursorBlob } from "./native-exec";
10
+ import { estimateTokens } from "../../lib/token-estimate";
10
11
  import {
11
12
  AgentClientMessageSchema,
12
13
  AgentConversationTurnStructureSchema,
@@ -23,6 +24,7 @@ import {
23
24
  McpToolResultContentItemSchema,
24
25
  McpToolResultSchema,
25
26
  McpToolsSchema,
27
+ type McpToolDefinition,
26
28
  ModelDetailsSchema,
27
29
  RequestedModelSchema,
28
30
  RequestedModel_ModelParameterbytesSchema,
@@ -76,13 +78,20 @@ function buildRequestContext() {
76
78
  });
77
79
  }
78
80
 
79
- function jsonBlob(value: unknown): Uint8Array {
80
- return encoder.encode(JSON.stringify(value));
81
+ function jsonBlob(value: unknown): { data: Uint8Array; serialized: string } {
82
+ const serialized = JSON.stringify(value);
83
+ return { data: encoder.encode(serialized), serialized };
81
84
  }
82
85
 
83
86
  type StoredRootBlob = {
84
87
  id: Uint8Array;
85
88
  byteLength: number;
89
+ /**
90
+ * The exact JSON handed to storeCursorBlob(). Retained so a token estimate can read
91
+ * what the wire actually carries without re-serializing — and without drifting from
92
+ * it after pruning or truncation (#373).
93
+ */
94
+ serialized: string;
86
95
  role: "system" | "user" | "assistant" | "toolResult";
87
96
  messageIndex?: number;
88
97
  /** Original JSON text payload used when an active tool result must be truncated to fit. */
@@ -94,10 +103,11 @@ function storedRootBlob(
94
103
  role: StoredRootBlob["role"],
95
104
  opts?: { messageIndex?: number; text?: string },
96
105
  ): StoredRootBlob {
97
- const data = jsonBlob(value);
106
+ const { data, serialized } = jsonBlob(value);
98
107
  return {
99
108
  id: storeCursorBlob(data),
100
109
  byteLength: data.byteLength,
110
+ serialized,
101
111
  role,
102
112
  ...(opts?.messageIndex !== undefined ? { messageIndex: opts.messageIndex } : {}),
103
113
  ...(opts?.text !== undefined ? { text: opts.text } : {}),
@@ -164,6 +174,8 @@ function rootPromptMessages(request: CursorRunRequest): {
164
174
  ids: Uint8Array[];
165
175
  byteLength: number;
166
176
  historyMessageStart: number;
177
+ /** Serialized text of the roots that survived pruning, in wire order. */
178
+ serialized: string[];
167
179
  } {
168
180
  const entries = systemPromptBlobs(request);
169
181
  const systemEntryCount = entries.length;
@@ -173,6 +185,7 @@ function rootPromptMessages(request: CursorRunRequest): {
173
185
  ids: entries.map(entry => entry.id),
174
186
  byteLength: entries.reduce((sum, entry) => sum + entry.byteLength, 0),
175
187
  historyMessageStart: 0,
188
+ serialized: entries.map(entry => entry.serialized),
176
189
  };
177
190
  }
178
191
 
@@ -289,6 +302,7 @@ function rootPromptMessages(request: CursorRunRequest): {
289
302
  ids: selected.map(entry => entry.id),
290
303
  byteLength: selected.reduce((sum, entry) => sum + entry.byteLength, 0),
291
304
  historyMessageStart,
305
+ serialized: selected.map(entry => entry.serialized),
292
306
  };
293
307
  }
294
308
 
@@ -503,7 +517,46 @@ export function activePromptText(request: CursorRunRequest): string {
503
517
  return last?.role === "tool" ? last.content : "";
504
518
  }
505
519
 
506
- export function encodeCursorRunRequest(request: CursorRunRequest): Uint8Array {
520
+ /**
521
+ * The model-visible text of one finalized tool definition. The schema travels as
522
+ * packed protobuf bytes, so it is decoded back to JSON to be counted the way the
523
+ * model reads it.
524
+ */
525
+ function modelVisibleToolText(definition: McpToolDefinition): string {
526
+ let inputSchema: unknown;
527
+ try {
528
+ inputSchema = toJson(ValueSchema, fromBinary(ValueSchema, definition.inputSchema));
529
+ } catch {
530
+ inputSchema = undefined;
531
+ }
532
+ return JSON.stringify({
533
+ name: definition.toolName || definition.name,
534
+ description: definition.description,
535
+ ...(inputSchema !== undefined ? { inputSchema } : {}),
536
+ });
537
+ }
538
+
539
+ export interface PreparedCursorRunRequest {
540
+ bytes: Uint8Array;
541
+ /** Only present when the caller asked for it; see prepareCursorRunRequest(). */
542
+ estimatedInputTokens?: number;
543
+ }
544
+
545
+ /**
546
+ * Build the wire payload once, and optionally derive a token estimate from the very
547
+ * same roots, action text, and tool definitions that produced it.
548
+ *
549
+ * Cursor only reports absolute context size in checkpoint frames, which live in a
550
+ * process-local map — so after a restart a turn with no checkpoint reports
551
+ * inputTokens=0 and Codex sees an almost-empty context (#373). The estimate fills
552
+ * that gap. Deriving it here, rather than from the original request, is what keeps
553
+ * it honest: history the pruner dropped and tools the filter removed are already
554
+ * gone by this point.
555
+ */
556
+ export function prepareCursorRunRequest(
557
+ request: CursorRunRequest,
558
+ options?: { estimateInputTokens?: boolean },
559
+ ): PreparedCursorRunRequest {
507
560
  const rawText = activePromptText(request);
508
561
  const lastRole = request.messages.at(-1)?.role;
509
562
  const text = lastRole === "user" || lastRole === "developer"
@@ -536,6 +589,10 @@ export function encodeCursorRunRequest(request: CursorRunRequest): Uint8Array {
536
589
  const rootPromptMessagesState = rootPromptMessages(request);
537
590
  const rootPromptMessageIds = rootPromptMessagesState.ids;
538
591
  const turnIds = conversationTurns(request, rootPromptMessagesState.historyMessageStart);
592
+ // Hoisted out of the mcp_tools spread below so the estimate can read the same
593
+ // filtered definitions the wire carries. Both helpers are pure.
594
+ const visibleTools = cursorToolsForActivePrompt(request.tools, rawText, request.toolChoice);
595
+ const mcpToolDefs = buildCursorToolDefinitions(visibleTools, request.toolChoice);
539
596
  debugProviderDiagnostic("cursor", "run-request", {
540
597
  wireModel: request.modelId,
541
598
  action: actionCase,
@@ -598,15 +655,29 @@ export function encodeCursorRunRequest(request: CursorRunRequest): Uint8Array {
598
655
  // the event-state `clientToolNames` use (live-transport.ts). Advertising the raw `request.tools`
599
656
  // here would let mcp_tools expose a tool that the event state does not recognize for a generic
600
657
  // tool-count prompt, so a call to it would be rejected as an unknown Responses tool.
601
- ...(() => {
602
- const visibleTools = cursorToolsForActivePrompt(request.tools, activePromptText(request), request.toolChoice);
603
- const mcpToolDefs = buildCursorToolDefinitions(visibleTools, request.toolChoice);
604
- return mcpToolDefs.length > 0 ? { mcpTools: create(McpToolsSchema, { mcpTools: mcpToolDefs }) } : {};
605
- })(),
658
+ ...(mcpToolDefs.length > 0 ? { mcpTools: create(McpToolsSchema, { mcpTools: mcpToolDefs }) } : {}),
606
659
  });
607
660
 
608
661
  const message = create(AgentClientMessageSchema, {
609
662
  message: { case: "runRequest", value: runRequest },
610
663
  });
611
- return toBinary(AgentClientMessageSchema, message);
664
+ const bytes = toBinary(AgentClientMessageSchema, message);
665
+ if (!options?.estimateInputTokens) return { bytes };
666
+
667
+ // Same instances that produced `bytes`, so the estimate cannot count history or
668
+ // tools the payload dropped — the defect that blocked PR #376.
669
+ const modelVisibleParts = [
670
+ ...rootPromptMessagesState.serialized,
671
+ ...(actionCase === "userMessageAction" ? [text] : []),
672
+ ...mcpToolDefs.map(modelVisibleToolText),
673
+ ];
674
+ return {
675
+ bytes,
676
+ estimatedInputTokens: estimateTokens(modelVisibleParts.join("\n"), request.modelId),
677
+ };
678
+ }
679
+
680
+ /** Back-compat wrapper: callers that only need the wire bytes. */
681
+ export function encodeCursorRunRequest(request: CursorRunRequest): Uint8Array {
682
+ return prepareCursorRunRequest(request).bytes;
612
683
  }
@@ -15,8 +15,10 @@ import {
15
15
  cursorMcpToolEncodedSize,
16
16
  cursorMcpToolsEncodedSize,
17
17
  cursorToolAllowedByChoice,
18
+ cursorToolChoiceAliases,
18
19
  cursorToolWireName,
19
20
  cursorToolsForActivePrompt,
21
+ isBareCodexShellBridgeTool,
20
22
  } from "./tool-definitions";
21
23
  import { lookupCursorThreadConversation } from "./thread-continuity";
22
24
 
@@ -35,10 +37,18 @@ function explicitlySelectedNames(choice: OcxToolChoice | undefined): Set<string>
35
37
  }
36
38
 
37
39
  function toolPriority(tool: OcxTool, selectedNames: ReadonlySet<string>): number {
38
- if (toolChoiceAliases(tool).some(name => selectedNames.has(name))) return 0;
39
- if (tool.loadedFromToolSearch) return 1;
40
- if (!tool.namespace) return 2;
41
- return 3;
40
+ // Shell bridge and apply_patch outrank unrelated allowed_tools entries so a large
41
+ // selected filler cannot starve the Codex execution path during truncation (#399).
42
+ if (isBareCodexShellBridgeTool(tool)) return 0;
43
+ if (!tool.namespace && tool.name === "apply_patch") return 1;
44
+ if (cursorToolChoiceAliases(tool).some(name => selectedNames.has(name))) return 2;
45
+ if (tool.loadedFromToolSearch) return 3;
46
+ if (!tool.namespace) return 4;
47
+ return 5;
48
+ }
49
+
50
+ function isPinnedCursorTool(tool: OcxTool, selectedNames: ReadonlySet<string>): boolean {
51
+ return toolPriority(tool, selectedNames) <= 2;
42
52
  }
43
53
 
44
54
  /**
@@ -50,7 +60,8 @@ export function applyCursorToolBudget(
50
60
  tools: readonly OcxTool[] | undefined,
51
61
  toolChoice: OcxToolChoice | undefined,
52
62
  ): CursorToolBudgetResult {
53
- const eligible = (tools ?? []).filter(tool => cursorToolAllowedByChoice(tool, toolChoice));
63
+ const catalog = tools ?? [];
64
+ const eligible = catalog.filter(tool => cursorToolAllowedByChoice(tool, toolChoice, catalog));
54
65
  if (
55
66
  eligible.length <= CURSOR_TOOL_COUNT_LIMIT
56
67
  && cursorMcpToolsEncodedSize(eligible, toolChoice) <= CURSOR_TOOL_BYTES_LIMIT
@@ -64,15 +75,28 @@ export function applyCursorToolBudget(
64
75
  const keptSet = new Set<OcxTool>();
65
76
  let keptBytes = 0;
66
77
 
67
- for (const candidate of candidates) {
68
- if (kept.length >= CURSOR_TOOL_COUNT_LIMIT) continue;
78
+ const tryKeep = (tool: OcxTool): boolean => {
79
+ if (keptSet.has(tool) || kept.length >= CURSOR_TOOL_COUNT_LIMIT) return keptSet.has(tool);
69
80
  // Repeated protobuf message fields serialize as concatenated tag/length/value entries,
70
81
  // so each one-entry wrapper size is the exact additive contribution to McpTools.
71
- const candidateBytes = cursorMcpToolEncodedSize(candidate.tool, toolChoice);
72
- if (keptBytes + candidateBytes > CURSOR_TOOL_BYTES_LIMIT) continue;
73
- kept.push(candidate.tool);
74
- keptSet.add(candidate.tool);
82
+ const candidateBytes = cursorMcpToolEncodedSize(tool, toolChoice);
83
+ if (keptBytes + candidateBytes > CURSOR_TOOL_BYTES_LIMIT) return false;
84
+ kept.push(tool);
85
+ keptSet.add(tool);
75
86
  keptBytes += candidateBytes;
87
+ return true;
88
+ };
89
+
90
+ // Phase 1: selected tools + shell bridge + apply_patch (priority <= 2).
91
+ // Pins are admitted before filler so a crowded catalog cannot drop the Codex execution path (#399).
92
+ for (const candidate of candidates) {
93
+ if (!isPinnedCursorTool(candidate.tool, selectedNames)) continue;
94
+ tryKeep(candidate.tool);
95
+ }
96
+
97
+ // Phase 2: remaining tools by priority.
98
+ for (const candidate of candidates) {
99
+ tryKeep(candidate.tool);
76
100
  }
77
101
 
78
102
  return {