@bitkyc08/opencodex 2.17.0 → 2.18.2
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.
- package/gui/dist/assets/{index-DOKr6RBR.js → index-CXI1262_.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/client-fingerprint.ts +2 -2
- package/src/adapters/cursor/live-transport.ts +17 -5
- package/src/adapters/cursor/protobuf-events.ts +662 -20
- package/src/adapters/cursor/tool-definitions.ts +12 -6
- package/src/adapters/google.ts +21 -2
- package/src/bridge.ts +12 -2
- package/src/cli/index.ts +11 -0
- package/src/codex/app-server-processes.ts +3 -3
- package/src/codex/user-identity.ts +36 -6
- package/src/config.ts +0 -2
- package/src/generated/compatibility-version.json +37 -33
- package/src/lib/token-estimate.ts +19 -2
- package/src/lib/windows-elevation.ts +37 -0
- package/src/lib/windows-secret-acl.ts +7 -0
- package/src/lib/windows-text.ts +106 -0
- package/src/lib/windows-user-principal.ts +0 -2
- package/src/oauth/index.ts +1 -1
- package/src/oauth/store.ts +32 -18
- package/src/providers/antigravity-models.ts +25 -5
- package/src/providers/free-directory.ts +1 -1
- package/src/providers/registry.ts +4 -3
- package/src/server/index.ts +5 -1
- package/src/server/management/logs-usage-routes.ts +7 -22
- package/src/server/request-log.ts +48 -3
- package/src/server/responses/core.ts +38 -13
- package/src/server/responses/encrypted-payload.ts +58 -38
- package/src/server/responses/fetch-helpers.ts +12 -4
- package/src/server/responses/policy-fallback.ts +13 -2
- package/src/server/responses/ws-upstream.ts +115 -6
- package/src/service-manager-probe.ts +21 -34
- package/src/service.ts +233 -25
- package/src/tray/windows.ts +0 -2
- package/src/update/job.ts +2 -2
- package/src/usage/summary.ts +21 -4
|
@@ -20,6 +20,7 @@ import type { TranslatorBudget } from "../../lib/translator-budget";
|
|
|
20
20
|
|
|
21
21
|
const DEFAULT_CONTEXT_USAGE_MAX_ENTRIES = 200;
|
|
22
22
|
const DEFAULT_CONTEXT_USAGE_TTL_MS = 60 * 60 * 1_000;
|
|
23
|
+
const DEFAULT_MAX_CLIENT_TOOL_CALLS = 330;
|
|
23
24
|
|
|
24
25
|
export interface CursorContextUsageControls {
|
|
25
26
|
/**
|
|
@@ -153,13 +154,17 @@ export interface CursorProtobufEventState {
|
|
|
153
154
|
*/
|
|
154
155
|
contextCarryForwardTokens?: number;
|
|
155
156
|
recordContextTokens?: (tokens: number) => void;
|
|
156
|
-
openToolCalls: Map<string, { name: string; args: string }>;
|
|
157
|
+
openToolCalls: Map<string, { name: string; args: string; awaitingNativeArgs?: boolean }>;
|
|
157
158
|
completedToolCalls: Set<string>;
|
|
158
159
|
/** Set once a terminal `done`/truncation has been emitted, so post-terminal frames stay inert. */
|
|
159
160
|
terminated?: boolean;
|
|
160
161
|
clientToolNames?: Set<string>;
|
|
162
|
+
/** Responses/Codex names of request-declared freeform tools advertised to Cursor. */
|
|
163
|
+
freeformToolNames?: ReadonlySet<string>;
|
|
161
164
|
parallelToolCalls?: boolean;
|
|
162
165
|
startedClientToolCalls: number;
|
|
166
|
+
/** Hard cap on client tool-call records retained during one upstream turn. */
|
|
167
|
+
maxClientToolCalls: number;
|
|
163
168
|
/** Tool wire-name → original JSON Schema parameters object, for arg-key normalization. */
|
|
164
169
|
toolSchemas?: Map<string, unknown>;
|
|
165
170
|
/** Cursor wire-name → original Responses/Codex tool name for this request. */
|
|
@@ -195,7 +200,9 @@ function structuredEditCallIsOurs(
|
|
|
195
200
|
|
|
196
201
|
export function createCursorProtobufEventState(options: {
|
|
197
202
|
clientToolNames?: Iterable<string>;
|
|
203
|
+
freeformToolNames?: Iterable<string>;
|
|
198
204
|
parallelToolCalls?: boolean;
|
|
205
|
+
maxClientToolCalls?: number;
|
|
199
206
|
toolSchemas?: Map<string, unknown>;
|
|
200
207
|
cursorToolNameMap?: Map<string, string>;
|
|
201
208
|
syntheticStructuredEditToolNames?: Iterable<string>;
|
|
@@ -215,11 +222,17 @@ export function createCursorProtobufEventState(options: {
|
|
|
215
222
|
openToolCalls: new Map(),
|
|
216
223
|
completedToolCalls: new Set(),
|
|
217
224
|
...(options.clientToolNames ? { clientToolNames: new Set(options.clientToolNames) } : {}),
|
|
225
|
+
...(options.freeformToolNames ? { freeformToolNames: new Set(options.freeformToolNames) } : {}),
|
|
218
226
|
...(options.syntheticStructuredEditToolNames
|
|
219
227
|
? { syntheticStructuredEditToolNames: new Set(options.syntheticStructuredEditToolNames) }
|
|
220
228
|
: {}),
|
|
221
229
|
...(options.parallelToolCalls !== undefined ? { parallelToolCalls: options.parallelToolCalls } : {}),
|
|
222
230
|
startedClientToolCalls: 0,
|
|
231
|
+
maxClientToolCalls: typeof options.maxClientToolCalls === "number"
|
|
232
|
+
&& Number.isFinite(options.maxClientToolCalls)
|
|
233
|
+
&& options.maxClientToolCalls > 0
|
|
234
|
+
? Math.floor(options.maxClientToolCalls)
|
|
235
|
+
: DEFAULT_MAX_CLIENT_TOOL_CALLS,
|
|
223
236
|
...(options.toolSchemas ? { toolSchemas: options.toolSchemas } : {}),
|
|
224
237
|
...(options.cursorToolNameMap ? { cursorToolNameMap: options.cursorToolNameMap } : {}),
|
|
225
238
|
...(options.translatorBudget ? { translatorBudget: options.translatorBudget } : {}),
|
|
@@ -335,17 +348,458 @@ function normalizeJsonText(text: string, toolName: string | undefined, state: Cu
|
|
|
335
348
|
* streamed onward), and/or as a structured protobuf map on `toolCallCompleted`. We emit the args
|
|
336
349
|
* exactly once, at completion, so they can always be schema-normalized regardless of which form
|
|
337
350
|
* arrived. The completed map wins when present (canonical); otherwise the buffered streamed text is
|
|
338
|
-
*
|
|
351
|
+
* preserved verbatim so the bridge can reject malformed or truncated JSON instead of silently
|
|
352
|
+
* converting it to `{}`. A genuinely empty buffer remains the no-argument case.
|
|
339
353
|
*/
|
|
340
354
|
function resolveCompletedArgs(buffered: string, args: McpArgs | undefined, state: CursorProtobufEventState): string {
|
|
341
355
|
if (hasMcpArgBytes(args)) return decodeMcpArgsNormalized(args, state);
|
|
342
356
|
const name = mcpWireNameFromArgs(args);
|
|
343
357
|
if (isCompleteJson(buffered)) return normalizeJsonText(buffered, name, state);
|
|
344
|
-
return
|
|
358
|
+
return buffered;
|
|
345
359
|
}
|
|
346
360
|
|
|
347
361
|
const PATCH_BEGIN = "*** Begin Patch";
|
|
348
362
|
const PATCH_END = "*** End Patch";
|
|
363
|
+
const GIT_HUNK_HEADER = /^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)?(?: @@.*)?$/;
|
|
364
|
+
const MARKDOWN_FENCE = /^```[\w+-]*\s*$/;
|
|
365
|
+
const PATH_ARG_KEYS = ["file_path", "filePath", "path", "filepath", "filename", "file", "target_file", "targetFile", "target_path", "targetPath"] as const;
|
|
366
|
+
const OLD_STRING_KEYS = ["old_string", "oldString", "oldtext", "old_text", "old_content", "oldContent", "before", "search"] as const;
|
|
367
|
+
const NEW_STRING_KEYS = ["new_string", "newString", "newtext", "new_text", "contents", "content", "new_contents", "newContents", "after", "replace"] as const;
|
|
368
|
+
|
|
369
|
+
export type StructuredEditPair = { old_string: string; new_string: string };
|
|
370
|
+
|
|
371
|
+
/** First index where `needle` appears as consecutive whole lines in `haystack`, or -1. */
|
|
372
|
+
function lineBlockIndex(haystack: string, needle: string): number {
|
|
373
|
+
if (needle.length === 0) return -1;
|
|
374
|
+
const hay = patchLines(haystack);
|
|
375
|
+
const ned = patchLines(needle);
|
|
376
|
+
if (ned.length === 0 || ned.length > hay.length) return -1;
|
|
377
|
+
for (let i = 0; i <= hay.length - ned.length; i++) {
|
|
378
|
+
if (ned.every((line, j) => hay[i + j] === line)) return i;
|
|
379
|
+
}
|
|
380
|
+
return -1;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function replaceLineBlock(haystack: string, needle: string, replacement: string): string {
|
|
384
|
+
const at = lineBlockIndex(haystack, needle);
|
|
385
|
+
if (at < 0) return haystack;
|
|
386
|
+
const hay = patchLines(haystack);
|
|
387
|
+
const ned = patchLines(needle);
|
|
388
|
+
const next = [...hay.slice(0, at), ...patchLines(replacement), ...hay.slice(at + ned.length)];
|
|
389
|
+
return next.join("\n");
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Codex apply_patch matches every hunk against the original file (atomic). Cursor models
|
|
394
|
+
* emit sequential multi_edit (later old_string is the text after an earlier replacement).
|
|
395
|
+
* Fold only when one side contains the other as whole lines — raw substring includes()
|
|
396
|
+
* merged independent edits (B8: `hello world` inside `x = hello world`).
|
|
397
|
+
*/
|
|
398
|
+
export function foldSequentialStructuredEdits(edits: StructuredEditPair[]): StructuredEditPair[] {
|
|
399
|
+
const folded: StructuredEditPair[] = [];
|
|
400
|
+
for (const edit of edits) {
|
|
401
|
+
let absorbed = false;
|
|
402
|
+
for (let i = folded.length - 1; i >= 0; i--) {
|
|
403
|
+
const prior = folded[i];
|
|
404
|
+
if (lineBlockIndex(prior.new_string, edit.old_string) >= 0) {
|
|
405
|
+
folded[i] = {
|
|
406
|
+
old_string: prior.old_string,
|
|
407
|
+
new_string: replaceLineBlock(prior.new_string, edit.old_string, edit.new_string),
|
|
408
|
+
};
|
|
409
|
+
absorbed = true;
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
if (lineBlockIndex(edit.old_string, prior.new_string) >= 0) {
|
|
413
|
+
folded[i] = {
|
|
414
|
+
old_string: replaceLineBlock(edit.old_string, prior.new_string, prior.old_string),
|
|
415
|
+
new_string: edit.new_string,
|
|
416
|
+
};
|
|
417
|
+
absorbed = true;
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (!absorbed) folded.push({ old_string: edit.old_string, new_string: edit.new_string });
|
|
422
|
+
}
|
|
423
|
+
return folded;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const GIT_NO_NEWLINE = /^\\s*$/;
|
|
427
|
+
const GIT_META_PREFIX = /^(diff --git |index |new file mode |deleted file mode |old mode |new mode |similarity index |dissimilarity index |rename from |rename to |copy from |copy to )/;
|
|
428
|
+
const GIT_FILE_HEADER = /^(---|\+\+\+) (?:\/dev\/null|"[ab]\/|[ab]\/)/;
|
|
429
|
+
|
|
430
|
+
function isCodexFileOpLine(line: string): boolean {
|
|
431
|
+
return line.startsWith("*** Update File:")
|
|
432
|
+
|| line.startsWith("*** Add File:")
|
|
433
|
+
|| line.startsWith("*** Delete File:");
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function canonicalizeCodexLine(line: string): string {
|
|
437
|
+
const trimmed = line.replace(/^\uFEFF/, "");
|
|
438
|
+
const lower = trimmed.toLowerCase();
|
|
439
|
+
if (lower === "*** begin patch" || lower.startsWith("*** begin patch ")) return PATCH_BEGIN;
|
|
440
|
+
if (lower === "*** end patch" || lower.startsWith("*** end patch ")) return PATCH_END;
|
|
441
|
+
const colonOps = [
|
|
442
|
+
["*** update file:", "*** Update File:"],
|
|
443
|
+
["*** add file:", "*** Add File:"],
|
|
444
|
+
["*** delete file:", "*** Delete File:"],
|
|
445
|
+
["*** move to:", "*** Move to:"],
|
|
446
|
+
] as const;
|
|
447
|
+
for (const [needle, canon] of colonOps) {
|
|
448
|
+
if (lower.startsWith(needle)) return `${canon}${trimmed.slice(needle.length)}`;
|
|
449
|
+
}
|
|
450
|
+
const spaceOps = [
|
|
451
|
+
["*** update file ", "*** Update File: "],
|
|
452
|
+
["*** add file ", "*** Add File: "],
|
|
453
|
+
["*** delete file ", "*** Delete File: "],
|
|
454
|
+
] as const;
|
|
455
|
+
for (const [needle, canon] of spaceOps) {
|
|
456
|
+
if (lower.startsWith(needle)) return `${canon}${trimmed.slice(needle.length)}`;
|
|
457
|
+
}
|
|
458
|
+
return trimmed;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function isGitPreambleLine(line: string): boolean {
|
|
462
|
+
return line === "---"
|
|
463
|
+
|| GIT_NO_NEWLINE.test(line)
|
|
464
|
+
|| GIT_META_PREFIX.test(line)
|
|
465
|
+
|| GIT_FILE_HEADER.test(line);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function unquoteGitPath(path: string): string {
|
|
469
|
+
const trimmed = path.trim();
|
|
470
|
+
if (
|
|
471
|
+
(trimmed.startsWith("\"") && trimmed.endsWith("\""))
|
|
472
|
+
|| (trimmed.startsWith("'") && trimmed.endsWith("'"))
|
|
473
|
+
) {
|
|
474
|
+
return normalizePatchPath(trimmed.slice(1, -1));
|
|
475
|
+
}
|
|
476
|
+
return normalizePatchPath(trimmed);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Grammar-only path cleanup: trim, POSIX slashes, drop a leading `./`. */
|
|
480
|
+
function normalizePatchPath(path: string): string {
|
|
481
|
+
let next = path.trim().replace(/\\/g, "/");
|
|
482
|
+
while (next.startsWith("./")) next = next.slice(2);
|
|
483
|
+
return next;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function parseDiffGitPaths(line: string): { a: string; b: string } | undefined {
|
|
487
|
+
const quoted = /^diff --git "a\/(.+)" "b\/(.+)"$/.exec(line);
|
|
488
|
+
if (quoted) return { a: unquoteGitPath(quoted[1]), b: unquoteGitPath(quoted[2]) };
|
|
489
|
+
const plain = /^diff --git a\/(.+) b\/(.+)$/.exec(line);
|
|
490
|
+
if (plain) return { a: unquoteGitPath(plain[1]), b: unquoteGitPath(plain[2]) };
|
|
491
|
+
return undefined;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function parseGitSidePath(line: string, side: "a" | "b"): string | undefined {
|
|
495
|
+
const quoted = new RegExp(`^(?:---|[+][+][+]) "${side}\\/(.+)"$`).exec(line);
|
|
496
|
+
if (quoted) return unquoteGitPath(quoted[1]);
|
|
497
|
+
const plain = new RegExp(`^(?:---|[+][+][+]) ${side}\\/(.+)$`).exec(line);
|
|
498
|
+
if (plain) return unquoteGitPath(plain[1]);
|
|
499
|
+
return undefined;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function isDevNull(line: string): boolean {
|
|
503
|
+
return /^(---|\+\+\+) \/dev\/null$/.test(line);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function rewriteHunkHeader(line: string): string {
|
|
507
|
+
return GIT_HUNK_HEADER.test(line) ? "@@" : line;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function isFenceLine(line: string): boolean {
|
|
511
|
+
return MARKDOWN_FENCE.test(line);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function isHunkBodyLine(line: string): boolean {
|
|
515
|
+
return line === "@@"
|
|
516
|
+
|| line.startsWith("@@ ")
|
|
517
|
+
|| GIT_HUNK_HEADER.test(line)
|
|
518
|
+
|| line.startsWith("+")
|
|
519
|
+
|| line.startsWith("-")
|
|
520
|
+
|| line.startsWith(" ")
|
|
521
|
+
|| line === "";
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function rewriteCodexFileOpLine(line: string): string {
|
|
525
|
+
for (const prefix of ["*** Update File:", "*** Add File:", "*** Delete File:", "*** Move to:"] as const) {
|
|
526
|
+
if (!line.startsWith(prefix)) continue;
|
|
527
|
+
const path = normalizePatchPath(line.slice(prefix.length).replace(/^\s+/, ""));
|
|
528
|
+
return path ? `${prefix} ${path}` : line;
|
|
529
|
+
}
|
|
530
|
+
return line;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function normalizeAddFileBody(lines: readonly string[]): string[] {
|
|
534
|
+
const out: string[] = [];
|
|
535
|
+
let inAdd = false;
|
|
536
|
+
for (const line of lines) {
|
|
537
|
+
if (line.startsWith("*** Add File:")) {
|
|
538
|
+
inAdd = true;
|
|
539
|
+
out.push(line);
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
if (isCodexFileOpLine(line)) {
|
|
543
|
+
inAdd = false;
|
|
544
|
+
out.push(line);
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
if (inAdd && (line === "@@" || line.startsWith("@@ ") || GIT_HUNK_HEADER.test(line))) continue;
|
|
548
|
+
if (inAdd && line.length > 0 && !line.startsWith("+")) {
|
|
549
|
+
out.push(`+${line}`);
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
out.push(line);
|
|
553
|
+
}
|
|
554
|
+
return out;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function hasNonEmptyCodexOp(lines: readonly string[]): boolean {
|
|
558
|
+
let kind: "add" | "update" | "delete" | undefined;
|
|
559
|
+
let hunk = false;
|
|
560
|
+
let any = false;
|
|
561
|
+
const flush = () => {
|
|
562
|
+
if (kind === "delete" || ((kind === "add" || kind === "update") && hunk)) any = true;
|
|
563
|
+
};
|
|
564
|
+
for (const line of lines) {
|
|
565
|
+
if (line.startsWith("*** Update File:")) {
|
|
566
|
+
flush();
|
|
567
|
+
kind = "update";
|
|
568
|
+
hunk = false;
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
if (line.startsWith("*** Add File:")) {
|
|
572
|
+
flush();
|
|
573
|
+
kind = "add";
|
|
574
|
+
hunk = false;
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
if (line.startsWith("*** Delete File:")) {
|
|
578
|
+
flush();
|
|
579
|
+
kind = "delete";
|
|
580
|
+
hunk = false;
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
if (line.startsWith("+") || line.startsWith("-") || line === "@@" || line.startsWith("@@ ")) hunk = true;
|
|
584
|
+
}
|
|
585
|
+
flush();
|
|
586
|
+
return any;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function trimEmptyEdges(lines: readonly string[]): string[] {
|
|
590
|
+
let start = 0;
|
|
591
|
+
let end = lines.length;
|
|
592
|
+
while (start < end && lines[start] === "") start++;
|
|
593
|
+
while (end > start && lines[end - 1] === "") end--;
|
|
594
|
+
return lines.slice(start, end);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function cleanHunkLines(lines: readonly string[]): string[] {
|
|
598
|
+
return trimEmptyEdges(
|
|
599
|
+
lines
|
|
600
|
+
.filter(line =>
|
|
601
|
+
!isGitPreambleLine(line)
|
|
602
|
+
&& !isFenceLine(line)
|
|
603
|
+
&& line !== PATCH_BEGIN
|
|
604
|
+
&& line !== PATCH_END
|
|
605
|
+
&& !isCodexFileOpLine(line)
|
|
606
|
+
&& !line.startsWith("*** Move to:")
|
|
607
|
+
)
|
|
608
|
+
.map(rewriteHunkHeader)
|
|
609
|
+
.filter(line => isHunkBodyLine(line)),
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function isGitSectionStart(line: string, splitOnDiffGit: boolean): boolean {
|
|
614
|
+
if (splitOnDiffGit) return line.startsWith("diff --git ");
|
|
615
|
+
return /^(--- )(?:\/dev\/null|"a\/|a\/)/.test(line);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function splitGitSections(lines: readonly string[]): string[][] {
|
|
619
|
+
const splitOnDiffGit = lines.some(line => line.startsWith("diff --git "));
|
|
620
|
+
const sections: string[][] = [];
|
|
621
|
+
let current: string[] = [];
|
|
622
|
+
for (const line of lines) {
|
|
623
|
+
if (isGitSectionStart(line, splitOnDiffGit) && current.length > 0) {
|
|
624
|
+
sections.push(current);
|
|
625
|
+
current = [line];
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
current.push(line);
|
|
629
|
+
}
|
|
630
|
+
if (current.length > 0) sections.push(current);
|
|
631
|
+
return sections;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function isGitBinarySection(lines: readonly string[]): boolean {
|
|
635
|
+
return lines.some(line => /^Binary files /.test(line) || line.startsWith("GIT binary patch"));
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function isGitCopySection(lines: readonly string[]): boolean {
|
|
639
|
+
return lines.some(line => line.startsWith("copy from ") || line.startsWith("copy to "));
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function isGitEmptyRenameSection(lines: readonly string[]): boolean {
|
|
643
|
+
const hasRenameMeta = lines.some(line => line.startsWith("rename from ") || line.startsWith("rename to "));
|
|
644
|
+
const diff = lines.map(parseDiffGitPaths).find(path => path !== undefined);
|
|
645
|
+
if (!hasRenameMeta && !(diff && diff.a !== diff.b)) return false;
|
|
646
|
+
const body = cleanHunkLines(lines);
|
|
647
|
+
return !body.some(line => line === "@@" || line.startsWith("@@ ") || line.startsWith("+") || line.startsWith("-"));
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function isGitUntranslatableSection(lines: readonly string[]): boolean {
|
|
651
|
+
return isGitBinarySection(lines) || isGitCopySection(lines) || isGitEmptyRenameSection(lines);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function convertGitSection(lines: readonly string[]): string[] | undefined {
|
|
655
|
+
if (isGitBinarySection(lines)) return undefined;
|
|
656
|
+
const existingOp = lines.find(isCodexFileOpLine);
|
|
657
|
+
if (existingOp) {
|
|
658
|
+
const move = lines.filter(line => line.startsWith("*** Move to:"));
|
|
659
|
+
return [existingOp, ...move, ...cleanHunkLines(lines)];
|
|
660
|
+
}
|
|
661
|
+
const diffPaths = lines.map(parseDiffGitPaths).find(path => path !== undefined);
|
|
662
|
+
const plusPath = lines.map(line => parseGitSidePath(line, "b")).find(path => path !== undefined);
|
|
663
|
+
const minusPath = lines.map(line => parseGitSidePath(line, "a")).find(path => path !== undefined);
|
|
664
|
+
const renameFrom = lines.find(line => line.startsWith("rename from "))?.slice("rename from ".length);
|
|
665
|
+
const renameTo = lines.find(line => line.startsWith("rename to "))?.slice("rename to ".length);
|
|
666
|
+
const plusIsNull = lines.some(line => line.startsWith("+++ ") && isDevNull(line));
|
|
667
|
+
const minusIsNull = lines.some(line => line.startsWith("--- ") && isDevNull(line));
|
|
668
|
+
const isNewFile = lines.some(line => line.startsWith("new file mode ")) || minusIsNull;
|
|
669
|
+
const isDeleted = lines.some(line => line.startsWith("deleted file mode ")) || plusIsNull;
|
|
670
|
+
const pathA = minusPath ?? (renameFrom ? unquoteGitPath(renameFrom) : undefined) ?? diffPaths?.a;
|
|
671
|
+
const pathB = plusPath ?? (renameTo ? unquoteGitPath(renameTo) : undefined) ?? diffPaths?.b;
|
|
672
|
+
const body = cleanHunkLines(lines);
|
|
673
|
+
if (isDeleted) {
|
|
674
|
+
const path = pathA ?? pathB;
|
|
675
|
+
return path ? [`*** Delete File: ${path}`] : undefined;
|
|
676
|
+
}
|
|
677
|
+
if (isNewFile) {
|
|
678
|
+
const path = pathB ?? pathA;
|
|
679
|
+
if (!path) return undefined;
|
|
680
|
+
return [`*** Add File: ${path}`, ...body.filter(line => line.startsWith("+"))];
|
|
681
|
+
}
|
|
682
|
+
const hasMinus = body.some(line => line.startsWith("-"));
|
|
683
|
+
if (plusPath && !minusPath && !diffPaths && !hasMinus) {
|
|
684
|
+
return [`*** Add File: ${plusPath}`, ...body.filter(line => line.startsWith("+"))];
|
|
685
|
+
}
|
|
686
|
+
const from = (renameFrom ? unquoteGitPath(renameFrom) : undefined) ?? pathA ?? pathB;
|
|
687
|
+
const to = (renameTo ? unquoteGitPath(renameTo) : undefined) ?? pathB;
|
|
688
|
+
if (!from) return undefined;
|
|
689
|
+
const hasHunk = body.some(line => line === "@@" || line.startsWith("@@ ") || line.startsWith("+") || line.startsWith("-"));
|
|
690
|
+
// Codex 0.147 rejects "Update file hunk ... is empty" (mode-only diffs, 100% renames).
|
|
691
|
+
if (!hasHunk) return undefined;
|
|
692
|
+
const header = [`*** Update File: ${from}`];
|
|
693
|
+
if (to && to !== from) header.push(`*** Move to: ${to}`);
|
|
694
|
+
return [...header, ...body];
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function hasCodexFileOp(lines: readonly string[]): boolean {
|
|
698
|
+
return lines.some(isCodexFileOpLine);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/** Grammar-only repair for Cursor-emitted freeform apply_patch. Not a fuzzy filesystem apply. */
|
|
702
|
+
export function sanitizeCodexApplyPatch(patch: string): string {
|
|
703
|
+
const trimmed = patch.replace(/^\uFEFF/, "").replace(/\n+$/, "");
|
|
704
|
+
const rawLines = trimmed.split("\n").map(line => canonicalizeCodexLine(line.endsWith("\r") ? line.slice(0, -1) : line));
|
|
705
|
+
const hasCodex = rawLines.some(isCodexFileOpLine);
|
|
706
|
+
const hasGitHeaders = rawLines.some(line => line.startsWith("diff --git ") || GIT_FILE_HEADER.test(line));
|
|
707
|
+
if (hasGitHeaders && !hasCodex) {
|
|
708
|
+
const sections = splitGitSections(rawLines);
|
|
709
|
+
// A binary hunk cannot be expressed in Codex apply_patch. Leave the original
|
|
710
|
+
// text alone rather than wrapping the text files and dropping the binary one.
|
|
711
|
+
if (sections.some(isGitUntranslatableSection)) return patch.replace(/^\uFEFF/, "");
|
|
712
|
+
const ops = sections
|
|
713
|
+
.map(convertGitSection)
|
|
714
|
+
.filter((section): section is string[] => section !== undefined && hasCodexFileOp(section))
|
|
715
|
+
.map(normalizeAddFileBody);
|
|
716
|
+
if (ops.length > 0) return [PATCH_BEGIN, ...ops.flat(), PATCH_END].join("\n");
|
|
717
|
+
}
|
|
718
|
+
const selected: string[] = [];
|
|
719
|
+
let inAdd = false;
|
|
720
|
+
for (const raw of rawLines) {
|
|
721
|
+
if (isGitPreambleLine(raw) || isFenceLine(raw) || raw === PATCH_BEGIN || raw === PATCH_END) continue;
|
|
722
|
+
const line = rewriteCodexFileOpLine(rewriteHunkHeader(raw));
|
|
723
|
+
if (line.startsWith("*** Add File:")) {
|
|
724
|
+
inAdd = true;
|
|
725
|
+
selected.push(line);
|
|
726
|
+
continue;
|
|
727
|
+
}
|
|
728
|
+
if (isCodexFileOpLine(line)) {
|
|
729
|
+
inAdd = false;
|
|
730
|
+
selected.push(line);
|
|
731
|
+
continue;
|
|
732
|
+
}
|
|
733
|
+
if (line.startsWith("*** Move to:") || isHunkBodyLine(line) || (inAdd && line.length > 0)) {
|
|
734
|
+
selected.push(line);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
const lines = normalizeAddFileBody(trimEmptyEdges(selected));
|
|
738
|
+
const looksLikePatch = lines.some(line =>
|
|
739
|
+
line === "@@"
|
|
740
|
+
|| line.startsWith("@@ ")
|
|
741
|
+
|| isCodexFileOpLine(line)
|
|
742
|
+
);
|
|
743
|
+
if (!looksLikePatch) return patch.replace(/^\uFEFF/, "");
|
|
744
|
+
// A hunk with no file op is not a valid Codex patch. Do not invent Begin/End around it.
|
|
745
|
+
if (!hasCodexFileOp(lines)) return lines.join("\n");
|
|
746
|
+
if (!hasNonEmptyCodexOp(lines)) return patch.replace(/^\uFEFF/, "");
|
|
747
|
+
return [PATCH_BEGIN, ...lines, PATCH_END].join("\n");
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function coercePatchInput(value: unknown): string | undefined {
|
|
751
|
+
if (typeof value === "string") {
|
|
752
|
+
const trimmed = value.trim();
|
|
753
|
+
if (trimmed.startsWith("{")) {
|
|
754
|
+
try {
|
|
755
|
+
const inner: unknown = JSON.parse(trimmed);
|
|
756
|
+
if (inner && typeof inner === "object" && !Array.isArray(inner)) {
|
|
757
|
+
const record = inner as Record<string, unknown>;
|
|
758
|
+
if (typeof record.input === "string") return record.input;
|
|
759
|
+
if (Array.isArray(record.input) && record.input.every(item => typeof item === "string")) {
|
|
760
|
+
return record.input.join("\n");
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
} catch {
|
|
764
|
+
// The string is the patch, not nested JSON.
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
return value;
|
|
768
|
+
}
|
|
769
|
+
if (Array.isArray(value) && value.every(item => typeof item === "string")) return value.join("\n");
|
|
770
|
+
return undefined;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
export function sanitizeEmittedApplyPatchArgs(argsText: string): string {
|
|
774
|
+
try {
|
|
775
|
+
const parsed: unknown = JSON.parse(argsText);
|
|
776
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
777
|
+
const record = parsed as Record<string, unknown>;
|
|
778
|
+
const raw = coercePatchInput(record.input) ?? coercePatchInput(record.patch) ?? coercePatchInput(record.content);
|
|
779
|
+
if (raw !== undefined) {
|
|
780
|
+
const input = sanitizeCodexApplyPatch(raw);
|
|
781
|
+
if (input !== record.input) {
|
|
782
|
+
const next: Record<string, unknown> = { ...record, input };
|
|
783
|
+
delete next.patch;
|
|
784
|
+
delete next.content;
|
|
785
|
+
return JSON.stringify(next);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
} catch {
|
|
790
|
+
if (
|
|
791
|
+
argsText.includes("@@")
|
|
792
|
+
|| argsText.includes("***")
|
|
793
|
+
|| argsText.includes("diff --git")
|
|
794
|
+
|| argsText.includes("--- a/")
|
|
795
|
+
|| argsText.includes("+++ b/")
|
|
796
|
+
|| argsText.includes("--- /dev/null")
|
|
797
|
+
) {
|
|
798
|
+
return JSON.stringify({ input: sanitizeCodexApplyPatch(argsText) });
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
return argsText;
|
|
802
|
+
}
|
|
349
803
|
|
|
350
804
|
function firstStringArg(args: Record<string, unknown>, keys: readonly string[]): string | undefined {
|
|
351
805
|
for (const key of keys) {
|
|
@@ -355,6 +809,17 @@ function firstStringArg(args: Record<string, unknown>, keys: readonly string[]):
|
|
|
355
809
|
return undefined;
|
|
356
810
|
}
|
|
357
811
|
|
|
812
|
+
function firstStringOrLines(args: Record<string, unknown>, keys: readonly string[]): string | undefined {
|
|
813
|
+
for (const key of keys) {
|
|
814
|
+
const value = args[key];
|
|
815
|
+
if (typeof value === "string") return value;
|
|
816
|
+
if (Array.isArray(value) && value.length > 0 && value.every(item => typeof item === "string")) {
|
|
817
|
+
return value.join("\n");
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
return undefined;
|
|
821
|
+
}
|
|
822
|
+
|
|
358
823
|
/** Split a replacement into patch lines, ignoring one trailing newline (line-based patch semantics). */
|
|
359
824
|
function patchLines(text: string): string[] {
|
|
360
825
|
const lines = text.split("\n");
|
|
@@ -362,6 +827,41 @@ function patchLines(text: string): string[] {
|
|
|
362
827
|
return lines;
|
|
363
828
|
}
|
|
364
829
|
|
|
830
|
+
/**
|
|
831
|
+
* Models often copy indent in old_string and omit it in new_string. Codex trim-matches the
|
|
832
|
+
* old line and writes new_string verbatim, which strips indent. Copy old leading whitespace
|
|
833
|
+
* onto a flush-left new line of the same line count. Do not change a new line that already
|
|
834
|
+
* has indent (intentional dedent stays possible).
|
|
835
|
+
*/
|
|
836
|
+
function restoreFlushLeftIndent(oldString: string, newString: string): string {
|
|
837
|
+
const oldLines = patchLines(oldString);
|
|
838
|
+
const newLines = patchLines(newString);
|
|
839
|
+
if (oldLines.length !== newLines.length || oldLines.length === 0) return newString;
|
|
840
|
+
// If the only difference is leading whitespace, the edit IS a deliberate
|
|
841
|
+
// indent change — restoring old indent would erase the user's intent.
|
|
842
|
+
const contentSame = oldLines.every((ol, i) => ol.trimStart() === newLines[i].trimStart());
|
|
843
|
+
const whitespaceDiffers = oldLines.some((ol, i) => {
|
|
844
|
+
const oldLead = /^[ \t]*/.exec(ol)?.[0] ?? "";
|
|
845
|
+
const newLead = /^[ \t]*/.exec(newLines[i])?.[0] ?? "";
|
|
846
|
+
return oldLead !== newLead;
|
|
847
|
+
});
|
|
848
|
+
if (contentSame && whitespaceDiffers) return newString;
|
|
849
|
+
return newLines.map((line, i) => {
|
|
850
|
+
const oldLead = /^[ \t]*/.exec(oldLines[i])?.[0] ?? "";
|
|
851
|
+
const newLead = /^[ \t]*/.exec(line)?.[0] ?? "";
|
|
852
|
+
if (oldLead.length > 0 && newLead.length === 0 && line.length > 0) return oldLead + line;
|
|
853
|
+
return line;
|
|
854
|
+
}).join("\n");
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function addFilePatch(path: string, newString: string): StructuredEditTranslation {
|
|
858
|
+
const newLines = patchLines(newString);
|
|
859
|
+
if (newLines.length === 0) {
|
|
860
|
+
return { error: "structured edit requires a non-empty old_string; an empty replacement is not a valid edit" };
|
|
861
|
+
}
|
|
862
|
+
return { patch: [PATCH_BEGIN, `*** Add File: ${path}`, ...newLines.map(line => `+${line}`), PATCH_END].join("\n") };
|
|
863
|
+
}
|
|
864
|
+
|
|
365
865
|
/** One `@@` hunk replacing `oldString` with `newString`. */
|
|
366
866
|
function replacementHunk(oldString: string, newString: string): { hunk: string } | { error: string } {
|
|
367
867
|
if (oldString.length === 0) {
|
|
@@ -371,16 +871,20 @@ function replacementHunk(oldString: string, newString: string): { hunk: string }
|
|
|
371
871
|
};
|
|
372
872
|
}
|
|
373
873
|
const oldLines = patchLines(oldString);
|
|
374
|
-
const
|
|
874
|
+
const rawNewLines = patchLines(newString);
|
|
375
875
|
// Line-based patch semantics cannot express an edit that only adds or removes the file's
|
|
376
876
|
// final newline, and an old/new pair that normalizes to the same lines is a silent no-op —
|
|
377
877
|
// reject it rather than emitting an empty hunk that apply_patch would drop.
|
|
378
|
-
if (oldLines.length === 0 &&
|
|
878
|
+
if (oldLines.length === 0 && rawNewLines.length === 0) {
|
|
379
879
|
return { error: "structured edit requires a non-empty old_string; an empty replacement is not a valid edit" };
|
|
380
880
|
}
|
|
381
|
-
|
|
881
|
+
// Check for no-op against the RAW new_string (before indent restoration) so that
|
|
882
|
+
// intentional dedent edits are not falsely classified as identical.
|
|
883
|
+
if (oldLines.length === rawNewLines.length && oldLines.every((line, i) => line === rawNewLines[i])) {
|
|
382
884
|
return { error: "structured edit old_string and new_string are identical after line normalization; the replacement is a no-op and was dropped" };
|
|
383
885
|
}
|
|
886
|
+
const restoredNew = restoreFlushLeftIndent(oldString, newString);
|
|
887
|
+
const newLines = patchLines(restoredNew);
|
|
384
888
|
const removed = oldLines.map(line => `-${line}`);
|
|
385
889
|
const added = newLines.map(line => `+${line}`);
|
|
386
890
|
return { hunk: ["@@", ...removed, ...added].join("\n") };
|
|
@@ -406,6 +910,7 @@ export function translateStructuredEditCall(
|
|
|
406
910
|
let parsed: unknown;
|
|
407
911
|
try {
|
|
408
912
|
parsed = JSON.parse(argsText);
|
|
913
|
+
if (typeof parsed === "string") parsed = JSON.parse(parsed);
|
|
409
914
|
} catch {
|
|
410
915
|
return {
|
|
411
916
|
error: `${toolName} arguments were not valid JSON; the call was dropped. ${
|
|
@@ -419,35 +924,115 @@ export function translateStructuredEditCall(
|
|
|
419
924
|
return { error: `${toolName} arguments must be a JSON object; the call was dropped.` };
|
|
420
925
|
}
|
|
421
926
|
const args = parsed as Record<string, unknown>;
|
|
422
|
-
const
|
|
423
|
-
|
|
927
|
+
const rawPath = firstStringArg(args, PATH_ARG_KEYS);
|
|
928
|
+
const path = rawPath ? normalizePatchPath(rawPath) : undefined;
|
|
929
|
+
if (!path) {
|
|
424
930
|
return { error: `${toolName} is missing a non-empty file_path; the call was dropped.` };
|
|
425
931
|
}
|
|
932
|
+
if (/[\n\r\0]/.test(path)) {
|
|
933
|
+
return { error: `${toolName} file_path must not contain a newline, CR, or NUL; the call was dropped.` };
|
|
934
|
+
}
|
|
935
|
+
if (args.replace_all === true || args.replaceAll === true) {
|
|
936
|
+
return {
|
|
937
|
+
error:
|
|
938
|
+
`${toolName} replace_all is not supported; Codex apply_patch first-matches only. Split into unique old_string hunks or include more surrounding lines.`,
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
if (args.delete_file === true || args.deleteFile === true) {
|
|
942
|
+
return { patch: [PATCH_BEGIN, `*** Delete File: ${path}`, PATCH_END].join("\n") };
|
|
943
|
+
}
|
|
426
944
|
const hunks: string[] = [];
|
|
427
945
|
const addReplacement = (record: Record<string, unknown>): StructuredEditTranslation => {
|
|
428
|
-
const oldString =
|
|
429
|
-
const newString =
|
|
946
|
+
const oldString = firstStringOrLines(record, OLD_STRING_KEYS);
|
|
947
|
+
const newString = firstStringOrLines(record, NEW_STRING_KEYS);
|
|
430
948
|
if (oldString === undefined || newString === undefined) {
|
|
431
949
|
return { error: `${toolName} requires old_string and new_string; the call was dropped.` };
|
|
432
950
|
}
|
|
433
951
|
const hunk = replacementHunk(oldString, newString);
|
|
434
952
|
if ("error" in hunk) return { error: hunk.error };
|
|
435
|
-
return { patch: hunk.hunk
|
|
953
|
+
return { patch: hunk.hunk };
|
|
436
954
|
};
|
|
437
955
|
if (toolName === CURSOR_MULTI_EDIT_TOOL) {
|
|
438
|
-
|
|
956
|
+
let edits: unknown = args.edits;
|
|
957
|
+
if (typeof edits === "string") {
|
|
958
|
+
try {
|
|
959
|
+
edits = JSON.parse(edits);
|
|
960
|
+
} catch {
|
|
961
|
+
return { error: "multi_edit edits were not valid JSON; the call was dropped." };
|
|
962
|
+
}
|
|
963
|
+
}
|
|
439
964
|
if (!Array.isArray(edits) || edits.length === 0) {
|
|
440
965
|
return { error: "multi_edit requires a non-empty edits array; the call was dropped." };
|
|
441
966
|
}
|
|
967
|
+
// Cap edit count to prevent quadratic CPU exhaustion in the fold and overlap scans.
|
|
968
|
+
if (edits.length > 500) {
|
|
969
|
+
return { error: "multi_edit exceeds the 500-edit limit; split into smaller batches." };
|
|
970
|
+
}
|
|
971
|
+
const pairs: StructuredEditPair[] = [];
|
|
442
972
|
for (const edit of edits) {
|
|
443
973
|
if (!edit || typeof edit !== "object" || Array.isArray(edit)) {
|
|
444
974
|
return { error: "multi_edit edits entries must be objects with old_string and new_string; the call was dropped." };
|
|
445
975
|
}
|
|
446
|
-
const
|
|
447
|
-
if (
|
|
448
|
-
|
|
976
|
+
const record = edit as Record<string, unknown>;
|
|
977
|
+
if (record.replace_all === true || record.replaceAll === true) {
|
|
978
|
+
return {
|
|
979
|
+
error:
|
|
980
|
+
"multi_edit replace_all is not supported; Codex apply_patch first-matches only. Split into unique old_string hunks or include more surrounding lines.",
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
const oldString = firstStringOrLines(record, OLD_STRING_KEYS);
|
|
984
|
+
const newString = firstStringOrLines(record, NEW_STRING_KEYS);
|
|
985
|
+
if (oldString === undefined || newString === undefined) {
|
|
986
|
+
return { error: `${toolName} requires old_string and new_string; the call was dropped.` };
|
|
987
|
+
}
|
|
988
|
+
pairs.push({ old_string: oldString, new_string: newString });
|
|
989
|
+
}
|
|
990
|
+
const folded = foldSequentialStructuredEdits(pairs);
|
|
991
|
+
const seenOld = new Set<string>();
|
|
992
|
+
for (const edit of folded) {
|
|
993
|
+
const oldKey = patchLines(edit.old_string).join("\n");
|
|
994
|
+
if (seenOld.has(oldKey)) {
|
|
995
|
+
return {
|
|
996
|
+
error:
|
|
997
|
+
"multi_edit has two edits with the same old_string after line normalization; Codex apply_patch first-matches, so both hunks would hit the same location. Include more surrounding lines to disambiguate.",
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
seenOld.add(oldKey);
|
|
1001
|
+
}
|
|
1002
|
+
for (let i = 0; i < folded.length; i++) {
|
|
1003
|
+
for (let j = 0; j < folded.length; j++) {
|
|
1004
|
+
if (i === j || folded[j].old_string.length === 0) continue;
|
|
1005
|
+
if (lineBlockIndex(folded[i].old_string, folded[j].old_string) >= 0) {
|
|
1006
|
+
return {
|
|
1007
|
+
error:
|
|
1008
|
+
"multi_edit hunks overlap: one old_string is a whole-line subset of another. Codex apply_patch matches every hunk against the original file, so both would first-match the same region. Include more surrounding lines to disambiguate.",
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
if (folded.length === 1 && folded[0].old_string.length === 0) {
|
|
1014
|
+
return addFilePatch(path, folded[0].new_string);
|
|
1015
|
+
}
|
|
1016
|
+
if (folded.some(edit => edit.old_string.length === 0)) {
|
|
1017
|
+
return {
|
|
1018
|
+
error:
|
|
1019
|
+
"multi_edit cannot mix an Add File (empty old_string) with an independent Update hunk on the same path; put the full new-file contents in one empty-old_string edit, or create the file first.",
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
for (const edit of folded) {
|
|
1023
|
+
const hunk = replacementHunk(edit.old_string, edit.new_string);
|
|
1024
|
+
if ("error" in hunk) return { error: hunk.error };
|
|
1025
|
+
hunks.push(hunk.hunk);
|
|
449
1026
|
}
|
|
450
1027
|
} else {
|
|
1028
|
+
const oldString = firstStringOrLines(args, OLD_STRING_KEYS);
|
|
1029
|
+
const newString = firstStringOrLines(args, NEW_STRING_KEYS);
|
|
1030
|
+
if (oldString === "") {
|
|
1031
|
+
if (newString === undefined) {
|
|
1032
|
+
return { error: `${toolName} requires old_string and new_string; the call was dropped.` };
|
|
1033
|
+
}
|
|
1034
|
+
return addFilePatch(path, newString);
|
|
1035
|
+
}
|
|
451
1036
|
const editResult = addReplacement(args);
|
|
452
1037
|
if (editResult.error !== undefined) return editResult;
|
|
453
1038
|
hunks.push(editResult.patch);
|
|
@@ -461,6 +1046,7 @@ export function mapSyntheticMcpExecToToolEvents(
|
|
|
461
1046
|
options: { allowEmptyArgs?: boolean; state?: CursorProtobufEventState } = {},
|
|
462
1047
|
): CursorServerMessage[] {
|
|
463
1048
|
if (args.providerIdentifier !== OCX_RESPONSES_TOOL_PROVIDER) return [];
|
|
1049
|
+
if (options.state?.terminated) return [];
|
|
464
1050
|
if (options.allowEmptyArgs !== true && !hasMcpArgBytes(args)) return [];
|
|
465
1051
|
const cursorWireName = mcpWireNameFromArgs(args);
|
|
466
1052
|
if (!cursorWireName) return [{ type: "error", message: "Cursor requested a Responses tool without a tool name" }];
|
|
@@ -518,6 +1104,9 @@ function recordToolCall(state: CursorProtobufEventState, callId: string, cursorW
|
|
|
518
1104
|
if (state.clientToolNames && !advertisedName) {
|
|
519
1105
|
return [{ type: "error", message: `Cursor requested unknown Responses tool: ${cursorWireName}` }];
|
|
520
1106
|
}
|
|
1107
|
+
if (state.startedClientToolCalls >= state.maxClientToolCalls) {
|
|
1108
|
+
return [{ type: "error", message: `Cursor exceeded client tool-call limit (${state.maxClientToolCalls})` }];
|
|
1109
|
+
}
|
|
521
1110
|
// Prefer the advertised catalog name for Responses mapping so shell_command/exec_command aliases
|
|
522
1111
|
// land on the tool Codex actually exposed this turn (#399).
|
|
523
1112
|
const mapKey = advertisedName ?? normalizeCursorWireName(cursorWireName);
|
|
@@ -533,6 +1122,25 @@ function recordToolCall(state: CursorProtobufEventState, callId: string, cursorW
|
|
|
533
1122
|
* recorded in `openToolCalls`. Because each completion emits a whole non-interleaved unit, the bridge
|
|
534
1123
|
* (which tracks a single current tool call) serializes parallel Cursor calls correctly.
|
|
535
1124
|
*/
|
|
1125
|
+
function cursorFreeformWrapperValid(args: string): boolean {
|
|
1126
|
+
try {
|
|
1127
|
+
const parsed = JSON.parse(args) as unknown;
|
|
1128
|
+
return !!parsed
|
|
1129
|
+
&& typeof parsed === "object"
|
|
1130
|
+
&& !Array.isArray(parsed)
|
|
1131
|
+
&& typeof (parsed as Record<string, unknown>).input === "string";
|
|
1132
|
+
} catch {
|
|
1133
|
+
return false;
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
function dropInvalidFreeformCall(state: CursorProtobufEventState, callId: string, toolName: string): CursorServerMessage[] {
|
|
1138
|
+
state.openToolCalls.delete(callId);
|
|
1139
|
+
state.translatorBudget?.closeCall(callId);
|
|
1140
|
+
state.completedToolCalls.add(callId);
|
|
1141
|
+
return [{ type: "error", message: `${toolName} call had invalid freeform arguments; expected {input:string}` }];
|
|
1142
|
+
}
|
|
1143
|
+
|
|
536
1144
|
function dropShellBridgeCall(state: CursorProtobufEventState, callId: string, toolName: string): CursorServerMessage[] {
|
|
537
1145
|
state.openToolCalls.delete(callId);
|
|
538
1146
|
state.translatorBudget?.closeCall(callId);
|
|
@@ -544,12 +1152,17 @@ function dropStructuredEditCall(state: CursorProtobufEventState, callId: string,
|
|
|
544
1152
|
state.openToolCalls.delete(callId);
|
|
545
1153
|
state.translatorBudget?.closeCall(callId);
|
|
546
1154
|
state.completedToolCalls.add(callId);
|
|
547
|
-
|
|
1155
|
+
// Recoverable: a fatal adapter error becomes response.failed / upstream_server_error and
|
|
1156
|
+
// the model never sees the reason. Completing with text keeps the turn alive (#1388).
|
|
1157
|
+
return [{ type: "text", text: `\n${toolName} call was not converted to apply_patch: ${reason}` }];
|
|
548
1158
|
}
|
|
549
1159
|
|
|
550
1160
|
function commitToolCall(state: CursorProtobufEventState, callId: string, finalArgs: string): CursorServerMessage[] {
|
|
551
1161
|
const open = state.openToolCalls.get(callId);
|
|
552
1162
|
if (!open) return [];
|
|
1163
|
+
if (state.freeformToolNames?.has(open.name) && !cursorFreeformWrapperValid(finalArgs)) {
|
|
1164
|
+
return dropInvalidFreeformCall(state, callId, open.name);
|
|
1165
|
+
}
|
|
553
1166
|
const schema = toolSchemaForWireName(state, open.name);
|
|
554
1167
|
if (!cursorShellBridgeArgsValid(finalArgs, open.name, schema)) {
|
|
555
1168
|
if (isCodexShellBridgeToolName(open.name)) return dropShellBridgeCall(state, callId, open.name);
|
|
@@ -573,7 +1186,8 @@ function commitToolCall(state: CursorProtobufEventState, callId: string, finalAr
|
|
|
573
1186
|
state.translatorBudget?.releaseRetained(previousBytes, { kind: "tool_args", callId });
|
|
574
1187
|
}
|
|
575
1188
|
const emittedName = translation ? CODEX_APPLY_PATCH_TOOL : open.name;
|
|
576
|
-
const
|
|
1189
|
+
const rawArgs = translation ? JSON.stringify({ input: translation.patch }) : finalArgs;
|
|
1190
|
+
const emittedArgs = emittedName === CODEX_APPLY_PATCH_TOOL ? sanitizeEmittedApplyPatchArgs(rawArgs) : rawArgs;
|
|
577
1191
|
const out: CursorServerMessage[] = [{ type: "tool_call_start", id: callId, name: emittedName }];
|
|
578
1192
|
if (emittedArgs.length > 0) out.push({ type: "tool_call_delta", arguments: emittedArgs });
|
|
579
1193
|
out.push(...endToolCall(state, callId));
|
|
@@ -659,12 +1273,27 @@ export function mapCursorProtobufServerMessage(
|
|
|
659
1273
|
const args = mcpArgsFromToolCall(update.value.toolCall);
|
|
660
1274
|
const openBeforeStart = state.openToolCalls.get(update.value.callId);
|
|
661
1275
|
// Empty-arg completion handling:
|
|
662
|
-
// - already
|
|
1276
|
+
// - already-open named ordinary call with no buffered or structured args -> wait for native exec.
|
|
1277
|
+
// - request-declared freeform completion -> wait while its required input wrapper is absent or
|
|
1278
|
+
// incomplete, whether the completion repeats the name or is compact callId-only. A valid
|
|
1279
|
+
// buffered wrapper can commit immediately.
|
|
1280
|
+
// A compact ordinary no-arg call remains legitimate and commits below.
|
|
663
1281
|
// - never started + not advertised -> Cursor prelude noise, drop it.
|
|
664
1282
|
// - advertised client tool, not yet open -> a legitimate no-arg call: commit it (start+end)
|
|
665
1283
|
// so it is not silently dropped; the bridge serializes empty args as "{}".
|
|
1284
|
+
if (
|
|
1285
|
+
openBeforeStart && !hasMcpArgBytes(args)
|
|
1286
|
+
&& (
|
|
1287
|
+
(name !== undefined && openBeforeStart.args.length === 0)
|
|
1288
|
+
|| (state.freeformToolNames?.has(openBeforeStart.name) === true
|
|
1289
|
+
&& !cursorFreeformWrapperValid(openBeforeStart.args)
|
|
1290
|
+
)
|
|
1291
|
+
)
|
|
1292
|
+
) {
|
|
1293
|
+
openBeforeStart.awaitingNativeArgs = true;
|
|
1294
|
+
return [];
|
|
1295
|
+
}
|
|
666
1296
|
if (name && !hasMcpArgBytes(args)) {
|
|
667
|
-
if (openBeforeStart && openBeforeStart.args.length === 0) return [];
|
|
668
1297
|
// Only commit a no-arg call when the tool is *explicitly* advertised. Without an advertised
|
|
669
1298
|
// tool list we cannot tell a real no-arg call from a Cursor prelude, so we keep dropping it.
|
|
670
1299
|
const advertised = state.clientToolNames?.has(name) ?? false;
|
|
@@ -675,6 +1304,17 @@ export function mapCursorProtobufServerMessage(
|
|
|
675
1304
|
if (name) out.push(...recordToolCall(state, update.value.callId, name));
|
|
676
1305
|
if (out.some(event => event.type === "error")) return out;
|
|
677
1306
|
const open = state.openToolCalls.get(update.value.callId);
|
|
1307
|
+
// A request-declared freeform call may first appear only in its completion frame. Record it so
|
|
1308
|
+
// later same-ID native mcpArgs can supply the authoritative wrapper, but do not broaden the
|
|
1309
|
+
// wait to ordinary advertised no-arg tools: those still commit immediately below.
|
|
1310
|
+
if (
|
|
1311
|
+
!openBeforeStart && open && !hasMcpArgBytes(args)
|
|
1312
|
+
&& state.freeformToolNames?.has(open.name) === true
|
|
1313
|
+
&& !cursorFreeformWrapperValid(open.args)
|
|
1314
|
+
) {
|
|
1315
|
+
open.awaitingNativeArgs = true;
|
|
1316
|
+
return [];
|
|
1317
|
+
}
|
|
678
1318
|
if (open) {
|
|
679
1319
|
const finalArgs = resolveCompletedArgs(open.args, args, state);
|
|
680
1320
|
out.push(...commitToolCall(state, update.value.callId, finalArgs));
|
|
@@ -721,8 +1361,10 @@ export function resolvedTurnUsage(state: CursorProtobufEventState): OcxUsage {
|
|
|
721
1361
|
export function finalizeTurnEvents(state: CursorProtobufEventState): CursorServerMessage[] {
|
|
722
1362
|
state.terminated = true;
|
|
723
1363
|
if (state.openToolCalls.size > 0) {
|
|
724
|
-
const
|
|
1364
|
+
const openCallIds = [...state.openToolCalls.keys()];
|
|
1365
|
+
const openIds = openCallIds.join(", ");
|
|
725
1366
|
// Clear so a second turnEnded (should not happen, but defensive) doesn't re-emit.
|
|
1367
|
+
for (const callId of openCallIds) state.translatorBudget?.closeCall(callId);
|
|
726
1368
|
state.openToolCalls.clear();
|
|
727
1369
|
return [{ type: "error", message: `Cursor stream ended with incomplete tool call(s): ${openIds}. Arguments may be truncated; the call was not committed.` }];
|
|
728
1370
|
}
|