@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.
Files changed (37) hide show
  1. package/gui/dist/assets/{index-DOKr6RBR.js → index-CXI1262_.js} +1 -1
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +1 -1
  4. package/src/adapters/client-fingerprint.ts +2 -2
  5. package/src/adapters/cursor/live-transport.ts +17 -5
  6. package/src/adapters/cursor/protobuf-events.ts +662 -20
  7. package/src/adapters/cursor/tool-definitions.ts +12 -6
  8. package/src/adapters/google.ts +21 -2
  9. package/src/bridge.ts +12 -2
  10. package/src/cli/index.ts +11 -0
  11. package/src/codex/app-server-processes.ts +3 -3
  12. package/src/codex/user-identity.ts +36 -6
  13. package/src/config.ts +0 -2
  14. package/src/generated/compatibility-version.json +37 -33
  15. package/src/lib/token-estimate.ts +19 -2
  16. package/src/lib/windows-elevation.ts +37 -0
  17. package/src/lib/windows-secret-acl.ts +7 -0
  18. package/src/lib/windows-text.ts +106 -0
  19. package/src/lib/windows-user-principal.ts +0 -2
  20. package/src/oauth/index.ts +1 -1
  21. package/src/oauth/store.ts +32 -18
  22. package/src/providers/antigravity-models.ts +25 -5
  23. package/src/providers/free-directory.ts +1 -1
  24. package/src/providers/registry.ts +4 -3
  25. package/src/server/index.ts +5 -1
  26. package/src/server/management/logs-usage-routes.ts +7 -22
  27. package/src/server/request-log.ts +48 -3
  28. package/src/server/responses/core.ts +38 -13
  29. package/src/server/responses/encrypted-payload.ts +58 -38
  30. package/src/server/responses/fetch-helpers.ts +12 -4
  31. package/src/server/responses/policy-fallback.ts +13 -2
  32. package/src/server/responses/ws-upstream.ts +115 -6
  33. package/src/service-manager-probe.ts +21 -34
  34. package/src/service.ts +233 -25
  35. package/src/tray/windows.ts +0 -2
  36. package/src/update/job.ts +2 -2
  37. 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
- * used. Returns an empty string when there are no args (the bridge serializes that as `{}`).
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 newLines = patchLines(newString);
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 && newLines.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
- if (oldLines.length === newLines.length && oldLines.every((line, i) => line === newLines[i])) {
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 path = firstStringArg(args, ["file_path", "filePath", "path", "filepath", "filename"]);
423
- if (!path || path.trim().length === 0) {
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 = firstStringArg(record, ["old_string", "oldString", "oldtext", "old_text"]);
429
- const newString = firstStringArg(record, ["new_string", "newString", "newtext", "new_text"]);
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 as string };
953
+ return { patch: hunk.hunk };
436
954
  };
437
955
  if (toolName === CURSOR_MULTI_EDIT_TOOL) {
438
- const edits = args.edits;
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 editResult = addReplacement(edit as Record<string, unknown>);
447
- if (editResult.error !== undefined) return editResult;
448
- hunks.push(editResult.patch);
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
- return [{ type: "error", message: `${toolName} call was not converted to apply_patch: ${reason}` }];
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 emittedArgs = translation ? JSON.stringify({ input: translation.patch }) : finalArgs;
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 open with empty args -> wait for the native-exec args path (do not commit yet).
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 openIds = [...state.openToolCalls.keys()].join(", ");
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
  }