@bitkyc08/opencodex 2.10.2 → 2.11.0-preview.20260808

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-B1P60C4o.js +70 -0
  4. package/gui/dist/assets/index-Bk-PN-70.css +1 -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
@@ -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(" ");
@@ -30,7 +30,7 @@ import {
30
30
  type TranslatorBudget,
31
31
  } from "../lib/translator-budget";
32
32
  import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge";
33
- import { mapReasoningEffort } from "../reasoning-effort";
33
+ import { configuredReasoningEfforts, mapReasoningEffort } from "../reasoning-effort";
34
34
 
35
35
  // Google-family models (Gemini/Vertex/Antigravity) tend to emit long running commentary between
36
36
  // tool calls. This steers them to keep the BETWEEN-STEP text to one line and reason internally
@@ -340,12 +340,22 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
340
340
  if (parsed.options.temperature !== undefined) generationConfig.temperature = parsed.options.temperature;
341
341
  if (parsed.options.topP !== undefined) generationConfig.topP = parsed.options.topP;
342
342
  if (parsed.options.stopSequences) generationConfig.stopSequences = parsed.options.stopSequences;
343
- const directFlashThinking = provider.googleMode !== "vertex"
344
- && provider.googleMode !== "cloud-code-assist"
345
- && (parsed.modelId === "gemini-3.5-flash" || parsed.modelId === "gemini-3.6-flash")
343
+ // Effort thinkingLevel follows the configured ladder: any model advertising reasoning
344
+ // efforts (registry preset or user config) sends the mapped level, so a picker-selected
345
+ // effort actually reaches the wire (gemini-3.1-pro-preview ships a ladder). The original
346
+ // gemini-3.5/3.6-flash direct-mode slice stays hardcoded so unladdered configs keep their
347
+ // current behavior; Vertex participates only through an explicitly configured ladder (the
348
+ // seed google-vertex entry ships none). Image models are excluded — thinkingConfig would
349
+ // suppress the responseModalities fallback below. CCA maps effort on its envelope path.
350
+ const thinkingEligible = provider.googleMode !== "cloud-code-assist"
351
+ && !isImageCapableModel(parsed.modelId)
352
+ && (configuredReasoningEfforts(provider, parsed.modelId) !== undefined
353
+ || (provider.googleMode !== "vertex"
354
+ && (parsed.modelId === "gemini-3.5-flash" || parsed.modelId === "gemini-3.6-flash")));
355
+ const thinkingLevel = thinkingEligible
346
356
  ? mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning)
347
357
  : undefined;
348
- if (directFlashThinking) generationConfig.thinkingConfig = { thinkingLevel: directFlashThinking };
358
+ if (thinkingLevel) generationConfig.thinkingConfig = { thinkingLevel };
349
359
  if (!generationConfig.thinkingConfig && isImageCapableModel(parsed.modelId)) {
350
360
  generationConfig.responseModalities = ["TEXT", "IMAGE"];
351
361
  }
@@ -3,6 +3,7 @@ import type { AdapterEvent, OcxAssistantMessage, OcxContentPart, OcxMessage, Ocx
3
3
  import { isAllowedToolChoice, modelInList, namespacedToolName, resolveToolChoiceWireName, toolAllowedByChoice } from "../types";
4
4
  import { mapReasoningEffort, modelRecordValue } from "../reasoning-effort";
5
5
  import { debugProviderDiagnostic } from "../lib/debug";
6
+ import { sseFieldValue } from "../lib/sse-decoder";
6
7
  import { isDebugEnabled } from "../lib/debug-settings";
7
8
  import { isCyberPolicyCode } from "../lib/errors";
8
9
  import { redactSecretString } from "../lib/redact";
@@ -818,6 +819,26 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
818
819
  if (provider.promptCacheKey && parsed.options.promptCacheKey !== undefined) {
819
820
  body.prompt_cache_key = parsed.options.promptCacheKey;
820
821
  }
822
+ // Responses `text.format` -> chat `response_format`. json_object maps 1:1; json_schema
823
+ // re-nests the flattened Responses fields under `json_schema` — the exact inverse of
824
+ // responseFormatToText in src/chat/inbound.ts. Forwarded unconditionally (like `stop`):
825
+ // response_format is a first-class Chat Completions field, it is only present when the
826
+ // caller explicitly asked for structured output, and a backend that rejects it should
827
+ // fail loud rather than silently return prose the caller will try to JSON.parse.
828
+ const textFormat = parsed.options.textFormat;
829
+ if (textFormat?.type === "json_object") {
830
+ body.response_format = { type: "json_object" };
831
+ } else if (textFormat?.type === "json_schema") {
832
+ body.response_format = {
833
+ type: "json_schema",
834
+ json_schema: {
835
+ name: textFormat.name ?? "response",
836
+ ...(textFormat.description !== undefined ? { description: textFormat.description } : {}),
837
+ ...(textFormat.schema !== undefined ? { schema: textFormat.schema } : {}),
838
+ ...(textFormat.strict !== undefined ? { strict: textFormat.strict } : {}),
839
+ },
840
+ };
841
+ }
821
842
 
822
843
  if (tools) {
823
844
  // Default-ON for chat-completions providers (user decision 260709): the buffered
@@ -927,8 +948,9 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
927
948
  // Yields adapter events and returns "terminate" for a terminal frame ([DONE] / error) that
928
949
  // must end the stream, or "continue" otherwise. Mutates the closure's terminal-signal state.
929
950
  const handleDataLine = function* (line: string): Generator<AdapterEvent, "continue" | "terminate"> {
930
- if (!line.startsWith("data: ")) return "continue";
931
- const payload = line.slice(6).trim();
951
+ const rawPayload = sseFieldValue(line, "data");
952
+ if (rawPayload === null) return "continue";
953
+ const payload = rawPayload.trim();
932
954
  if (payload === "[DONE]") {
933
955
  yield* flushToolCalls();
934
956
  const stopReason = stopReasonFor(finishReason);
@@ -1048,7 +1048,8 @@ function stripInputImagesDeep(value: unknown): unknown {
1048
1048
  */
1049
1049
  function buildRoutedCompactionBody(body: unknown): unknown {
1050
1050
  if (!isPlainObject(body)) return body;
1051
- const { tools: _tools, tool_choice: _toolChoice, parallel_tool_calls: _parallel, ...rest } = body;
1051
+ // `text` goes with the tool fields: the summary must be prose, not schema-constrained JSON.
1052
+ const { tools: _tools, tool_choice: _toolChoice, parallel_tool_calls: _parallel, text: _text, ...rest } = body;
1052
1053
  const input = Array.isArray(body.input) ? body.input : [];
1053
1054
  const kept = input.filter(item => !isPlainObject(item)
1054
1055
  // `additional_tools` is how Codex Desktop's responses-lite shape carries tools;
package/src/bridge.ts CHANGED
@@ -827,8 +827,10 @@ export function bridgeToResponsesSSE(
827
827
  if (currentReasoning) closeCurrentReasoning();
828
828
  if (currentRawReasoning) closeCurrentRawReasoning();
829
829
  flushHiddenRawReasoning();
830
- // Reasoning consumed by a text turn, not a tool call: no cache target.
831
- rawReasoningForNextToolCall = "";
830
+ // Reasoning consumed by a REAL text turn, not a tool call: no cache target.
831
+ // Empty text deltas must not wipe reasoning that precedes a tool call
832
+ // (chat-completions providers emit empty content deltas mid-tool-turn).
833
+ if (event.text.length > 0) rawReasoningForNextToolCall = "";
832
834
  if (currentToolCall) closeCurrentToolCall();
833
835
  // Only flush on an explicit phase change. A later delta that omits `phase` must
834
836
  // keep appending to the current message rather than wiping the earlier phase.
@@ -880,7 +882,7 @@ export function bridgeToResponsesSSE(
880
882
  if (currentMsg) closeCurrentMessage("commentary");
881
883
  if (currentRawReasoning) closeCurrentRawReasoning();
882
884
  flushHiddenRawReasoning();
883
- rawReasoningForNextToolCall = "";
885
+ if (event.thinking.length > 0) rawReasoningForNextToolCall = "";
884
886
  if (currentToolCall) closeCurrentToolCall();
885
887
  if (!currentReasoning) {
886
888
  const itemId = `rs_${uuid()}`;
@@ -1561,7 +1563,9 @@ function buildResponseJSONWithBudget(
1561
1563
  if (currentText && e.phase !== undefined && currentTextPhase !== e.phase) flushText("commentary");
1562
1564
  if (currentSummaryReasoning) flushSummaryReasoning();
1563
1565
  if (currentRawReasoning) flushRawReasoning();
1564
- rawReasoningForNextToolCall = "";
1566
+ // Empty text deltas (batch chat responses always carry content, often "") must
1567
+ // not wipe reasoning that precedes a tool call (#950 non-streaming path).
1568
+ if (e.text.length > 0) rawReasoningForNextToolCall = "";
1565
1569
  if (currentToolCallId) flushToolCall();
1566
1570
  // Compaction turns keep the summary out of normal message output (replay dedup — see
1567
1571
  // bridgeToResponsesSSE); it ships only inside the synthetic compaction item below.
@@ -1580,7 +1584,7 @@ function buildResponseJSONWithBudget(
1580
1584
  case "thinking_delta":
1581
1585
  if (currentText) flushText("commentary");
1582
1586
  if (currentRawReasoning) flushRawReasoning();
1583
- rawReasoningForNextToolCall = "";
1587
+ if (e.thinking.length > 0) rawReasoningForNextToolCall = "";
1584
1588
  if (currentToolCallId) flushToolCall();
1585
1589
  {
1586
1590
  ({ value: currentSummaryReasoning, bytes: currentSummaryReasoningBytes } = appendBatchString(
@@ -7,7 +7,7 @@
7
7
  */
8
8
  type Rec = Record<string, unknown>;
9
9
 
10
- import { decodeServerSentEvents } from "../lib/sse-decoder";
10
+ import { decodeServerSentEvents, sseFieldValue } from "../lib/sse-decoder";
11
11
  import { isTranslatorBudgetExceededError, type TranslatorBudget } from "../lib/translator-budget";
12
12
  import { classifyError, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode, isCyberPolicyMessage } from "../lib/errors";
13
13
 
@@ -671,8 +671,9 @@ export async function collectChatCompletion(
671
671
  const rawFrame = buffer.slice(0, sep);
672
672
  buffer = replaceRetained(buffer, buffer.slice(sep + 2), "live_transient");
673
673
  for (const line of rawFrame.split("\n")) {
674
- if (!line.startsWith("data: ")) continue;
675
- const data = line.slice(6).trim();
674
+ const rawData = sseFieldValue(line, "data");
675
+ if (rawData === null) continue;
676
+ const data = rawData.trim();
676
677
  if (!data || data === "[DONE]") continue;
677
678
  let parsed: unknown;
678
679
  try { parsed = JSON.parse(data); } catch { continue; }