@bitkyc08/opencodex 2.57.0 → 2.58.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 (86) hide show
  1. package/gui/dist/assets/{index-Cz7CLdif.js → index-BbrHOIY0.js} +2 -2
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +2 -2
  4. package/src/adapters/codebuddy/scaffold-guard.ts +5 -4
  5. package/src/adapters/command-code.ts +13 -4
  6. package/src/adapters/cursor/cursor-errors.ts +15 -0
  7. package/src/adapters/cursor/discovery.ts +65 -1
  8. package/src/adapters/cursor/live-transport.ts +5 -1
  9. package/src/adapters/cursor/protobuf-events.ts +110 -11
  10. package/src/adapters/cursor/protobuf-request.ts +19 -1
  11. package/src/adapters/cursor/text-toolcall.ts +230 -0
  12. package/src/adapters/cursor/thread-continuity.ts +67 -0
  13. package/src/adapters/cursor/types.ts +5 -0
  14. package/src/adapters/cursor.ts +55 -5
  15. package/src/adapters/google-http.ts +38 -13
  16. package/src/adapters/mimo-free.ts +32 -17
  17. package/src/adapters/ollama-native.ts +42 -8
  18. package/src/adapters/openai-responses/passthrough.ts +30 -4
  19. package/src/adapters/openai-responses/request-strips.ts +43 -0
  20. package/src/adapters/physical-send.ts +50 -0
  21. package/src/bridge/response-json.ts +1 -1
  22. package/src/bridge/sse.ts +1 -1
  23. package/src/claude/outbound.ts +14 -4
  24. package/src/cli/config-command.ts +35 -18
  25. package/src/cli/dispatch.ts +17 -4
  26. package/src/cli/index.ts +44 -2
  27. package/src/cli/system-command.ts +70 -1
  28. package/src/cli/uninstall-client-state.ts +12 -0
  29. package/src/codex/auth-context.ts +42 -8
  30. package/src/codex/desktop-switches.ts +145 -0
  31. package/src/codex/history-job.ts +5 -1
  32. package/src/codex/history-provider.ts +33 -4
  33. package/src/codex/history-worker.ts +14 -1
  34. package/src/codex/inject/remove.ts +145 -7
  35. package/src/codex/inject/restore.ts +204 -32
  36. package/src/codex/inject.ts +3 -7
  37. package/src/codex/loopback-target.ts +9 -0
  38. package/src/codex/native-profile-startup.ts +64 -20
  39. package/src/config/atomic-write.ts +83 -8
  40. package/src/config/schema/config-schema.ts +2 -0
  41. package/src/config/schema/leaf-validators.ts +1 -0
  42. package/src/generated/compatibility-version.json +132 -68
  43. package/src/lib/bounded-subprocess.ts +62 -10
  44. package/src/lib/windows-secret-acl.ts +151 -15
  45. package/src/lib/windows-user-principal.ts +5 -1
  46. package/src/providers/derive.ts +6 -0
  47. package/src/providers/model-discovery.ts +19 -7
  48. package/src/providers/registry/entries-core.ts +11 -0
  49. package/src/providers/registry/entries-extended.ts +50 -28
  50. package/src/providers/registry/model-seeds.ts +67 -17
  51. package/src/providers/registry/types.ts +9 -0
  52. package/src/responses/spill-store.ts +17 -0
  53. package/src/responses/state/body-policy.ts +25 -0
  54. package/src/responses/state/spill-queue.ts +8 -6
  55. package/src/responses/state.ts +3 -22
  56. package/src/router.ts +4 -0
  57. package/src/server/auth-cors.ts +1 -0
  58. package/src/server/index/websocket-handler.ts +48 -1
  59. package/src/server/management/config-routes.ts +27 -5
  60. package/src/server/models-capabilities.ts +24 -3
  61. package/src/server/responses/codex-ws-exchange.ts +65 -4
  62. package/src/server/responses/combo-stream-preflight.ts +68 -5
  63. package/src/server/responses/core-combo.ts +26 -0
  64. package/src/server/responses/core-options.ts +3 -0
  65. package/src/server/responses/fetch-helpers.ts +4 -1
  66. package/src/server/responses/native-injection-protocol.ts +42 -0
  67. package/src/server/responses/native-injection-replay.ts +105 -0
  68. package/src/server/responses/native-injection.ts +242 -0
  69. package/src/server/responses/native-response-control.ts +56 -0
  70. package/src/server/responses/native-response-json.ts +14 -0
  71. package/src/server/responses/native-response-output.ts +37 -0
  72. package/src/server/responses/native-steering-log.ts +44 -0
  73. package/src/server/responses/native-steering-policy.ts +49 -0
  74. package/src/server/responses/native-steering-replay.ts +126 -0
  75. package/src/server/responses/native-steering-settings.ts +76 -0
  76. package/src/server/responses/native-steering.ts +400 -0
  77. package/src/server/responses/native-tool-results.ts +130 -0
  78. package/src/server/responses/passthrough-delivery.ts +11 -0
  79. package/src/server/responses/passthrough-dispatch.ts +33 -1
  80. package/src/server/responses/request-prepare.ts +41 -0
  81. package/src/server/responses/ws-upstream.ts +21 -1
  82. package/src/server/stop-teardown.ts +8 -1
  83. package/src/server/ws-bridge.ts +16 -1
  84. package/src/service/cli.ts +13 -1
  85. package/src/types/config.ts +4 -0
  86. package/src/types/provider.ts +13 -0
@@ -16,7 +16,7 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-Cz7CLdif.js"></script>
19
+ <script type="module" crossorigin src="/assets/index-BbrHOIY0.js"></script>
20
20
  <link rel="stylesheet" crossorigin href="/assets/index-C5-RdDmD.css">
21
21
  </head>
22
22
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.57.0",
3
+ "version": "2.58.0",
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",
@@ -86,7 +86,7 @@
86
86
  "overrides": {
87
87
  "@hono/node-server": "2.1.0",
88
88
  "fast-uri": "^3.1.7",
89
- "hono": "4.13.1",
89
+ "hono": "4.13.8",
90
90
  "ip-address": "^10.4.0",
91
91
  "qs": "^6.16.0"
92
92
  },
@@ -5,10 +5,10 @@ export const CODEBUDDY_SCAFFOLD_ERROR_CODE = "vendor_scaffold_detected";
5
5
 
6
6
  // The observed control protocol uses FULLWIDTH VERTICAL LINE (U+FF5C). Detection stays
7
7
  // deliberately narrower than the marker spelling: a calls control line must be followed by an
8
- // invoke line for a functions.* tool. That distinguishes an agent scaffold from prose quoting or
8
+ // invoke line with a non-empty tool name. That distinguishes an agent scaffold from prose quoting or
9
9
  // discussing one tag.
10
10
  const DSML_CALLS_LINE = "<||dsml|| calls>";
11
- const DSML_INVOKE_PREFIX = "<||dsml|| invoke name=\"functions.";
11
+ const DSML_INVOKE_PREFIX = "<||dsml|| invoke name=\"";
12
12
 
13
13
  export interface CodeBuddyScaffoldFilterResult {
14
14
  /** Bytes released from a suffix withheld by an earlier event on this channel. */
@@ -39,7 +39,7 @@ function prefixAtEnd(text: string, at: number, expected: string): boolean {
39
39
  * Control tags are recognized only at column zero and outside fenced Markdown. Inline code,
40
40
  * quoted strings, blockquotes, indented source, and prose all add syntax before the tag and are
41
41
  * therefore forwarded unchanged. A calls line alone is harmless; refusal requires the observed
42
- * two-line calls-plus-functions-invoke grammar.
42
+ * two-line calls-plus-named-invoke grammar.
43
43
  */
44
44
  function scan(
45
45
  text: string,
@@ -77,7 +77,8 @@ function scan(
77
77
 
78
78
  if (invokeAt >= 0) {
79
79
  const invokeRest = text.slice(invokeAt).toLowerCase();
80
- if (invokeRest.startsWith(DSML_INVOKE_PREFIX)) {
80
+ const invokeNameStart = invokeRest[DSML_INVOKE_PREFIX.length];
81
+ if (invokeRest.startsWith(DSML_INVOKE_PREFIX) && invokeNameStart && !/[\s"]/.test(invokeNameStart)) {
81
82
  return { safe: text.slice(0, index), held: "", fail: true, fence, lineStart };
82
83
  }
83
84
  if (invokeRest.length === 0 || DSML_INVOKE_PREFIX.startsWith(invokeRest)) {
@@ -13,6 +13,8 @@ import { commandCodeReasoningEfforts, refreshCommandCodeReasoningEfforts } from
13
13
  import { identifyRoutedModel } from "./identity";
14
14
  import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge";
15
15
  import { parseDataUrl } from "./image";
16
+ import { createAdapterPhysicalSend } from "./physical-send";
17
+ import { SendBudgetExhaustedError } from "../lib/upstream-retry";
16
18
 
17
19
  // Retain the short ids emitted by the first local integration. New requests use the live catalog's
18
20
  // provider-native IDs directly; this map is compatibility-only and is not a model fallback list.
@@ -469,7 +471,7 @@ async function fetchCommandCode(request: AdapterRequest, ctx: AdapterFetchContex
469
471
  const timer = setTimeout(() => timeout.abort(new DOMException("Timeout elapsed", "TimeoutError")), ctx?.timeoutMs ?? 200_000);
470
472
  const callerSignal = ctx?.abortSignal ?? new AbortController().signal;
471
473
  try {
472
- return await (ctx?.executor ?? executor)(request.url, {
474
+ return await executor(request.url, {
473
475
  method: request.method,
474
476
  headers: request.headers,
475
477
  body: request.body,
@@ -556,7 +558,8 @@ export function createCommandCodeAdapter(provider: OcxProviderConfig): ProviderA
556
558
  };
557
559
  },
558
560
  async fetchResponse(request: AdapterRequest, ctx?: AdapterFetchContext): Promise<Response> {
559
- const response = await fetchCommandCode(request, ctx, executor);
561
+ const send = createAdapterPhysicalSend(ctx, executor);
562
+ const response = await send({ url: request.url, dispatch: physical => fetchCommandCode(request, ctx, physical) });
560
563
  if (response.ok) return response;
561
564
  const currentEffort = (() => {
562
565
  try { return (JSON.parse(request.body) as { params?: { reasoning_effort?: unknown } }).params?.reasoning_effort; } catch { return undefined; }
@@ -577,8 +580,14 @@ export function createCommandCodeAdapter(provider: OcxProviderConfig): ProviderA
577
580
  if (!refreshed || refreshed.includes(currentEffort)) return response;
578
581
  const retry = requestWithoutReasoningEffort(request);
579
582
  if (!retry) return response;
580
- try { void response.body?.cancel(); } catch { /* already closed */ }
581
- return fetchCommandCode(retry, ctx, executor);
583
+ try {
584
+ return await send({ url: retry.url, sendClass: "repair", recovery: "reasoning-effort-downgrade",
585
+ beforeDispatch: () => { try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ } },
586
+ dispatch: physical => fetchCommandCode(retry, ctx, physical) });
587
+ } catch (error) {
588
+ if (error instanceof SendBudgetExhaustedError) return response;
589
+ throw error;
590
+ }
582
591
  },
583
592
  async *parseStream(response: Response, budget: TranslatorBudget): AsyncGenerator<AdapterEvent> {
584
593
  let sawFinish = false;
@@ -46,6 +46,21 @@ export class CursorStreamTruncatedError extends Error {
46
46
  }
47
47
  }
48
48
 
49
+ export const CURSOR_INCOMPLETE_TOOL_CALL_MESSAGE_PREFIX =
50
+ "Cursor stream ended with incomplete tool call(s):";
51
+
52
+ /**
53
+ * True when Cursor ended the stream with a client tool still open. The adapter fail-closes
54
+ * the current turn (no partial `tool_call_start`) and remints the conversation afterwards
55
+ * so the next turn does not resume a session left waiting for `mcpResult`.
56
+ */
57
+ export function isCursorIncompleteToolCallMessage(value: unknown): boolean {
58
+ const message = typeof value === "string" ? value : errorMessage(value);
59
+ const lower = message.toLowerCase();
60
+ return lower.includes(CURSOR_INCOMPLETE_TOOL_CALL_MESSAGE_PREFIX.toLowerCase())
61
+ || lower.includes("tool call(s) left incomplete");
62
+ }
63
+
49
64
  /**
50
65
  * A cancel-shaped stream failure that WE did not request. `cancelCursorRun` is the only place
51
66
  * that cancels our own stream, and it sets `expectedClose` first, so a cancel arriving without it
@@ -24,8 +24,54 @@ const CONTEXT_272K = 272_000;
24
24
  const CONTEXT_262K = 262_144;
25
25
  const CONTEXT_256K = 256_000;
26
26
  const CONTEXT_200K = 200_000;
27
+ export const CURSOR_OBSERVED_CONTEXT_WINDOW_MAX_ENTRIES = 2_048;
27
28
 
28
- export function inferCursorContextWindow(modelId: string): number {
29
+ /**
30
+ * Process-local ceilings from `ConversationTokenDetails.maxTokens` on live
31
+ * checkpoints. Each observation belongs to the Cursor identity scope that
32
+ * produced it; plan-gated accounts sharing one proxy must not overwrite each
33
+ * other's overflow prior (senpi `cursor-context-limit`).
34
+ */
35
+ const observedCursorContextWindows = new Map<string, number>();
36
+
37
+ interface CursorContextWindowOptions {
38
+ identityScope?: string;
39
+ observed?: number;
40
+ }
41
+
42
+ function normalizeObservedWindowKey(modelId: string, identityScope?: string): string {
43
+ return `${identityScope?.trim() || "local"}\0${modelId.trim().toLowerCase()}`;
44
+ }
45
+
46
+ export function recordObservedCursorContextWindow(
47
+ modelId: string,
48
+ maxTokens: number | undefined,
49
+ options: Pick<CursorContextWindowOptions, "identityScope"> = {},
50
+ ): void {
51
+ if (!modelId.trim()) return;
52
+ if (typeof maxTokens !== "number" || !Number.isFinite(maxTokens) || maxTokens <= 0) return;
53
+ const key = normalizeObservedWindowKey(modelId, options.identityScope);
54
+ observedCursorContextWindows.delete(key);
55
+ observedCursorContextWindows.set(key, Math.floor(maxTokens));
56
+ while (observedCursorContextWindows.size > CURSOR_OBSERVED_CONTEXT_WINDOW_MAX_ENTRIES) {
57
+ const oldest = observedCursorContextWindows.keys().next().value;
58
+ if (oldest === undefined) break;
59
+ observedCursorContextWindows.delete(oldest);
60
+ }
61
+ }
62
+
63
+ export function observedCursorContextWindow(
64
+ modelId: string,
65
+ options: Pick<CursorContextWindowOptions, "identityScope"> = {},
66
+ ): number | undefined {
67
+ return observedCursorContextWindows.get(normalizeObservedWindowKey(modelId, options.identityScope));
68
+ }
69
+
70
+ export function resetObservedCursorContextWindowsForTests(): void {
71
+ observedCursorContextWindows.clear();
72
+ }
73
+
74
+ function inferCursorContextWindowHeuristic(modelId: string): number {
29
75
  const id = modelId.trim().toLowerCase();
30
76
  if (id.includes("1m")) return CONTEXT_1M;
31
77
  if (id.startsWith("gemini-")) return CONTEXT_1M;
@@ -40,6 +86,24 @@ export function inferCursorContextWindow(modelId: string): number {
40
86
  return CURSOR_DEFAULT_CONTEXT_WINDOW;
41
87
  }
42
88
 
89
+ /**
90
+ * Infer a conservative context window for a Cursor model id.
91
+ *
92
+ * A positive explicit observation wins, then an identity-scoped process-local
93
+ * checkpoint `maxTokens`, then the id heuristic. Cursor's `AvailableModelsResponse`
94
+ * does not currently include per-model context window metadata.
95
+ */
96
+ export function inferCursorContextWindow(
97
+ modelId: string,
98
+ options: CursorContextWindowOptions = {},
99
+ ): number {
100
+ const { observed } = options;
101
+ if (typeof observed === "number" && Number.isFinite(observed) && observed > 0) {
102
+ return Math.floor(observed);
103
+ }
104
+ return observedCursorContextWindow(modelId, options) ?? inferCursorContextWindowHeuristic(modelId);
105
+ }
106
+
43
107
  function normalizeInputModalities(input: string[] | undefined): string[] {
44
108
  const values = (input ?? [...CURSOR_DEFAULT_INPUT_MODALITIES])
45
109
  .map(item => item.trim())
@@ -20,6 +20,7 @@ import {
20
20
  createCursorContextUsageTracker,
21
21
  createCursorProtobufEventState,
22
22
  finalizeTurnEvents,
23
+ hasBufferedTextToolCalls,
23
24
  mapCursorProtobufServerMessage,
24
25
  mapSyntheticMcpExecToToolEvents,
25
26
  reportableContextTokens,
@@ -732,6 +733,8 @@ class LiveCursorTransport implements CursorTransport {
732
733
  syntheticStructuredEditToolNames,
733
734
  translatorBudget: this.translatorBudget,
734
735
  contextUsage,
736
+ wireModelId: request.modelId,
737
+ identityScope: request._cursorIdentityScope,
735
738
  ...(prepared.estimatedInputTokens !== undefined
736
739
  ? { estimatedInputTokens: prepared.estimatedInputTokens }
737
740
  : {}),
@@ -1265,6 +1268,7 @@ class LiveCursorTransport implements CursorTransport {
1265
1268
  state.openToolCalls.size > 0
1266
1269
  || this.sawAssistantText
1267
1270
  || hasPendingClientToolFinalization
1271
+ || hasBufferedTextToolCalls(state)
1268
1272
  )
1269
1273
  ) {
1270
1274
  const terminal = hasPendingClientToolFinalization && state.openToolCalls.size === 0
@@ -1431,7 +1435,7 @@ class LiveCursorTransport implements CursorTransport {
1431
1435
  settler.settleFinish();
1432
1436
  return;
1433
1437
  }
1434
- if (this.framesReceived > 0 && this.sawAssistantText) {
1438
+ if (this.framesReceived > 0 && (this.sawAssistantText || hasBufferedTextToolCalls(state))) {
1435
1439
  for (const event of finalizeTurnEvents(state)) push(event);
1436
1440
  releaseBacklogLease();
1437
1441
  settler.settleFinish();
@@ -11,13 +11,19 @@ import {
11
11
  isCodexShellBridgeToolName,
12
12
  isCursorStructuredEditToolName,
13
13
  normalizeCursorWireName,
14
- normalizeCursorTextToolMarkers,
15
14
  OCX_RESPONSES_TOOL_PROVIDER,
16
15
  resolveShellBridgeAliasKey,
17
16
  responsesToolNameFromCursorWire,
18
17
  } from "./tool-definitions";
18
+ import {
19
+ drainCursorTextToolCalls,
20
+ type DrainedTextToolCall,
21
+ type SuppressedTextToolCallScan,
22
+ } from "./text-toolcall";
23
+ import { recordObservedCursorContextWindow } from "./discovery";
19
24
  import type { CursorServerMessage } from "./types";
20
25
  import type { TranslatorBudget } from "../../lib/translator-budget";
26
+ import { CURSOR_INCOMPLETE_TOOL_CALL_MESSAGE_PREFIX } from "./cursor-errors";
21
27
 
22
28
  const DEFAULT_CONTEXT_USAGE_MAX_ENTRIES = 200;
23
29
  const DEFAULT_CONTEXT_USAGE_TTL_MS = 60 * 60 * 1_000;
@@ -176,6 +182,23 @@ export interface CursorProtobufEventState {
176
182
  */
177
183
  syntheticStructuredEditToolNames?: ReadonlySet<string>;
178
184
  translatorBudget?: TranslatorBudget;
185
+ /**
186
+ * Incomplete `[TOOL_CALL]…[ARGS]{` prefix held across `textDelta` frames so a
187
+ * marker split by the stream cannot leak into assistant text.
188
+ */
189
+ pendingTextToolCall?: string;
190
+ /** Constant-space scanner used after an incomplete textual marker exceeds its retained byte cap. */
191
+ suppressedTextToolCall?: SuppressedTextToolCallScan;
192
+ /** Parsed textual fallback calls held until turn finalization establishes that no real frame won. */
193
+ bufferedTextToolCalls?: DrainedTextToolCall[];
194
+ /** True once this turn carries any real client-tool frame, including an incomplete one. */
195
+ sawRealClientToolCall?: boolean;
196
+ /** Monotonic id suffix for tool calls promoted from text markers. */
197
+ textToolCallSeq?: number;
198
+ /** Wire model id used to record checkpoint `maxTokens` for the next turn. */
199
+ wireModelId?: string;
200
+ /** Normalized Cursor identity scope that owns the observed checkpoint ceiling. */
201
+ identityScope: string;
179
202
  }
180
203
 
181
204
 
@@ -215,6 +238,10 @@ export function createCursorProtobufEventState(options: {
215
238
  */
216
239
  estimatedInputTokens?: number;
217
240
  translatorBudget?: TranslatorBudget;
241
+ /** Wire model id for recording checkpoint `maxTokens` into the process-local window map. */
242
+ wireModelId?: string;
243
+ /** Cursor request identity scope; normalized identically to request-builder continuity. */
244
+ identityScope?: string;
218
245
  } = {}): CursorProtobufEventState {
219
246
  return {
220
247
  // Cursor provides no authoritative usage frame; token counts are heuristic estimates from
@@ -244,6 +271,8 @@ export function createCursorProtobufEventState(options: {
244
271
  && options.estimatedInputTokens > 0
245
272
  ? { estimatedInputTokens: options.estimatedInputTokens }
246
273
  : {}),
274
+ ...(options.wireModelId?.trim() ? { wireModelId: options.wireModelId.trim() } : {}),
275
+ identityScope: options.identityScope?.trim() || "local",
247
276
  };
248
277
  }
249
278
 
@@ -1048,6 +1077,7 @@ export function mapSyntheticMcpExecToToolEvents(
1048
1077
  ): CursorServerMessage[] {
1049
1078
  if (args.providerIdentifier !== OCX_RESPONSES_TOOL_PROVIDER) return [];
1050
1079
  if (options.state?.terminated) return [];
1080
+ if (options.state) options.state.sawRealClientToolCall = true;
1051
1081
  if (options.allowEmptyArgs !== true && !hasMcpArgBytes(args)) return [];
1052
1082
  const cursorWireName = mcpWireNameFromArgs(args);
1053
1083
  if (!cursorWireName) return [{ type: "error", message: "Cursor requested a Responses tool without a tool name" }];
@@ -1117,6 +1147,11 @@ function recordToolCall(state: CursorProtobufEventState, callId: string, cursorW
1117
1147
  return [];
1118
1148
  }
1119
1149
 
1150
+ function recordRealToolCall(state: CursorProtobufEventState, callId: string, cursorWireName: string): CursorServerMessage[] {
1151
+ state.sawRealClientToolCall = true;
1152
+ return recordToolCall(state, callId, cursorWireName);
1153
+ }
1154
+
1120
1155
  /**
1121
1156
  * Emit a completed client tool call as one atomic unit: `tool_call_start` (deferred from open time),
1122
1157
  * the full normalized arguments delta when present, then `tool_call_end`. The call must already be
@@ -1232,33 +1267,70 @@ export function mapCursorProtobufServerMessage(
1232
1267
  if (state.terminated) return [];
1233
1268
 
1234
1269
  if (serverMessage.message.case === "conversationCheckpointUpdate") {
1235
- const usedTokens = serverMessage.message.value.tokenDetails?.usedTokens ?? 0;
1270
+ const tokenDetails = serverMessage.message.value.tokenDetails;
1271
+ const usedTokens = tokenDetails?.usedTokens ?? 0;
1236
1272
  // `usedTokens` is the ABSOLUTE conversation context size, not a per-turn output delta. Track it
1237
1273
  // separately (monotonic max) and surface it as `done.usage.totalTokens`; folding it into
1238
1274
  // `outputTokens` (which also accumulates `tokenDelta`) double-counts in Codex. See contextTokens.
1239
1275
  observeContextTokens(state, usedTokens);
1276
+ // First checkpoints often send maxTokens=0 (senpi). Only a positive ceiling
1277
+ // replaces the id heuristic for the next turn's overflow vs 429 size prior.
1278
+ if (state.wireModelId) {
1279
+ recordObservedCursorContextWindow(state.wireModelId, tokenDetails?.maxTokens, {
1280
+ identityScope: state.identityScope,
1281
+ });
1282
+ }
1240
1283
  return [];
1241
1284
  }
1242
1285
 
1243
1286
  if (serverMessage.message.case !== "interactionUpdate") return [];
1244
1287
  const update = serverMessage.message.value.message;
1245
1288
  switch (update.case) {
1246
- case "textDelta":
1247
- // #2305: fold Cursor display aliases inside textual pseudo tool-call markers back to
1248
- // the advertised wire name before any client sees the text. Real frames are already
1249
- // normalized structurally (mcpWireNameFromArgs above).
1250
- return update.value.text ? [{ type: "text", text: normalizeCursorTextToolMarkers(update.value.text) }] : [];
1289
+ case "textDelta": {
1290
+ // Textual `[TOOL_CALL]name[ARGS]{…}` is not assistant prose. Leaving it in
1291
+ // the text channel (even after #2305 renamed the display alias) leaks a
1292
+ // synthetic protocol marker that later turns few-shot-mimic as inert text.
1293
+ // Strip complete markers, buffer advertised fallbacks until finalize,
1294
+ // and hold or suppress-scan an incomplete opener across deltas.
1295
+ const chunk = update.value.text ?? "";
1296
+ if (!chunk && !state.pendingTextToolCall && !state.suppressedTextToolCall) return [];
1297
+ const drained = drainCursorTextToolCalls(
1298
+ state.pendingTextToolCall ?? "",
1299
+ chunk,
1300
+ state.suppressedTextToolCall,
1301
+ );
1302
+ if (drained.pending) state.pendingTextToolCall = drained.pending;
1303
+ else delete state.pendingTextToolCall;
1304
+ if (drained.suppressed) state.suppressedTextToolCall = drained.suppressed;
1305
+ else delete state.suppressedTextToolCall;
1306
+ const out: CursorServerMessage[] = [];
1307
+ if (drained.text) out.push({ type: "text", text: drained.text });
1308
+ for (const call of drained.calls) {
1309
+ const advertised = resolveAdvertisedClientToolName(state, call.name);
1310
+ if (
1311
+ state.sawRealClientToolCall
1312
+ || !state.clientToolNames
1313
+ || !advertised
1314
+ || (state.bufferedTextToolCalls?.length ?? 0) >= state.maxClientToolCalls
1315
+ ) continue;
1316
+ (state.bufferedTextToolCalls ??= []).push({
1317
+ name: advertised,
1318
+ args: normalizeJsonText(call.args, advertised, state),
1319
+ });
1320
+ }
1321
+ return out;
1322
+ }
1251
1323
  case "thinkingDelta":
1252
1324
  return update.value.text ? [{ type: "thinking", thinking: update.value.text }] : [];
1253
1325
  case "toolCallStarted": {
1254
1326
  const name = mcpCursorWireName(update.value.toolCall);
1255
1327
  // Record the open call but defer the outward tool_call_start to completion (atomic emission).
1256
- return name ? recordToolCall(state, update.value.callId, name) : [];
1328
+ return name ? recordRealToolCall(state, update.value.callId, name) : [];
1257
1329
  }
1258
1330
  case "partialToolCall": {
1259
1331
  const out: CursorServerMessage[] = [];
1260
1332
  const name = mcpCursorWireName(update.value.toolCall);
1261
- if (name) out.push(...recordToolCall(state, update.value.callId, name));
1333
+ if (name) out.push(...recordRealToolCall(state, update.value.callId, name));
1262
1334
  if (out.some(event => event.type === "error")) return out;
1263
1335
  // Buffer cumulative args; do not emit a delta. Args are emitted once, normalized, at completion.
1264
1336
  if (state.openToolCalls.has(update.value.callId)) {
@@ -1274,6 +1346,7 @@ export function mapCursorProtobufServerMessage(
1274
1346
  const out: CursorServerMessage[] = [];
1275
1347
  if (state.completedToolCalls.has(update.value.callId)) return [];
1276
1348
  const name = mcpCursorWireName(update.value.toolCall);
1349
+ if (name) state.sawRealClientToolCall = true;
1277
1350
  const args = mcpArgsFromToolCall(update.value.toolCall);
1278
1351
  const openBeforeStart = state.openToolCalls.get(update.value.callId);
1279
1352
  // Empty-arg completion handling:
@@ -1362,20 +1435,46 @@ export function resolvedTurnUsage(state: CursorProtobufEventState): OcxUsage {
1362
1435
  * with corrupt/empty arguments. Emit an explicit error instead of done (fail-closed).
1363
1436
  * Mirrors kiro-truncation.ts behavior.
1364
1437
  */
1438
+ /**
1439
+ * True when this turn holds textual fallback tool calls that only turn finalization can emit.
1440
+ *
1441
+ * Cursor can close a stream with a clean Connect END_STREAM and no turnEnded frame. The transport
1442
+ * finalizes that path only for a turn it can see is unfinished, and a turn whose entire visible
1443
+ * text was a stripped marker looks empty from the outside. Without this the deferred fallback
1444
+ * would be dropped exactly when the marker was the turn's only content.
1445
+ */
1446
+ export function hasBufferedTextToolCalls(state: CursorProtobufEventState): boolean {
1447
+ return (state.bufferedTextToolCalls?.length ?? 0) > 0;
1448
+ }
1449
+
1365
1450
  export function finalizeTurnEvents(state: CursorProtobufEventState): CursorServerMessage[] {
1366
1451
  state.terminated = true;
1452
+ delete state.pendingTextToolCall;
1453
+ delete state.suppressedTextToolCall;
1454
+ const bufferedTextToolCalls = state.bufferedTextToolCalls ?? [];
1455
+ delete state.bufferedTextToolCalls;
1367
1456
  if (state.openToolCalls.size > 0) {
1368
1457
  const openCallIds = [...state.openToolCalls.keys()];
1369
1458
  const openIds = openCallIds.join(", ");
1370
1459
  // Clear so a second turnEnded (should not happen, but defensive) doesn't re-emit.
1371
1460
  for (const callId of openCallIds) state.translatorBudget?.closeCall(callId);
1372
1461
  state.openToolCalls.clear();
1373
- return [{ type: "error", message: `Cursor stream ended with incomplete tool call(s): ${openIds}. Arguments may be truncated; the call was not committed.` }];
1462
+ return [{ type: "error", message: `${CURSOR_INCOMPLETE_TOOL_CALL_MESSAGE_PREFIX} ${openIds}. Arguments may be truncated; the call was not committed.` }];
1463
+ }
1464
+ const out: CursorServerMessage[] = [];
1465
+ if (!state.sawRealClientToolCall) {
1466
+ for (const call of bufferedTextToolCalls) {
1467
+ state.textToolCallSeq = (state.textToolCallSeq ?? 0) + 1;
1468
+ const callId = `textcall_${state.textToolCallSeq}`;
1469
+ out.push(...recordToolCall(state, callId, call.name));
1470
+ if (state.openToolCalls.has(callId)) out.push(...commitToolCall(state, callId, call.args));
1471
+ }
1374
1472
  }
1375
1473
  // Surface the absolute context size (when Cursor reported a checkpoint) as both totalTokens and
1376
1474
  // the estimated input side of Codex's visible `input + output` counter. Codex status lines can
1377
1475
  // render the additive pair instead of total_tokens, so leaving inputTokens at 0 makes a 16k-context
1378
1476
  // first turn display as "9 used". Keep outputTokens as the per-turn delta and clamp the inferred
1379
1477
  // input to 0 in case Cursor reports a checkpoint smaller than the streamed output delta.
1380
- return [{ type: "done", usage: resolvedTurnUsage(state) }];
1478
+ out.push({ type: "done", usage: resolvedTurnUsage(state) });
1479
+ return out;
1381
1480
  }
@@ -75,6 +75,8 @@ export const CURSOR_ROUTING_LEVEL_PARAMETER_ID = "optimization";
75
75
  export const CURSOR_EXTERNAL_ROOT_BLOB_LIMIT = 192;
76
76
  /** Approximate prompt-size guard; tool schemas and protocol framing consume context separately. */
77
77
  export const CURSOR_EXTERNAL_ROOT_BYTE_LIMIT = 512 * 1024;
78
+ /** Honest placeholder when native Composer history has a toolCall with no matching toolResult. */
79
+ export const CURSOR_MISSING_TOOL_RESULT = "[missing tool_result for this tool_use in history]";
78
80
  /**
79
81
  * Byte budget for the serialized arguments named inside ONE replayed tool-result envelope. The
80
82
  * invocation identifies the call; the result is the payload. Without an independent cap, a single
@@ -1218,6 +1220,20 @@ function argBytes(value: unknown): Uint8Array {
1218
1220
  }
1219
1221
  }
1220
1222
 
1223
+ function missingToolResultFor(
1224
+ part: Extract<OcxAssistantContentPart, { type: "toolCall" }>,
1225
+ ): OcxToolResultMessage {
1226
+ return {
1227
+ role: "toolResult",
1228
+ toolCallId: part.id,
1229
+ toolName: part.name,
1230
+ ...(part.namespace ? { toolNamespace: part.namespace } : {}),
1231
+ content: CURSOR_MISSING_TOOL_RESULT,
1232
+ isError: true,
1233
+ timestamp: 0,
1234
+ };
1235
+ }
1236
+
1221
1237
  function toolCallStep(
1222
1238
  part: Extract<OcxAssistantContentPart, { type: "toolCall" }>,
1223
1239
  requestScope: CursorBlobRequestScopeToken,
@@ -1332,7 +1348,9 @@ function conversationTurns(
1332
1348
  const pendingToolCalls = new Map<string, Extract<OcxAssistantContentPart, { type: "toolCall" }>>();
1333
1349
  const flush = () => {
1334
1350
  if (!current) return;
1335
- for (const part of pendingToolCalls.values()) current.steps.push(toolCallStep(part, requestScope));
1351
+ for (const part of pendingToolCalls.values()) {
1352
+ current.steps.push(toolCallStep(part, requestScope, missingToolResultFor(part), codeMode));
1353
+ }
1336
1354
  turns.push(storeCursorBlob(toBinary(ConversationTurnStructureSchema, create(ConversationTurnStructureSchema, {
1337
1355
  turn: {
1338
1356
  case: "agentConversationTurn",