@bitkyc08/opencodex 2.10.2 → 2.11.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 (121) hide show
  1. package/README.md +31 -0
  2. package/bin/ocx.mjs +10 -0
  3. package/gui/dist/assets/index-Bk-PN-70.css +1 -0
  4. package/gui/dist/assets/index-BynIEIV-.js +70 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +4 -2
  7. package/src/adapters/cursor/effort-map.ts +11 -0
  8. package/src/adapters/cursor/live-transport.ts +11 -0
  9. package/src/adapters/cursor/native-exec-fs.ts +9 -6
  10. package/src/adapters/cursor/native-exec.ts +4 -2
  11. package/src/adapters/cursor/protobuf-events.ts +176 -4
  12. package/src/adapters/cursor/request-builder.ts +15 -4
  13. package/src/adapters/cursor/tool-definitions.ts +118 -2
  14. package/src/adapters/google.ts +15 -5
  15. package/src/adapters/openai-chat.ts +24 -2
  16. package/src/adapters/openai-responses.ts +2 -1
  17. package/src/bridge.ts +9 -5
  18. package/src/chat/outbound.ts +4 -3
  19. package/src/claude/desktop-3p.ts +222 -2
  20. package/src/claude/outbound.ts +15 -6
  21. package/src/cli/account-api.ts +4 -0
  22. package/src/cli/account-extended.ts +112 -0
  23. package/src/cli/account.ts +23 -6
  24. package/src/cli/claude-desktop.ts +26 -3
  25. package/src/cli/config-command.ts +9 -0
  26. package/src/cli/help.ts +18 -2
  27. package/src/cli/index.ts +277 -55
  28. package/src/cli/models.ts +5 -1
  29. package/src/cli/provider.ts +8 -2
  30. package/src/cli/ready.ts +301 -0
  31. package/src/cli/system-restart-client.ts +146 -0
  32. package/src/cli/tray-proxy.ts +153 -6
  33. package/src/clients/config-export.ts +12 -19
  34. package/src/codex/account-lifecycle.ts +3 -0
  35. package/src/codex/account-namespaces.ts +49 -3
  36. package/src/codex/account-priority.ts +83 -0
  37. package/src/codex/auth-api.ts +83 -0
  38. package/src/codex/auth-context.ts +5 -2
  39. package/src/codex/catalog/provider-fetch.ts +11 -0
  40. package/src/codex/catalog/sync.ts +23 -1
  41. package/src/codex/codex-write-lock.ts +16 -4
  42. package/src/codex/desired-state.ts +37 -4
  43. package/src/codex/history-job.ts +15 -5
  44. package/src/codex/history-provider.ts +31 -14
  45. package/src/codex/history-worker.ts +28 -4
  46. package/src/codex/inject-coordination.ts +13 -1
  47. package/src/codex/inject.ts +360 -66
  48. package/src/codex/internal/history-writer.ts +1 -1
  49. package/src/codex/native-main-lock-file.ts +5 -1
  50. package/src/codex/native-main-owner.ts +17 -3
  51. package/src/codex/native-profile-manager.ts +19 -0
  52. package/src/codex/native-profile-startup.ts +8 -0
  53. package/src/codex/native-residue.ts +140 -27
  54. package/src/codex/pool-rotation.ts +74 -4
  55. package/src/codex/refresh.ts +7 -0
  56. package/src/codex/routing.ts +177 -36
  57. package/src/codex/subagent-model-fallback.ts +34 -4
  58. package/src/codex/sync.ts +61 -0
  59. package/src/codex/upstream-host-health.ts +329 -31
  60. package/src/combos/request.ts +2 -0
  61. package/src/config.ts +221 -2
  62. package/src/images/loop.ts +1 -1
  63. package/src/integrations/native/ownership-preflight.ts +39 -2
  64. package/src/lib/bun-stream-caps.ts +3 -3
  65. package/src/lib/sse-decoder.ts +41 -0
  66. package/src/lib/system-restart-contract.ts +73 -0
  67. package/src/lib/windows-secret-acl.ts +141 -39
  68. package/src/lib/windows-user-principal.ts +283 -0
  69. package/src/lib/winsw.ts +18 -2
  70. package/src/oauth/key-providers.ts +12 -0
  71. package/src/providers/derive.ts +54 -2
  72. package/src/providers/free-directory.ts +6 -5
  73. package/src/providers/model-discovery.ts +9 -3
  74. package/src/providers/quota.ts +592 -0
  75. package/src/providers/registry.ts +316 -13
  76. package/src/responses/parser.ts +26 -10
  77. package/src/responses/reasoning-replay-cache.ts +1 -0
  78. package/src/routing/profile-namespace.ts +15 -0
  79. package/src/routing/profile.ts +2 -1
  80. package/src/server/auth-cors.ts +44 -13
  81. package/src/server/chat-completions.ts +0 -4
  82. package/src/server/claude-messages.ts +73 -15
  83. package/src/server/github-copilot-responses-repair.ts +338 -0
  84. package/src/server/index.ts +328 -111
  85. package/src/server/lifecycle.ts +36 -0
  86. package/src/server/management/agent-settings-routes.ts +147 -56
  87. package/src/server/management/config-routes.ts +7 -2
  88. package/src/server/management/context.ts +4 -0
  89. package/src/server/management/native-integration-routes.ts +199 -20
  90. package/src/server/management/provider-routes.ts +41 -0
  91. package/src/server/management/routing-profile-routes.ts +234 -5
  92. package/src/server/management/system-restart.ts +12 -10
  93. package/src/server/management/system-routes.ts +20 -0
  94. package/src/server/management-auth.ts +51 -3
  95. package/src/server/ports.ts +41 -1
  96. package/src/server/proxy-liveness.ts +129 -4
  97. package/src/server/readiness.ts +99 -0
  98. package/src/server/relay.ts +113 -97
  99. package/src/server/request-log.ts +10 -4
  100. package/src/server/responses/compact.ts +107 -12
  101. package/src/server/responses/core.ts +220 -39
  102. package/src/server/responses-item-id-repair.ts +22 -3
  103. package/src/server/responses-model-rewrite.ts +29 -0
  104. package/src/server/sse-frame-buffer.ts +292 -0
  105. package/src/server/sse-payload-rewrite.ts +25 -14
  106. package/src/server/ws-bridge.ts +27 -22
  107. package/src/service-manager-probe.ts +520 -10
  108. package/src/service.ts +134 -2
  109. package/src/storage/worker-lifecycle.ts +14 -14
  110. package/src/tray/windows-tray.ps1 +74 -9
  111. package/src/types.ts +68 -2
  112. package/src/update/index.ts +12 -0
  113. package/src/update/job.ts +392 -18
  114. package/src/update/npm-cache-preflight.d.mts +47 -0
  115. package/src/update/npm-cache-preflight.mjs +201 -0
  116. package/src/usage/log.ts +1 -1
  117. package/src/vision/index.ts +77 -2
  118. package/src/web-search/loop.ts +1 -1
  119. package/src/web-search/parse.ts +4 -1
  120. package/gui/dist/assets/index-BKVqyYqT.js +0 -70
  121. package/gui/dist/assets/index-Ca_3269W.css +0 -1
@@ -16,8 +16,8 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-BKVqyYqT.js"></script>
20
- <link rel="stylesheet" crossorigin href="/assets/index-Ca_3269W.css">
19
+ <script type="module" crossorigin src="/assets/index-BynIEIV-.js"></script>
20
+ <link rel="stylesheet" crossorigin href="/assets/index-Bk-PN-70.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.10.2",
3
+ "version": "2.11.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",
@@ -49,8 +49,10 @@
49
49
  "prepublishOnly": "bun run audit:high && bun run typecheck && bun run build:gui",
50
50
  "release": "bun scripts/release.ts",
51
51
  "release:watch": "bun scripts/release.ts watch",
52
- "prepush": "bun run typecheck && bun run lint:gui && bun run test && bun run privacy:scan && bun run doctor:gui:if-changed",
52
+ "prepush": "bun run typecheck && bun run lint:gui:if-changed && bun run test && bun run privacy:scan && bun run doctor:gui:if-changed",
53
53
  "lint:gui": "cd gui && bun run lint",
54
+ "lint:gui:if-changed": "bun scripts/lint-gui-if-changed.ts",
55
+ "postmerge": "bun scripts/build-gui-if-changed.ts",
54
56
  "doctor:gui": "cd gui && bun run doctor",
55
57
  "doctor:gui:full": "cd gui && bun run doctor:full",
56
58
  "doctor:gui:if-changed": "bun scripts/doctor-gui-if-changed.ts",
@@ -126,3 +126,14 @@ export function cursorWireModelIdWithEffort(baseModelId: string, effortSuffix: s
126
126
  }
127
127
  return `${baseModelId}-${effortSuffix}`;
128
128
  }
129
+
130
+ /**
131
+ * Compose the exact flattened id sent by AgentService/Run. Discovery normalizes Cursor's optional
132
+ * `cursor-` prefix only for catalog matching, but regular Grok 4.5 requests require that prefix on
133
+ * the wire. Keep this separate from {@link cursorWireModelIdWithEffort} so discovery can continue
134
+ * comparing canonical, prefix-free ids. Grok Fast uses requested_model parameters instead.
135
+ */
136
+ export function cursorRequestWireModelIdWithEffort(baseModelId: string, effortSuffix: string): string {
137
+ const flattened = cursorWireModelIdWithEffort(baseModelId, effortSuffix);
138
+ return baseModelId === "grok-4.5" ? `cursor-${flattened}` : flattened;
139
+ }
@@ -70,6 +70,7 @@ import {
70
70
  cursorToolArgNormalizeSchema,
71
71
  cursorToolWireName,
72
72
  cursorToolsForActivePrompt,
73
+ isCursorSyntheticStructuredEditTool,
73
74
  isGenericToolUseCountDemoPrompt,
74
75
  requestedCursorToolUseCount,
75
76
  } from "./tool-definitions";
@@ -540,10 +541,19 @@ class LiveCursorTransport implements CursorTransport {
540
541
  this.activeClientToolFinalizeGraceMs = clientToolFinalizeGraceMsForRequest(request, this.clientToolFinalizeGraceMs);
541
542
  const cursorVisibleTools = cursorToolsForActivePrompt(request.tools, activeText, request.toolChoice);
542
543
  const clientToolDefs = buildCursorToolDefinitions(cursorVisibleTools, request.toolChoice);
544
+ // `request.tools` is the catalog already filtered and budgeted by request-builder. Derive
545
+ // conversion provenance only from tagged synthetic tools that also survive this final prompt
546
+ // filter; a client tool with the same wire name can never opt into conversion by collision.
547
+ const syntheticStructuredEditToolNames = new Set(
548
+ (cursorVisibleTools ?? [])
549
+ .filter(isCursorSyntheticStructuredEditTool)
550
+ .map(cursorToolWireName),
551
+ );
543
552
  this.execContext = {
544
553
  ...this.execContext,
545
554
  clientToolDefs,
546
555
  rejectNativeFileMutations: cursorRequestAdvertisesApplyPatch(request.tools, request.toolChoice),
556
+ structuredEditAvailable: syntheticStructuredEditToolNames.size > 0,
547
557
  };
548
558
  const toolSchemas = new Map<string, unknown>();
549
559
  const cursorToolNameMap = new Map<string, string>();
@@ -571,6 +581,7 @@ class LiveCursorTransport implements CursorTransport {
571
581
  parallelToolCalls: request.parallelToolCalls,
572
582
  toolSchemas,
573
583
  cursorToolNameMap,
584
+ syntheticStructuredEditToolNames,
574
585
  translatorBudget: this.translatorBudget,
575
586
  contextUsage,
576
587
  ...(prepared.estimatedInputTokens !== undefined
@@ -39,8 +39,11 @@ const MAX_GREP_FILES = 500;
39
39
  const MAX_GREP_RESULTS = 200;
40
40
  const MAX_FILE_BYTES = 1_000_000;
41
41
 
42
- function codexNativeMutationRefusal(operation: "write" | "delete"): string {
43
- return `Cursor-native ${operation} is disabled for this Codex request because apply_patch is available. Use the apply_patch tool for file edits so Codex can approve the change, enforce sandbox policy, show diffs, and record rollout. No file was changed.`;
42
+ function codexNativeMutationRefusal(operation: "write" | "delete", structuredEditAvailable: boolean): string {
43
+ const structuredHint = structuredEditAvailable
44
+ ? " Use the structured edit tools (`edit_file` / `multi_edit`) or the `apply_patch` tool for file edits so Codex can approve the change, enforce sandbox policy, show diffs, and record rollout."
45
+ : " Use the `apply_patch` tool for file edits so Codex can approve the change, enforce sandbox policy, show diffs, and record rollout.";
46
+ return `Cursor-native ${operation} is disabled for this Codex request because apply_patch is available.${structuredHint} No file was changed.`;
44
47
  }
45
48
 
46
49
  const NATIVE_LOCAL_EXEC_DISABLED =
@@ -84,13 +87,13 @@ export function readExec(execMsg: ExecServerMessage): Uint8Array {
84
87
  }
85
88
  }
86
89
 
87
- export function rejectWriteExecForApplyPatch(execMsg: ExecServerMessage): Uint8Array {
90
+ export function rejectWriteExecForApplyPatch(execMsg: ExecServerMessage, structuredEditAvailable = false): Uint8Array {
88
91
  if (execMsg.message.case !== "writeArgs") throw new Error("invalid write exec");
89
92
  const path = resolve(execMsg.message.value.path);
90
93
  return execBytes(execMsg, "writeResult", create(WriteResultSchema, {
91
94
  result: {
92
95
  case: "rejected",
93
- value: create(WriteRejectedSchema, { path, reason: codexNativeMutationRefusal("write") }),
96
+ value: create(WriteRejectedSchema, { path, reason: codexNativeMutationRefusal("write", structuredEditAvailable) }),
94
97
  },
95
98
  }));
96
99
  }
@@ -133,13 +136,13 @@ export function writeExec(execMsg: ExecServerMessage): Uint8Array {
133
136
  }
134
137
  }
135
138
 
136
- export function rejectDeleteExecForApplyPatch(execMsg: ExecServerMessage): Uint8Array {
139
+ export function rejectDeleteExecForApplyPatch(execMsg: ExecServerMessage, structuredEditAvailable = false): Uint8Array {
137
140
  if (execMsg.message.case !== "deleteArgs") throw new Error("invalid delete exec");
138
141
  const path = resolve(execMsg.message.value.path);
139
142
  return execBytes(execMsg, "deleteResult", create(DeleteResultSchema, {
140
143
  result: {
141
144
  case: "rejected",
142
- value: create(DeleteRejectedSchema, { path, reason: codexNativeMutationRefusal("delete") }),
145
+ value: create(DeleteRejectedSchema, { path, reason: codexNativeMutationRefusal("delete", structuredEditAvailable) }),
143
146
  },
144
147
  }));
145
148
  }
@@ -70,6 +70,8 @@ export interface CursorNativeExecContext extends CursorNativeExecDeps {
70
70
  unsafeAllowNativeLocalExec?: boolean;
71
71
  /** apply_patch is visible for this request; Cursor-native write/delete must not bypass Codex. */
72
72
  rejectNativeFileMutations?: boolean;
73
+ /** The synthetic exact-match edit tools (edit_file / multi_edit) are advertised this request. */
74
+ structuredEditAvailable?: boolean;
73
75
  }
74
76
 
75
77
  export function cursorUnsafeNativeLocalExecEnabled(input: Pick<CursorNativeExecContext, "unsafeAllowNativeLocalExec"> = {}): boolean {
@@ -514,8 +516,8 @@ export async function handleCursorNativeExec(execMsg: ExecServerMessage, deps: C
514
516
  if (execCase === "fetchArgs") return [rejectFetchExecForPolicy(execMsg)];
515
517
  }
516
518
  if (execCase === "readArgs") return [readExec(execMsg)];
517
- if (execCase === "writeArgs") return [deps.rejectNativeFileMutations ? rejectWriteExecForApplyPatch(execMsg) : writeExec(execMsg)];
518
- if (execCase === "deleteArgs") return [deps.rejectNativeFileMutations ? rejectDeleteExecForApplyPatch(execMsg) : deleteExec(execMsg)];
519
+ if (execCase === "writeArgs") return [deps.rejectNativeFileMutations ? rejectWriteExecForApplyPatch(execMsg, deps.structuredEditAvailable === true) : writeExec(execMsg)];
520
+ if (execCase === "deleteArgs") return [deps.rejectNativeFileMutations ? rejectDeleteExecForApplyPatch(execMsg, deps.structuredEditAvailable === true) : deleteExec(execMsg)];
519
521
  if (execCase === "lsArgs") return [lsExec(execMsg)];
520
522
  if (execCase === "grepArgs") return [grepExec(execMsg)];
521
523
  if (execCase === "shellArgs") return [shellExec(execMsg)];
@@ -3,10 +3,13 @@ import type { AgentServerMessage, McpArgs, ToolCall } from "./gen/agent_pb";
3
3
  import { decodeCursorArgsMap } from "./arg-codec";
4
4
  import { normalizeArgKeys } from "./arg-normalize";
5
5
  import {
6
+ CODEX_APPLY_PATCH_TOOL,
7
+ CURSOR_MULTI_EDIT_TOOL,
6
8
  cursorShellBridgeArgsValid,
7
9
  cursorShellBridgeDropError,
8
10
  defaultShellBridgeArgNormalizeSchema,
9
11
  isCodexShellBridgeToolName,
12
+ isCursorStructuredEditToolName,
10
13
  normalizeCursorWireName,
11
14
  OCX_RESPONSES_TOOL_PROVIDER,
12
15
  resolveShellBridgeAliasKey,
@@ -161,14 +164,41 @@ export interface CursorProtobufEventState {
161
164
  toolSchemas?: Map<string, unknown>;
162
165
  /** Cursor wire-name → original Responses/Codex tool name for this request. */
163
166
  cursorToolNameMap?: Map<string, string>;
167
+ /**
168
+ * Bare names WE advertised as synthetic structured-edit tools on this request.
169
+ * See structuredEditCallIsOurs: conversion is gated on provenance, not on the name.
170
+ */
171
+ syntheticStructuredEditToolNames?: ReadonlySet<string>;
164
172
  translatorBudget?: TranslatorBudget;
165
173
  }
166
174
 
175
+
176
+ /**
177
+ * Did WE advertise this bare tool name as a synthetic structured-edit tool on this request?
178
+ *
179
+ * Provenance, not a name test. `edit_file` / `multi_edit` are ordinary names a client or MCP
180
+ * server may legitimately expose, and `cursorStructuredEditTools` already refuses to shadow one
181
+ * that exists. Converting on the name alone would undo that refusal at the other end of the
182
+ * request: the client's own call would be silently re-emitted as `apply_patch`, or dropped with
183
+ * an error naming a conversion the user never asked for.
184
+ *
185
+ * Absent set = we advertised nothing, so nothing converts. Fail-closed in the safe direction:
186
+ * an unconverted structured call is a visible, recoverable failure; a wrongly converted one
187
+ * edits a file.
188
+ */
189
+ function structuredEditCallIsOurs(
190
+ advertised: ReadonlySet<string> | undefined,
191
+ toolName: string,
192
+ ): boolean {
193
+ return advertised?.has(toolName) === true;
194
+ }
195
+
167
196
  export function createCursorProtobufEventState(options: {
168
197
  clientToolNames?: Iterable<string>;
169
198
  parallelToolCalls?: boolean;
170
199
  toolSchemas?: Map<string, unknown>;
171
200
  cursorToolNameMap?: Map<string, string>;
201
+ syntheticStructuredEditToolNames?: Iterable<string>;
172
202
  contextUsage?: CursorContextUsageControls;
173
203
  /**
174
204
  * Request-local input estimate derived from the payload actually sent. Used only
@@ -185,6 +215,9 @@ export function createCursorProtobufEventState(options: {
185
215
  openToolCalls: new Map(),
186
216
  completedToolCalls: new Set(),
187
217
  ...(options.clientToolNames ? { clientToolNames: new Set(options.clientToolNames) } : {}),
218
+ ...(options.syntheticStructuredEditToolNames
219
+ ? { syntheticStructuredEditToolNames: new Set(options.syntheticStructuredEditToolNames) }
220
+ : {}),
188
221
  ...(options.parallelToolCalls !== undefined ? { parallelToolCalls: options.parallelToolCalls } : {}),
189
222
  startedClientToolCalls: 0,
190
223
  ...(options.toolSchemas ? { toolSchemas: options.toolSchemas } : {}),
@@ -311,6 +344,117 @@ function resolveCompletedArgs(buffered: string, args: McpArgs | undefined, state
311
344
  return "";
312
345
  }
313
346
 
347
+ const PATCH_BEGIN = "*** Begin Patch";
348
+ const PATCH_END = "*** End Patch";
349
+
350
+ function firstStringArg(args: Record<string, unknown>, keys: readonly string[]): string | undefined {
351
+ for (const key of keys) {
352
+ const value = args[key];
353
+ if (typeof value === "string") return value;
354
+ }
355
+ return undefined;
356
+ }
357
+
358
+ /** Split a replacement into patch lines, ignoring one trailing newline (line-based patch semantics). */
359
+ function patchLines(text: string): string[] {
360
+ const lines = text.split("\n");
361
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
362
+ return lines;
363
+ }
364
+
365
+ /** One `@@` hunk replacing `oldString` with `newString`. */
366
+ function replacementHunk(oldString: string, newString: string): { hunk: string } | { error: string } {
367
+ if (oldString.length === 0) {
368
+ return {
369
+ error:
370
+ "structured edit requires a non-empty old_string to locate the replacement; for new files or insertions without existing text, call apply_patch with an `*** Add File` / context hunk or use the shell bridge",
371
+ };
372
+ }
373
+ const oldLines = patchLines(oldString);
374
+ const newLines = patchLines(newString);
375
+ // Line-based patch semantics cannot express an edit that only adds or removes the file's
376
+ // final newline, and an old/new pair that normalizes to the same lines is a silent no-op —
377
+ // reject it rather than emitting an empty hunk that apply_patch would drop.
378
+ if (oldLines.length === 0 && newLines.length === 0) {
379
+ return { error: "structured edit requires a non-empty old_string; an empty replacement is not a valid edit" };
380
+ }
381
+ if (oldLines.length === newLines.length && oldLines.every((line, i) => line === newLines[i])) {
382
+ return { error: "structured edit old_string and new_string are identical after line normalization; the replacement is a no-op and was dropped" };
383
+ }
384
+ const removed = oldLines.map(line => `-${line}`);
385
+ const added = newLines.map(line => `+${line}`);
386
+ return { hunk: ["@@", ...removed, ...added].join("\n") };
387
+ }
388
+
389
+ /**
390
+ * Convert a completed Cursor structured edit call (`edit_file` / `multi_edit`) into a valid Codex
391
+ * apply_patch freeform payload (#1017). Cursor-trained models cannot emit Codex's freeform patch
392
+ * grammar, so the adapter advertises exact-match replacement tools and performs the grammar here.
393
+ * Returns `{ patch }` for a valid conversion, `{ error }` for a malformed call (which must never be
394
+ * relayed verbatim: Codex would reject it locally after the HTTP 200, the reported failure mode),
395
+ * and `undefined` for tools that are not structured edits.
396
+ */
397
+ export type StructuredEditTranslation =
398
+ | { patch: string; error?: undefined }
399
+ | { error: string; patch?: undefined };
400
+
401
+ export function translateStructuredEditCall(
402
+ toolName: string,
403
+ argsText: string,
404
+ ): StructuredEditTranslation | undefined {
405
+ if (!isCursorStructuredEditToolName(toolName)) return undefined;
406
+ let parsed: unknown;
407
+ try {
408
+ parsed = JSON.parse(argsText);
409
+ } catch {
410
+ return {
411
+ error: `${toolName} arguments were not valid JSON; the call was dropped. ${
412
+ toolName === CURSOR_MULTI_EDIT_TOOL
413
+ ? "Use file_path and edits[] (each edit with old_string and new_string)."
414
+ : "Use file_path, old_string and new_string."
415
+ }`,
416
+ };
417
+ }
418
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
419
+ return { error: `${toolName} arguments must be a JSON object; the call was dropped.` };
420
+ }
421
+ const args = parsed as Record<string, unknown>;
422
+ const path = firstStringArg(args, ["file_path", "filePath", "path", "filepath", "filename"]);
423
+ if (!path || path.trim().length === 0) {
424
+ return { error: `${toolName} is missing a non-empty file_path; the call was dropped.` };
425
+ }
426
+ const hunks: string[] = [];
427
+ const addReplacement = (record: Record<string, unknown>): StructuredEditTranslation => {
428
+ const oldString = firstStringArg(record, ["old_string", "oldString", "oldtext", "old_text"]);
429
+ const newString = firstStringArg(record, ["new_string", "newString", "newtext", "new_text"]);
430
+ if (oldString === undefined || newString === undefined) {
431
+ return { error: `${toolName} requires old_string and new_string; the call was dropped.` };
432
+ }
433
+ const hunk = replacementHunk(oldString, newString);
434
+ if ("error" in hunk) return { error: hunk.error };
435
+ return { patch: hunk.hunk as string };
436
+ };
437
+ if (toolName === CURSOR_MULTI_EDIT_TOOL) {
438
+ const edits = args.edits;
439
+ if (!Array.isArray(edits) || edits.length === 0) {
440
+ return { error: "multi_edit requires a non-empty edits array; the call was dropped." };
441
+ }
442
+ for (const edit of edits) {
443
+ if (!edit || typeof edit !== "object" || Array.isArray(edit)) {
444
+ return { error: "multi_edit edits entries must be objects with old_string and new_string; the call was dropped." };
445
+ }
446
+ const editResult = addReplacement(edit as Record<string, unknown>);
447
+ if (editResult.error !== undefined) return editResult;
448
+ hunks.push(editResult.patch);
449
+ }
450
+ } else {
451
+ const editResult = addReplacement(args);
452
+ if (editResult.error !== undefined) return editResult;
453
+ hunks.push(editResult.patch);
454
+ }
455
+ return { patch: [PATCH_BEGIN, `*** Update File: ${path}`, ...hunks, PATCH_END].join("\n") };
456
+ }
457
+
314
458
  export function mapSyntheticMcpExecToToolEvents(
315
459
  args: McpArgs,
316
460
  fallbackCallId = "cursor_mcp_exec",
@@ -341,9 +485,20 @@ export function mapSyntheticMcpExecToToolEvents(
341
485
  }
342
486
  }
343
487
  // Stateless fallback (no shared event state): emit a complete, self-contained tool call.
488
+ //
489
+ // No conversion happens here by design (#1036 review). Structured-edit translation is gated on
490
+ // provenance — did WE advertise this bare name on THIS request — and that record lives on the
491
+ // request state, which this branch does not have. Converting anyway would reinstate the exact
492
+ // hazard the gate exists to close: a client or MCP tool legitimately named `edit_file` would be
493
+ // rewritten into an apply_patch it never asked for. The live path always carries state
494
+ // (live-transport seeds it), so this only affects direct/unit callers.
495
+ const emittedName = responsesName;
496
+ const emittedArgs = normalizedArgs;
344
497
  return [
345
- { type: "tool_call_start", id: callId, name: responsesName },
346
- ...(normalizedArgs.length > 2 ? [{ type: "tool_call_delta" as const, arguments: normalizedArgs }] : []),
498
+ { type: "tool_call_start", id: callId, name: emittedName },
499
+ ...(emittedArgs.length > 2
500
+ ? [{ type: "tool_call_delta" as const, arguments: emittedArgs }]
501
+ : []),
347
502
  { type: "tool_call_end", id: callId },
348
503
  ];
349
504
  }
@@ -385,6 +540,13 @@ function dropShellBridgeCall(state: CursorProtobufEventState, callId: string, to
385
540
  return [{ type: "error", message: cursorShellBridgeDropError(toolName) }];
386
541
  }
387
542
 
543
+ function dropStructuredEditCall(state: CursorProtobufEventState, callId: string, toolName: string, reason: string): CursorServerMessage[] {
544
+ state.openToolCalls.delete(callId);
545
+ state.translatorBudget?.closeCall(callId);
546
+ state.completedToolCalls.add(callId);
547
+ return [{ type: "error", message: `${toolName} call was not converted to apply_patch: ${reason}` }];
548
+ }
549
+
388
550
  function commitToolCall(state: CursorProtobufEventState, callId: string, finalArgs: string): CursorServerMessage[] {
389
551
  const open = state.openToolCalls.get(callId);
390
552
  if (!open) return [];
@@ -392,6 +554,14 @@ function commitToolCall(state: CursorProtobufEventState, callId: string, finalAr
392
554
  if (!cursorShellBridgeArgsValid(finalArgs, open.name, schema)) {
393
555
  if (isCodexShellBridgeToolName(open.name)) return dropShellBridgeCall(state, callId, open.name);
394
556
  }
557
+ // Structured edit calls are converted to apply_patch here so both the interactionUpdate and the
558
+ // native-exec mcpArgs paths emit the same valid freeform payload (#1017).
559
+ const translation = structuredEditCallIsOurs(state.syntheticStructuredEditToolNames, open.name)
560
+ ? translateStructuredEditCall(open.name, finalArgs)
561
+ : undefined;
562
+ if (translation?.error !== undefined) {
563
+ return dropStructuredEditCall(state, callId, open.name, translation.error);
564
+ }
395
565
  if (finalArgs !== open.args) {
396
566
  const previousBytes = Buffer.byteLength(open.args);
397
567
  const reservation = state.translatorBudget?.reserveTransient(
@@ -402,8 +572,10 @@ function commitToolCall(state: CursorProtobufEventState, callId: string, finalAr
402
572
  reservation?.commitRetained();
403
573
  state.translatorBudget?.releaseRetained(previousBytes, { kind: "tool_args", callId });
404
574
  }
405
- const out: CursorServerMessage[] = [{ type: "tool_call_start", id: callId, name: open.name }];
406
- if (finalArgs.length > 0) out.push({ type: "tool_call_delta", arguments: finalArgs });
575
+ const emittedName = translation ? CODEX_APPLY_PATCH_TOOL : open.name;
576
+ const emittedArgs = translation ? JSON.stringify({ input: translation.patch }) : finalArgs;
577
+ const out: CursorServerMessage[] = [{ type: "tool_call_start", id: callId, name: emittedName }];
578
+ if (emittedArgs.length > 0) out.push({ type: "tool_call_delta", arguments: emittedArgs });
407
579
  out.push(...endToolCall(state, callId));
408
580
  return out;
409
581
  }
@@ -10,14 +10,16 @@ import type {
10
10
  import { isAllowedToolChoice, namespacedToolName, toolChoiceAliases, type OcxTool, type OcxToolChoice } from "../../types";
11
11
  import type { CursorRequestMessage, CursorRequestedModelParameter, CursorRunRequest } from "./types";
12
12
  import { cursorWireModelSelection, type CursorRoutingLevel } from "./discovery";
13
- import { cursorEffortSuffix, cursorWireModelIdWithEffort } from "./effort-map";
13
+ import { cursorEffortSuffix, cursorRequestWireModelIdWithEffort } from "./effort-map";
14
14
  import {
15
15
  cursorMcpToolEncodedSize,
16
16
  cursorMcpToolsEncodedSize,
17
17
  cursorToolAllowedByChoice,
18
18
  cursorToolChoiceAliases,
19
+ cursorStructuredEditTools,
19
20
  cursorToolWireName,
20
21
  cursorToolsForActivePrompt,
22
+ isCursorStructuredEditToolName,
21
23
  isBareCodexShellBridgeTool,
22
24
  } from "./tool-definitions";
23
25
  import { lookupCursorThreadConversation } from "./thread-continuity";
@@ -41,6 +43,9 @@ function toolPriority(tool: OcxTool, selectedNames: ReadonlySet<string>): number
41
43
  // selected filler cannot starve the Codex execution path during truncation (#399).
42
44
  if (isBareCodexShellBridgeTool(tool)) return 0;
43
45
  if (!tool.namespace && tool.name === "apply_patch") return 1;
46
+ // Structured edit tools convert to apply_patch on the return path, so they must survive the
47
+ // same byte/count truncation as the freeform tool they stand in for (#1017).
48
+ if (!tool.namespace && isCursorStructuredEditToolName(tool.name)) return 1;
44
49
  if (cursorToolChoiceAliases(tool).some(name => selectedNames.has(name))) return 2;
45
50
  if (tool.loadedFromToolSearch) return 3;
46
51
  if (!tool.namespace) return 4;
@@ -61,7 +66,11 @@ export function applyCursorToolBudget(
61
66
  toolChoice: OcxToolChoice | undefined,
62
67
  ): CursorToolBudgetResult {
63
68
  const catalog = tools ?? [];
64
- const eligible = catalog.filter(tool => cursorToolAllowedByChoice(tool, toolChoice, catalog));
69
+ const baseEligible = catalog.filter(tool => cursorToolAllowedByChoice(tool, toolChoice, catalog));
70
+ // Synthetic structured edit tools ride along with the freeform apply_patch tool (#1017). They are
71
+ // part of the advertised catalog, so their serialized size counts toward the byte ceiling here.
72
+ const synthetic = cursorStructuredEditTools(catalog, toolChoice);
73
+ const eligible = [...baseEligible, ...synthetic];
65
74
  if (
66
75
  eligible.length <= CURSOR_TOOL_COUNT_LIMIT
67
76
  && cursorMcpToolsEncodedSize(eligible, toolChoice) <= CURSOR_TOOL_BYTES_LIMIT
@@ -101,7 +110,9 @@ export function applyCursorToolBudget(
101
110
 
102
111
  return {
103
112
  tools: eligible.filter(tool => keptSet.has(tool)),
104
- omitted: eligible.filter(tool => !keptSet.has(tool)),
113
+ // Synthetic tools are pinned in phase 1 and never reported as omitted; the note counts only
114
+ // tools the client itself requested.
115
+ omitted: baseEligible.filter(tool => !keptSet.has(tool)),
105
116
  };
106
117
  }
107
118
 
@@ -140,7 +151,7 @@ function normalizeCursorModelId(modelId: string, reasoning?: string): {
140
151
  ],
141
152
  };
142
153
  }
143
- return { ...selection, modelId: suffix ? cursorWireModelIdWithEffort(id, suffix) : id };
154
+ return { ...selection, modelId: suffix ? cursorRequestWireModelIdWithEffort(id, suffix) : id };
144
155
  }
145
156
 
146
157
  function contentPartToText(part: OcxContentPart | OcxAssistantContentPart): string | undefined {
@@ -8,6 +8,9 @@ export const OCX_RESPONSES_TOOL_PROVIDER = "opencodex-responses";
8
8
  export const CODEX_EXEC_COMMAND_TOOL = "exec_command";
9
9
  export const CODEX_SHELL_COMMAND_TOOL = "shell_command";
10
10
  export const CODEX_APPLY_PATCH_TOOL = "apply_patch";
11
+ export const CURSOR_EDIT_FILE_TOOL = "edit_file";
12
+ export const CURSOR_MULTI_EDIT_TOOL = "multi_edit";
13
+ export const CURSOR_STRUCTURED_EDIT_TOOLS = [CURSOR_EDIT_FILE_TOOL, CURSOR_MULTI_EDIT_TOOL] as const;
11
14
  export const CURSOR_EXEC_COMMAND_TOOL = CODEX_EXEC_COMMAND_TOOL;
12
15
  export const CODEX_SHELL_BRIDGE_TOOL_NAMES = [CODEX_EXEC_COMMAND_TOOL, CODEX_SHELL_COMMAND_TOOL] as const;
13
16
  export const CURSOR_SHELL_ALIAS_SYSTEM_NOTE =
@@ -41,6 +44,47 @@ export const CURSOR_EXEC_COMMAND_INPUT_SCHEMA = {
41
44
  additionalProperties: false,
42
45
  } as const;
43
46
 
47
+ /**
48
+ * Structured single-replacement schema advertised to Cursor models in addition to the freeform
49
+ * `apply_patch` tool. Cursor-trained models reliably emit exact-match replacements (the native
50
+ * Edit shape) but cannot produce Codex's freeform patch grammar, so every file edit attempt on the
51
+ * Cursor route produced malformed `apply_patch` payloads that the Codex client rejected locally
52
+ * (#1017). Calls to this tool are converted server-side into a valid apply_patch payload.
53
+ */
54
+ export const CURSOR_EDIT_FILE_INPUT_SCHEMA = {
55
+ type: "object",
56
+ properties: {
57
+ file_path: { type: "string", description: "Path of the file to edit, relative to the workspace root." },
58
+ old_string: { type: "string", description: "Exact text to replace. Must match the current file content, including line breaks." },
59
+ new_string: { type: "string", description: "Replacement text. Empty removes the matched text." },
60
+ },
61
+ required: ["file_path", "old_string", "new_string"],
62
+ additionalProperties: false,
63
+ } as const;
64
+
65
+ /** Structured multi-replacement schema; mirrors Cursor's native MultiEdit shape. */
66
+ export const CURSOR_MULTI_EDIT_INPUT_SCHEMA = {
67
+ type: "object",
68
+ properties: {
69
+ file_path: { type: "string", description: "Path of the file to edit, relative to the workspace root." },
70
+ edits: {
71
+ type: "array",
72
+ items: {
73
+ type: "object",
74
+ properties: {
75
+ old_string: { type: "string", description: "Exact text to replace. Must match the current file content, including line breaks." },
76
+ new_string: { type: "string", description: "Replacement text. Empty removes the matched text." },
77
+ },
78
+ required: ["old_string", "new_string"],
79
+ additionalProperties: false,
80
+ },
81
+ description: "Ordered replacement edits for this file. Each old_string must match the current file content.",
82
+ },
83
+ },
84
+ required: ["file_path", "edits"],
85
+ additionalProperties: false,
86
+ } as const;
87
+
44
88
  /**
45
89
  * Responses/Codex-side schema used ONLY for arg-key normalization after Cursor returns a call.
46
90
  * Cursor models are trained to emit `cmd`; Codex `shell_command` / `exec_command` validate
@@ -140,6 +184,73 @@ export function cursorRequestAdvertisesApplyPatch(
140
184
  return catalog.some(tool => !tool.namespace && tool.name === CODEX_APPLY_PATCH_TOOL && tool.freeform === true && cursorToolAllowedByChoice(tool, toolChoice, catalog));
141
185
  }
142
186
 
187
+ export function isCursorStructuredEditToolName(name: string): boolean {
188
+ return (CURSOR_STRUCTURED_EDIT_TOOLS as readonly string[]).includes(name);
189
+ }
190
+
191
+ /** Internal provenance gate for synthetic edits after prompt filtering and catalog budgeting. */
192
+ export function isCursorSyntheticStructuredEditTool(
193
+ tool: Pick<OcxTool, "namespace" | "name" | "cursorStructuredEdit">,
194
+ ): boolean {
195
+ return !tool.namespace && tool.cursorStructuredEdit === true && isCursorStructuredEditToolName(tool.name);
196
+ }
197
+
198
+ /**
199
+ * Synthetic structured edit tools for the Cursor route (#1017).
200
+ *
201
+ * Codex exposes `apply_patch` as a freeform custom tool whose body must be the exact Codex patch
202
+ * grammar (`*** Begin Patch` envelope, `@@` hunks, `-`/`+` prefixes). Cursor-trained models are
203
+ * trained on exact-match edit tools instead and emit malformed patch text on every attempt, which
204
+ * the Codex client then rejects locally ("invalid hunk"). When the request advertises the freeform
205
+ * `apply_patch` tool, also advertise Cursor-native-shaped `edit_file` / `multi_edit` tools; the
206
+ * adapter converts their exact-match replacements into a valid apply_patch payload (see
207
+ * protobuf-events.translateStructuredEditCall).
208
+ *
209
+ * Never widened when the caller pinned an explicit tool choice: a forced `apply_patch` selection
210
+ * must not gain sibling tools the client did not ask for.
211
+ */
212
+ export function cursorStructuredEditTools(
213
+ tools: readonly Pick<OcxTool, "namespace" | "name" | "freeform">[] | undefined,
214
+ toolChoice?: OcxRequestOptions["toolChoice"],
215
+ ): OcxTool[] {
216
+ if (!cursorRequestAdvertisesApplyPatch(tools, toolChoice)) return [];
217
+ if (toolChoice && toolChoice !== "auto" && toolChoice !== "required") return [];
218
+ // Never shadow an already-advertised bare tool with the same name (a client catalog could
219
+ // legitimately expose its own `edit_file` / `multi_edit` MCP-style tools).
220
+ const existingBareNames = new Set(
221
+ (tools ?? []).filter(tool => !tool.namespace).map(tool => tool.name),
222
+ );
223
+ const candidates: OcxTool[] = [
224
+ {
225
+ name: CURSOR_EDIT_FILE_TOOL,
226
+ cursorStructuredEdit: true,
227
+ description:
228
+ "Replace one block of exact text in a file. OpenCodex converts the replacement into a Codex apply_patch change, which the Codex client applies with its normal approval and sandbox policy. old_string must match the current file content exactly at exactly one location (apply_patch rejects ambiguous hunks). Matching is line-based, so an edit cannot add or remove only the file's final newline, and old_string/new_string that are identical after line normalization are rejected as a no-op.",
229
+ parameters: { ...CURSOR_EDIT_FILE_INPUT_SCHEMA },
230
+ },
231
+ {
232
+ name: CURSOR_MULTI_EDIT_TOOL,
233
+ cursorStructuredEdit: true,
234
+ description:
235
+ "Apply several exact-text replacements to one file. OpenCodex converts the edits into a single Codex apply_patch change, which the Codex client applies with its normal approval and sandbox policy. Each old_string must match the current file content exactly at exactly one location (apply_patch rejects ambiguous hunks). Edits are independent: every old_string is matched against the ORIGINAL file content, so a later edit must not rely on text introduced by an earlier one. Matching is line-based, so an edit cannot add or remove only the file's final newline, and old_string/new_string that are identical after line normalization are rejected as a no-op.",
236
+ parameters: { ...CURSOR_MULTI_EDIT_INPUT_SCHEMA },
237
+ },
238
+ ];
239
+ return candidates.filter(tool => !existingBareNames.has(tool.name));
240
+ }
241
+
242
+ /**
243
+ * True when this request actually advertises the synthetic structured edit tools (`edit_file` /
244
+ * `multi_edit`) — i.e. a freeform `apply_patch` is advertised, no tool-choice pin blocks widening,
245
+ * and neither name is shadowed by an existing bare tool in the client catalog.
246
+ */
247
+ export function cursorRequestAdvertisesStructuredEdits(
248
+ tools: readonly Pick<OcxTool, "namespace" | "name" | "freeform">[] | undefined,
249
+ toolChoice?: OcxRequestOptions["toolChoice"],
250
+ ): boolean {
251
+ return cursorStructuredEditTools(tools, toolChoice).length > 0;
252
+ }
253
+
143
254
  export function cursorToolWireName(tool: Pick<OcxTool, "namespace" | "name">): string {
144
255
  return namespacedToolName(tool.namespace, tool.name);
145
256
  }
@@ -415,6 +526,9 @@ export function buildCursorToolGuidanceSystemNote(
415
526
  const hasBareExec = shellBridgeNames.length > 0;
416
527
  const shellBridgeLabel = quotedNames(shellBridgeNames.length > 0 ? shellBridgeNames : [...CODEX_SHELL_BRIDGE_TOOL_NAMES]);
417
528
  const hasApplyPatch = cursorRequestAdvertisesApplyPatch(tools, toolChoice);
529
+ const structuredEditNames = tools
530
+ ?.filter(tool => !tool.namespace && isCursorStructuredEditToolName(tool.name))
531
+ .map(tool => tool.name) ?? [];
418
532
  const discoveryTools = discoveryToolLabel(wireNames);
419
533
  const unavailableNeighborNames = unavailableNeighborAgentToolNames(wireNames);
420
534
  // Host-shell-neutral: the Codex client executes bridge commands, and may differ from
@@ -443,7 +557,9 @@ export function buildCursorToolGuidanceSystemNote(
443
557
  ? `For file read/search/listing, use ${shellBridgeLabel} when no more specific listed tool is available.`
444
558
  : undefined,
445
559
  hasApplyPatch
446
- ? "For file edits, use the `apply_patch` tool, not built-in file write/delete tools."
560
+ ? structuredEditNames.length > 0
561
+ ? `For file edits, prefer the structured edit tools ${quotedNames(structuredEditNames)} — they take exact-match replacements that OpenCodex converts into Codex \`apply_patch\` changes for approval. Use \`apply_patch\` directly only when you can emit its exact freeform syntax (\`*** Begin Patch\` envelope with \`@@\` hunks and \`-\`/\`+\` line prefixes); never emit patch-like plain text as tool arguments.`
562
+ : "For file edits, use the `apply_patch` tool, not built-in file write/delete tools."
447
563
  : undefined,
448
564
  hasBareExec
449
565
  ? "For tool-count demos, each counted tool must be a separate Codex shell-bridge invocation/result; do not collapse several requested tools into one chained shell command."
@@ -457,7 +573,7 @@ export function buildCursorToolGuidanceSystemNote(
457
573
  : undefined,
458
574
  "Do not count or report a tool call unless a tool result was actually returned.",
459
575
  hasBareExec
460
- ? `If a Cursor-native file read, directory listing, grep, or shell operation is rejected by the runtime, silently use ${shellBridgeLabel} with an equivalent host-shell-safe command (POSIX: \`cat\`/\`ls\`/\`rg\`; Windows PowerShell: \`Get-Content\`/\`Get-ChildItem\`/\`Select-String\`). Do not tell the user access is blocked. For file edits, use \`apply_patch\` when available.`
576
+ ? `If a Cursor-native file read, directory listing, grep, or shell operation is rejected by the runtime, silently use ${shellBridgeLabel} with an equivalent host-shell-safe command (POSIX: \`cat\`/\`ls\`/\`rg\`; Windows PowerShell: \`Get-Content\`/\`Get-ChildItem\`/\`Select-String\`). Do not tell the user access is blocked. For file edits, use ${structuredEditNames.length > 0 ? `the structured edit tools (${quotedNames(structuredEditNames)}) or ` : ""}\`apply_patch\` when available.`
461
577
  : undefined,
462
578
  ].filter((note): note is string => typeof note === "string");
463
579
  return notes.join(" ");