@pentoshi/clai 4.11.2 → 4.11.4

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 (101) hide show
  1. package/dist/agent/runner.js +37 -6
  2. package/dist/agent/runner.js.map +1 -1
  3. package/dist/agent/tool-history.js +4 -2
  4. package/dist/agent/tool-history.js.map +1 -1
  5. package/dist/app/controllers/session-prompt-queue.d.ts +6 -7
  6. package/dist/app/controllers/session-prompt-queue.js +80 -85
  7. package/dist/app/controllers/session-prompt-queue.js.map +1 -1
  8. package/dist/app/controllers/turn-continuation.js +3 -0
  9. package/dist/app/controllers/turn-continuation.js.map +1 -1
  10. package/dist/classic/input/terminal-sequences.d.ts +1 -3
  11. package/dist/classic/input/terminal-sequences.js +1 -3
  12. package/dist/classic/input/terminal-sequences.js.map +1 -1
  13. package/dist/llm/token-estimate-calibration.d.ts +0 -1
  14. package/dist/llm/token-estimate-calibration.js +3 -3
  15. package/dist/llm/token-estimate-calibration.js.map +1 -1
  16. package/dist/llm/tool-protocol.d.ts +1 -0
  17. package/dist/llm/tool-protocol.js +29 -7
  18. package/dist/llm/tool-protocol.js.map +1 -1
  19. package/dist/prompts/embedded.js +1 -1
  20. package/dist/prompts/embedded.js.map +1 -1
  21. package/dist/prompts/system.agent.md +7 -7
  22. package/dist/session-runtime/binding.d.ts +1 -1
  23. package/dist/session-runtime/binding.js +3 -1
  24. package/dist/session-runtime/binding.js.map +1 -1
  25. package/dist/session-runtime/child-bridge.d.ts +7 -2
  26. package/dist/session-runtime/child-bridge.js +55 -4
  27. package/dist/session-runtime/child-bridge.js.map +1 -1
  28. package/dist/session-runtime/client.d.ts +5 -0
  29. package/dist/session-runtime/client.js +13 -5
  30. package/dist/session-runtime/client.js.map +1 -1
  31. package/dist/session-runtime/host.d.ts +7 -0
  32. package/dist/session-runtime/host.js +112 -19
  33. package/dist/session-runtime/host.js.map +1 -1
  34. package/dist/session-runtime/types.d.ts +10 -0
  35. package/dist/tools/definitions.js +2 -2
  36. package/dist/tools/definitions.js.map +1 -1
  37. package/dist/tools/fs.d.ts +0 -5
  38. package/dist/tools/fs.js +363 -272
  39. package/dist/tools/fs.js.map +1 -1
  40. package/dist/tools/shell.d.ts +5 -2
  41. package/dist/tools/shell.js +313 -20
  42. package/dist/tools/shell.js.map +1 -1
  43. package/dist/tui-v2/app/App.js +67 -61
  44. package/dist/tui-v2/app/App.js.map +1 -1
  45. package/dist/tui-v2/app/layout-widths.d.ts +21 -0
  46. package/dist/tui-v2/app/layout-widths.js +50 -0
  47. package/dist/tui-v2/app/layout-widths.js.map +1 -0
  48. package/dist/tui-v2/bootstrap/resize-repaint.d.ts +8 -1
  49. package/dist/tui-v2/bootstrap/resize-repaint.js +8 -0
  50. package/dist/tui-v2/bootstrap/resize-repaint.js.map +1 -1
  51. package/dist/tui-v2/bootstrap/start-tui-v2.js +22 -7
  52. package/dist/tui-v2/bootstrap/start-tui-v2.js.map +1 -1
  53. package/dist/tui-v2/components/modal/secret-modal.js +10 -10
  54. package/dist/tui-v2/components/modal/secret-modal.js.map +1 -1
  55. package/dist/tui-v2/components/pager/pager.js +6 -1
  56. package/dist/tui-v2/components/pager/pager.js.map +1 -1
  57. package/dist/tui-v2/components/transcript/assistant-message.d.ts +2 -0
  58. package/dist/tui-v2/components/transcript/assistant-message.js +4 -2
  59. package/dist/tui-v2/components/transcript/assistant-message.js.map +1 -1
  60. package/dist/tui-v2/components/transcript/compacted-row.js +4 -1
  61. package/dist/tui-v2/components/transcript/compacted-row.js.map +1 -1
  62. package/dist/tui-v2/components/transcript/tool-card.js +4 -1
  63. package/dist/tui-v2/components/transcript/tool-card.js.map +1 -1
  64. package/dist/tui-v2/components/transcript/transcript-row.js +1 -1
  65. package/dist/tui-v2/components/transcript/transcript-row.js.map +1 -1
  66. package/dist/tui-v2/input/chord-from-opentui-key.d.ts +6 -0
  67. package/dist/tui-v2/input/chord-from-opentui-key.js +32 -6
  68. package/dist/tui-v2/input/chord-from-opentui-key.js.map +1 -1
  69. package/dist/tui-v2/input/escape-cancellation.d.ts +7 -0
  70. package/dist/tui-v2/input/escape-cancellation.js +11 -0
  71. package/dist/tui-v2/input/escape-cancellation.js.map +1 -0
  72. package/dist/tui-v2/rendering/pager-markdown.d.ts +4 -0
  73. package/dist/tui-v2/rendering/pager-markdown.js +2 -0
  74. package/dist/tui-v2/rendering/pager-markdown.js.map +1 -1
  75. package/dist/ui-core/actions/cancel-timing.d.ts +6 -0
  76. package/dist/ui-core/actions/cancel-timing.js +7 -0
  77. package/dist/ui-core/actions/cancel-timing.js.map +1 -0
  78. package/dist/ui-core/actions/chord.d.ts +4 -0
  79. package/dist/ui-core/actions/chord.js.map +1 -1
  80. package/dist/ui-core/bootstrap/capabilities.d.ts +6 -0
  81. package/dist/ui-core/bootstrap/capabilities.js +51 -4
  82. package/dist/ui-core/bootstrap/capabilities.js.map +1 -1
  83. package/dist/ui-core/react/use-session-state.d.ts +9 -2
  84. package/dist/ui-core/react/use-session-state.js +35 -4
  85. package/dist/ui-core/react/use-session-state.js.map +1 -1
  86. package/dist/ui-core/rendering/code-block.d.ts +10 -5
  87. package/dist/ui-core/rendering/code-block.js +68 -40
  88. package/dist/ui-core/rendering/code-block.js.map +1 -1
  89. package/dist/ui-core/rendering/markdown.d.ts +6 -3
  90. package/dist/ui-core/rendering/markdown.js +92 -71
  91. package/dist/ui-core/rendering/markdown.js.map +1 -1
  92. package/dist/ui-core/rendering/render-markdown-lines.d.ts +4 -0
  93. package/dist/ui-core/rendering/render-markdown-lines.js +4 -1
  94. package/dist/ui-core/rendering/render-markdown-lines.js.map +1 -1
  95. package/dist/ui-core/rendering/streaming-markdown.js +14 -1
  96. package/dist/ui-core/rendering/streaming-markdown.js.map +1 -1
  97. package/dist/ui-core/rendering/tool-presenter.js +6 -2
  98. package/dist/ui-core/rendering/tool-presenter.js.map +1 -1
  99. package/dist/version.generated.d.ts +2 -2
  100. package/dist/version.generated.js +2 -2
  101. package/package.json +1 -1
package/dist/tools/fs.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createReadStream, lstatSync, readlinkSync, realpathSync } from "node:fs";
2
- import { open, readdir, readFile, writeFile, appendFile, unlink, rm, rename, mkdir, stat, chmod } from "node:fs/promises";
2
+ import { open, readdir, readFile, appendFile, unlink, rm, rename, mkdir, stat, chmod } from "node:fs/promises";
3
3
  import { join, dirname, basename, relative, resolve } from "node:path";
4
4
  import { createHash } from "node:crypto";
5
5
  import { createInterface } from "node:readline";
@@ -20,7 +20,73 @@ import { buildFileChange, formatUnifiedPreview, } from "./file-diff.js";
20
20
  * any failure so a crash cannot leave a stray `.clai-*` file behind.
21
21
  */
22
22
  let atomicWriteCounter = 0;
23
- export async function writeFileAtomic(resolved, contents) {
23
+ const fileMutationLanes = new Map();
24
+ const RETRYABLE_FS_CODES = new Set(["EAGAIN", "EBUSY", "EMFILE", "ENFILE", "EPERM"]);
25
+ function mutationKey(path) {
26
+ return process.platform === "win32" ? path.toLowerCase() : path;
27
+ }
28
+ function mutationTarget(path) {
29
+ return canonicalizeForContainment(path) ?? path;
30
+ }
31
+ async function withFileMutation(path, operation) {
32
+ const key = mutationKey(mutationTarget(path));
33
+ const previous = fileMutationLanes.get(key) ?? Promise.resolve();
34
+ let release;
35
+ const current = new Promise((resolve) => {
36
+ release = resolve;
37
+ });
38
+ fileMutationLanes.set(key, current);
39
+ await previous.catch(() => undefined);
40
+ try {
41
+ return await operation();
42
+ }
43
+ finally {
44
+ release();
45
+ if (fileMutationLanes.get(key) === current)
46
+ fileMutationLanes.delete(key);
47
+ }
48
+ }
49
+ function appendShrankFailure(path, expected, actual) {
50
+ return {
51
+ ok: false,
52
+ output: `fs.append integrity check failed: expected prior bytes=${expected} but the file is only ${actual}. ` +
53
+ `It was truncated or this is the wrong path — appending would silently drop the missing content. (${path})`,
54
+ exitCode: 1,
55
+ };
56
+ }
57
+ function appendAlreadyApplied(original, content, position) {
58
+ if (content.length === 0)
59
+ return false;
60
+ return position === "start"
61
+ ? original.startsWith(content)
62
+ : original.endsWith(content);
63
+ }
64
+ async function fileTailEquals(path, size, content) {
65
+ const expected = Buffer.from(content, "utf8");
66
+ if (expected.length === 0 || size < expected.length)
67
+ return false;
68
+ let handle;
69
+ try {
70
+ handle = await open(path, "r");
71
+ const buffer = Buffer.alloc(expected.length);
72
+ await handle.read(buffer, 0, expected.length, size - expected.length);
73
+ return buffer.equals(expected);
74
+ }
75
+ catch {
76
+ return false;
77
+ }
78
+ finally {
79
+ await handle?.close().catch(() => undefined);
80
+ }
81
+ }
82
+ function retryableFsError(error) {
83
+ const code = error && typeof error === "object" && "code" in error
84
+ ? String(error.code ?? "")
85
+ : "";
86
+ return RETRYABLE_FS_CODES.has(code);
87
+ }
88
+ async function writeFileAtomicAttempt(path, contents) {
89
+ const resolved = mutationTarget(path);
24
90
  const priorMode = await stat(resolved)
25
91
  .then((st) => st.mode & 0o7777)
26
92
  .catch(() => undefined);
@@ -36,9 +102,8 @@ export async function writeFileAtomic(resolved, contents) {
36
102
  await handle.sync();
37
103
  await handle.close();
38
104
  handle = undefined;
39
- if (priorMode !== undefined) {
105
+ if (priorMode !== undefined)
40
106
  await chmod(tempPath, priorMode);
41
- }
42
107
  await rename(tempPath, resolved);
43
108
  }
44
109
  catch (error) {
@@ -48,6 +113,19 @@ export async function writeFileAtomic(resolved, contents) {
48
113
  throw error;
49
114
  }
50
115
  }
116
+ export async function writeFileAtomic(resolved, contents) {
117
+ for (let attempt = 0;; attempt += 1) {
118
+ try {
119
+ await writeFileAtomicAttempt(resolved, contents);
120
+ return;
121
+ }
122
+ catch (error) {
123
+ if (attempt >= 3 || !retryableFsError(error))
124
+ throw error;
125
+ await new Promise((resolve) => setTimeout(resolve, 15 * 2 ** attempt));
126
+ }
127
+ }
128
+ }
51
129
  /**
52
130
  * Threshold above which whole-file mutation is refused or replaced by an
53
131
  * in-place strategy. Reading and rewriting a very large file holds 2x its size
@@ -648,34 +726,32 @@ export async function fsRead(path, options = {}) {
648
726
  }
649
727
  export async function fsWrite(path, content, options = {}) {
650
728
  const resolved = ensureWriteAllowed(path, options.confirmed);
651
- // Create any missing parent directories so writing "src/index.js" into a
652
- // fresh project just works — the agent should not have to chain a separate
653
- // mkdir before every file write. This was the most common failure: ENOENT
654
- // on a path whose parent dir did not exist yet.
655
- let before = "";
656
- let existed = false;
657
- try {
658
- before = await readFile(resolved, "utf8");
659
- existed = true;
660
- }
661
- catch (error) {
662
- if (error?.code !== "ENOENT")
663
- throw error;
664
- }
665
- await mkdir(dirname(resolved), { recursive: true });
666
- await writeFile(resolved, content, "utf8");
667
- const change = buildFileChange({
668
- path: resolved,
669
- before,
670
- after: content,
671
- kind: existed ? "overwrite" : "create",
729
+ return withFileMutation(resolved, async () => {
730
+ let before = "";
731
+ let existed = false;
732
+ try {
733
+ before = await readFile(resolved, "utf8");
734
+ existed = true;
735
+ }
736
+ catch (error) {
737
+ if (error?.code !== "ENOENT")
738
+ throw error;
739
+ }
740
+ await mkdir(dirname(resolved), { recursive: true });
741
+ await writeFileAtomic(resolved, content);
742
+ const change = buildFileChange({
743
+ path: resolved,
744
+ before,
745
+ after: content,
746
+ kind: existed ? "overwrite" : "create",
747
+ });
748
+ const verb = existed ? "Wrote" : "Created";
749
+ return {
750
+ ok: true,
751
+ output: describeWrite(resolved, content, verb),
752
+ fileChanges: [change],
753
+ };
672
754
  });
673
- const verb = existed ? "Wrote" : "Created";
674
- return {
675
- ok: true,
676
- output: describeWrite(resolved, content, verb),
677
- fileChanges: [change],
678
- };
679
755
  }
680
756
  /** Atomically replace an inclusive, 1-indexed line range in an existing file. */
681
757
  export async function fsReplaceLines(path, startLine, endLine, content, options = {}) {
@@ -683,36 +759,37 @@ export async function fsReplaceLines(path, startLine, endLine, content, options
683
759
  if (!Number.isInteger(startLine) || !Number.isInteger(endLine) || startLine < 1 || endLine < startLine) {
684
760
  return { ok: false, output: "fs.replaceLines requires integers with 1 <= startLine <= endLine", exitCode: 1 };
685
761
  }
686
- const original = await readFile(resolved, "utf8");
687
- const newline = original.includes("\r\n") ? "\r\n" : "\n";
688
- const hadFinalNewline = original.endsWith("\n");
689
- const lines = original.split(/\r?\n/);
690
- if (hadFinalNewline)
691
- lines.pop();
692
- if (endLine > lines.length) {
693
- return { ok: false, output: `fs.replaceLines range ${startLine}-${endLine} exceeds ${lines.length} lines`, exitCode: 1 };
694
- }
695
- const replacement = content === "" ? [] : content.split(/\r?\n/);
696
- if (replacement.at(-1) === "")
697
- replacement.pop();
698
- lines.splice(startLine - 1, endLine - startLine + 1, ...replacement);
699
- const next = lines.join(newline) + (hadFinalNewline ? newline : "");
700
- await writeFileAtomic(resolved, next);
701
- // X10: receipt with line count + hash so the model notices size changes.
702
- const verb = content === ""
703
- ? `Deleted lines ${startLine}-${endLine} in`
704
- : `Replaced lines ${startLine}-${endLine} in`;
705
- const change = buildFileChange({
706
- path: resolved,
707
- before: original,
708
- after: next,
709
- kind: "edit",
762
+ return withFileMutation(resolved, async () => {
763
+ const original = await readFile(resolved, "utf8");
764
+ const newline = original.includes("\r\n") ? "\r\n" : "\n";
765
+ const hadFinalNewline = original.endsWith("\n");
766
+ const lines = original.split(/\r?\n/);
767
+ if (hadFinalNewline)
768
+ lines.pop();
769
+ if (endLine > lines.length) {
770
+ return { ok: false, output: `fs.replaceLines range ${startLine}-${endLine} exceeds ${lines.length} lines`, exitCode: 1 };
771
+ }
772
+ const replacement = content === "" ? [] : content.split(/\r?\n/);
773
+ if (replacement.at(-1) === "")
774
+ replacement.pop();
775
+ lines.splice(startLine - 1, endLine - startLine + 1, ...replacement);
776
+ const next = lines.join(newline) + (hadFinalNewline ? newline : "");
777
+ await writeFileAtomic(resolved, next);
778
+ const verb = content === ""
779
+ ? `Deleted lines ${startLine}-${endLine} in`
780
+ : `Replaced lines ${startLine}-${endLine} in`;
781
+ const change = buildFileChange({
782
+ path: resolved,
783
+ before: original,
784
+ after: next,
785
+ kind: "edit",
786
+ });
787
+ return {
788
+ ok: true,
789
+ output: describeWrite(resolved, next, verb),
790
+ fileChanges: [change],
791
+ };
710
792
  });
711
- return {
712
- ok: true,
713
- output: describeWrite(resolved, next, verb),
714
- fileChanges: [change],
715
- };
716
793
  }
717
794
  const WRITE_MANY_MAX_FILES = 50;
718
795
  /**
@@ -754,18 +831,21 @@ export async function fsWriteMany(files, options = {}) {
754
831
  }
755
832
  try {
756
833
  const resolved = ensureWriteAllowed(file.path, options.confirmed);
757
- let before = "";
758
- let existed = false;
759
- try {
760
- before = await readFile(resolved, "utf8");
761
- existed = true;
762
- }
763
- catch (error) {
764
- if (error?.code !== "ENOENT")
765
- throw error;
766
- }
767
- await mkdir(dirname(resolved), { recursive: true });
768
- await writeFile(resolved, file.content, "utf8");
834
+ const mutation = await withFileMutation(resolved, async () => {
835
+ let before = "";
836
+ let existed = false;
837
+ try {
838
+ before = await readFile(resolved, "utf8");
839
+ existed = true;
840
+ }
841
+ catch (error) {
842
+ if (error?.code !== "ENOENT")
843
+ throw error;
844
+ }
845
+ await mkdir(dirname(resolved), { recursive: true });
846
+ await writeFileAtomic(resolved, file.content);
847
+ return { before, existed };
848
+ });
769
849
  const bytes = Buffer.byteLength(file.content, "utf8");
770
850
  const nLines = file.content.length === 0 ? 0 : file.content.split(/\r?\n/).length;
771
851
  const sha = createHash("sha256")
@@ -775,9 +855,9 @@ export async function fsWriteMany(files, options = {}) {
775
855
  written.push(`${resolved} (bytes=${bytes} lines=${nLines} sha256_12=${sha})`);
776
856
  changes.push(buildFileChange({
777
857
  path: resolved,
778
- before,
858
+ before: mutation.before,
779
859
  after: file.content,
780
- kind: existed ? "overwrite" : "create",
860
+ kind: mutation.existed ? "overwrite" : "create",
781
861
  }));
782
862
  }
783
863
  catch (error) {
@@ -989,101 +1069,103 @@ export async function fsSearch(pattern, path = safeCwd(), options = {}) {
989
1069
  */
990
1070
  export async function fsEdit(path, oldText, newText, expectedReplacements, options = {}) {
991
1071
  const resolved = ensureWriteAllowed(path, options.confirmed);
992
- const priorStat = await stat(resolved).catch(() => undefined);
993
- if (priorStat?.isFile() && priorStat.size > LARGE_MUTATION_BYTES) {
994
- return {
995
- ok: false,
996
- output: `fs.edit refuses ${resolved}: ${priorStat.size.toLocaleString()} bytes exceeds the ${Math.round(LARGE_MUTATION_BYTES / (1024 * 1024))}MB whole-file edit limit. ` +
997
- `Use fs.replaceLines for a bounded line range, or a streaming tool (sed -i / awk) via shell.exec.`,
998
- exitCode: 1,
999
- };
1000
- }
1001
- const content = await readFile(resolved, "utf8");
1002
- const expected = expectedReplacements ?? 1;
1003
- let targetOldText = oldText;
1004
- let targetNewText = newText;
1005
- // Count occurrences (exact match first)
1006
- let count = 0;
1007
- let searchPos = 0;
1008
- while (true) {
1009
- const idx = content.indexOf(targetOldText, searchPos);
1010
- if (idx === -1)
1011
- break;
1012
- count += 1;
1013
- searchPos = idx + targetOldText.length;
1014
- }
1015
- // Fallback: match CRLF / LF line ending differences or trailing whitespace
1016
- if (count === 0) {
1017
- const hasCRLF = content.includes("\r\n");
1018
- const candidateOld = hasCRLF
1019
- ? oldText.replace(/\r?\n/g, "\r\n")
1020
- : oldText.replace(/\r\n/g, "\n");
1021
- const candidateNew = hasCRLF
1022
- ? newText.replace(/\r?\n/g, "\r\n")
1023
- : newText.replace(/\r\n/g, "\n");
1024
- let altCount = 0;
1025
- let altPos = 0;
1072
+ return withFileMutation(resolved, async () => {
1073
+ const priorStat = await stat(resolved).catch(() => undefined);
1074
+ if (priorStat?.isFile() && priorStat.size > LARGE_MUTATION_BYTES) {
1075
+ return {
1076
+ ok: false,
1077
+ output: `fs.edit refuses ${resolved}: ${priorStat.size.toLocaleString()} bytes exceeds the ${Math.round(LARGE_MUTATION_BYTES / (1024 * 1024))}MB whole-file edit limit. ` +
1078
+ `Use fs.replaceLines for a bounded line range, or a streaming tool (sed -i / awk) via shell.exec.`,
1079
+ exitCode: 1,
1080
+ };
1081
+ }
1082
+ const content = await readFile(resolved, "utf8");
1083
+ const expected = expectedReplacements ?? 1;
1084
+ let targetOldText = oldText;
1085
+ let targetNewText = newText;
1086
+ // Count occurrences (exact match first)
1087
+ let count = 0;
1088
+ let searchPos = 0;
1026
1089
  while (true) {
1027
- const idx = content.indexOf(candidateOld, altPos);
1090
+ const idx = content.indexOf(targetOldText, searchPos);
1028
1091
  if (idx === -1)
1029
1092
  break;
1030
- altCount += 1;
1031
- altPos = idx + candidateOld.length;
1032
- }
1033
- if (altCount > 0) {
1034
- targetOldText = candidateOld;
1035
- targetNewText = candidateNew;
1036
- count = altCount;
1093
+ count += 1;
1094
+ searchPos = idx + targetOldText.length;
1037
1095
  }
1038
- else {
1039
- const trimmedOld = candidateOld.trimEnd();
1040
- if (trimmedOld.length > 0) {
1041
- let trimCount = 0;
1042
- let trimPos = 0;
1043
- while (true) {
1044
- const idx = content.indexOf(trimmedOld, trimPos);
1045
- if (idx === -1)
1046
- break;
1047
- trimCount += 1;
1048
- trimPos = idx + trimmedOld.length;
1049
- }
1050
- if (trimCount > 0) {
1051
- targetOldText = trimmedOld;
1052
- targetNewText = candidateNew.trimEnd();
1053
- count = trimCount;
1096
+ // Fallback: match CRLF / LF line ending differences or trailing whitespace
1097
+ if (count === 0) {
1098
+ const hasCRLF = content.includes("\r\n");
1099
+ const candidateOld = hasCRLF
1100
+ ? oldText.replace(/\r?\n/g, "\r\n")
1101
+ : oldText.replace(/\r\n/g, "\n");
1102
+ const candidateNew = hasCRLF
1103
+ ? newText.replace(/\r?\n/g, "\r\n")
1104
+ : newText.replace(/\r\n/g, "\n");
1105
+ let altCount = 0;
1106
+ let altPos = 0;
1107
+ while (true) {
1108
+ const idx = content.indexOf(candidateOld, altPos);
1109
+ if (idx === -1)
1110
+ break;
1111
+ altCount += 1;
1112
+ altPos = idx + candidateOld.length;
1113
+ }
1114
+ if (altCount > 0) {
1115
+ targetOldText = candidateOld;
1116
+ targetNewText = candidateNew;
1117
+ count = altCount;
1118
+ }
1119
+ else {
1120
+ const trimmedOld = candidateOld.trimEnd();
1121
+ if (trimmedOld.length > 0) {
1122
+ let trimCount = 0;
1123
+ let trimPos = 0;
1124
+ while (true) {
1125
+ const idx = content.indexOf(trimmedOld, trimPos);
1126
+ if (idx === -1)
1127
+ break;
1128
+ trimCount += 1;
1129
+ trimPos = idx + trimmedOld.length;
1130
+ }
1131
+ if (trimCount > 0) {
1132
+ targetOldText = trimmedOld;
1133
+ targetNewText = candidateNew.trimEnd();
1134
+ count = trimCount;
1135
+ }
1054
1136
  }
1055
1137
  }
1056
1138
  }
1057
- }
1058
- if (count === 0) {
1059
- return {
1060
- ok: false,
1061
- output: `No matches found for the search text in ${resolved}. The text to replace was not found.`,
1062
- exitCode: 1,
1063
- };
1064
- }
1065
- if (count !== expected) {
1139
+ if (count === 0) {
1140
+ return {
1141
+ ok: false,
1142
+ output: `No matches found for the search text in ${resolved}. The text to replace was not found.`,
1143
+ exitCode: 1,
1144
+ };
1145
+ }
1146
+ if (count !== expected) {
1147
+ return {
1148
+ ok: false,
1149
+ output: `Found ${count} occurrence(s) of the search text, but expected exactly ${expected}. Aborting to avoid unintended changes. Use expectedReplacements=${count} if you want to replace all.`,
1150
+ exitCode: 1,
1151
+ };
1152
+ }
1153
+ const updated = content.replaceAll(targetOldText, targetNewText);
1154
+ // Atomic, mode-preserving, race-safe replacement.
1155
+ await writeFileAtomic(resolved, updated);
1156
+ const change = buildFileChange({
1157
+ path: resolved,
1158
+ before: content,
1159
+ after: updated,
1160
+ kind: "edit",
1161
+ });
1162
+ const preview = formatUnifiedPreview(change, { maxLines: 24 });
1066
1163
  return {
1067
- ok: false,
1068
- output: `Found ${count} occurrence(s) of the search text, but expected exactly ${expected}. Aborting to avoid unintended changes. Use expectedReplacements=${count} if you want to replace all.`,
1069
- exitCode: 1,
1164
+ ok: true,
1165
+ output: `Replaced ${count} occurrence(s) in ${resolved}.\n${preview}`,
1166
+ fileChanges: [change],
1070
1167
  };
1071
- }
1072
- const updated = content.replaceAll(targetOldText, targetNewText);
1073
- // Atomic, mode-preserving, race-safe replacement.
1074
- await writeFileAtomic(resolved, updated);
1075
- const change = buildFileChange({
1076
- path: resolved,
1077
- before: content,
1078
- after: updated,
1079
- kind: "edit",
1080
1168
  });
1081
- const preview = formatUnifiedPreview(change, { maxLines: 24 });
1082
- return {
1083
- ok: true,
1084
- output: `Replaced ${count} occurrence(s) in ${resolved}.\n${preview}`,
1085
- fileChanges: [change],
1086
- };
1087
1169
  }
1088
1170
  /**
1089
1171
  * Delete a file or directory. Requires the path to be inside the
@@ -1091,54 +1173,57 @@ export async function fsEdit(path, oldText, newText, expectedReplacements, optio
1091
1173
  */
1092
1174
  export async function fsDelete(path, recursive, options = {}) {
1093
1175
  const resolved = ensureWriteAllowed(path, options.confirmed);
1094
- try {
1095
- // Snapshot text content for diff UI when deleting a single small file.
1096
- let before = "";
1097
- let canDiff = false;
1098
- if (!recursive) {
1099
- try {
1100
- const st = await stat(resolved);
1101
- if (st.isFile() && st.size <= 200_000) {
1102
- before = await readFile(resolved, "utf8");
1103
- canDiff = true;
1176
+ return withFileMutation(resolved, async () => {
1177
+ try {
1178
+ // Snapshot text content for diff UI when deleting a single small file.
1179
+ let before = "";
1180
+ let canDiff = false;
1181
+ if (!recursive) {
1182
+ try {
1183
+ const st = await stat(resolved);
1184
+ if (st.isFile() && st.size <= 200_000) {
1185
+ before = await readFile(resolved, "utf8");
1186
+ canDiff = true;
1187
+ }
1188
+ }
1189
+ catch {
1190
+ /* ignore — delete may still succeed or fail below */
1104
1191
  }
1105
1192
  }
1106
- catch {
1107
- /* ignore delete may still succeed or fail below */
1193
+ if (recursive) {
1194
+ await rm(resolved, { recursive: true, force: false });
1195
+ return { ok: true, output: `Deleted (recursive): ${resolved}` };
1108
1196
  }
1197
+ await unlink(resolved);
1198
+ const change = canDiff
1199
+ ? buildFileChange({
1200
+ path: resolved,
1201
+ before,
1202
+ after: "",
1203
+ kind: "delete",
1204
+ })
1205
+ : buildFileChange({
1206
+ path: resolved,
1207
+ before: "",
1208
+ after: "",
1209
+ kind: "delete",
1210
+ });
1211
+ return {
1212
+ ok: true,
1213
+ output: `Deleted: ${resolved}`,
1214
+ fileChanges: [change],
1215
+ };
1109
1216
  }
1110
- if (recursive) {
1111
- await rm(resolved, { recursive: true, force: false });
1112
- return { ok: true, output: `Deleted (recursive): ${resolved}` };
1217
+ catch (error) {
1218
+ const msg = error instanceof Error ? error.message : String(error);
1219
+ return { ok: false, output: `Delete failed: ${msg}`, exitCode: 1 };
1113
1220
  }
1114
- await unlink(resolved);
1115
- const change = canDiff
1116
- ? buildFileChange({
1117
- path: resolved,
1118
- before,
1119
- after: "",
1120
- kind: "delete",
1121
- })
1122
- : buildFileChange({
1123
- path: resolved,
1124
- before: "",
1125
- after: "",
1126
- kind: "delete",
1127
- });
1128
- return {
1129
- ok: true,
1130
- output: `Deleted: ${resolved}`,
1131
- fileChanges: [change],
1132
- };
1133
- }
1134
- catch (error) {
1135
- const msg = error instanceof Error ? error.message : String(error);
1136
- return { ok: false, output: `Delete failed: ${msg}`, exitCode: 1 };
1137
- }
1221
+ });
1138
1222
  }
1139
1223
  export async function fsAppend(path, content, options = {}) {
1140
1224
  const resolved = ensureWriteAllowed(path, options.confirmed);
1141
1225
  const position = options.position ?? "end";
1226
+ const expectedPriorBytes = options.expectedPriorBytes;
1142
1227
  if (position !== "start" && position !== "end") {
1143
1228
  return {
1144
1229
  ok: false,
@@ -1146,94 +1231,100 @@ export async function fsAppend(path, content, options = {}) {
1146
1231
  exitCode: 1,
1147
1232
  };
1148
1233
  }
1149
- let original = "";
1150
- // Large-file guard: reading + rewriting a 500 MB log to append a few lines
1151
- // holds 2x the file in heap. Above the threshold, append in place with
1152
- // `appendFile` and say the diff was skipped for size.
1153
- const priorStat = await stat(resolved).catch(() => undefined);
1154
- if (priorStat?.isFile() &&
1155
- priorStat.size > LARGE_MUTATION_BYTES &&
1156
- position === "end") {
1157
- const priorBytesLarge = priorStat.size;
1158
- if (typeof options.expectedPriorBytes === "number" &&
1159
- options.expectedPriorBytes !== priorBytesLarge) {
1160
- return {
1161
- ok: false,
1162
- output: `fs.append integrity check failed for ${resolved}: expected prior bytes=${options.expectedPriorBytes}, actual=${priorBytesLarge}. ` +
1163
- `Do NOT append again until you reconcile (read the last ~20 lines or re-write).`,
1164
- exitCode: 1,
1165
- };
1166
- }
1167
- await appendFile(resolved, content, "utf8");
1168
- const afterStat = await stat(resolved).catch(() => undefined);
1234
+ if (expectedPriorBytes !== undefined &&
1235
+ (!Number.isSafeInteger(expectedPriorBytes) || expectedPriorBytes < 0)) {
1169
1236
  return {
1170
- ok: true,
1171
- output: describeWrite(resolved, content, "Appended (end) to") +
1172
- `\n prior_bytes=${priorBytesLarge} after_bytes=${afterStat?.size ?? priorBytesLarge + Buffer.byteLength(content, "utf8")}` +
1173
- `\n note: file exceeds ${Math.round(LARGE_MUTATION_BYTES / (1024 * 1024))}MB — appended in place and skipped the diff/receipt hash of the whole file.`,
1237
+ ok: false,
1238
+ output: "fs.append expectedPriorBytes must be a non-negative safe integer.",
1239
+ exitCode: 1,
1174
1240
  };
1175
1241
  }
1176
- try {
1177
- original = await readFile(resolved, "utf8");
1178
- }
1179
- catch (error) {
1180
- if (error?.code !== "ENOENT") {
1181
- throw error;
1242
+ return withFileMutation(resolved, async () => {
1243
+ const priorStat = await stat(resolved).catch(() => undefined);
1244
+ if (priorStat?.isFile() &&
1245
+ priorStat.size > LARGE_MUTATION_BYTES &&
1246
+ position === "end") {
1247
+ const priorBytes = priorStat.size;
1248
+ if (expectedPriorBytes !== undefined && expectedPriorBytes > priorBytes) {
1249
+ return appendShrankFailure(resolved, expectedPriorBytes, priorBytes);
1250
+ }
1251
+ const stale = expectedPriorBytes !== undefined && expectedPriorBytes < priorBytes;
1252
+ if (stale && (await fileTailEquals(resolved, priorBytes, content))) {
1253
+ return {
1254
+ ok: true,
1255
+ output: describeWrite(resolved, content, "Append already applied to") +
1256
+ `\n prior_bytes=${priorBytes} expected_prior_bytes=${expectedPriorBytes}`,
1257
+ };
1258
+ }
1259
+ await appendFile(resolved, content, "utf8");
1260
+ const afterBytes = (await stat(resolved).catch(() => undefined))?.size ??
1261
+ priorBytes + Buffer.byteLength(content, "utf8");
1262
+ return {
1263
+ ok: true,
1264
+ output: describeWrite(resolved, content, "Appended (end) to") +
1265
+ `\n prior_bytes=${priorBytes} after_bytes=${afterBytes}` +
1266
+ (stale ? `\n note: expectedPriorBytes=${expectedPriorBytes} was stale; appended at ${priorBytes}.` : "") +
1267
+ `\n note: file exceeds ${Math.round(LARGE_MUTATION_BYTES / (1024 * 1024))}MB — appended in place and skipped the diff/receipt hash of the whole file.`,
1268
+ };
1269
+ }
1270
+ let original = "";
1271
+ try {
1272
+ original = await readFile(resolved, "utf8");
1273
+ }
1274
+ catch (error) {
1275
+ if (error?.code !== "ENOENT")
1276
+ throw error;
1277
+ if (expectedPriorBytes !== undefined && expectedPriorBytes !== 0) {
1278
+ return {
1279
+ ok: false,
1280
+ output: `fs.append integrity check failed: expected prior bytes=${expectedPriorBytes} but file does not exist.`,
1281
+ exitCode: 1,
1282
+ };
1283
+ }
1284
+ await mkdir(dirname(resolved), { recursive: true });
1285
+ await writeFileAtomic(resolved, content);
1286
+ const change = buildFileChange({
1287
+ path: resolved,
1288
+ before: "",
1289
+ after: content,
1290
+ kind: "create",
1291
+ });
1292
+ return {
1293
+ ok: true,
1294
+ output: describeWrite(resolved, content, "Created"),
1295
+ fileChanges: [change],
1296
+ };
1182
1297
  }
1183
- if (typeof options.expectedPriorBytes === "number" &&
1184
- options.expectedPriorBytes !== 0) {
1298
+ const priorBytes = Buffer.byteLength(original, "utf8");
1299
+ if (expectedPriorBytes !== undefined && expectedPriorBytes > priorBytes) {
1300
+ return appendShrankFailure(resolved, expectedPriorBytes, priorBytes);
1301
+ }
1302
+ const stale = expectedPriorBytes !== undefined && expectedPriorBytes < priorBytes;
1303
+ if (stale && appendAlreadyApplied(original, content, position)) {
1185
1304
  return {
1186
- ok: false,
1187
- output: `fs.append integrity check failed: expected prior bytes=${options.expectedPriorBytes} but file does not exist.`,
1188
- exitCode: 1,
1305
+ ok: true,
1306
+ output: describeWrite(resolved, original, "Append already applied to") +
1307
+ `\n prior_bytes=${priorBytes} expected_prior_bytes=${expectedPriorBytes}`,
1189
1308
  };
1190
1309
  }
1191
- // File does not exist: create missing parent directories and write content
1192
- await mkdir(dirname(resolved), { recursive: true });
1193
- await writeFile(resolved, content, "utf8");
1310
+ const next = position === "start" ? content + original : original + content;
1311
+ await writeFileAtomic(resolved, next);
1312
+ const afterBytes = Buffer.byteLength(next, "utf8");
1194
1313
  const change = buildFileChange({
1195
1314
  path: resolved,
1196
- before: "",
1197
- after: content,
1198
- kind: "create",
1315
+ before: original,
1316
+ after: next,
1317
+ kind: "append",
1199
1318
  });
1200
1319
  return {
1201
1320
  ok: true,
1202
- output: describeWrite(resolved, content, "Created"),
1321
+ output: describeWrite(resolved, next, `Appended (${position}) to`) +
1322
+ `\n prior_bytes=${priorBytes} after_bytes=${afterBytes}` +
1323
+ (stale
1324
+ ? `\n note: expectedPriorBytes=${expectedPriorBytes} was stale; appended at ${priorBytes}.`
1325
+ : ""),
1203
1326
  fileChanges: [change],
1204
1327
  };
1205
- }
1206
- const priorBytes = Buffer.byteLength(original, "utf8");
1207
- if (typeof options.expectedPriorBytes === "number" &&
1208
- options.expectedPriorBytes !== priorBytes) {
1209
- return {
1210
- ok: false,
1211
- output: `fs.append integrity check failed for ${resolved}: expected prior bytes=${options.expectedPriorBytes}, actual=${priorBytes}. ` +
1212
- `Do NOT append again until you reconcile (read the last ~20 lines or re-write).`,
1213
- exitCode: 1,
1214
- };
1215
- }
1216
- let next = "";
1217
- if (position === "start") {
1218
- next = content + original;
1219
- }
1220
- else {
1221
- next = original + content;
1222
- }
1223
- // Atomic, mode-preserving, race-safe replacement.
1224
- await writeFileAtomic(resolved, next);
1225
- const st = await stat(resolved).catch(() => undefined);
1226
- const change = buildFileChange({
1227
- path: resolved,
1228
- before: original,
1229
- after: next,
1230
- kind: "append",
1231
1328
  });
1232
- return {
1233
- ok: true,
1234
- output: describeWrite(resolved, next, `Appended (${position}) to`) +
1235
- `\n prior_bytes=${priorBytes} after_bytes=${st?.size ?? Buffer.byteLength(next, "utf8")}`,
1236
- fileChanges: [change],
1237
- };
1238
1329
  }
1239
1330
  //# sourceMappingURL=fs.js.map